From cd5537a8f0cf115d8d2bd35991e94d51407666eb Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Wed, 26 Aug 2026 18:16:32 +0200 Subject: [PATCH 1/5] feat: Add tanstack start --- apps/web/.cta.json | 20 + apps/web/.gitignore | 13 + apps/web/.vscode/settings.json | 11 + apps/web/eslint.config.mjs | 22 + apps/web/package.json | 47 + apps/web/prettier.config.js | 10 + apps/web/src/router.tsx | 19 + apps/web/src/routes/__root.tsx | 54 + apps/web/src/routes/index.tsx | 14 + apps/web/src/styles.css | 17 + apps/web/tsconfig.json | 29 + apps/web/tsr.config.json | 3 + apps/web/vite.config.ts | 21 + pnpm-lock.yaml | 2056 +++++++++++++++++++++++++++++++- pnpm-workspace.yaml | 1 + 15 files changed, 2312 insertions(+), 25 deletions(-) create mode 100644 apps/web/.cta.json create mode 100644 apps/web/.gitignore create mode 100644 apps/web/.vscode/settings.json create mode 100644 apps/web/eslint.config.mjs create mode 100644 apps/web/package.json create mode 100644 apps/web/prettier.config.js create mode 100644 apps/web/src/router.tsx create mode 100644 apps/web/src/routes/__root.tsx create mode 100644 apps/web/src/routes/index.tsx create mode 100644 apps/web/src/styles.css create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/tsr.config.json create mode 100644 apps/web/vite.config.ts diff --git a/apps/web/.cta.json b/apps/web/.cta.json new file mode 100644 index 000000000..d42f13032 --- /dev/null +++ b/apps/web/.cta.json @@ -0,0 +1,20 @@ +{ + "projectName": "web", + "mode": "file-router", + "typescript": true, + "packageManager": "npm", + "includeExamples": false, + "tailwind": true, + "projectPreset": "default", + "addOnOptions": {}, + "git": false, + "install": true, + "intent": true, + "routerOnly": false, + "version": 1, + "framework": "react", + "chosenAddOns": [ + "eslint", + "nitro" + ] +} \ No newline at end of file diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 000000000..8b25bb54e --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,13 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local +.env +.nitro +.tanstack +.wrangler +.output +.vinxi +__unconfig* +todos.json diff --git a/apps/web/.vscode/settings.json b/apps/web/.vscode/settings.json new file mode 100644 index 000000000..00b5278e5 --- /dev/null +++ b/apps/web/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "files.watcherExclude": { + "**/routeTree.gen.ts": true + }, + "search.exclude": { + "**/routeTree.gen.ts": true + }, + "files.readonlyInclude": { + "**/routeTree.gen.ts": true + } +} diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs new file mode 100644 index 000000000..d190aca3c --- /dev/null +++ b/apps/web/eslint.config.mjs @@ -0,0 +1,22 @@ +import eslintVitNode from "@vitnode/config/eslint"; +import eslintVitNodeReact from "@vitnode/config/eslint.react"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default [ + ...eslintVitNode, + ...eslintVitNodeReact, + { + ignores: [".source"], + }, + { + languageOptions: { + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: __dirname, + }, + }, + }, +]; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 000000000..e38b9ec77 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,47 @@ +{ + "name": "web", + "private": true, + "type": "module", + "imports": { + "#/*": "./src/*" + }, + "scripts": { + "dev": "vite dev --port 3000", + "generate-routes": "tsr generate", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint", + "format": "prettier --write . && eslint --fix", + "check": "prettier --check ." + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-devtools": "latest", + "@tanstack/react-router": "latest", + "@tanstack/react-router-devtools": "latest", + "@tanstack/react-start": "latest", + "nitro": "3.0.260610-beta", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tailwindcss": "^4.1.18" + }, + "devDependencies": { + "@tanstack/devtools-vite": "latest", + "@tanstack/eslint-config": "latest", + "@tanstack/router-cli": "^1.132.0", + "@types/node": "^22.10.2", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.0.1", + "@vitnode/config": "workspace:*", + "eslint": "^10.7.0", + "typescript": "^6.0.2", + "vite": "^8.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild", + "lightningcss" + ] + } +} diff --git a/apps/web/prettier.config.js b/apps/web/prettier.config.js new file mode 100644 index 000000000..aea1c4804 --- /dev/null +++ b/apps/web/prettier.config.js @@ -0,0 +1,10 @@ +// @ts-check + +/** @type {import('prettier').Config} */ +const config = { + semi: false, + singleQuote: true, + trailingComma: "all", +}; + +export default config; diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx new file mode 100644 index 000000000..e7b1c4d2a --- /dev/null +++ b/apps/web/src/router.tsx @@ -0,0 +1,19 @@ +import { createRouter as createTanStackRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + const router = createTanStackRouter({ + routeTree, + scrollRestoration: true, + defaultPreload: 'intent', + defaultPreloadStaleTime: 0, + }) + + return router +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx new file mode 100644 index 000000000..f4b63b109 --- /dev/null +++ b/apps/web/src/routes/__root.tsx @@ -0,0 +1,54 @@ +import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' +import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' +import { TanStackDevtools } from '@tanstack/react-devtools' + +import appCss from '../styles.css?url' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { + charSet: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + { + title: 'TanStack Start Starter', + }, + ], + links: [ + { + rel: 'stylesheet', + href: appCss, + }, + ], + }), + shellComponent: RootDocument, +}) + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + {children} + , + }, + ]} + /> + + + + ) +} diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx new file mode 100644 index 000000000..667864957 --- /dev/null +++ b/apps/web/src/routes/index.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ component: Home }) + +function Home() { + return ( +
+

Welcome to TanStack Start

+

+ Edit src/routes/index.tsx to get started. +

+
+ ) +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css new file mode 100644 index 000000000..50dba6e82 --- /dev/null +++ b/apps/web/src/styles.css @@ -0,0 +1,17 @@ + +@import "tailwindcss"; + +* { + box-sizing: border-box; +} + +html, +body, +#app { + min-height: 100%; +} + +body { + margin: 0; +} + diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 000000000..9bdc820fb --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,29 @@ +{ + "include": ["**/*.ts", "**/*.tsx", "eslint.config.js", "prettier.config.js", "vite.config.js"], + + "compilerOptions": { + "target": "ES2022", + "jsx": "react-jsx", + "module": "ESNext", + "paths": { + "#/*": ["./src/*"], + "@/*": ["./src/*"] + }, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + /* Linting */ + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + } +} diff --git a/apps/web/tsr.config.json b/apps/web/tsr.config.json new file mode 100644 index 000000000..8b6b6eddb --- /dev/null +++ b/apps/web/tsr.config.json @@ -0,0 +1,3 @@ +{ + "target": "react" +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 000000000..5a46a66cf --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite' +import { devtools } from '@tanstack/devtools-vite' + +import { tanstackStart } from '@tanstack/react-start/plugin/vite' + +import viteReact from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { nitro } from 'nitro/vite' + +const config = defineConfig({ + resolve: { tsconfigPaths: true }, + plugins: [ + devtools(), + nitro({ rollupConfig: { external: [/^@sentry\//] } }), + tailwindcss(), + tanstackStart(), + viteReact(), + ], +}) + +export default config diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcee1dccc..b730afd94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,13 +155,13 @@ importers: version: 1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.4.3) fumadocs-core: specifier: ^16.11.5 - version: 16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + version: 16.11.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) fumadocs-mdx: specifier: ^15.2.0 - version: 15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) fumadocs-ui: specifier: ^16.11.5 - version: 16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + version: 16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) hono: specifier: ^4.12.31 version: 4.12.31 @@ -266,6 +266,70 @@ importers: specifier: ^4.4.3 version: 4.4.3 + apps/web: + dependencies: + '@tailwindcss/vite': + specifier: ^4.1.18 + version: 4.3.3(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@tanstack/react-devtools': + specifier: latest + version: 0.10.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(solid-js@1.9.15) + '@tanstack/react-router': + specifier: latest + version: 1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/react-router-devtools': + specifier: latest + version: 1.167.1(@tanstack/react-router@1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@tanstack/router-core@1.171.27)(csstype@3.2.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/react-start': + specifier: latest + version: 1.168.49(crossws@0.4.12(srvx@0.11.22))(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + nitro: + specifier: 3.0.260610-beta + version: 3.0.260610-beta(chokidar@5.0.0)(dotenv@17.4.2)(jiti@2.7.0)(lru-cache@11.5.2)(rollup@4.62.2)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + react: + specifier: ^19.2.0 + version: 19.2.8 + react-dom: + specifier: ^19.2.0 + version: 19.2.8(react@19.2.8) + tailwindcss: + specifier: ^4.1.18 + version: 4.3.3 + devDependencies: + '@tanstack/devtools-vite': + specifier: latest + version: 0.8.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@tanstack/eslint-config': + specifier: latest + version: 0.4.0(@typescript-eslint/utils@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) + '@tanstack/router-cli': + specifier: ^1.132.0 + version: 1.167.33 + '@types/node': + specifier: ^22.10.2 + version: 22.20.1 + '@types/react': + specifier: ^19.2.0 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.0 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.4(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@vitnode/config': + specifier: workspace:* + version: link:../../packages/config + eslint: + specifier: ^10.7.0 + version: 10.7.0(jiti@2.7.0) + typescript: + specifier: ^6.0.2 + version: 6.0.3 + vite: + specifier: ^8.0.0 + version: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + packages/config: dependencies: '@eslint-react/eslint-plugin': @@ -1050,6 +1114,10 @@ packages: resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} engines: {node: '>=18.0.0'} + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -1340,12 +1408,21 @@ packages: resolution: {integrity: sha512-L38Ax21uF2OPUmCRWycZ/dZdMYf7gMrtClcxvVrqJVFmn8ET2M++GYmFGJpLqOHS1beATxOXLWe7y2ijSQz/ng==} engines: {node: '>=20'} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} @@ -2617,6 +2694,15 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@neodrag/core@3.0.0-next.11': + resolution: {integrity: sha512-3WQWxyrbxiaK9zS5JU2wJsW2gpoQlZBXVghduBh61JpqaeE0T0cte8R0qYK2RuJo3J2TYQYqxO19CpG/C1i5eg==} + + '@neodrag/solid@3.0.0-next.11': + resolution: {integrity: sha512-vCBIn/pimjWMQ6vhTS2/O1XNAwzVtc4eUhdbQ91WykbZWWqQ5NocDXt/1OdYrEkeRzJcpCv8wEz5PnMkgKP81Q==} + peerDependencies: + '@neodrag/core': 3.0.0-next.11 + solid-js: ^1.0.0 + '@next/bundle-analyzer@16.3.1': resolution: {integrity: sha512-/6XQeYPHM6jF1gTeJ82Mu6yXOHQIFVxzAn3DkPtHDp5v6SDXoCicBMTHloN+2h4WKFCQujSLCgLZHItJ3jN1uw==} @@ -2742,6 +2828,22 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@oozcitak/dom@2.0.2': + resolution: {integrity: sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==} + engines: {node: '>=20.0'} + + '@oozcitak/infra@2.0.2': + resolution: {integrity: sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==} + engines: {node: '>=20.0'} + + '@oozcitak/url@3.0.0': + resolution: {integrity: sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==} + engines: {node: '>=20.0'} + + '@oozcitak/util@10.0.0': + resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==} + engines: {node: '>=20.0'} + '@opentelemetry/api-logs@0.220.0': resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} engines: {node: '>=8.0.0'} @@ -2788,48 +2890,97 @@ packages: resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} engines: {node: '>= 20.0.0'} + '@oxc-parser/binding-android-arm-eabi@0.120.0': + resolution: {integrity: sha512-WU3qtINx802wOl8RxAF1v0VvmC2O4D9M8Sv486nLeQ7iPHVmncYZrtBhB4SYyX+XZxj2PNnCcN+PW21jHgiOxg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@oxc-parser/binding-android-arm-eabi@0.143.0': resolution: {integrity: sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] + '@oxc-parser/binding-android-arm64@0.120.0': + resolution: {integrity: sha512-SEf80EHdhlbjZEgzeWm0ZA/br4GKMenDW3QB/gtyeTV1gStvvZeFi40ioHDZvds2m4Z9J1bUAUL8yn1/+A6iGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@oxc-parser/binding-android-arm64@0.143.0': resolution: {integrity: sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@oxc-parser/binding-darwin-arm64@0.120.0': + resolution: {integrity: sha512-xVrrbCai8R8CUIBu3CjryutQnEYhZqs1maIqDvtUCFZb8vY33H7uh9mHpL3a0JBIKoBUKjPH8+rzyAeXnS2d6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@oxc-parser/binding-darwin-arm64@0.143.0': resolution: {integrity: sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@oxc-parser/binding-darwin-x64@0.120.0': + resolution: {integrity: sha512-xyHBbnJ6mydnQUH7MAcafOkkrNzQC6T+LXgDH/3InEq2BWl/g424IMRiJVSpVqGjB+p2bd0h0WRR8iIwzjU7rw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@oxc-parser/binding-darwin-x64@0.143.0': resolution: {integrity: sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@oxc-parser/binding-freebsd-x64@0.120.0': + resolution: {integrity: sha512-UMnVRllquXUYTeNfFKmxTTEdZ/ix1nLl0ducDzMSREoWYGVIHnOOxoKMWlCOvRr9Wk/HZqo2rh1jeumbPGPV9A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@oxc-parser/binding-freebsd-x64@0.143.0': resolution: {integrity: sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@oxc-parser/binding-linux-arm-gnueabihf@0.120.0': + resolution: {integrity: sha512-tkvn2CQ7QdcsMnpfiX3fd3wA3EFsWKYlcQzq9cFw/xc89Al7W6Y4O0FgLVkVQpo0Tnq/qtE1XfkJOnRRA9S/NA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': resolution: {integrity: sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm-musleabihf@0.120.0': + resolution: {integrity: sha512-WN5y135Ic42gQDk9grbwY9++fDhqf8knN6fnP+0WALlAUh4odY/BDK1nfTJRSfpJD9P3r1BwU0m3pW2DU89whQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': resolution: {integrity: sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm64-gnu@0.120.0': + resolution: {integrity: sha512-1GgQBCcXvFMw99EPdMy+4NZ3aYyXsxjf9kbUUg8HuAy3ZBXzOry5KfFEzT9nqmgZI1cuetvApkiJBZLAPo8uaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-arm64-gnu@0.143.0': resolution: {integrity: sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2837,6 +2988,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-arm64-musl@0.120.0': + resolution: {integrity: sha512-gmMQ70gsPdDBgpcErvJEoWNBr7bJooSLlvOBVBSGfOzlP5NvJ3bFvnUeZZ9d+dPrqSngtonf7nyzWUTUj/U+lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-linux-arm64-musl@0.143.0': resolution: {integrity: sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2844,6 +3002,13 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-ppc64-gnu@0.120.0': + resolution: {integrity: sha512-T/kZuU0ajop0xhzVMwH5r3srC9Nqup5HaIo+3uFjIN5uPxa0LvSxC1ZqP4aQGJVW5G0z8/nCkjIfSMS91P/wzw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': resolution: {integrity: sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2851,6 +3016,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-riscv64-gnu@0.120.0': + resolution: {integrity: sha512-vn21KXLAXzaI3N5CZWlBr1iWeXLl9QFIMor7S1hUjUGTeUuWCoE6JZB040/ZNDwf+JXPX8Ao9KbmJq9FMC2iGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': resolution: {integrity: sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2858,6 +3030,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-riscv64-musl@0.120.0': + resolution: {integrity: sha512-SUbUxlar007LTGmSLGIC5x/WJvwhdX+PwNzFJ9f/nOzZOrCFbOT4ikt7pJIRg1tXVsEfzk5mWpGO1NFiSs4PIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-linux-riscv64-musl@0.143.0': resolution: {integrity: sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2865,6 +3044,13 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-s390x-gnu@0.120.0': + resolution: {integrity: sha512-hYiPJTxyfJY2+lMBFk3p2bo0R9GN+TtpPFlRqVchL1qvLG+pznstramHNvJlw9AjaoRUHwp9IKR7UZQnRPGjgQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-s390x-gnu@0.143.0': resolution: {integrity: sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2872,6 +3058,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-x64-gnu@0.120.0': + resolution: {integrity: sha512-q+5jSVZkprJCIy3dzJpApat0InJaoxQLsJuD6DkX8hrUS61z2lHQ1Fe9L2+TYbKHXCLWbL0zXe7ovkIdopBGMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-x64-gnu@0.143.0': resolution: {integrity: sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2879,6 +3072,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-x64-musl@0.120.0': + resolution: {integrity: sha512-D9QDDZNnH24e7X4ftSa6ar/2hCavETfW3uk0zgcMIrZNy459O5deTbWrjGzZiVrSWigGtlQwzs2McBP0QsfV1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-linux-x64-musl@0.143.0': resolution: {integrity: sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2886,30 +3086,62 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-openharmony-arm64@0.120.0': + resolution: {integrity: sha512-TBU8ZwOUWAOUWVfmI16CYWbvh4uQb9zHnGBHsw5Cp2JUVG044OIY1CSHODLifqzQIMTXvDvLzcL89GGdUIqNrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@oxc-parser/binding-openharmony-arm64@0.143.0': resolution: {integrity: sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@oxc-parser/binding-wasm32-wasi@0.120.0': + resolution: {integrity: sha512-WG/FOZgDJCpJnuF3ToG/K28rcOmSY7FmFmfBKYb2fmLyhDzPpUldFGV7/Fz4ru0Iz/v4KPmf8xVgO8N3lO4KHA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.120.0': + resolution: {integrity: sha512-1T0HKGcsz/BKo77t7+89L8Qvu4f9DoleKWHp3C5sJEcbCjDOLx3m9m722bWZTY+hANlUEs+yjlK+lBFsA+vrVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@oxc-parser/binding-win32-arm64-msvc@0.143.0': resolution: {integrity: sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@oxc-parser/binding-win32-ia32-msvc@0.120.0': + resolution: {integrity: sha512-L7vfLzbOXsjBXV0rv/6Y3Jd9BRjPeCivINZAqrSyAOZN3moCopDN+Psq9ZrGNZtJzP8946MtlRFZ0Als0wBCOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + '@oxc-parser/binding-win32-ia32-msvc@0.143.0': resolution: {integrity: sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.120.0': + resolution: {integrity: sha512-ys+upfqNtSu58huAhJMBKl3XCkGzyVFBlMlGPzHeFKgpFF/OdgNs1MMf8oaJIbgMH8ZxgGF7qfue39eJohmKIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.143.0': resolution: {integrity: sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@oxc-project/types@0.120.0': + resolution: {integrity: sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==} + '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} @@ -4029,6 +4261,36 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@solid-primitives/event-listener@2.4.6': + resolution: {integrity: sha512-5I0YJcTVYIWoMmgBSROBZGcz+ymhew/pGTg2dHW74BUjFKsV8Li4bOZYl0YAGP4mHw5o4UBd9/BEesqBci3wxw==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/keyboard@1.3.7': + resolution: {integrity: sha512-558RPNYnXx4nGh537DSqAn4xMrC8iFipl/5+xzgzWoTNFst4RnUN3BOLmtDjJ0UGGoQXVMALYR3bNOHM0xnt1Q==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/resize-observer@2.2.0': + resolution: {integrity: sha512-9Fuu/EWBeGj+atGHRJp70HKhdfalmpjwxY8a32NZixdLNmfCJ45AfhLQNr6uOzETbbiMx4iCKlTrJ8KZCHC2Ww==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/rootless@1.5.4': + resolution: {integrity: sha512-TOIZa1VUfVJ+9nkCcRajw3U4t9vBOP1HxX1WHNTbXq32mXwlqTvUnC4CRIilohcryBkT9u2ZkhUDSHRTaGp55g==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/static-store@0.1.4': + resolution: {integrity: sha512-LgtVaVBtB7EbmS4+M0b8xY5Iq6pUWXBsIC4VgtrFKDGDdyCaDt88sHk0fUlx1Enxm/XZnZyLXJABRoa39RjJqA==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/utils@6.4.1': + resolution: {integrity: sha512-ISSB5QX1qP2ynrheIpYwc4oKR5Ny4siNuUyf1qZniy+Il+p/PtDB0QK1Dnle8noiHpwRD3gpPdubOC3qI/Zamg==} + peerDependencies: + solid-js: ^1.6.12 + '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} @@ -4038,6 +4300,12 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@stylistic/eslint-plugin@5.10.0': + resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + '@supabase/storage-js@2.110.8': resolution: {integrity: sha512-CcfhkZFBLxsthgUabZKxwfsoXdrikIGsL3LsGoV3FZTqCMx/s1y49taT4jT/oya5+1IuB0sFFHw6pF0o0iJniQ==} engines: {node: '>=22.0.0'} @@ -4253,14 +4521,231 @@ packages: '@tailwindcss/postcss@4.3.3': resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/devtools-bundler-core@0.1.3': + resolution: {integrity: sha512-F0tlxIyfFqXkZ1mJP1EjtkiSeJA+ztXY2AYOHf7r4goCIEAOp86N9PFJ/yv8vu1TnmxGS6vKsZCS3Kyls9xQQA==} + engines: {node: '>=18'} + + '@tanstack/devtools-client@0.0.8': + resolution: {integrity: sha512-cG3iZkGWCwN330bLBKa8+9r4Of2AXNoz2zUqcsy/4XsD3105ghVBx78cGyvJj9fSclNomPxoqAnDGXXhg1WLvA==} + engines: {node: '>=18'} + + '@tanstack/devtools-event-bus@0.4.3': + resolution: {integrity: sha512-NeegBt5/n2E5q4DbrXHqECBq42+kDi6JBOp8/+RNqkIE+P4hJpbM36kGyjnGQFMWOoku31qhMyX9/48VuTTdmg==} + engines: {node: '>=18'} + + '@tanstack/devtools-event-client@0.5.0': + resolution: {integrity: sha512-H+OH3zC6Vhu/K0NaVfQKknEKawc/+2PT+D3SB3Ox0V8SiMlTo0abbmH2rH0721R2aNYbjdMXA1oENOd8E2UVoA==} + engines: {node: '>=18'} + hasBin: true + + '@tanstack/devtools-ui@0.7.1': + resolution: {integrity: sha512-3xQ/ezZ2qVNszhjpCN2N3jn7uHc2J1PMgcyjHzH4XZBt9xAQyMMcPNoR2cd7rzReyxWoJpZUWiDBmOJiCtLj9A==} + engines: {node: '>=18'} + peerDependencies: + solid-js: '>=1.9.7' + + '@tanstack/devtools-vite@0.8.5': + resolution: {integrity: sha512-xaifCEmiwwzizlbp973oISXNsnmuU/BugLa66gryAZaPJJ/qo1kJd2DxY5X960eHwmyIS3SN0KYrL3/aRhucmA==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + + '@tanstack/devtools@0.14.2': + resolution: {integrity: sha512-8FVVmDU+x3iEwrl5rtbIhud8gwp9eSXPlhs36SCV59yAtXmheFFvLqLAUXpfIU79sZVBg3LLTy2VlTIXaWbaBw==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + solid-js: '>=1.9.7' + + '@tanstack/eslint-config@0.4.0': + resolution: {integrity: sha512-V+Cd81W/f65dqKJKpytbwTGx9R+IwxKAHsG/uJ3nSLYEh36hlAr54lRpstUhggQB8nf/cP733cIw8DuD2dzQUg==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + '@tanstack/history@1.162.1': + resolution: {integrity: sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w==} + engines: {node: '>=20.19'} + '@tanstack/query-core@5.101.4': resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==} + '@tanstack/react-devtools@0.10.12': + resolution: {integrity: sha512-dgoz7TFm97Izo/D34z91PD0h+ufk+eBmoN9OgRHJlj/c7Ol5xpIP7bqLBNByjgt5paRE2eSu5AQHV92Ul+G6iw==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8' + '@types/react-dom': '>=16.8' + react: '>=16.8' + react-dom: '>=16.8' + '@tanstack/react-query@5.101.4': resolution: {integrity: sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==} peerDependencies: react: ^18 || ^19 + '@tanstack/react-router-devtools@1.167.1': + resolution: {integrity: sha512-pjfGrmjj4d7naEPM7oshqFfwBoxDPNo/UxltlHH5ePbHsJ+plBhd+JaAewm1ueYOjZ0js9hckjWWDYXpCrSfKw==} + engines: {node: '>=20.19'} + peerDependencies: + '@tanstack/react-router': ^1.170.19 + '@tanstack/router-core': ^1.171.16 + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + peerDependenciesMeta: + '@tanstack/router-core': + optional: true + + '@tanstack/react-router@1.170.32': + resolution: {integrity: sha512-SIpxvaTKco100a5ZR3ePmArbhtm3XOx+w1dpGYY9gxHDta4iXSKDdQuhLonwJbIMkVJsU1rwXf0UDHMrF/1snw==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-start-client@1.168.30': + resolution: {integrity: sha512-qsZuykUl1EF0/rc1bin1RtjFzz07YMOTBzhOstSDzbOVm/WKf1QKFTN+qAZi74xlbXSWNuT2MiFRyg4RKwj8iw==} + engines: {node: '>=22.12.0'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-start-rsc@0.1.48': + resolution: {integrity: sha512-UglRdTMuF3c4dvzL/gh4dMVbMWHsPy8ZgTQdT2qpTlyt1b/3m+R40tBFdsfTgw76VTXivW+qV/ih0PN9000XTw==} + engines: {node: '>=22.12.0'} + peerDependencies: + '@rspack/core': '>=2.0.0-0' + '@vitejs/plugin-rsc': '>=0.5.30' + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + react-server-dom-rspack: '>=0.0.2' + peerDependenciesMeta: + '@rspack/core': + optional: true + '@vitejs/plugin-rsc': + optional: true + react-server-dom-rspack: + optional: true + + '@tanstack/react-start-server@1.167.37': + resolution: {integrity: sha512-cODHpFU8vIm7AdHii9W3NEuwyruNmT5wLDZjRsJLT9Jp+7FACnfJrRbvxnp1ldSv/9mxcHKi/OgwbdHQeMZcAQ==} + engines: {node: '>=22.12.0'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-start@1.168.49': + resolution: {integrity: sha512-iQb1ZoEHqvZMGLR4G7v3tTtgL06yv/bwxvGP3waVHxVn7bRpyopM44YbOluaGkcJzc13ZTvdLKWYNAN3bxMX/Q==} + engines: {node: '>=22.12.0'} + peerDependencies: + '@rsbuild/core': ^2.0.0 + '@vitejs/plugin-rsc': '*' + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + vite: '>=7.0.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@vitejs/plugin-rsc': + optional: true + vite: + optional: true + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-cli@1.167.33': + resolution: {integrity: sha512-OZcP4zmzj85rLq2Cr6I3tZDT9Yb8gsquDgka+baKOKXJULBqP5uZ56X8Ggrgq5IBypk47/b1K2tA1qChd1qUig==} + engines: {node: '>=20.19'} + hasBin: true + + '@tanstack/router-core@1.171.27': + resolution: {integrity: sha512-wDwSLvoLwIaNcnx9UNcN9Mb7Y8QwCYq1U1RQZwyN186gnkIoIYI2SOxy8VqH1vFigbkHkk4FmwMAQlghPgDK2g==} + engines: {node: '>=20.19'} + + '@tanstack/router-devtools-core@1.168.1': + resolution: {integrity: sha512-qr4voa4cpSMwQvS3867xkU3AB3MtJbTuovKIy+btjJ/Faju6er9w0nDylmD+005Mk/3YKw9/iueZJl2JAB7JOA==} + engines: {node: '>=20.19'} + peerDependencies: + '@tanstack/router-core': ^1.171.16 + csstype: ^3.0.10 + peerDependenciesMeta: + csstype: + optional: true + + '@tanstack/router-generator@1.167.33': + resolution: {integrity: sha512-Z3lCWIPuRUMPmuI8Mm48x/s49TxmHOaFVZ52j1W1QKYrsFHyT6U/h9bqfHJDxfQ8kz7y9q+W1YPKZ15Ee7yuCA==} + engines: {node: '>=20.19'} + + '@tanstack/router-plugin@1.168.35': + resolution: {integrity: sha512-foDAZKFqHXae+oFbIgcsSvy2QCVRn7XdS3nhwcRvD+ed6JrKPUP/1lQMsZLJqWycgR1vkZF7gs955KGa0NZQ0w==} + engines: {node: '>=20.19'} + peerDependencies: + '@rsbuild/core': '>=1.0.2 || ^2.0.0' + '@tanstack/react-router': ^1.170.32 + vite: '>=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0' + vite-plugin-solid: ^2.11.10 || ^3.0.0-0 + webpack: '>=5.92.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@tanstack/react-router': + optional: true + vite: + optional: true + vite-plugin-solid: + optional: true + webpack: + optional: true + + '@tanstack/router-utils@1.162.2': + resolution: {integrity: sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==} + engines: {node: '>=20.19'} + + '@tanstack/start-client-core@1.170.27': + resolution: {integrity: sha512-Ro6ZSM0NgYKDMxM0e8qyU4mBfnld2Zb74BA/9f4i35C0Y3IAA8Zxs/DIPfOo9VRYWp5c5n5Q8dRBn2qn0vtPhQ==} + engines: {node: '>=22.12.0'} + + '@tanstack/start-fn-stubs@1.162.0': + resolution: {integrity: sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ==} + engines: {node: '>=22.12.0'} + + '@tanstack/start-plugin-core@1.171.39': + resolution: {integrity: sha512-Zyj6G4MDFLXcHYhPavhewCBo8dsxi3qvPk31zl/QtTzYzOY9rJHDDBGarKsJq20ziChmkGn/sttYqb4vGCxJDA==} + engines: {node: '>=22.12.0'} + peerDependencies: + '@rsbuild/core': ^2.0.0 + vite: '>=7.0.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + vite: + optional: true + + '@tanstack/start-server-core@1.169.31': + resolution: {integrity: sha512-56w8l+Fao01YCrmv0hzNxL3b3FRmqLRGdE11izZNQsW+1CcSYuehAM5khWKABuI1DI/UIBLGV7BwL4Dlg0eHCw==} + engines: {node: '>=22.12.0'} + + '@tanstack/start-storage-context@1.167.29': + resolution: {integrity: sha512-8qfprC5774XMRDQlMogkfiGpFLiBf0xDG4bMFUbfkSzpCAQwpLbgGY4Zwft22O9rKYq2vUXusKAdVjvJTUEquQ==} + engines: {node: '>=22.12.0'} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + + '@tanstack/virtual-file-routes@1.162.0': + resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} + engines: {node: '>=20.19'} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -4550,6 +5035,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + '@types/node@25.9.5': resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} @@ -4647,9 +5135,129 @@ packages: '@ungap/structured-clone@1.3.3': resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} - '@vercel/oidc@3.2.0': - resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} - engines: {node: '>= 20'} + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.12.2': + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.12.2': + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} + cpu: [arm64] + os: [openharmony] + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} + cpu: [x64] + os: [win32] + + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} '@vitejs/plugin-react@6.0.4': resolution: {integrity: sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==} @@ -4885,10 +5493,18 @@ packages: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + ansi-styles@5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -4987,6 +5603,9 @@ packages: react-native-b4a: optional: true + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + babel-plugin-react-compiler@1.0.0: resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} @@ -5192,6 +5811,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -5216,6 +5839,13 @@ packages: collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -5255,6 +5885,10 @@ packages: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} + comment-parser@1.4.8: + resolution: {integrity: sha512-rKZTGo4fzKYna8UcL0isTg5wkBNla7bxTypLwZQXjIdi++IdP1OJ41rI5Mti3/jltkPujbu4i9LIARYA+zpotQ==} + engines: {node: '>= 12.0.0'} + compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} @@ -5305,6 +5939,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -5338,6 +5975,14 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crossws@0.4.12: + resolution: {integrity: sha512-aypfsr6t0uNvkqaZc6zvBfXzC6pLI0/sIulpkV6RwCVtZqG5ebBzv4weImKK0VNCj91Wl9F5j7p5WU4MNrybng==} + peerDependencies: + srvx: '>=0.11.5' + peerDependenciesMeta: + srvx: + optional: true + css-in-js-utils@3.1.0: resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} @@ -5423,6 +6068,32 @@ packages: date-fns@4.4.0: resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + + db0@0.3.4: + resolution: {integrity: sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw==} + peerDependencies: + '@electric-sql/pglite': '*' + '@libsql/client': '*' + better-sqlite3: '*' + drizzle-orm: '*' + mysql2: '*' + sqlite3: '*' + peerDependenciesMeta: + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + better-sqlite3: + optional: true + drizzle-orm: + optional: true + mysql2: + optional: true + sqlite3: + optional: true + debounce-fn@4.0.0: resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} engines: {node: '>=10'} @@ -5735,6 +6406,9 @@ packages: emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -5778,6 +6452,24 @@ packages: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + env-runner@0.1.16: + resolution: {integrity: sha512-2LRJM4P2KLX6J83QZZrMqvgCDt/D5ea7wPcI3yYiy5cG/9rX5QwdwZFx0D7ktWnjdRyZxYjttGGorb5nFqb1CA==} + hasBin: true + peerDependencies: + '@netlify/runtime': ^4.1.23 + '@vercel/queue': '>=0.2.0' + miniflare: ^4.20260515.0 + wrangler: ^4.0.0 + peerDependenciesMeta: + '@netlify/runtime': + optional: true + '@vercel/queue': + optional: true + miniflare: + optional: true + wrangler: + optional: true + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -5858,18 +6550,58 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + eslint-compat-utils@0.5.1: + resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + eslint-config-prettier@10.1.8: resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true peerDependencies: eslint: '>=7.0.0' + eslint-import-context@0.1.9: + resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + peerDependencies: + unrs-resolver: ^1.0.0 + peerDependenciesMeta: + unrs-resolver: + optional: true + + eslint-plugin-es-x@7.8.0: + resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '>=8' + + eslint-plugin-import-x@4.17.1: + resolution: {integrity: sha512-4cdstYkKCyjumM2Q9NSI03K8D2a9F4Ssz33K2lv2hQa4KmR9jPLwk3uWGtNvclfqBrPGfGuMBwsGMbe6dMRbfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/utils': ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + eslint-import-resolver-node: '*' + peerDependenciesMeta: + '@typescript-eslint/utils': + optional: true + eslint-import-resolver-node: + optional: true + eslint-plugin-jsx-a11y@6.10.2: resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} engines: {node: '>=4.0'} peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + eslint-plugin-n@17.24.0: + resolution: {integrity: sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: '>=8.23.0' + eslint-plugin-perfectionist@5.10.0: resolution: {integrity: sha512-HiqpDrUDbGrMC6iHQbemgDyHJ0366Vyz/qRWmxQcSAkmG25cXr8BdRgx8yAhOKhEfBXn8Rnf/mTCsV4EqUJSxg==} engines: {node: ^20.0.0 || >=22.0.0} @@ -5952,6 +6684,10 @@ packages: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@5.0.1: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -5966,6 +6702,10 @@ packages: jiti: optional: true + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + espree@11.2.0: resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -6062,6 +6802,9 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + ext-list@2.2.2: resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==} engines: {node: '>=0.10.0'} @@ -6129,6 +6872,9 @@ packages: picomatch: optional: true + fetchdts@0.1.7: + resolution: {integrity: sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -6367,6 +7113,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.6.0: resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} @@ -6421,10 +7171,18 @@ packages: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + globals@16.5.0: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} + globals@17.11.0: + resolution: {integrity: sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==} + engines: {node: '>=18'} + globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} @@ -6433,6 +7191,14 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + goober@2.1.19: + resolution: {integrity: sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==} + peerDependencies: + csstype: ^3.0.10 + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -6448,6 +7214,26 @@ packages: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} + h3@2.0.1-rc.20: + resolution: {integrity: sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg==} + engines: {node: '>=20.11.1'} + hasBin: true + peerDependencies: + crossws: ^0.4.1 + peerDependenciesMeta: + crossws: + optional: true + + h3@2.0.1-rc.22: + resolution: {integrity: sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA==} + engines: {node: '>=20.11.1'} + hasBin: true + peerDependencies: + crossws: ^0.4.1 + peerDependenciesMeta: + crossws: + optional: true + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -6512,6 +7298,9 @@ packages: resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==} engines: {node: '>=16.9.0'} + hookable@6.1.1: + resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + hpagent@1.2.0: resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==} engines: {node: '>=14'} @@ -6547,6 +7336,9 @@ packages: resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} engines: {node: '>=10.19.0'} + httpxy@0.5.5: + resolution: {integrity: sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA==} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -6702,6 +7494,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -6839,6 +7635,10 @@ packages: isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isbot@5.2.1: + resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -6974,6 +7774,9 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} + launch-editor@2.14.1: + resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==} + leac@0.6.0: resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} @@ -7507,6 +8310,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -7583,6 +8391,40 @@ packages: sass: optional: true + nf3@0.3.24: + resolution: {integrity: sha512-HxLK4bo+5jNsEETZp4w3tJblHOA9MCBY14IN9nZJJV8JDxt9yNIYxuBuLMjTAR5GFa3HL61+8VQDUrXv3/C8fw==} + + nitro@3.0.260610-beta: + resolution: {integrity: sha512-KPb4L5yaF/Rx/xoGMpgHRJvZhbhGiqbRKOwwPLCH9jKTKTsEUHLjnJas85AeCzaswqa8Wi52eQBtRsODC4PS0Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@vercel/queue': ^0.3.0 + dotenv: '*' + giget: '*' + jiti: ^2.7.0 + rollup: ^4.61.1 + vite: ^7 || ^8 + xml2js: ^0.6.2 + zephyr-agent: ^0.2.0 + peerDependenciesMeta: + '@vercel/queue': + optional: true + dotenv: + optional: true + giget: + optional: true + jiti: + optional: true + rollup: + optional: true + vite: + optional: true + xml2js: + optional: true + zephyr-agent: + optional: true + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -7655,6 +8497,15 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + ocache@0.1.5: + resolution: {integrity: sha512-kNNnkkVQup/QDvmTz8Q84wc2ntiyoVHDxa6eHWKt5qdGAmFRBIxy83rxgCYEjW0x06UJ9E3P6VgM2yY4rOBH4w==} + + ofetch@2.0.0-alpha.3: + resolution: {integrity: sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA==} + + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -7714,6 +8565,10 @@ packages: resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} engines: {node: '>= 0.4'} + oxc-parser@0.120.0: + resolution: {integrity: sha512-WyPWZlcIm+Fkte63FGfgFB8mAAk33aH9h5N9lphXVOHSXEBFFsmYdOBedVKly363aWABjZdaj/m9lBfEY4wt+w==} + engines: {node: ^20.19.0 || >=22.12.0} + oxc-parser@0.143.0: resolution: {integrity: sha512-ov0NzaDCOInknS7mP1cwKdJERt3utPW8ldjtdUXQ8Ty0GEFD08wk422vCUN0d7pST6kqtV7dxoI9w1Zi0l/9TA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8325,6 +9180,10 @@ packages: remark@15.0.1: resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -8387,6 +9246,9 @@ packages: rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + rou3@0.8.1: + resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -8464,6 +9326,26 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval-plugins@1.6.4: + resolution: {integrity: sha512-R0f1U9hmn38+dFMz6b6ab8lwucmw4AtiY7St+JPWudy1dm+Bs3g884nyrsH9Cy6rKpZKLYayXuMda9GZ/fl8JQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + + seroval@1.6.4: + resolution: {integrity: sha512-LErWMNS2RRFdu2RMA5u/PA59/IWs0XsikyEXGQ2/36iEWFrdG0ABmg17E17cikrv76891kOAMq3TkTFXpwAHXw==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -8516,6 +9398,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} + engines: {node: '>= 0.4'} + shiki@4.3.1: resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==} engines: {node: '>=20'} @@ -8568,6 +9454,9 @@ packages: resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} engines: {node: '>=10.2.0'} + solid-js@1.9.15: + resolution: {integrity: sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg==} + sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -8601,6 +9490,15 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + srvx@0.11.22: + resolution: {integrity: sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ==} + engines: {node: '>=20.16.0'} + hasBin: true + + stable-hash-x@0.2.0: + resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} + engines: {node: '>=12.0.0'} + stack-generator@2.0.10: resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} @@ -8644,6 +9542,10 @@ packages: string-ts@2.3.1: resolution: {integrity: sha512-xSJq+BS52SaFFAVxuStmx6n5aYZU571uYUnUrPXkPFCfdHyZMMlbP2v2Wx5sNBnAVzq/2+0+mcBLBa3Xa5ubYw==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -8873,6 +9775,11 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-declaration-location@1.0.7: + resolution: {integrity: sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==} + peerDependencies: + typescript: '>=4.0.0' + ts-easing@0.2.0: resolution: {integrity: sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==} @@ -8991,6 +9898,9 @@ packages: unbzip2-stream@1.4.3: resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} @@ -9001,6 +9911,9 @@ packages: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -9070,6 +9983,83 @@ packages: webpack: optional: true + unrs-resolver@1.12.2: + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + + unstorage@2.0.0-alpha.7: + resolution: {integrity: sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog==} + peerDependencies: + '@azure/app-configuration': ^1.11.0 + '@azure/cosmos': ^4.9.1 + '@azure/data-tables': ^13.3.2 + '@azure/identity': ^4.13.0 + '@azure/keyvault-secrets': ^4.10.0 + '@azure/storage-blob': ^12.31.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.13.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.36.2 + '@vercel/blob': '>=0.27.3' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1.0.1 + aws4fetch: ^1.0.20 + chokidar: ^4 || ^5 + db0: '>=0.3.4' + idb-keyval: ^6.2.2 + ioredis: ^5.9.3 + lru-cache: ^11.2.6 + mongodb: ^6 || ^7 + ofetch: '*' + uploadthing: ^7.7.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + chokidar: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + lru-cache: + optional: true + mongodb: + optional: true + ofetch: + optional: true + uploadthing: + optional: true + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -9191,6 +10181,14 @@ packages: yaml: optional: true + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + vitest@4.1.10: resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -9252,7 +10250,13 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} - w3c-keyname@2.2.8: + vue-eslint-parser@10.4.1: + resolution: {integrity: sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} w3c-xmlserializer@5.0.0: @@ -9323,6 +10327,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -9358,9 +10366,17 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xmlbuilder2@4.0.3: + resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} + engines: {node: '>=20.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -9369,6 +10385,14 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + yauzl@3.4.0: resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} engines: {node: '>=12'} @@ -9665,6 +10689,12 @@ snapshots: '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -10023,17 +11053,33 @@ snapshots: transitivePeerDependencies: - supports-color + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 tslib: 2.8.1 optional: true + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.11.1': dependencies: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 @@ -10951,6 +11997,13 @@ snapshots: '@napi-rs/nice-win32-x64-msvc': 1.1.1 optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.3 + optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -10958,6 +12011,13 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@neodrag/core@3.0.0-next.11': {} + + '@neodrag/solid@3.0.0-next.11(@neodrag/core@3.0.0-next.11)(solid-js@1.9.15)': + dependencies: + '@neodrag/core': 3.0.0-next.11 + solid-js: 1.9.15 + '@next/bundle-analyzer@16.3.1': dependencies: webpack-bundle-analyzer: 4.10.1 @@ -11029,6 +12089,23 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@oozcitak/dom@2.0.2': + dependencies: + '@oozcitak/infra': 2.0.2 + '@oozcitak/url': 3.0.0 + '@oozcitak/util': 10.0.0 + + '@oozcitak/infra@2.0.2': + dependencies: + '@oozcitak/util': 10.0.0 + + '@oozcitak/url@3.0.0': + dependencies: + '@oozcitak/infra': 2.0.2 + '@oozcitak/util': 10.0.0 + + '@oozcitak/util@10.0.0': {} + '@opentelemetry/api-logs@0.220.0': dependencies: '@opentelemetry/api': 1.9.1 @@ -11074,63 +12151,130 @@ snapshots: '@orama/orama@3.1.18': {} + '@oxc-parser/binding-android-arm-eabi@0.120.0': + optional: true + '@oxc-parser/binding-android-arm-eabi@0.143.0': optional: true + '@oxc-parser/binding-android-arm64@0.120.0': + optional: true + '@oxc-parser/binding-android-arm64@0.143.0': optional: true + '@oxc-parser/binding-darwin-arm64@0.120.0': + optional: true + '@oxc-parser/binding-darwin-arm64@0.143.0': optional: true + '@oxc-parser/binding-darwin-x64@0.120.0': + optional: true + '@oxc-parser/binding-darwin-x64@0.143.0': optional: true + '@oxc-parser/binding-freebsd-x64@0.120.0': + optional: true + '@oxc-parser/binding-freebsd-x64@0.143.0': optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.120.0': + optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': optional: true + '@oxc-parser/binding-linux-arm-musleabihf@0.120.0': + optional: true + '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.120.0': + optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.143.0': optional: true + '@oxc-parser/binding-linux-arm64-musl@0.120.0': + optional: true + '@oxc-parser/binding-linux-arm64-musl@0.143.0': optional: true + '@oxc-parser/binding-linux-ppc64-gnu@0.120.0': + optional: true + '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.120.0': + optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': optional: true + '@oxc-parser/binding-linux-riscv64-musl@0.120.0': + optional: true + '@oxc-parser/binding-linux-riscv64-musl@0.143.0': optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.120.0': + optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.143.0': optional: true + '@oxc-parser/binding-linux-x64-gnu@0.120.0': + optional: true + '@oxc-parser/binding-linux-x64-gnu@0.143.0': optional: true + '@oxc-parser/binding-linux-x64-musl@0.120.0': + optional: true + '@oxc-parser/binding-linux-x64-musl@0.143.0': optional: true + '@oxc-parser/binding-openharmony-arm64@0.120.0': + optional: true + '@oxc-parser/binding-openharmony-arm64@0.143.0': optional: true + '@oxc-parser/binding-wasm32-wasi@0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.120.0': + optional: true + '@oxc-parser/binding-win32-arm64-msvc@0.143.0': optional: true + '@oxc-parser/binding-win32-ia32-msvc@0.120.0': + optional: true + '@oxc-parser/binding-win32-ia32-msvc@0.143.0': optional: true + '@oxc-parser/binding-win32-x64-msvc@0.120.0': + optional: true + '@oxc-parser/binding-win32-x64-msvc@0.143.0': optional: true + '@oxc-project/types@0.120.0': {} + '@oxc-project/types@0.139.0': {} '@oxc-project/types@0.143.0': {} @@ -12019,12 +13163,56 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} + '@solid-primitives/event-listener@2.4.6(solid-js@1.9.15)': + dependencies: + '@solid-primitives/utils': 6.4.1(solid-js@1.9.15) + solid-js: 1.9.15 + + '@solid-primitives/keyboard@1.3.7(solid-js@1.9.15)': + dependencies: + '@solid-primitives/event-listener': 2.4.6(solid-js@1.9.15) + '@solid-primitives/rootless': 1.5.4(solid-js@1.9.15) + '@solid-primitives/utils': 6.4.1(solid-js@1.9.15) + solid-js: 1.9.15 + + '@solid-primitives/resize-observer@2.2.0(solid-js@1.9.15)': + dependencies: + '@solid-primitives/event-listener': 2.4.6(solid-js@1.9.15) + '@solid-primitives/rootless': 1.5.4(solid-js@1.9.15) + '@solid-primitives/static-store': 0.1.4(solid-js@1.9.15) + '@solid-primitives/utils': 6.4.1(solid-js@1.9.15) + solid-js: 1.9.15 + + '@solid-primitives/rootless@1.5.4(solid-js@1.9.15)': + dependencies: + '@solid-primitives/utils': 6.4.1(solid-js@1.9.15) + solid-js: 1.9.15 + + '@solid-primitives/static-store@0.1.4(solid-js@1.9.15)': + dependencies: + '@solid-primitives/utils': 6.4.1(solid-js@1.9.15) + solid-js: 1.9.15 + + '@solid-primitives/utils@6.4.1(solid-js@1.9.15)': + dependencies: + solid-js: 1.9.15 + '@stablelib/base64@1.0.1': {} '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} + '@stylistic/eslint-plugin@5.10.0(eslint@10.7.0(jiti@2.7.0))': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.7.0(jiti@2.7.0)) + '@typescript-eslint/types': 8.65.0 + eslint: 10.7.0(jiti@2.7.0) + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + estraverse: 5.3.0 + picomatch: 4.0.5 + '@supabase/storage-js@2.110.8': dependencies: iceberg-js: 0.8.1 @@ -12186,20 +13374,366 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/postcss@4.3.3': + '@tailwindcss/postcss@4.3.3': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + postcss: 8.5.22 + tailwindcss: 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + + '@tanstack/devtools-bundler-core@0.1.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@tanstack/devtools-client': 0.0.8 + '@tanstack/devtools-event-bus': 0.4.3 + chalk: 5.6.2 + launch-editor: 2.14.1 + magic-string: 0.30.21 + oxc-parser: 0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + picomatch: 4.0.5 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - bufferutil + - utf-8-validate + + '@tanstack/devtools-client@0.0.8': + dependencies: + '@tanstack/devtools-event-client': 0.5.0 + + '@tanstack/devtools-event-bus@0.4.3': + dependencies: + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@tanstack/devtools-event-client@0.5.0': {} + + '@tanstack/devtools-ui@0.7.1(csstype@3.2.3)(solid-js@1.9.15)': + dependencies: + clsx: 2.1.1 + dayjs: 1.11.23 + goober: 2.1.19(csstype@3.2.3) + solid-js: 1.9.15 + transitivePeerDependencies: + - csstype + + '@tanstack/devtools-vite@0.8.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + dependencies: + '@tanstack/devtools-bundler-core': 0.1.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@tanstack/devtools-client': 0.0.8 + '@tanstack/devtools-event-bus': 0.4.3 + chalk: 5.6.2 + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - bufferutil + - utf-8-validate + + '@tanstack/devtools@0.14.2(csstype@3.2.3)(solid-js@1.9.15)': + dependencies: + '@neodrag/core': 3.0.0-next.11 + '@neodrag/solid': 3.0.0-next.11(@neodrag/core@3.0.0-next.11)(solid-js@1.9.15) + '@solid-primitives/event-listener': 2.4.6(solid-js@1.9.15) + '@solid-primitives/keyboard': 1.3.7(solid-js@1.9.15) + '@solid-primitives/resize-observer': 2.2.0(solid-js@1.9.15) + '@tanstack/devtools-client': 0.0.8 + '@tanstack/devtools-event-bus': 0.4.3 + '@tanstack/devtools-ui': 0.7.1(csstype@3.2.3)(solid-js@1.9.15) + clsx: 2.1.1 + goober: 2.1.19(csstype@3.2.3) + solid-js: 1.9.15 + transitivePeerDependencies: + - bufferutil + - csstype + - utf-8-validate + + '@tanstack/eslint-config@0.4.0(@typescript-eslint/utils@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint/js': 10.0.1(eslint@10.7.0(jiti@2.7.0)) + '@stylistic/eslint-plugin': 5.10.0(eslint@10.7.0(jiti@2.7.0)) + eslint: 10.7.0(jiti@2.7.0) + eslint-plugin-import-x: 4.17.1(@typescript-eslint/utils@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0)) + eslint-plugin-n: 17.24.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) + globals: 17.11.0 + typescript-eslint: 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) + vue-eslint-parser: 10.4.1(eslint@10.7.0(jiti@2.7.0)) + transitivePeerDependencies: + - '@typescript-eslint/utils' + - eslint-import-resolver-node + - supports-color + - typescript + + '@tanstack/history@1.162.1': {} + + '@tanstack/query-core@5.101.4': {} + + '@tanstack/react-devtools@0.10.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(solid-js@1.9.15)': + dependencies: + '@tanstack/devtools': 0.14.2(csstype@3.2.3)(solid-js@1.9.15) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + transitivePeerDependencies: + - bufferutil + - csstype + - solid-js + - utf-8-validate + + '@tanstack/react-query@5.101.4(react@19.2.8)': + dependencies: + '@tanstack/query-core': 5.101.4 + react: 19.2.8 + + '@tanstack/react-router-devtools@1.167.1(@tanstack/react-router@1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@tanstack/router-core@1.171.27)(csstype@3.2.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/react-router': 1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/router-devtools-core': 1.168.1(@tanstack/router-core@1.171.27)(csstype@3.2.3) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@tanstack/router-core': 1.171.27 + transitivePeerDependencies: + - csstype + + '@tanstack/react-router@1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/history': 1.162.1 + '@tanstack/react-store': 0.9.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/router-core': 1.171.27 + isbot: 5.2.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@tanstack/react-start-client@1.168.30(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/react-router': 1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/router-core': 1.171.27 + '@tanstack/start-client-core': 1.170.27 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@tanstack/react-start-rsc@0.1.48(crossws@0.4.12(srvx@0.11.22))(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + dependencies: + '@tanstack/react-router': 1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/router-core': 1.171.27 + '@tanstack/router-utils': 1.162.2 + '@tanstack/start-client-core': 1.170.27 + '@tanstack/start-fn-stubs': 1.162.0 + '@tanstack/start-plugin-core': 1.171.39(@tanstack/react-router@1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(crossws@0.4.12(srvx@0.11.22))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@tanstack/start-storage-context': 1.167.29 + pathe: 2.0.3 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + transitivePeerDependencies: + - '@farmfe/core' + - '@rsbuild/core' + - bun-types-no-globals + - crossws + - esbuild + - rolldown + - rollup + - supports-color + - unloader + - vite + - vite-plugin-solid + - webpack + + '@tanstack/react-start-server@1.167.37(crossws@0.4.12(srvx@0.11.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/react-router': 1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/router-core': 1.171.27 + '@tanstack/start-server-core': 1.169.31(crossws@0.4.12(srvx@0.11.22)) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + transitivePeerDependencies: + - crossws + + '@tanstack/react-start@1.168.49(crossws@0.4.12(srvx@0.11.22))(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + dependencies: + '@tanstack/react-router': 1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/react-start-client': 1.168.30(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/react-start-rsc': 0.1.48(crossws@0.4.12(srvx@0.11.22))(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@tanstack/react-start-server': 1.167.37(crossws@0.4.12(srvx@0.11.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/router-utils': 1.162.2 + '@tanstack/start-client-core': 1.170.27 + '@tanstack/start-plugin-core': 1.171.39(@tanstack/react-router@1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(crossws@0.4.12(srvx@0.11.22))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@tanstack/start-server-core': 1.169.31(crossws@0.4.12(srvx@0.11.22)) + pathe: 2.0.3 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - crossws + - esbuild + - react-server-dom-rspack + - rolldown + - rollup + - supports-color + - unloader + - vite-plugin-solid + - webpack + + '@tanstack/react-store@0.9.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.2.8) + + '@tanstack/router-cli@1.167.33': + dependencies: + '@tanstack/router-generator': 1.167.33 + chokidar: 5.0.0 + yargs: 17.7.3 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-core@1.171.27': + dependencies: + '@tanstack/history': 1.162.1 + cookie-es: 3.1.1 + seroval: 1.6.4 + seroval-plugins: 1.6.4(seroval@1.6.4) + + '@tanstack/router-devtools-core@1.168.1(@tanstack/router-core@1.171.27)(csstype@3.2.3)': + dependencies: + '@tanstack/router-core': 1.171.27 + clsx: 2.1.1 + goober: 2.1.19(csstype@3.2.3) + optionalDependencies: + csstype: 3.2.3 + + '@tanstack/router-generator@1.167.33': + dependencies: + '@babel/types': 7.29.7 + '@tanstack/router-core': 1.171.27 + '@tanstack/router-utils': 1.162.2 + '@tanstack/virtual-file-routes': 1.162.0 + jiti: 2.7.0 + magic-string: 0.30.21 + prettier: 3.9.6 + zod: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-plugin@1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + dependencies: + '@babel/core': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + '@tanstack/router-core': 1.171.27 + '@tanstack/router-generator': 1.167.33 + '@tanstack/router-utils': 1.162.2 + chokidar: 5.0.0 + unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + zod: 4.4.3 + optionalDependencies: + '@tanstack/react-router': 1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown + - rollup + - supports-color + - unloader + + '@tanstack/router-utils@1.162.2': + dependencies: + '@babel/generator': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + ansis: 4.3.1 + babel-dead-code-elimination: 1.0.12 + diff: 8.0.4 + pathe: 2.0.3 + tinyglobby: 0.2.17 + transitivePeerDependencies: + - supports-color + + '@tanstack/start-client-core@1.170.27': + dependencies: + '@tanstack/router-core': 1.171.27 + '@tanstack/start-fn-stubs': 1.162.0 + '@tanstack/start-storage-context': 1.167.29 + seroval: 1.6.4 + + '@tanstack/start-fn-stubs@1.162.0': {} + + '@tanstack/start-plugin-core@1.171.39(@tanstack/react-router@1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(crossws@0.4.12(srvx@0.11.22))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/core': 7.29.7 + '@babel/types': 7.29.7 + '@tanstack/router-core': 1.171.27 + '@tanstack/router-generator': 1.167.33 + '@tanstack/router-plugin': 1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@tanstack/router-utils': 1.162.2 + '@tanstack/start-server-core': 1.169.31(crossws@0.4.12(srvx@0.11.22)) + exsolve: 1.1.1 + lightningcss: 1.33.0 + pathe: 2.0.3 + picomatch: 4.0.5 + seroval: 1.6.4 + source-map: 0.7.6 + srvx: 0.11.22 + tinyglobby: 0.2.17 + ufo: 1.6.4 + vitefu: 1.1.3(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + xmlbuilder2: 4.0.3 + zod: 4.4.3 + optionalDependencies: + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - '@tanstack/react-router' + - bun-types-no-globals + - crossws + - esbuild + - rolldown + - rollup + - supports-color + - unloader + - vite-plugin-solid + - webpack + + '@tanstack/start-server-core@1.169.31(crossws@0.4.12(srvx@0.11.22))': dependencies: - '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.3.3 - '@tailwindcss/oxide': 4.3.3 - postcss: 8.5.22 - tailwindcss: 4.3.3 - - '@tanstack/query-core@5.101.4': {} + '@tanstack/history': 1.162.1 + '@tanstack/router-core': 1.171.27 + '@tanstack/start-client-core': 1.170.27 + '@tanstack/start-storage-context': 1.167.29 + fetchdts: 0.1.7 + h3-v2: h3@2.0.1-rc.20(crossws@0.4.12(srvx@0.11.22)) + seroval: 1.6.4 + transitivePeerDependencies: + - crossws - '@tanstack/react-query@5.101.4(react@19.2.8)': + '@tanstack/start-storage-context@1.167.29': dependencies: - '@tanstack/query-core': 5.101.4 - react: 19.2.8 + '@tanstack/router-core': 1.171.27 + + '@tanstack/store@0.9.3': {} + + '@tanstack/virtual-file-routes@1.162.0': {} '@testing-library/dom@10.4.1': dependencies: @@ -12504,6 +14038,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + '@types/node@25.9.5': dependencies: undici-types: 7.24.6 @@ -12635,8 +14173,85 @@ snapshots: '@ungap/structured-clone@1.3.3': {} + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + optional: true + + '@unrs/resolver-binding-android-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + optional: true + '@vercel/oidc@3.2.0': {} + '@vitejs/plugin-react@6.0.4(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + optionalDependencies: + babel-plugin-react-compiler: 1.0.0 + '@vitejs/plugin-react@6.0.4(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -12921,8 +14536,14 @@ snapshots: ansi-regex@6.2.2: {} + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansis@4.3.1: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -13027,6 +14648,15 @@ snapshots: b4a@1.8.1: {} + babel-dead-code-elimination@1.0.12: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + babel-plugin-react-compiler@1.0.0: dependencies: '@babel/types': 7.29.7 @@ -13222,6 +14852,12 @@ snapshots: client-only@0.0.1: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clsx@2.1.1: {} cluster-key-slot@1.1.2: {} @@ -13244,6 +14880,12 @@ snapshots: collapse-white-space@2.1.0: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + comma-separated-tokens@2.0.3: {} commander@11.1.0: {} @@ -13264,6 +14906,8 @@ snapshots: commander@9.5.0: {} + comment-parser@1.4.8: {} + compare-versions@6.1.1: {} compute-scroll-into-view@3.1.1: {} @@ -13313,6 +14957,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@3.1.1: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -13345,6 +14991,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crossws@0.4.12(srvx@0.11.22): + optionalDependencies: + srvx: 0.11.22 + css-in-js-utils@3.1.0: dependencies: hyphenate-style-name: 1.1.0 @@ -13430,6 +15080,10 @@ snapshots: date-fns@4.4.0: {} + dayjs@1.11.23: {} + + db0@0.3.4: {} + debounce-fn@4.0.0: dependencies: mimic-fn: 3.1.0 @@ -13587,6 +15241,8 @@ snapshots: emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} encodeurl@2.0.0: {} @@ -13630,6 +15286,13 @@ snapshots: env-paths@3.0.0: {} + env-runner@0.1.16: + dependencies: + crossws: 0.4.12(srvx@0.11.22) + exsolve: 1.1.1 + httpxy: 0.5.5 + srvx: 0.11.22 + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -13843,10 +15506,46 @@ snapshots: escape-string-regexp@5.0.0: {} + eslint-compat-utils@0.5.1(eslint@10.7.0(jiti@2.7.0)): + dependencies: + eslint: 10.7.0(jiti@2.7.0) + semver: 7.8.5 + eslint-config-prettier@10.1.8(eslint@10.7.0(jiti@2.7.0)): dependencies: eslint: 10.7.0(jiti@2.7.0) + eslint-import-context@0.1.9(unrs-resolver@1.12.2): + dependencies: + get-tsconfig: 4.14.0 + stable-hash-x: 0.2.0 + optionalDependencies: + unrs-resolver: 1.12.2 + + eslint-plugin-es-x@7.8.0(eslint@10.7.0(jiti@2.7.0)): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.7.0(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + eslint: 10.7.0(jiti@2.7.0) + eslint-compat-utils: 0.5.1(eslint@10.7.0(jiti@2.7.0)) + + eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0)): + dependencies: + '@typescript-eslint/types': 8.65.0 + comment-parser: 1.4.8 + debug: 4.4.3 + eslint: 10.7.0(jiti@2.7.0) + eslint-import-context: 0.1.9(unrs-resolver@1.12.2) + is-glob: 4.0.3 + minimatch: 10.2.5 + semver: 7.8.5 + stable-hash-x: 0.2.0 + unrs-resolver: 1.12.2 + optionalDependencies: + '@typescript-eslint/utils': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) + transitivePeerDependencies: + - supports-color + eslint-plugin-jsx-a11y@6.10.2(eslint@10.7.0(jiti@2.7.0)): dependencies: aria-query: 5.3.2 @@ -13866,6 +15565,21 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 + eslint-plugin-n@17.24.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.7.0(jiti@2.7.0)) + enhanced-resolve: 5.24.3 + eslint: 10.7.0(jiti@2.7.0) + eslint-plugin-es-x: 7.8.0(eslint@10.7.0(jiti@2.7.0)) + get-tsconfig: 4.14.0 + globals: 15.15.0 + globrex: 0.1.2 + ignore: 5.3.2 + semver: 7.8.5 + ts-declaration-location: 1.0.7(typescript@6.0.3) + transitivePeerDependencies: + - typescript + eslint-plugin-perfectionist@5.10.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3): dependencies: '@typescript-eslint/utils': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) @@ -14003,6 +15717,8 @@ snapshots: eslint-visitor-keys@3.4.3: {} + eslint-visitor-keys@4.2.1: {} + eslint-visitor-keys@5.0.1: {} eslint@10.7.0(jiti@2.7.0): @@ -14042,6 +15758,12 @@ snapshots: transitivePeerDependencies: - supports-color + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + espree@11.2.0: dependencies: acorn: 8.17.0 @@ -14199,6 +15921,8 @@ snapshots: transitivePeerDependencies: - supports-color + exsolve@1.1.1: {} + ext-list@2.2.2: dependencies: mime-db: 1.54.0 @@ -14256,6 +15980,8 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fetchdts@0.1.7: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -14355,7 +16081,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3): + fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3): dependencies: '@orama/orama': 3.1.18 estree-util-value-to-estree: 3.5.0 @@ -14376,6 +16102,7 @@ snapshots: yaml: 2.9.0 optionalDependencies: '@mdx-js/mdx': 3.1.1 + '@tanstack/react-router': 1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 '@types/mdast': 4.0.4 @@ -14388,14 +16115,14 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-mdx@15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + fumadocs-mdx@15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.1 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) github-slugger: 2.0.0 magic-string: 0.30.21 mdast-util-mdx: 3.0.0 @@ -14421,7 +16148,7 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-ui@16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): + fumadocs-ui@16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.3) @@ -14437,7 +16164,7 @@ snapshots: '@radix-ui/react-tabs': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) class-variance-authority: 0.7.1 cnfast: 0.0.8 - fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) lucide-react: 1.25.0(react@19.2.8) motion: 12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -14481,6 +16208,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-east-asian-width@1.6.0: {} get-intrinsic@1.3.0: @@ -14540,8 +16269,12 @@ snapshots: minipass: 7.1.3 path-scurry: 2.0.2 + globals@15.15.0: {} + globals@16.5.0: {} + globals@17.11.0: {} + globalthis@1.0.4: dependencies: define-properties: 1.2.1 @@ -14556,6 +16289,12 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + globrex@0.1.2: {} + + goober@2.1.19(csstype@3.2.3): + dependencies: + csstype: 3.2.3 + gopd@1.2.0: {} got@14.6.6: @@ -14579,6 +16318,20 @@ snapshots: dependencies: duplexer: 0.1.2 + h3@2.0.1-rc.20(crossws@0.4.12(srvx@0.11.22)): + dependencies: + rou3: 0.8.1 + srvx: 0.11.22 + optionalDependencies: + crossws: 0.4.12(srvx@0.11.22) + + h3@2.0.1-rc.22(crossws@0.4.12(srvx@0.11.22)): + dependencies: + rou3: 0.8.1 + srvx: 0.11.22 + optionalDependencies: + crossws: 0.4.12(srvx@0.11.22) + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -14717,6 +16470,8 @@ snapshots: hono@4.12.31: {} + hookable@6.1.1: {} + hpagent@1.2.0: {} html-encoding-sniffer@6.0.0: @@ -14761,6 +16516,8 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + httpxy@0.5.5: {} + human-signals@2.1.0: {} human-signals@5.0.0: {} @@ -14899,6 +16656,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -15007,6 +16766,8 @@ snapshots: isarray@2.0.5: {} + isbot@5.2.1: {} + isexe@2.0.0: {} isexe@3.1.5: {} @@ -15130,6 +16891,11 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 + launch-editor@2.14.1: + dependencies: + picocolors: 1.1.1 + shell-quote: 1.10.0 + leac@0.6.0: {} levn@0.4.1: @@ -15844,6 +17610,8 @@ snapshots: nanoid@3.3.16: {} + napi-postinstall@0.3.4: {} + natural-compare@1.4.0: {} natural-orderby@5.0.0: {} @@ -15931,6 +17699,61 @@ snapshots: - '@types/node' - babel-plugin-macros + nf3@0.3.24: {} + + nitro@3.0.260610-beta(chokidar@5.0.0)(dotenv@17.4.2)(jiti@2.7.0)(lru-cache@11.5.2)(rollup@4.62.2)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + dependencies: + consola: 3.4.2 + crossws: 0.4.12(srvx@0.11.22) + db0: 0.3.4 + env-runner: 0.1.16 + h3: 2.0.1-rc.22(crossws@0.4.12(srvx@0.11.22)) + hookable: 6.1.1 + nf3: 0.3.24 + ocache: 0.1.5 + ofetch: 2.0.0-alpha.3 + ohash: 2.0.12 + rolldown: 1.1.5 + srvx: 0.11.22 + unenv: 2.0.0-rc.24 + unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4)(lru-cache@11.5.2)(ofetch@2.0.0-alpha.3) + optionalDependencies: + dotenv: 17.4.2 + jiti: 2.7.0 + rollup: 4.62.2 + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@netlify/runtime' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - better-sqlite3 + - chokidar + - drizzle-orm + - idb-keyval + - ioredis + - lru-cache + - miniflare + - mongodb + - mysql2 + - sqlite3 + - uploadthing + - wrangler + node-addon-api@7.1.1: {} node-cron@4.6.0: {} @@ -15995,6 +17818,14 @@ snapshots: obug@2.1.4: {} + ocache@0.1.5: + dependencies: + ohash: 2.0.12 + + ofetch@2.0.0-alpha.3: {} + + ohash@2.0.12: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -16085,6 +17916,34 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + oxc-parser@0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): + dependencies: + '@oxc-project/types': 0.120.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.120.0 + '@oxc-parser/binding-android-arm64': 0.120.0 + '@oxc-parser/binding-darwin-arm64': 0.120.0 + '@oxc-parser/binding-darwin-x64': 0.120.0 + '@oxc-parser/binding-freebsd-x64': 0.120.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.120.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.120.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.120.0 + '@oxc-parser/binding-linux-arm64-musl': 0.120.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.120.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.120.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.120.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.120.0 + '@oxc-parser/binding-linux-x64-gnu': 0.120.0 + '@oxc-parser/binding-linux-x64-musl': 0.120.0 + '@oxc-parser/binding-openharmony-arm64': 0.120.0 + '@oxc-parser/binding-wasm32-wasi': 0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@oxc-parser/binding-win32-arm64-msvc': 0.120.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.120.0 + '@oxc-parser/binding-win32-x64-msvc': 0.120.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + oxc-parser@0.143.0: dependencies: '@oxc-project/types': 0.143.0 @@ -16845,6 +18704,8 @@ snapshots: transitivePeerDependencies: - supports-color + require-directory@2.1.1: {} + require-from-string@2.0.2: {} require-in-the-middle@8.0.1: @@ -16938,6 +18799,8 @@ snapshots: rope-sequence@1.3.4: {} + rou3@0.8.1: {} + router@2.2.0: dependencies: debug: 4.4.3 @@ -17029,6 +18892,18 @@ snapshots: transitivePeerDependencies: - supports-color + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + + seroval-plugins@1.6.4(seroval@1.6.4): + dependencies: + seroval: 1.6.4 + + seroval@1.5.6: {} + + seroval@1.6.4: {} + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -17177,6 +19052,8 @@ snapshots: shebang-regex@3.0.0: {} + shell-quote@1.10.0: {} + shiki@4.3.1: dependencies: '@shikijs/core': 4.3.1 @@ -17262,6 +19139,12 @@ snapshots: - supports-color - utf-8-validate + solid-js@1.9.15: + dependencies: + csstype: 3.2.3 + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + sonner@2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 @@ -17285,6 +19168,10 @@ snapshots: space-separated-tokens@2.0.2: {} + srvx@0.11.22: {} + + stable-hash-x@0.2.0: {} + stack-generator@2.0.10: dependencies: stackframe: 1.3.4 @@ -17333,6 +19220,12 @@ snapshots: string-ts@2.3.1: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -17557,6 +19450,11 @@ snapshots: dependencies: typescript: 6.0.3 + ts-declaration-location@1.0.7(typescript@6.0.3): + dependencies: + picomatch: 4.0.5 + typescript: 6.0.3 + ts-easing@0.2.0: {} ts-interface-checker@0.1.13: {} @@ -17712,6 +19610,8 @@ snapshots: buffer: 5.7.1 through: 2.3.8 + undici-types@6.21.0: {} + undici-types@7.24.6: optional: true @@ -17719,6 +19619,10 @@ snapshots: undici@7.28.0: {} + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + unicorn-magic@0.3.0: {} unified@11.0.5: @@ -17779,6 +19683,51 @@ snapshots: vite: 8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) optional: true + unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.5 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + esbuild: 0.28.1 + rolldown: 1.1.5 + rollup: 4.62.2 + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + + unrs-resolver@1.12.2: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 + '@unrs/resolver-binding-android-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-x64': 1.12.2 + '@unrs/resolver-binding-freebsd-x64': 1.12.2 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + + unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4)(lru-cache@11.5.2)(ofetch@2.0.0-alpha.3): + optionalDependencies: + chokidar: 5.0.0 + db0: 0.3.4 + lru-cache: 11.5.2 + ofetch: 2.0.0-alpha.3 + update-browserslist-db@1.2.3(browserslist@4.28.7): dependencies: browserslist: 4.28.7 @@ -17869,6 +19818,21 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.23 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.1 + yaml: 2.9.0 + vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 @@ -17899,6 +19863,10 @@ snapshots: tsx: 4.23.1 yaml: 2.9.0 + vitefu@1.1.3(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + optionalDependencies: + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 @@ -17976,6 +19944,18 @@ snapshots: vscode-uri@3.1.0: {} + vue-eslint-parser@10.4.1(eslint@10.7.0(jiti@2.7.0)): + dependencies: + debug: 4.4.3 + eslint: 10.7.0(jiti@2.7.0) + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + w3c-keyname@2.2.8: {} w3c-xmlserializer@5.0.0: @@ -18007,8 +19987,7 @@ snapshots: - bufferutil - utf-8-validate - webpack-virtual-modules@0.6.2: - optional: true + webpack-virtual-modules@0.6.2: {} whatwg-mimetype@5.0.0: {} @@ -18078,6 +20057,12 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: {} ws@7.5.13: {} @@ -18091,12 +20076,33 @@ snapshots: xml-name-validator@5.0.0: {} + xmlbuilder2@4.0.3: + dependencies: + '@oozcitak/dom': 2.0.2 + '@oozcitak/infra': 2.0.2 + '@oozcitak/util': 10.0.0 + js-yaml: 4.3.0 + xmlchars@2.2.0: {} + y18n@5.0.8: {} + yallist@3.1.1: {} yaml@2.9.0: {} + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yauzl@3.4.0: dependencies: pend: 1.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 225da7ac4..b476f83ee 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,6 +9,7 @@ allowBuilds: esbuild: true msw: true sharp: true + unrs-resolver: true ignoredBuiltDependencies: - '@swc/core' From bf94b10c641a171df21d5b7601f0f6cb4794eec2 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Wed, 26 Aug 2026 18:54:13 +0200 Subject: [PATCH 2/5] feat: Migrate tanstack start stage 1 --- apps/web/.env.example | 19 + apps/web/eslint.config.mjs | 12 +- apps/web/package.json | 26 +- apps/web/src/lib/session.ts | 53 +++ apps/web/src/routeTree.gen.ts | 122 +++++++ apps/web/src/routes/api-check.tsx | 129 +++++++ apps/web/src/routes/api/$.ts | 25 ++ apps/web/src/routes/session-check.tsx | 60 ++++ apps/web/src/server/api-bridge.ts | 22 ++ apps/web/src/server/fetcher.server.ts | 113 ++++++ apps/web/src/server/vitnode-api.server.ts | 29 ++ apps/web/src/tests/api-bridge-contract.ts | 333 ++++++++++++++++++ apps/web/src/tests/env-plugin.test.ts | 126 +++++++ .../src/tests/fetcher-request-context.test.ts | 273 ++++++++++++++ apps/web/src/tests/hono-bridge.test.ts | 90 +++++ apps/web/src/tests/isolation.test.ts | 200 +++++++++++ apps/web/src/tests/ssr-through-bridge.test.ts | 114 ++++++ apps/web/src/tests/stage-1-runtime.test.ts | 110 ++++++ apps/web/src/vitnode.api.config.ts | 39 ++ apps/web/tsconfig.json | 2 +- apps/web/vite.config.ts | 24 +- apps/web/vitest.config.ts | 27 ++ apps/web/vitnode-env.ts | 69 ++++ packages/vitnode/src/lib/fetcher.ts | 17 +- .../vitnode/src/lib/fetcher/helpers-server.ts | 24 +- packages/vitnode/src/lib/fetcher/raw.test.ts | 196 +++++++++++ .../src/lib/fetcher/request-context.test.ts | 65 ++++ .../src/lib/fetcher/request-context.ts | 62 ++++ .../src/lib/fetcher/set-cookie.test.ts | 77 ++++ .../vitnode/src/lib/fetcher/set-cookie.ts | 91 +++++ pnpm-lock.yaml | 168 ++++++++- turbo.json | 16 +- 32 files changed, 2686 insertions(+), 47 deletions(-) create mode 100644 apps/web/.env.example create mode 100644 apps/web/src/lib/session.ts create mode 100644 apps/web/src/routeTree.gen.ts create mode 100644 apps/web/src/routes/api-check.tsx create mode 100644 apps/web/src/routes/api/$.ts create mode 100644 apps/web/src/routes/session-check.tsx create mode 100644 apps/web/src/server/api-bridge.ts create mode 100644 apps/web/src/server/fetcher.server.ts create mode 100644 apps/web/src/server/vitnode-api.server.ts create mode 100644 apps/web/src/tests/api-bridge-contract.ts create mode 100644 apps/web/src/tests/env-plugin.test.ts create mode 100644 apps/web/src/tests/fetcher-request-context.test.ts create mode 100644 apps/web/src/tests/hono-bridge.test.ts create mode 100644 apps/web/src/tests/isolation.test.ts create mode 100644 apps/web/src/tests/ssr-through-bridge.test.ts create mode 100644 apps/web/src/tests/stage-1-runtime.test.ts create mode 100644 apps/web/src/vitnode.api.config.ts create mode 100644 apps/web/vitest.config.ts create mode 100644 apps/web/vitnode-env.ts create mode 100644 packages/vitnode/src/lib/fetcher/raw.test.ts create mode 100644 packages/vitnode/src/lib/fetcher/request-context.test.ts create mode 100644 packages/vitnode/src/lib/fetcher/request-context.ts create mode 100644 packages/vitnode/src/lib/fetcher/set-cookie.test.ts create mode 100644 packages/vitnode/src/lib/fetcher/set-cookie.ts diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 000000000..b5b5c0c77 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,19 @@ +POSTGRES_URL=postgresql://root:root@localhost:5432/vitnode +REDIS_URL=redis://localhost:6379 + +# This app serves its own API at `/api/*`, so both point at the same origin. +# `@vitnode/core`'s fetcher builds absolute URLs from `NEXT_PUBLIC_API_URL`; +# leaving it equal to the web origin is what keeps API access same-origin. +NEXT_PUBLIC_WEB_URL=http://localhost:3000 +NEXT_PUBLIC_API_URL=http://localhost:3000 + +# === CRON Secret for Internal API Calls === +CRON_SECRET=your-secure-cron-secret-key + +# === Docker Database Postgres === +POSTGRES_USER=root +POSTGRES_PASSWORD=root +POSTGRES_NAME=vitnode + +# === Docker Redis === +REDIS_PASSWORD=root diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index d190aca3c..e2c4cbd83 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -9,7 +9,17 @@ export default [ ...eslintVitNode, ...eslintVitNodeReact, { - ignores: [".source"], + // Build output, not source. `eslint .` walks these otherwise and every file + // in them fails to parse: they are outside `tsconfig.json`'s `include`. + ignores: [ + ".source", + ".nitro/**", + ".output/**", + ".tanstack/**", + "dist/**", + "src/routeTree.gen.ts", + "prettier.config.js", + ], }, { languageOptions: { diff --git a/apps/web/package.json b/apps/web/package.json index e38b9ec77..ecd492437 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,24 +6,39 @@ "#/*": "./src/*" }, "scripts": { - "dev": "vite dev --port 3000", + "dev": "vite dev --port 3001", "generate-routes": "tsr generate", "build": "vite build", "preview": "vite preview", - "lint": "eslint", + "test": "vitest run", + "lint": "eslint .", "format": "prettier --write . && eslint --fix", - "check": "prettier --check ." + "check": "prettier --check .", + "start": "node .output/server/index.mjs", + "typecheck": "tsc --noEmit", + "test:types": "tsc --noEmit", + "lint:fix": "eslint . --fix" }, "dependencies": { + "@hono/zod-openapi": "^1.5.1", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-devtools": "latest", "@tanstack/react-router": "latest", "@tanstack/react-router-devtools": "latest", "@tanstack/react-start": "latest", + "@vitnode/blog": "workspace:*", + "@vitnode/core": "workspace:*", + "@vitnode/example": "workspace:*", + "dotenv": "^17.4.2", + "drizzle-kit": "1.0.0-rc.4", + "drizzle-orm": "1.0.0-rc.4", + "hono": "^4.12.31", + "next-intl": "^4.13.7", "nitro": "3.0.260610-beta", "react": "^19.2.0", "react-dom": "^19.2.0", - "tailwindcss": "^4.1.18" + "tailwindcss": "^4.1.18", + "zod": "^4.4.3" }, "devDependencies": { "@tanstack/devtools-vite": "latest", @@ -36,7 +51,8 @@ "@vitnode/config": "workspace:*", "eslint": "^10.7.0", "typescript": "^6.0.2", - "vite": "^8.0.0" + "vite": "^8.0.0", + "vitest": "^4.1.10" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/apps/web/src/lib/session.ts b/apps/web/src/lib/session.ts new file mode 100644 index 000000000..1dfc3c9d0 --- /dev/null +++ b/apps/web/src/lib/session.ts @@ -0,0 +1,53 @@ +import type { usersModule } from '@vitnode/core/api/modules/users/users.module' + +import { createServerFn } from '@tanstack/react-start' +import { clientModule } from '@vitnode/core/lib/fetcher-client' + +import { fetcherServer } from '#/server/fetcher.server' + +/** + * The users module by type only, so nothing the API needs at runtime - Hono, + * Drizzle, the plugin tree - is reachable from a module the router imports. + * `clientModule` keeps the route paths, methods and response schemas fully + * typed while carrying just the `pluginId` the fetcher reads. + */ +const users = clientModule('@vitnode/core') + +export type SessionApi = Awaited> + +/** + * The signed-in visitor, or `{ user: null }` - the TanStack Start counterpart of + * `@vitnode/core`'s `getSessionApi()`. + * + * A `createServerFn` rather than a route `loader`, because a loader also runs in + * the browser on client-side navigation and there is no request to read there. + * As a server function it runs on the server both times: directly during SSR, + * and over same-origin RPC afterwards - which carries the visitor's cookies to + * this server, where `fetcherServer` forwards them on. + * + * Deliberately not cached, for the same reason `getSessionApi()` is not: the + * response is per-visitor and changes the moment they sign in or edit their + * profile, so there is no shared entry to hand out. The database work behind it + * is cached in Redis by the API instead. + * + * One call per navigation as long as callers read it through the route's loader + * data. Core wraps its version in React's `cache()` because a Next layout, + * header and page each ask for the session while rendering one page; if the same + * shape appears here, that per-render memoisation has to come with it. + */ +export const getSession = createServerFn().handler(async () => { + const response = await fetcherServer(users, { + method: 'get', + module: 'users', + path: '/session', + }) + + // A non-200 (a 429 from the rate limiter, say) carries something other than a + // session, so read it as "nobody is signed in" rather than crashing the render + // while parsing it. One shape either way, so callers never have to narrow. + if (response.status !== 200) { + return { ai: { models: [] }, user: null } + } + + return await response.json() +}) diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts new file mode 100644 index 000000000..25524f746 --- /dev/null +++ b/apps/web/src/routeTree.gen.ts @@ -0,0 +1,122 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' +import { Route as ApiCheckRouteImport } from './routes/api-check' +import { Route as SessionCheckRouteImport } from './routes/session-check' +import { Route as ApiSplatRouteImport } from './routes/api/$' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const ApiCheckRoute = ApiCheckRouteImport.update({ + id: '/api-check', + path: '/api-check', + getParentRoute: () => rootRouteImport, +} as any) +const SessionCheckRoute = SessionCheckRouteImport.update({ + id: '/session-check', + path: '/session-check', + getParentRoute: () => rootRouteImport, +} as any) +const ApiSplatRoute = ApiSplatRouteImport.update({ + id: '/api/$', + path: '/api/$', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/api-check': typeof ApiCheckRoute + '/session-check': typeof SessionCheckRoute + '/api/$': typeof ApiSplatRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/api-check': typeof ApiCheckRoute + '/session-check': typeof SessionCheckRoute + '/api/$': typeof ApiSplatRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/api-check': typeof ApiCheckRoute + '/session-check': typeof SessionCheckRoute + '/api/$': typeof ApiSplatRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/api-check' | '/session-check' | '/api/$' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/api-check' | '/session-check' | '/api/$' + id: '__root__' | '/' | '/api-check' | '/session-check' | '/api/$' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ApiCheckRoute: typeof ApiCheckRoute + SessionCheckRoute: typeof SessionCheckRoute + ApiSplatRoute: typeof ApiSplatRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/api-check': { + id: '/api-check' + path: '/api-check' + fullPath: '/api-check' + preLoaderRoute: typeof ApiCheckRouteImport + parentRoute: typeof rootRouteImport + } + '/session-check': { + id: '/session-check' + path: '/session-check' + fullPath: '/session-check' + preLoaderRoute: typeof SessionCheckRouteImport + parentRoute: typeof rootRouteImport + } + '/api/$': { + id: '/api/$' + path: '/api/$' + fullPath: '/api/$' + preLoaderRoute: typeof ApiSplatRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ApiCheckRoute: ApiCheckRoute, + SessionCheckRoute: SessionCheckRoute, + ApiSplatRoute: ApiSplatRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/apps/web/src/routes/api-check.tsx b/apps/web/src/routes/api-check.tsx new file mode 100644 index 000000000..d0bb558d6 --- /dev/null +++ b/apps/web/src/routes/api-check.tsx @@ -0,0 +1,129 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { getRequestHeader, getRequestUrl } from '@tanstack/react-start/server' + +interface ApiProbe { + body: string + label: string + ok: boolean + path: string + status: number +} + +/** + * Two endpoints of the mounted API, both real: + * + * - the OpenAPI document, registered by `VitNodeAPI` itself, which answers + * without touching the database - so it isolates "is Hono mounted" from "is + * Postgres up"; + * - a plugin route, which runs the whole chain the API always runs: cors, csrf, + * rate limiter, `globalMiddleware` (session lookup included) and the plugin + * router the plugin id resolves to. + */ +const PROBES = [ + { label: 'OpenAPI document (no database)', path: '/api/swagger/doc' }, + { + label: 'Core plugin route (full middleware chain)', + path: '/api/@vitnode/core/middleware', + }, +] as const + +const probeApi = createServerFn().handler(async (): Promise => { + // Same-origin by construction: the API is mounted in this app, so the origin + // of the request being rendered is the origin to call. No `NEXT_PUBLIC_API_URL` + // and no second server involved. + const { origin } = getRequestUrl() + const cookie = getRequestHeader('cookie') + const userAgent = getRequestHeader('user-agent') + + return await Promise.all( + PROBES.map(async ({ label, path }) => { + const headers = new Headers() + // Forwarded so a signed-in SSR render is answered as that user. This is a + // verification page, not the fetcher - real calls go through + // `@vitnode/core`'s fetcher. + if (cookie) headers.set('cookie', cookie) + if (userAgent) headers.set('user-agent', userAgent) + + try { + const response = await fetch(new URL(path, origin), { headers }) + const body = await response.text() + + return { + body: body.slice(0, 600), + label, + ok: response.ok, + path, + status: response.status, + } + } catch (error) { + return { + body: error instanceof Error ? error.message : String(error), + label, + ok: false, + path, + status: 0, + } + } + }), + ) +}) + +export const Route = createFileRoute('/api-check')({ + loader: async () => probeApi(), + component: ApiCheck, +}) + +function ApiCheck() { + const probes = Route.useLoaderData() + + return ( +
+
+

+ Hono API bridge +

+

+ Rendered on the server. Each row is a same-origin request this app + made to /api/* during SSR, answered by the VitNode Hono + application mounted in this process. +

+
+ +
    + {probes.map((probe) => ( +
  • +
    +

    + {probe.label} +

    + + + {probe.ok ? 'Succeeded with status ' : 'Failed with status '} + + {probe.status || 'no response'} + +
    + + GET {probe.path} + +
    +
    +                {probe.body || '(empty body)'}
    +              
    +
    +
  • + ))} +
+
+ ) +} diff --git a/apps/web/src/routes/api/$.ts b/apps/web/src/routes/api/$.ts new file mode 100644 index 000000000..bb7d76169 --- /dev/null +++ b/apps/web/src/routes/api/$.ts @@ -0,0 +1,25 @@ +import { createFileRoute } from '@tanstack/react-router' + +import { apiBridge } from '#/server/vitnode-api.server' + +/** + * `/api/*` - the existing VitNode Hono application, mounted. + * + * `server` is the only option on this route on purpose. TanStack Start prunes a + * route file whose sole option is `server` out of the client route tree + * entirely (and its client code-splitter deletes the `server` node on top of + * that), so none of the API - Hono, Drizzle, the plugins - can reach the browser + * bundle. The `.server.ts` import is refused by import protection if that ever + * stops being true. + * + * `ANY` rather than a handler per method: routing, OpenAPI, middleware, auth, + * plugin mounting and error handling all stay inside Hono, exactly as they are + * when the same app runs standalone in `apps/api` or under the Next.js catch-all + * in `apps/docs`. + */ +export const Route = createFileRoute('/api/$')({ + server: { + handlers: ({ createHandlers }) => + createHandlers({ ANY: async ({ request }) => apiBridge(request) }), + }, +}) diff --git a/apps/web/src/routes/session-check.tsx b/apps/web/src/routes/session-check.tsx new file mode 100644 index 000000000..dfc946c58 --- /dev/null +++ b/apps/web/src/routes/session-check.tsx @@ -0,0 +1,60 @@ +import { createFileRoute } from '@tanstack/react-router' + +import { getSession } from '#/lib/session' + +/** + * Whether the API recognises the visitor rendering this page. + * + * The one thing Stage 1 has to be able to show: `@vitnode/core`'s fetcher, + * called during SSR, answered for the *browser's* session rather than for the + * server. Signed out it reads "anonymous"; sign in through any VitNode app on + * this host and it names the user - without this page knowing anything about + * authentication. + */ +export const Route = createFileRoute('/session-check')({ + component: SessionCheck, + loader: async () => getSession(), +}) + +function SessionCheck() { + const { user } = Route.useLoaderData() + + return ( +
+
+

+ Session forwarding +

+

+ Rendered on the server. The API was asked who is signed in through{' '} + @vitnode/core's fetcher, with this request's + cookies, user-agent and forwarded IP attached. +

+
+ +
+
+
+ Identified as +
+
+ {user ? user.name : 'anonymous'} +
+
+ + {user ? ( +
+
User ID
+
{user.id}
+
+ ) : null} +
+
+ ) +} diff --git a/apps/web/src/server/api-bridge.ts b/apps/web/src/server/api-bridge.ts new file mode 100644 index 000000000..491d59177 --- /dev/null +++ b/apps/web/src/server/api-bridge.ts @@ -0,0 +1,22 @@ +/** + * The seam between the web runtime and the Hono API. + * + * A bridge is handed the `Request` the browser (or an SSR loader) made to + * `/api/*` on this origin and answers it with whatever Hono answers. It is one + * line, and that is the point: status, body, every `Set-Cookie`, the cookie and + * `x-forwarded-*` headers the API reads are all already correct on the request + * the platform built, and stay correct exactly as long as nobody rebuilds them. + * + * `src/tests/api-bridge-contract.ts` holds this to that behaviour, including the + * ways a rebuilt request loses it. + */ +export type ApiBridge = (request: Request) => Promise | Response + +interface FetchableApp { + fetch: (request: Request) => Promise | Response +} + +export const createApiBridge = + (app: FetchableApp): ApiBridge => + async (request) => + app.fetch(request) diff --git a/apps/web/src/server/fetcher.server.ts b/apps/web/src/server/fetcher.server.ts new file mode 100644 index 000000000..c58b1eee1 --- /dev/null +++ b/apps/web/src/server/fetcher.server.ts @@ -0,0 +1,113 @@ +import '@tanstack/react-start/server-only' +import { + getRequestHeaders, + getRequestIP, + setCookie, +} from '@tanstack/react-start/server' +import { coreFetcher } from '@vitnode/core/lib/fetcher/core' +import { buildForwardedHeaders } from '@vitnode/core/lib/fetcher/request-context' +import { parseSetCookies } from '@vitnode/core/lib/fetcher/set-cookie' +import { config } from 'dotenv' + +/** + * `@vitnode/core`'s fetcher builds absolute URLs from + * `process.env.NEXT_PUBLIC_API_URL`, read lazily on every call, so that value + * has to be in `process.env` before the first request - not before the first + * import. + * + * Vite's config loads `.env` into `process.env` for `vite dev` and `vite build`. + * This covers `node .output/server/index.mjs`, where Vite is not involved, the + * same way `apps/api` does it. dotenv does not overwrite what is already set, so + * a platform that injects real environment variables still wins. + */ +config({ quiet: true }) + +if (!process.env.NEXT_PUBLIC_API_URL && process.env.NODE_ENV === 'production') { + // The fallback is `http://localhost:3000`, which in production is either + // nothing at all or - worse - this very server, so the failure reads as a + // hanging page rather than a missing variable. + // eslint-disable-next-line no-console + console.warn( + '\x1b[34m[VitNode]\x1b[0m \x1b[33mNEXT_PUBLIC_API_URL is not set; API calls will fall back to http://localhost:3000\x1b[0m', + ) +} + +/** + * The request state this app forwards to the API, read off the request being + * rendered. + * + * The API derives who is asking from `Cookie`, the device record from + * `user-agent`, and the rate-limit key and audit IP from `x-forwarded-for`. Send + * none of it and every SSR render is answered as an anonymous visitor sharing a + * single rate-limit bucket - so this is the difference between signed-in HTML and + * signed-out HTML, not a nicety. + * + * The allowlist itself lives in `@vitnode/core` because Next's `fetcher()` sends + * exactly the same set; only the reading differs. Nothing else is copied + * across: `host` and `content-length` describe the page request rather than the + * API call, and `origin`, `referer` and `authorization` are values the API + * trusts, so forwarding whatever a visitor put in them would hand them state + * they should not control. + */ +export const getForwardedApiHeaders = ({ + captchaToken, +}: { captchaToken?: string } = {}): Record => { + const headers = getRequestHeaders() + + return buildForwardedHeaders({ + captchaToken, + cookie: headers.get('cookie'), + // The header first, verbatim, chain included: that is what the API stores + // and what Next's `fetcher()` sends, and re-deriving it would log this + // server's hop as the visitor's IP. `getRequestIP()` is the fallback for a + // directly-exposed server, where there is no proxy to have written one - + // better than the `0.0.0.0` the header's absence would otherwise mean. + forwardedFor: headers.get('x-forwarded-for') ?? getRequestIP(), + userAgent: headers.get('user-agent'), + }) +} + +/** + * `coreFetcher` with this request's context attached - the TanStack Start + * equivalent of `@vitnode/core/lib/fetcher`, which reads the same state through + * `next/headers` and is unusable here. + * + * Typed as `typeof coreFetcher` so route literals, methods and response schemas + * keep inferring exactly as they do everywhere else in VitNode. + * + * Server-side only, and only inside a request: the headers come from the request + * currently being handled, so a module-scope call has nothing to read. In + * TanStack Start that means a `createServerFn` handler or a server route - not a + * route `loader`, which also runs in the browser on client-side navigation. + */ +export const fetcherServer: typeof coreFetcher = async ( + moduleReturn, + options, +) => + coreFetcher(moduleReturn, { + ...options, + additionalHeaders: { + ...getForwardedApiHeaders(), + ...options.additionalHeaders, + }, + }) + +/** + * Copies the cookies the API just minted onto this response - the counterpart of + * `allowSaveCookies` on Next's `fetcher()`. + * + * Sign-in, sign-up, sign-out and the SSO callback all answer with a + * `Set-Cookie`, and so does any first call from a browser with no device cookie. + * Those land on the API's response to *this server*, which the browser never + * sees, so without this the visitor is signed in for exactly one render. + * + * Call it only for a response you meant to trust: it writes every cookie the + * response carries. + */ +export const saveApiCookies = (response: Response): void => { + for (const { name, options, value } of parseSetCookies( + response.headers.getSetCookie(), + )) { + setCookie(name, value, options) + } +} diff --git a/apps/web/src/server/vitnode-api.server.ts b/apps/web/src/server/vitnode-api.server.ts new file mode 100644 index 000000000..c417a28fe --- /dev/null +++ b/apps/web/src/server/vitnode-api.server.ts @@ -0,0 +1,29 @@ +import '@tanstack/react-start/server-only' +import { OpenAPIHono } from '@hono/zod-openapi' +import { VitNodeAPI } from '@vitnode/core/api/config' + +import { createApiBridge } from '#/server/api-bridge' +import { vitNodeApiConfig } from '#/vitnode.api.config' + +// The same two lines `apps/api` and `apps/docs` run. `basePath("/api")` is what +// makes the mount point part of the API's own routing, so every path the plugins +// register - `/api/@vitnode/core/...` - resolves identically here. +const createVitNodeApi = () => { + const app = new OpenAPIHono().basePath('/api') + + VitNodeAPI({ app, vitNodeApiConfig }) + + return app +} + +// `VitNodeAPI` is a boot step, not a request step: it opens the Redis client, +// starts the cron scheduler and registers every plugin route. Vite re-evaluates +// server modules on HMR, so the instance is parked on `globalThis` to keep one +// API per process instead of one per edit. +const cache = globalThis as typeof globalThis & { + __vitnodeApi?: ReturnType +} + +export const vitNodeApi = (cache.__vitnodeApi ??= createVitNodeApi()) + +export const apiBridge = createApiBridge(vitNodeApi) diff --git a/apps/web/src/tests/api-bridge-contract.ts b/apps/web/src/tests/api-bridge-contract.ts new file mode 100644 index 000000000..5d3eb2289 --- /dev/null +++ b/apps/web/src/tests/api-bridge-contract.ts @@ -0,0 +1,333 @@ +import { Hono } from 'hono' +import { beforeEach, describe, expect, it } from 'vitest' + +/** + * The seam Stage 1 is built around. + * + * A bridge takes the request the browser (or the SSR loader) made to + * `/api/*` on the web origin and answers it with whatever the Hono VitNode + * API answers. Everything in this file is stated as behaviour of that + * function, so any implementation - a TanStack server route, a Nitro + * handler, a plain `fetch` proxy - can be held to it. + */ +export type ApiBridge = (request: Request) => Promise | Response + +/** + * Builds the bridge for one Hono app. Tests hand in the fixture app below so + * the contract can assert against known routes instead of the real API, which + * needs a database. + */ +export type ApiBridgeFactory = (app: Hono) => ApiBridge + +export interface ReceivedRequest { + body: string + headers: Record + method: string + path: string + search: string + url: string +} + +export interface ApiFixture { + app: Hono + received: ReceivedRequest[] +} + +/** + * The plugin id every VitNode route is namespaced under. It contains an `@` + * and a `/`, which is exactly the shape a bridge that re-encodes the path + * quietly breaks - so the fixture uses the real one. + */ +export const PLUGIN_ID = '@vitnode/core' + +export const API_BASE = `/api/${PLUGIN_ID}` + +/** + * A stand-in for the VitNode API, mounted the way `apps/api` mounts it: + * `basePath("/api")` with the plugin's router underneath. It records every + * request it is handed so the contract can assert on what actually crossed + * the boundary. + */ +export const createApiFixture = (): ApiFixture => { + const received: ReceivedRequest[] = [] + + const plugin = new Hono() + + plugin.use('*', async (c, next) => { + const url = new URL(c.req.url) + received.push({ + body: await c.req.raw.clone().text(), + headers: Object.fromEntries(c.req.raw.headers.entries()), + method: c.req.method, + path: url.pathname, + search: url.search, + url: c.req.url, + }) + + return next() + }) + + plugin.all('/echo', (c) => { + const url = new URL(c.req.url) + + return c.json({ + method: c.req.method, + path: url.pathname, + query: url.searchParams.getAll('q'), + search: url.search, + }) + }) + + plugin.post('/body', async (c) => c.json(await c.req.json(), 201)) + + plugin.get('/status/:code', (c) => { + const code = Number(c.req.param('code')) + + return c.json({ code }, code as 200) + }) + + plugin.get('/text', (c) => c.text('plain body')) + + plugin.get('/empty', (c) => c.body(null, 204)) + + plugin.get('/cookies', (c) => { + c.header('set-cookie', 'session=abc; Path=/; HttpOnly', { append: true }) + c.header('set-cookie', 'device=xyz; Path=/; HttpOnly', { append: true }) + c.header('x-vitnode-marker', 'from-hono') + + return c.json({ ok: true }) + }) + + const app = new Hono().basePath('/api') + app.route(`/${PLUGIN_ID}`, plugin) + + return { app, received } +} + +const WEB_ORIGIN = 'https://web.test' + +const request = (path: string, init?: RequestInit): Request => + new Request(new URL(path, WEB_ORIGIN), init) + +/** + * Every assertion Stage 1's `/api/*` integration has to satisfy. + * + * Point it at a bridge factory and the whole suite runs against it, so the + * reference implementation and the app's real one are held to one spec. + */ +export const describeApiBridgeContract = ( + label: string, + createBridge: ApiBridgeFactory, +): void => { + describe(`api bridge contract: ${label}`, () => { + let fixture: ApiFixture + let bridge: ApiBridge + + beforeEach(() => { + fixture = createApiFixture() + bridge = createBridge(fixture.app) + }) + + describe('reaches the Hono application', () => { + it('answers a GET with the Hono handler response', async () => { + const res = await bridge(request(`${API_BASE}/echo`)) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + method: 'GET', + path: `${API_BASE}/echo`, + query: [], + search: '', + }) + }) + + it('keeps the plugin id intact in the path', async () => { + // `@vitnode/core` survives only if the bridge forwards the pathname + // rather than rebuilding it through an encoder. + await bridge(request(`${API_BASE}/echo`)) + + expect(fixture.received.at(0)?.path).toBe(`${API_BASE}/echo`) + }) + + it.each(['DELETE', 'PATCH', 'POST', 'PUT'])( + 'forwards a %s request', + async (method) => { + const res = await bridge(request(`${API_BASE}/echo`, { method })) + + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ method }) + }, + ) + + it('forwards the request body', async () => { + const res = await bridge( + request(`${API_BASE}/body`, { + method: 'POST', + body: JSON.stringify({ name: 'VitNode', tags: ['a', 'b'] }), + headers: { 'content-type': 'application/json' }, + }), + ) + + expect(res.status).toBe(201) + expect(await res.json()).toEqual({ name: 'VitNode', tags: ['a', 'b'] }) + }) + + it('preserves the query string, repeats and encoding included', async () => { + const res = await bridge( + request(`${API_BASE}/echo?q=one&q=two&search=a%20b%26c`), + ) + + expect(await res.json()).toMatchObject({ query: ['one', 'two'] }) + expect( + new URLSearchParams(fixture.received.at(0)?.search).get('search'), + ).toBe('a b&c') + }) + }) + + describe('preserves the response', () => { + it.each([200, 201, 400, 401, 403, 404, 409, 422, 500])( + 'passes status %i through untouched', + async (code) => { + const res = await bridge(request(`${API_BASE}/status/${code}`)) + + expect(res.status).toBe(code) + expect(await res.json()).toEqual({ code }) + }, + ) + + it('keeps a 204 empty', async () => { + const res = await bridge(request(`${API_BASE}/empty`)) + + expect(res.status).toBe(204) + expect(await res.text()).toBe('') + }) + + it('keeps a non-JSON body and its content type', async () => { + const res = await bridge(request(`${API_BASE}/text`)) + + expect(res.headers.get('content-type')).toContain('text/plain') + expect(await res.text()).toBe('plain body') + }) + + it('keeps every Set-Cookie the API sent', async () => { + // Auth lives in these. A bridge that copies headers through a plain + // object collapses them to one and silently drops the device cookie. + const res = await bridge(request(`${API_BASE}/cookies`)) + + expect(res.headers.getSetCookie()).toEqual([ + 'session=abc; Path=/; HttpOnly', + 'device=xyz; Path=/; HttpOnly', + ]) + }) + + it('keeps custom response headers', async () => { + const res = await bridge(request(`${API_BASE}/cookies`)) + + expect(res.headers.get('x-vitnode-marker')).toBe('from-hono') + }) + }) + + describe('unknown API routes', () => { + it('answers an unknown path under a known plugin with the API 404', async () => { + const res = await bridge(request(`${API_BASE}/nope`)) + + expect(res.status).toBe(404) + // Not the SPA shell: an unknown API path has to fail as an API call, + // otherwise a typo'd fetch resolves with HTML and a 200-shaped body. + expect(res.headers.get('content-type') ?? '').not.toContain('text/html') + }) + + it('answers an unknown plugin with the API 404', async () => { + const res = await bridge(request('/api/@vitnode/unknown/echo')) + + expect(res.status).toBe(404) + }) + + it('answers `/api` itself with the API 404 rather than the app shell', async () => { + const res = await bridge(request('/api')) + + expect(res.status).toBe(404) + }) + }) + + describe('forwards request context', () => { + it('forwards the Cookie header verbatim', async () => { + const cookie = 'vitnode_session=s3cr3t; vitnode_device=d3v1c3' + await bridge(request(`${API_BASE}/echo`, { headers: { cookie } })) + + expect(fixture.received.at(0)?.headers.cookie).toBe(cookie) + }) + + it('forwards the User-Agent header verbatim', async () => { + const userAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' + await bridge( + request(`${API_BASE}/echo`, { headers: { 'user-agent': userAgent } }), + ) + + expect(fixture.received.at(0)?.headers['user-agent']).toBe(userAgent) + }) + + it.each([ + 'x-forwarded-for', + 'x-real-ip', + 'cf-connecting-ip', + 'true-client-ip', + ])('forwards the %s header verbatim', async (header) => { + // `globalMiddleware` reads this list in order to fill `c.get("ipAddress")`, + // which the rate limiter, the device log and sign-up all persist. + await bridge( + request(`${API_BASE}/echo`, { headers: { [header]: '203.0.113.7' } }), + ) + + expect(fixture.received.at(0)?.headers[header]).toBe('203.0.113.7') + }) + + it('keeps the client first in a proxy chain', async () => { + await bridge( + request(`${API_BASE}/echo`, { + headers: { 'x-forwarded-for': '203.0.113.7, 70.41.3.18' }, + }), + ) + + // The API takes the header as-is and stores it, so a bridge that + // prepends its own hop would log the proxy as the user's IP. + expect(fixture.received.at(0)?.headers['x-forwarded-for']).toBe( + '203.0.113.7, 70.41.3.18', + ) + }) + + it('does not invent a client IP when the request has none', async () => { + await bridge(request(`${API_BASE}/echo`)) + + expect( + fixture.received.at(0)?.headers['x-forwarded-for'], + ).toBeUndefined() + }) + + it('forwards VitNode custom headers', async () => { + await bridge( + request(`${API_BASE}/echo`, { + headers: { 'x-vitnode-captcha-token': 'token-123' }, + }), + ) + + expect(fixture.received.at(0)?.headers['x-vitnode-captcha-token']).toBe( + 'token-123', + ) + }) + + it('forwards the Authorization header', async () => { + await bridge( + request(`${API_BASE}/echo`, { + headers: { authorization: 'Bearer abc.def' }, + }), + ) + + expect(fixture.received.at(0)?.headers.authorization).toBe( + 'Bearer abc.def', + ) + }) + }) + }) +} diff --git a/apps/web/src/tests/env-plugin.test.ts b/apps/web/src/tests/env-plugin.test.ts new file mode 100644 index 000000000..4a6f1a935 --- /dev/null +++ b/apps/web/src/tests/env-plugin.test.ts @@ -0,0 +1,126 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { vitNodeEnv } from '../../vitnode-env' + +const ENV_FILE = [ + 'NEXT_PUBLIC_API_URL=https://api.example.test', + 'NEXT_PUBLIC_WEB_URL=https://web.example.test', + 'NEXT_PUBLIC_UNLISTED=also-public-by-name', + 'POSTGRES_URL=postgresql://root:hunter2@db.internal:5432/vitnode', + 'CRON_SECRET=super-secret', + 'REDIS_PASSWORD=another-secret', +].join('\n') + +const TOUCHED = [ + 'CRON_SECRET', + 'NEXT_PUBLIC_API_URL', + 'NEXT_PUBLIC_UNLISTED', + 'NEXT_PUBLIC_WEB_URL', + 'POSTGRES_URL', + 'REDIS_PASSWORD', +] + +/** Calls the plugin's `config` hook the way Vite calls it. */ +const runConfig = async (root: string) => { + const { config } = vitNodeEnv() + if (typeof config !== 'function') throw new Error('expected a config hook') + + return config.call( + // The hook only reads `root` and only returns config, so the plugin context + // Vite would pass is not involved. + undefined as never, + { root }, + { command: 'build', mode: 'production' }, + ) +} + +const clientDefine = ( + result: Awaited>, +): Record => + (result?.environments?.client?.define ?? {}) as Record + +describe('vitNodeEnv', () => { + let root: string + const saved = new Map() + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'vitnode-env-')) + writeFileSync(join(root, '.env'), ENV_FILE) + for (const key of TOUCHED) { + saved.set(key, process.env[key]) + delete process.env[key] + } + }) + + afterEach(() => { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + saved.clear() + }) + + it('loads the whole .env into process.env for the server', async () => { + await runConfig(root) + + // Including the values only the API needs - it runs in this process. + expect(process.env.POSTGRES_URL).toBe( + 'postgresql://root:hunter2@db.internal:5432/vitnode', + ) + expect(process.env.NEXT_PUBLIC_API_URL).toBe('https://api.example.test') + }) + + it('lets a real environment variable win over the .env file', async () => { + process.env.NEXT_PUBLIC_API_URL = 'https://from-the-platform.test' + + await runConfig(root) + + expect(process.env.NEXT_PUBLIC_API_URL).toBe( + 'https://from-the-platform.test', + ) + }) + + it('inlines the API and web URLs into the client bundle', async () => { + expect(clientDefine(await runConfig(root))).toStrictEqual({ + 'process.env.NEXT_PUBLIC_API_URL': '"https://api.example.test"', + 'process.env.NEXT_PUBLIC_WEB_URL': '"https://web.example.test"', + }) + }) + + it('publishes nothing to the client beyond the two listed keys', async () => { + // The point of an explicit list. A secret reaching a browser bundle is not + // recoverable by rotating a build, and `NEXT_PUBLIC_UNLISTED` shows that + // even a public-looking name is not enough to get there. + const define = clientDefine(await runConfig(root)) + + for (const secret of ['POSTGRES_URL', 'CRON_SECRET', 'REDIS_PASSWORD']) { + expect(define).not.toHaveProperty(`process.env.${secret}`) + } + expect(define).not.toHaveProperty('process.env.NEXT_PUBLIC_UNLISTED') + expect(JSON.stringify(define)).not.toContain('hunter2') + }) + + it('leaves the server bundle reading the live environment', async () => { + // Nothing is defined for `ssr`, so `CONFIG`'s lazy getters keep reading + // `process.env` at request time and a built server can be pointed at a + // different API by its host. + const result = await runConfig(root) + + expect(result?.define).toBeUndefined() + expect(result?.environments?.ssr).toBeUndefined() + }) + + it('replaces an unset key with undefined rather than leaving the read in', async () => { + writeFileSync(join(root, '.env'), 'POSTGRES_URL=postgresql://only/this') + + // Left in place, `process.env.NEXT_PUBLIC_API_URL` throws in a browser + // instead of falling through to the default the core config has for it. + expect(clientDefine(await runConfig(root))).toStrictEqual({ + 'process.env.NEXT_PUBLIC_API_URL': 'undefined', + 'process.env.NEXT_PUBLIC_WEB_URL': 'undefined', + }) + }) +}) diff --git a/apps/web/src/tests/fetcher-request-context.test.ts b/apps/web/src/tests/fetcher-request-context.test.ts new file mode 100644 index 000000000..cfda10d4f --- /dev/null +++ b/apps/web/src/tests/fetcher-request-context.test.ts @@ -0,0 +1,273 @@ +import { requestHandler } from '@tanstack/react-start/server' +import { Hono } from 'hono' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + fetcherServer, + getForwardedApiHeaders, + saveApiCookies, +} from '#/server/fetcher.server' + +import { API_BASE, PLUGIN_ID } from './api-bridge-contract' + +const WEB_ORIGIN = 'https://web.test' + +/** + * The fetcher against a made-up route table. + * + * These tests are about what crosses the wire, so they call routes the fixture + * below defines rather than the real ones, and the route-literal inference is + * out of the way. `src/lib/session.ts` is where that inference is exercised for + * real - it only compiles if the module, path and method line up. + */ +const callFetcher = fetcherServer as unknown as ( + moduleReturn: { pluginId: string }, + options: { method: string; module: string; path: string }, +) => Promise + +/** The real one drags in Hono, Drizzle and the plugin tree; only `pluginId` is read. */ +const usersModule = { pluginId: PLUGIN_ID } + +interface Recorded { + headers: Record + path: string +} + +/** + * A stand-in for the mounted API that records what reached it and answers with + * the two cookies the real one mints on a first request. + */ +const createApi = (recorded: Recorded[]) => { + const plugin = new Hono() + + plugin.get('/users/session', (c) => { + recorded.push({ + headers: Object.fromEntries(c.req.raw.headers.entries()), + path: new URL(c.req.url).pathname, + }) + c.header('set-cookie', 'vitnode_auth=token; Path=/; HttpOnly', { + append: true, + }) + c.header('set-cookie', 'vitnode_device=device; Path=/; HttpOnly', { + append: true, + }) + + return c.json({ user: { id: 7, name: 'Test' } }) + }) + + plugin.get('/users/rate-limited', (c) => + c.json({ message: 'slow down' }, 429), + ) + + const app = new Hono().basePath('/api') + app.route(`/${PLUGIN_ID}`, plugin) + + return app +} + +/** + * Runs `handler` the way the server runtime runs a request, so the + * `getRequest*` helpers have a request to read - the same `requestHandler` that + * wraps every real TanStack Start request. + */ +const withRequest = async ( + init: { headers?: Record }, + handler: () => Promise | T, +): Promise<{ result: T; setCookie: string[] }> => { + let result!: T + + const response = await requestHandler(async () => { + result = await handler() + + return new Response(null, { status: 204 }) + })(new Request(`${WEB_ORIGIN}/session-check`, init), {}) + + return { result, setCookie: response.headers.getSetCookie() } +} + +describe('SSR request context reaches the API', () => { + let recorded: Recorded[] + const realFetch = globalThis.fetch + + beforeEach(() => { + recorded = [] + const api = createApi(recorded) + process.env.NEXT_PUBLIC_API_URL = WEB_ORIGIN + // The fetcher builds an absolute URL and calls `fetch`; the API is answered + // in-process here so the test stays a unit test with no server to start. + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => + api.fetch(new Request(input, init)) + }) + + afterEach(() => { + globalThis.fetch = realFetch + }) + + describe('getForwardedApiHeaders', () => { + it('forwards the cookie, user-agent and forwarded IP of the page request', async () => { + const { result } = await withRequest( + { + headers: { + authorization: 'Bearer should-not-travel', + cookie: 'vitnode_auth=s3cr3t; vitnode_device=d3v1c3', + host: 'web.test', + origin: WEB_ORIGIN, + referer: `${WEB_ORIGIN}/`, + 'user-agent': 'Mozilla/5.0 (SSR test)', + 'x-forwarded-for': '203.0.113.7, 70.41.3.18', + }, + }, + () => getForwardedApiHeaders(), + ) + + expect(result).toStrictEqual({ + Cookie: 'vitnode_auth=s3cr3t; vitnode_device=d3v1c3', + 'user-agent': 'Mozilla/5.0 (SSR test)', + // Verbatim, chain included: the API stores this value, so re-deriving it + // would record this server's hop as the visitor's IP. + 'x-forwarded-for': '203.0.113.7, 70.41.3.18', + }) + }) + + it('forwards nothing the API trusts but the request does not own', async () => { + const { result } = await withRequest( + { + headers: { + authorization: 'Bearer should-not-travel', + 'content-length': '0', + host: 'web.test', + origin: 'https://attacker.test', + referer: 'https://attacker.test/', + 'x-vitnode-captcha-token': 'not-from-the-client', + }, + }, + () => getForwardedApiHeaders(), + ) + + expect(Object.keys(result).sort()).toStrictEqual([ + 'Cookie', + 'user-agent', + 'x-forwarded-for', + ]) + }) + + it('sends the captcha token the caller passes, not one off the request', async () => { + const { result } = await withRequest( + { headers: { 'x-vitnode-captcha-token': 'spoofed' } }, + () => getForwardedApiHeaders({ captchaToken: 'solved-by-the-client' }), + ) + + expect(result['x-vitnode-captcha-token']).toBe('solved-by-the-client') + }) + + it('falls back rather than sending an empty user-agent or IP', async () => { + const { result } = await withRequest({}, () => getForwardedApiHeaders()) + + expect(result).toStrictEqual({ + Cookie: '', + 'user-agent': 'node', + 'x-forwarded-for': '0.0.0.0', + }) + }) + }) + + describe('fetcherServer', () => { + it('reaches the route with the plugin id intact', async () => { + const { result } = await withRequest({}, async () => + callFetcher(usersModule, { + method: 'get', + module: 'users', + path: '/session', + }), + ) + + expect(result.status).toBe(200) + expect(recorded.at(0)?.path).toBe(`${API_BASE}/users/session`) + }) + + it('carries the visitor session into the call', async () => { + const cookie = 'vitnode_auth=s3cr3t; vitnode_device=d3v1c3' + await withRequest( + { + headers: { + cookie, + 'user-agent': 'Mozilla/5.0 (SSR test)', + 'x-forwarded-for': '203.0.113.7', + }, + }, + async () => + callFetcher(usersModule, { + method: 'get', + module: 'users', + path: '/session', + }), + ) + + // Without this the API answers as an anonymous visitor: signed-out HTML + // for a signed-in user, and every render sharing one rate-limit bucket. + expect(recorded.at(0)?.headers).toMatchObject({ + cookie, + 'user-agent': 'Mozilla/5.0 (SSR test)', + 'x-forwarded-for': '203.0.113.7', + }) + }) + + it('does not leak the page request headers the allowlist leaves out', async () => { + await withRequest( + { headers: { authorization: 'Bearer nope', origin: WEB_ORIGIN } }, + async () => + callFetcher(usersModule, { + method: 'get', + module: 'users', + path: '/session', + }), + ) + + const headers = recorded.at(0)?.headers ?? {} + expect(headers.authorization).toBeUndefined() + expect(headers.origin).toBeUndefined() + }) + + it('surfaces a non-200 to the caller instead of throwing', async () => { + const { result } = await withRequest({}, async () => + callFetcher(usersModule, { + method: 'get', + module: 'users', + path: '/rate-limited', + }), + ) + + expect(result.status).toBe(429) + }) + }) + + describe('saveApiCookies', () => { + it('puts every cookie the API minted on this response', async () => { + const { setCookie } = await withRequest({}, async () => { + const response = await callFetcher(usersModule, { + method: 'get', + module: 'users', + path: '/session', + }) + + saveApiCookies(response) + }) + + // Both, not just the last one: the session cookie signs the visitor in and + // the device cookie is half of the key the session is stored under, so + // losing either signs them straight back out. + expect(setCookie).toEqual([ + 'vitnode_auth=token; Path=/; HttpOnly', + 'vitnode_device=device; Path=/; HttpOnly', + ]) + }) + + it('writes nothing for a response that set no cookies', async () => { + const { setCookie } = await withRequest({}, () => { + saveApiCookies(new Response(null)) + }) + + expect(setCookie).toEqual([]) + }) + }) +}) diff --git a/apps/web/src/tests/hono-bridge.test.ts b/apps/web/src/tests/hono-bridge.test.ts new file mode 100644 index 000000000..bb04378dd --- /dev/null +++ b/apps/web/src/tests/hono-bridge.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' + +import type { ApiBridge } from './api-bridge-contract' + +import { + API_BASE, + createApiFixture, + describeApiBridgeContract, +} from './api-bridge-contract' + +/** + * The reference bridge: hand the incoming `Request` to Hono untouched. + * + * It is one line on purpose. Everything the contract asks for - status, + * body, `Set-Cookie`, cookies, user-agent, forwarded IPs - is already correct + * in the request the platform built, and stays correct exactly as long as + * nobody rebuilds it. The tests below show what breaks when somebody does. + */ +describeApiBridgeContract( + 'hono app.fetch', + (app) => async (request) => app.fetch(request), +) + +describe('bridges that rebuild the request', () => { + const call = async (bridge: ApiBridge, init?: RequestInit) => { + const request = new Request(`https://web.test${API_BASE}/echo`, init) + + return bridge(request) + } + + it('drops the request context when only method and body are carried over', async () => { + const fixture = createApiFixture() + // The tempting shortcut: read what you think you need off the request. + const lossy: ApiBridge = async (request) => + fixture.app.fetch( + new Request(request.url, { method: request.method, body: null }), + ) + + await call(lossy, { + headers: { + cookie: 'vitnode_session=s3cr3t', + 'user-agent': 'Firefox/140.0', + 'x-forwarded-for': '203.0.113.7', + }, + }) + + const received = fixture.received.at(0) + expect(received?.headers.cookie).toBeUndefined() + expect(received?.headers['user-agent']).toBeUndefined() + // Which is the whole auth session, the device record and the rate-limit + // key gone at once - and nothing in the response says so. + expect(received?.headers['x-forwarded-for']).toBeUndefined() + }) + + it('collapses Set-Cookie when the response headers go through a plain object', async () => { + const fixture = createApiFixture() + const lossy: ApiBridge = async (request) => { + const response = await fixture.app.fetch(request) + + return new Response(response.body, { + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + }) + } + + const res = await lossy(new Request(`https://web.test${API_BASE}/cookies`)) + + // Two cookies went in, one came out: `Object.fromEntries` keeps the last + // value of a repeated header, and `Headers` joins them into one string. + expect(res.headers.getSetCookie()).not.toEqual([ + 'session=abc; Path=/; HttpOnly', + 'device=xyz; Path=/; HttpOnly', + ]) + }) + + it('loses the query string when the path is forwarded without the search', async () => { + const fixture = createApiFixture() + const lossy: ApiBridge = async (request) => { + const url = new URL(request.url) + + return fixture.app.request(url.pathname, request) + } + + const res = await lossy( + new Request(`https://web.test${API_BASE}/echo?q=one&q=two`), + ) + + expect(await res.json()).toMatchObject({ query: [] }) + }) +}) diff --git a/apps/web/src/tests/isolation.test.ts b/apps/web/src/tests/isolation.test.ts new file mode 100644 index 000000000..1527e2dcd --- /dev/null +++ b/apps/web/src/tests/isolation.test.ts @@ -0,0 +1,200 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const here = dirname(fileURLToPath(import.meta.url)) +const repoRoot = resolve(here, '../../../..') + +const SKIP_DIRECTORIES = [ + '.next', + '.output', + '.source', + '.turbo', + 'dist', + 'node_modules', +] + +const filesUnder = (directory: string): string[] => { + if (!existsSync(directory)) return [] + + const entries: string[] = [] + + for (const name of readdirSync(directory)) { + const path = join(directory, name) + + if (statSync(path).isDirectory()) { + if (SKIP_DIRECTORIES.includes(name)) continue + entries.push(...filesUnder(path)) + continue + } + + if (/\.tsx?$/.test(name) && !name.endsWith('.d.ts')) entries.push(path) + } + + return entries +} + +const importsFrom = (path: string): string[] => + [ + ...readFileSync(path, 'utf8').matchAll( + /from\s+["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']|(?:^|\n)\s*import\s+["']([^"']+)["']/g, + ), + ] + .map((match) => match[1] ?? match[2] ?? match[3]) + .filter((specifier): specifier is string => Boolean(specifier)) + +const matches = (specifier: string, forbidden: string): boolean => + specifier === forbidden || specifier.startsWith(`${forbidden}/`) + +const offendersIn = (files: string[], forbidden: string[]): string[] => + files + .filter((path) => + importsFrom(path).some((specifier) => + forbidden.some((entry) => matches(specifier, entry)), + ), + ) + .map((path) => relative(repoRoot, path)) + +/** Anything that only exists inside a TanStack Start app. */ +const TANSTACK_ONLY = ['@tanstack/react-start', '@tanstack/react-router'] + +/** Anything that only exists inside a Next.js app. */ +const NEXT_ONLY = ['next', 'server-only'] + +/** + * next-intl's Next-only halves. + * + * The root entry is not on this list on purpose: it re-exports `use-intl`, which + * is framework-free, and the API already uses `createTranslator` from it to + * render emails and error messages in the user's language. These four are the + * ones that reach for Next's request scope, its middleware or its build plugin. + */ +const NEXT_INTL_RUNTIME = [ + 'next-intl/middleware', + 'next-intl/navigation', + 'next-intl/plugin', + 'next-intl/server', +] + +describe('the repository root is where these tests think it is', () => { + it('resolves to the workspace root', () => { + // Every assertion below is vacuously true against an empty file list, so + // a move of this file has to fail here rather than silently pass. + expect(existsSync(join(repoRoot, 'pnpm-workspace.yaml'))).toBe(true) + }) +}) + +describe('the import scan finds what it is looking for', () => { + // Every assertion below is a "found nothing" one, which an import scanner + // that silently matches nothing also satisfies. These two are the control: + // they point it at code that provably does import the forbidden things. + it('finds the Next.js imports in the layer that is allowed them', () => { + expect( + offendersIn( + filesUnder(join(repoRoot, 'packages/vitnode/src/content/next')), + NEXT_ONLY, + ), + ).not.toEqual([]) + }) + + it('finds the TanStack imports in this app', () => { + expect( + offendersIn(filesUnder(join(repoRoot, 'apps/web/src/routes')), [ + '@tanstack/react-router', + ]), + ).not.toEqual([]) + }) +}) + +describe('adding TanStack Start does not reach the rest of the workspace', () => { + const targets = [ + // The plain `@hono/node-server` process. Neither TanStack nor Next has a + // runtime there, so an import fails when someone boots the API, not in CI. + { + files: () => filesUnder(join(repoRoot, 'apps/api/src')), + name: 'apps/api', + }, + // Loaded by `apps/api` and executed by drizzle-kit when it reads the tables. + { + files: () => [ + ...filesUnder(join(repoRoot, 'packages/vitnode/src/api')), + ...filesUnder(join(repoRoot, 'packages/vitnode/src/database')), + ], + name: '@vitnode/core api + database layers', + }, + // Adapter packages: they run wherever the API runs, framework-free. + { + files: () => + [ + 'elasticsearch', + 'node-cron', + 'nodemailer', + 'resend', + 's3', + 'supabase-storage', + ].flatMap((name) => + filesUnder(join(repoRoot, 'packages', name, 'src')), + ), + name: 'framework-independent packages', + }, + ] + + it.each(targets)('$name has files to check', ({ files }) => { + expect(files().length).toBeGreaterThan(0) + }) + + it.each(targets)('$name never imports TanStack Start', ({ files }) => { + expect(offendersIn(files(), TANSTACK_ONLY)).toEqual([]) + }) + + it.each(targets)('$name never imports Next.js', ({ files }) => { + expect(offendersIn(files(), NEXT_ONLY)).toEqual([]) + }) + + it.each(targets)( + "$name never imports next-intl's Next-only entries", + ({ files }) => { + expect(offendersIn(files(), NEXT_INTL_RUNTIME)).toEqual([]) + }, + ) +}) + +describe('the existing Next.js application stays Next-only', () => { + const docsFiles = () => filesUnder(join(repoRoot, 'apps/docs')) + + it('has files to check', () => { + expect(docsFiles().length).toBeGreaterThan(0) + }) + + it('never imports TanStack Start', () => { + expect(offendersIn(docsFiles(), TANSTACK_ONLY)).toEqual([]) + }) +}) + +describe('the TanStack Start application stays Next-free', () => { + const webFiles = () => filesUnder(join(repoRoot, 'apps/web/src')) + + it('has files to check', () => { + expect(webFiles().length).toBeGreaterThan(0) + }) + + it('never imports next/* or server-only', () => { + // `@vitnode/core` splits its Next-only helpers into their own modules + // precisely so an app that is not Next can use the rest. Importing one + // here would drag `next/headers` into the Nitro build. + expect(offendersIn(webFiles(), NEXT_ONLY)).toEqual([]) + }) + + it("never imports next-intl's Next-only entries", () => { + expect(offendersIn(webFiles(), NEXT_INTL_RUNTIME)).toEqual([]) + }) + + it('does not depend on next', () => { + const manifest = JSON.parse( + readFileSync(join(repoRoot, 'apps/web/package.json'), 'utf8'), + ) as { dependencies?: Record } + + expect(manifest.dependencies?.next).toBeUndefined() + }) +}) diff --git a/apps/web/src/tests/ssr-through-bridge.test.ts b/apps/web/src/tests/ssr-through-bridge.test.ts new file mode 100644 index 000000000..958dc1906 --- /dev/null +++ b/apps/web/src/tests/ssr-through-bridge.test.ts @@ -0,0 +1,114 @@ +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '@tanstack/react-router' +import { describe, expect, it } from 'vitest' + +import type { ApiBridge } from './api-bridge-contract' + +import { API_BASE, createApiFixture } from './api-bridge-contract' + +const WEB_ORIGIN = 'https://web.test' + +/** + * What SSR actually has to work with: the request the browser made to the + * page, not to the API. Everything the loader sends onward has to be derived + * from it. + */ +const ssrRequest = (path: string, headers: Record): Request => + new Request(new URL(path, WEB_ORIGIN), { headers }) + +/** + * Runs a loader the way the router runs it during SSR and hands back what it + * resolved to. + */ +const loadThrough = async (loader: () => Promise) => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + return router.state.matches.at(-1)?.loaderData +} + +describe('SSR through the Hono bridge', () => { + it('resolves loader data from the API', async () => { + const fixture = createApiFixture() + const bridge: ApiBridge = async (request) => fixture.app.fetch(request) + const incoming = ssrRequest('/', { + cookie: 'vitnode_session=s3cr3t', + 'user-agent': 'Mozilla/5.0 (SSR test)', + 'x-forwarded-for': '203.0.113.7', + }) + + const data = await loadThrough(async () => { + const response = await bridge( + new Request(new URL(`${API_BASE}/echo`, incoming.url), { + headers: incoming.headers, + }), + ) + + return response.json() + }) + + expect(data).toMatchObject({ method: 'GET', path: `${API_BASE}/echo` }) + }) + + it('carries the page request context into the API call', async () => { + const fixture = createApiFixture() + const incoming = ssrRequest('/', { + cookie: 'vitnode_session=s3cr3t', + 'user-agent': 'Mozilla/5.0 (SSR test)', + 'x-forwarded-for': '203.0.113.7', + }) + + await loadThrough(async () => { + const response = await fixture.app.fetch( + new Request(new URL(`${API_BASE}/echo`, incoming.url), { + headers: incoming.headers, + }), + ) + + return response.json() + }) + + // Without this the API sees an anonymous request from the server itself: + // signed-out SSR HTML, and every visitor sharing one rate-limit bucket. + expect(fixture.received.at(0)?.headers).toMatchObject({ + cookie: 'vitnode_session=s3cr3t', + 'user-agent': 'Mozilla/5.0 (SSR test)', + 'x-forwarded-for': '203.0.113.7', + }) + }) + + it('surfaces an API error status to the loader instead of swallowing it', async () => { + const fixture = createApiFixture() + + const data = await loadThrough(async () => { + const response = await fixture.app.fetch( + new Request(`${WEB_ORIGIN}${API_BASE}/status/403`), + ) + + return { status: response.status } + }) + + expect(data).toEqual({ status: 403 }) + }) + + it('needs an absolute URL: a same-origin path alone does not resolve on the server', async () => { + // Pins why the loader builds its URL against the incoming request. On the + // client `fetch("/api/...")` is fine; during SSR there is no document to + // resolve it against and it throws before Hono is ever reached. + await expect(fetch(`${API_BASE}/echo`)).rejects.toThrow() + }) +}) diff --git a/apps/web/src/tests/stage-1-runtime.test.ts b/apps/web/src/tests/stage-1-runtime.test.ts new file mode 100644 index 000000000..378f9fb53 --- /dev/null +++ b/apps/web/src/tests/stage-1-runtime.test.ts @@ -0,0 +1,110 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +import type { ApiBridgeFactory } from './api-bridge-contract' + +import { describeApiBridgeContract } from './api-bridge-contract' + +const here = dirname(fileURLToPath(import.meta.url)) +const srcRoot = resolve(here, '..') + +const EXTENSIONS = ['.ts', '.tsx'] + +const resolveModule = (path: string): string | undefined => + [path, ...EXTENSIONS.map((extension) => `${path}${extension}`)].find( + (candidate) => existsSync(candidate) && !candidate.endsWith('/'), + ) + +/** + * Where a TanStack Start server route that owns `/api/*` can live. The + * framework builds the path from the file name, so this is the whole surface: + * `api.$.ts` and `api/$.ts` both resolve to `/api/$`. + */ +const routePath = ['api.$.ts', 'api.$.tsx', 'api/$.ts', 'api/$.tsx'] + .map((name) => resolve(srcRoot, 'routes', name)) + .find((path) => existsSync(path)) + +const importsFrom = (path: string): string[] => + [...readFileSync(path, 'utf8').matchAll(/from\s+["']([^"']+)["']/g)] + .map((match) => match[1]) + .filter((specifier): specifier is string => Boolean(specifier)) + +/** Every first-party module reachable from `entry`, as `src`-relative paths. */ +const importGraphFrom = (entry: string): string[] => { + const seen = new Set() + const queue = [entry] + + while (queue.length > 0) { + const current = queue.shift() + if (current === undefined || seen.has(current)) continue + seen.add(current) + + for (const specifier of importsFrom(current)) { + const target = specifier.startsWith('#/') + ? resolve(srcRoot, specifier.slice(2)) + : specifier.startsWith('.') + ? resolve(dirname(current), specifier) + : undefined + + const resolved = target === undefined ? undefined : resolveModule(target) + if (resolved) queue.push(resolved) + } + } + + return [...seen].map((path) => relative(srcRoot, path)) +} + +/** + * The app's own bridge, when it exposes one that can be pointed at an arbitrary + * Hono app. When it does, the whole contract runs against the real + * implementation rather than only against a reference one. + */ +const loadBridgeFactory = async (): Promise => { + for (const specifier of ['../server/api-bridge', '../lib/api-bridge']) { + if (!resolveModule(resolve(here, specifier))) continue + + const module = (await import(/* @vite-ignore */ specifier)) as { + createApiBridge?: ApiBridgeFactory + } + + if (module.createApiBridge) return module.createApiBridge + } + + return undefined +} + +const bridgeFactory = await loadBridgeFactory() + +describe('Stage 1 runtime wiring', () => { + // Skipped while the `/api/*` server route is still to be written. The skip is + // the marker: both turn into real assertions the moment the file lands. + it.skipIf(routePath === undefined)( + 'mounts the API on a splat route so every path under /api reaches it', + () => { + // `/api/@vitnode/core/users/{id}` is four segments past the mount point. + // Anything narrower than a splat answers the app shell for most of them. + expect(routePath).toMatch(/api[./]\$\.tsx?$/) + }, + ) + + it.skipIf(routePath === undefined || bridgeFactory === undefined)( + 'serves /api/* through the bridge this suite covers', + () => { + // The risk: a second forwarder written inline in the route file. The + // contract below would still be green while production ran untested code. + expect(importGraphFrom(routePath ?? '')).toContain('server/api-bridge.ts') + }, + ) +}) + +if (bridgeFactory) { + describeApiBridgeContract('apps/web createApiBridge', bridgeFactory) +} else { + describe.skip('api bridge contract: apps/web createApiBridge', () => { + it('runs once src/server/api-bridge.ts exports createApiBridge(app)', () => { + expect(bridgeFactory).toBeDefined() + }) + }) +} diff --git a/apps/web/src/vitnode.api.config.ts b/apps/web/src/vitnode.api.config.ts new file mode 100644 index 000000000..567fcf2fc --- /dev/null +++ b/apps/web/src/vitnode.api.config.ts @@ -0,0 +1,39 @@ +import { blogApiPlugin } from '@vitnode/blog/config.api' +import { coreRelations } from '@vitnode/core/database/relations' +import { buildApiConfig } from '@vitnode/core/vitnode.config' +import { exampleApiPlugin } from '@vitnode/example/config.api' +import { config } from 'dotenv' +import { drizzle } from 'drizzle-orm/postgres-js' + +// Vite's own `loadEnv` populates `process.env` at config time, which covers +// `vite dev` and `vite build` but not `node .output/server/index.mjs`. dotenv +// covers the production server too, the same way `apps/api` does it. +config({ quiet: true }) + +export const POSTGRES_URL = + process.env.POSTGRES_URL ?? 'postgresql://root:root@localhost:5432/vitnode' + +/** + * The API this app serves at `/api/*`, identical in shape to the config + * `apps/api` and `apps/docs` build. Nothing here is TanStack-specific: the + * Hono application is unchanged, only the runtime that hands it requests is. + * + * Left out on purpose, because each one is a deployment decision rather than + * part of the mount: `email`, `storage`, `ai`, `cron` and the SSO adapters. Add + * them exactly as `apps/api/src/vitnode.api.config.ts` does when this app needs + * them - `buildApiConfig` treats all of them as optional. + */ +export const vitNodeApiConfig = buildApiConfig({ + plugins: [blogApiPlugin(), exampleApiPlugin()], + dbProvider: drizzle({ + connection: POSTGRES_URL, + relations: coreRelations, + }), + redis: process.env.REDIS_URL + ? { url: process.env.REDIS_URL, password: process.env.REDIS_PASSWORD } + : undefined, + metadata: { + title: 'VitNode API', + shortTitle: 'VitNode', + }, +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 9bdc820fb..314b699c7 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -10,7 +10,7 @@ "@/*": ["./src/*"] }, "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["vite/client"], + "types": ["vite/client", "node"], /* Bundler mode */ "moduleResolution": "bundler", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 5a46a66cf..f0dd840a6 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,15 +1,31 @@ -import { defineConfig } from 'vite' +import tailwindcss from '@tailwindcss/vite' import { devtools } from '@tanstack/devtools-vite' - import { tanstackStart } from '@tanstack/react-start/plugin/vite' - import viteReact from '@vitejs/plugin-react' -import tailwindcss from '@tailwindcss/vite' import { nitro } from 'nitro/vite' +import { defineConfig } from 'vite' + +import { vitNodeEnv } from './vitnode-env' const config = defineConfig({ resolve: { tsconfigPaths: true }, + ssr: { + /** + * The VitNode API packages mounted at `/api/*`, kept out of the SSR pass. + * + * They are Node libraries rather than app source: `@vitnode/core` loads its + * locale files with a runtime `import("./en.json", { with: { type: "json" } })` + * relative to its own `dist`. Bundling them in this pass moves that chunk + * and the JSON stops resolving, which fails the build outright. Left + * external here, Nitro resolves them from the package itself. + * + * Vite treats workspace-linked packages as `noExternal` by default, which is + * why they have to be named. + */ + external: ['@vitnode/core', '@vitnode/blog', '@vitnode/example'], + }, plugins: [ + vitNodeEnv(), devtools(), nitro({ rollupConfig: { external: [/^@sentry\//] } }), tailwindcss(), diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 000000000..d9dc4a9bd --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,27 @@ +import { resolve } from 'node:path' +import { defineConfig } from 'vitest/config' + +// Deliberately free of the app's Vite plugins (`tanstackStart`, `nitro`, +// `viteReact`): these are server-side integration tests over plain +// `Request`/`Response`, and loading the Start plugin here would pull the whole +// router build pipeline into the test run for no assertion it makes. +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/.output/**', + '**/.nitro/**', + '**/.tanstack/**', + ], + }, + resolve: { + alias: { + '#': resolve(import.meta.dirname, './src'), + '@': resolve(import.meta.dirname, './src'), + }, + }, +}) diff --git a/apps/web/vitnode-env.ts b/apps/web/vitnode-env.ts new file mode 100644 index 000000000..3b6ffa978 --- /dev/null +++ b/apps/web/vitnode-env.ts @@ -0,0 +1,69 @@ +import type { Plugin } from 'vite' + +import { loadEnv } from 'vite' + +/** + * The `NEXT_PUBLIC_*` values a browser bundle needs literally. + * + * `@vitnode/core`'s config reads `process.env.NEXT_PUBLIC_API_URL` to build + * absolute API URLs, and it is the same module on both sides of the render - so + * the fetcher running in a client component needs that read to resolve to + * something in a browser, where there is no `process`. Next.js solves this by + * inlining `NEXT_PUBLIC_*` into the client bundle; this is the same trick, so the + * variable names stay exactly as they are and no existing install has to rename + * anything. + * + * An explicit list rather than a prefix rule: everything named here is compiled + * into JavaScript that anyone can read, so it should be a decision, not a + * consequence of what somebody happened to call a variable. Add a key to publish + * one more. + */ +const CLIENT_ENV_KEYS = ['NEXT_PUBLIC_API_URL', 'NEXT_PUBLIC_WEB_URL'] as const + +/** + * Environment handling for a TanStack Start app that serves a VitNode API. + * + * Two halves, deliberately different: + * + * - **Server.** `.env` is loaded into `process.env` so anything reading it at + * config or request time sees it, whatever import runs first. Nothing is + * inlined, so `CONFIG`'s lazy getters keep reading the live environment and a + * built server can still be pointed at a different API by its host. + * - **Client.** Only the keys above, and only as literals in the browser bundle. + * + * Secrets - `POSTGRES_URL`, `REDIS_URL`, `CRON_SECRET` - are loaded for the + * server and never defined for the client, which is the entire reason the two + * halves are written separately. + */ +export const vitNodeEnv = (): Plugin => ({ + config: (userConfig, { mode }) => { + // Empty prefix: the whole `.env`, not just the public keys. This is the + // server's copy, and the API needs the database and Redis URLs from it. + const env = loadEnv(mode, userConfig.root ?? process.cwd(), '') + + // `??=`, so a real environment variable - Docker, Vercel, CI - always wins + // over a `.env` file left in the working directory. + for (const [key, value] of Object.entries(env)) { + process.env[key] ??= value + } + + return { + environments: { + client: { + define: Object.fromEntries( + CLIENT_ENV_KEYS.map((key) => [ + `process.env.${key}`, + // `undefined` when unset rather than nothing at all: the read has + // to be replaced either way, or it throws in the browser instead + // of falling through to the default `CONFIG` already has for it. + process.env[key] === undefined + ? 'undefined' + : JSON.stringify(process.env[key]), + ]), + ), + }, + }, + } + }, + name: 'vitnode:env', +}) diff --git a/packages/vitnode/src/lib/fetcher.ts b/packages/vitnode/src/lib/fetcher.ts index c27924251..ed0893604 100644 --- a/packages/vitnode/src/lib/fetcher.ts +++ b/packages/vitnode/src/lib/fetcher.ts @@ -17,6 +17,7 @@ import type { import { coreFetcher } from "./fetcher/core"; import { handleSetCookiesFetcher } from "./fetcher/helpers-server"; +import { buildForwardedHeaders } from "./fetcher/request-context"; export async function fetcher< M extends string, @@ -58,16 +59,12 @@ export async function fetcher< cookies(), ]); - const additionalHeaders: Record = { - Cookie: cookie.toString(), - ["user-agent"]: nextInternalHeaders.get("user-agent") ?? "node", - ["x-forwarded-for"]: - nextInternalHeaders.get("x-forwarded-for") ?? "0.0.0.0", - }; - - if (captchaToken) { - additionalHeaders["x-vitnode-captcha-token"] = captchaToken; - } + const additionalHeaders = buildForwardedHeaders({ + captchaToken, + cookie: cookie.toString(), + forwardedFor: nextInternalHeaders.get("x-forwarded-for"), + userAgent: nextInternalHeaders.get("user-agent"), + }); const response = await coreFetcher(moduleReturn, { path, diff --git a/packages/vitnode/src/lib/fetcher/helpers-server.ts b/packages/vitnode/src/lib/fetcher/helpers-server.ts index 21ff015fc..30301b178 100644 --- a/packages/vitnode/src/lib/fetcher/helpers-server.ts +++ b/packages/vitnode/src/lib/fetcher/helpers-server.ts @@ -1,24 +1,14 @@ import "server-only"; import { cookies } from "next/headers"; -import { cookieFromStringToObject } from "./cookie-from-string-to-object"; +import { parseSetCookies } from "./set-cookie"; export const handleSetCookiesFetcher = async (res: Response) => { - await Promise.all( - cookieFromStringToObject(res.headers.getSetCookie()).map(async cookie => { - const key = Object.keys(cookie)[0]; - const value = Object.values(cookie)[0]; + const store = await cookies(); - if (typeof value !== "string" || typeof key !== "string") return; - - (await cookies()).set(key, value, { - domain: cookie.Domain, - path: cookie.Path, - expires: new Date(cookie.Expires), - secure: cookie.Secure, - httpOnly: cookie.HttpOnly, - sameSite: cookie.SameSite, - }); - }), - ); + for (const { name, options, value } of parseSetCookies( + res.headers.getSetCookie(), + )) { + store.set(name, value, options); + } }; diff --git a/packages/vitnode/src/lib/fetcher/raw.test.ts b/packages/vitnode/src/lib/fetcher/raw.test.ts new file mode 100644 index 000000000..2defa92e1 --- /dev/null +++ b/packages/vitnode/src/lib/fetcher/raw.test.ts @@ -0,0 +1,196 @@ +// @vitest-environment node +import { Hono } from "hono"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { CONFIG } from "../config"; +import { buildApiUrl, rawApiFetch } from "./raw"; +import { buildForwardedHeaders } from "./request-context"; + +const PLUGIN_ID = "@vitnode/core"; +const ORIGIN = "http://localhost:3000"; + +/** + * The API mounted the way every VitNode runtime mounts it: `basePath("/api")` + * with the plugin's router underneath. `apps/api` does it in its entry file, + * and the TanStack Start app does it behind its `/api/*` server route - so the + * paths the fetcher builds have to resolve here either way. + */ +const mountedApi = () => { + const seen: Request[] = []; + const plugin = new Hono(); + + plugin.use("*", async (c, next) => { + seen.push(c.req.raw.clone()); + + return next(); + }); + plugin.get("/middleware", c => c.json({ ok: true })); + plugin.get("/users/:id", c => c.json({ id: c.req.param("id") })); + plugin.get("/guarded", c => c.json({ error: "Unauthorized" }, 401)); + plugin.get("/broken", () => { + throw new Error("boom"); + }); + + const app = new Hono().basePath("/api"); + app.route(`/${PLUGIN_ID}`, plugin); + + return { app, seen }; +}; + +describe("buildApiUrl", () => { + beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_API_URL", ORIGIN); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("namespaces the path under the plugin id", () => { + expect( + buildApiUrl({ + module: "middleware", + path: "/", + pluginId: PLUGIN_ID, + }).toString(), + ).toBe(`${ORIGIN}/api/${PLUGIN_ID}/middleware`); + }); + + it("substitutes path params", () => { + expect( + buildApiUrl({ + module: "users", + params: { id: 42 }, + path: "/{id}", + pluginId: PLUGIN_ID, + }).pathname, + ).toBe(`/api/${PLUGIN_ID}/users/42`); + }); + + it("stays on the web origin when the API is mounted same-origin", () => { + // The whole point of the mount: with the two origins equal, an SSR call + // never leaves the process that is rendering the page. + vi.stubEnv("NEXT_PUBLIC_WEB_URL", ORIGIN); + + expect( + buildApiUrl({ module: "middleware", path: "/", pluginId: PLUGIN_ID }) + .origin, + ).toBe(CONFIG.web.origin); + }); + + it("leaves the web origin when the API is configured elsewhere", () => { + // Pins the env contract rather than an implementation: point + // `NEXT_PUBLIC_API_URL` at a second server and the same call becomes a + // cross-origin one, cookies and CORS included. + vi.stubEnv("NEXT_PUBLIC_API_URL", "https://api.example.com"); + + expect( + buildApiUrl({ module: "middleware", path: "/", pluginId: PLUGIN_ID }) + .origin, + ).toBe("https://api.example.com"); + }); + + it("adds the pagination defaults only when asked", () => { + const url = buildApiUrl({ + module: "users", + path: "/", + pluginId: PLUGIN_ID, + query: {}, + withPagination: true, + }); + + expect(url.searchParams.get("first")).toBe("10"); + expect(url.searchParams.get("search")).toBe(""); + }); +}); + +describe("rawApiFetch against the mounted API", () => { + let api: ReturnType; + + beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_API_URL", ORIGIN); + api = mountedApi(); + vi.stubGlobal( + "fetch", + async (input: RequestInfo | URL, init?: RequestInit) => + api.app.fetch(new Request(input, init)), + ); + // The fetcher logs every >= 400 response. The tests below make those on + // purpose, so keep the report readable. + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("reaches the route the URL builder addressed", async () => { + const response = await rawApiFetch({ + method: "get", + module: "middleware", + path: "/", + pluginId: PLUGIN_ID, + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + }); + + it("forwards the caller's cookie, user-agent and IP", async () => { + // The three headers the API reads to identify the user, fingerprint the + // device and key the rate limiter. + await rawApiFetch({ + additionalHeaders: buildForwardedHeaders({ + cookie: "vitnode_session=s3cr3t", + forwardedFor: "203.0.113.7", + userAgent: "Mozilla/5.0 (SSR test)", + }), + method: "get", + module: "middleware", + path: "/", + pluginId: PLUGIN_ID, + }); + + const headers = api.seen.at(0)?.headers; + expect(headers?.get("cookie")).toBe("vitnode_session=s3cr3t"); + expect(headers?.get("user-agent")).toBe("Mozilla/5.0 (SSR test)"); + expect(headers?.get("x-forwarded-for")).toBe("203.0.113.7"); + }); + + it("hands back a non-2xx response instead of throwing", async () => { + // A 401 is data the caller renders (a sign-in prompt), not a crash. + const response = await rawApiFetch({ + method: "get", + module: "guarded", + path: "/", + pluginId: PLUGIN_ID, + }); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "Unauthorized" }); + }); + + it("hands back the API's 404 for an unknown route", async () => { + const response = await rawApiFetch({ + method: "get", + module: "does-not-exist", + path: "/", + pluginId: PLUGIN_ID, + }); + + expect(response.status).toBe(404); + }); + + it("throws on a 500 with the URL and the body", async () => { + await expect( + rawApiFetch({ + method: "get", + module: "broken", + path: "/", + pluginId: PLUGIN_ID, + }), + ).rejects.toThrow(`/api/${PLUGIN_ID}/broken`); + }); +}); diff --git a/packages/vitnode/src/lib/fetcher/request-context.test.ts b/packages/vitnode/src/lib/fetcher/request-context.test.ts new file mode 100644 index 000000000..dd7493f60 --- /dev/null +++ b/packages/vitnode/src/lib/fetcher/request-context.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { + buildForwardedHeaders, + CAPTCHA_TOKEN_HEADER, + FORWARDED_IP_FALLBACK, + FORWARDED_USER_AGENT_FALLBACK, +} from "./request-context"; + +describe("buildForwardedHeaders", () => { + it("forwards the cookie header verbatim", () => { + expect( + buildForwardedHeaders({ + cookie: "vitnode_auth=abc; vitnode_device=def", + }).Cookie, + ).toBe("vitnode_auth=abc; vitnode_device=def"); + }); + + it("falls back for a caller with no user-agent or forwarded ip", () => { + expect(buildForwardedHeaders({})).toStrictEqual({ + Cookie: "", + "user-agent": FORWARDED_USER_AGENT_FALLBACK, + "x-forwarded-for": FORWARDED_IP_FALLBACK, + }); + }); + + it("keeps an x-forwarded-for chain intact", () => { + expect( + buildForwardedHeaders({ forwardedFor: "203.0.113.7, 10.0.0.1" })[ + "x-forwarded-for" + ], + ).toBe("203.0.113.7, 10.0.0.1"); + }); + + it("adds the captcha token only when there is one", () => { + expect(buildForwardedHeaders({})).not.toHaveProperty(CAPTCHA_TOKEN_HEADER); + expect(buildForwardedHeaders({ captchaToken: "solved" })).toHaveProperty( + CAPTCHA_TOKEN_HEADER, + "solved", + ); + expect(buildForwardedHeaders({ captchaToken: "" })).not.toHaveProperty( + CAPTCHA_TOKEN_HEADER, + ); + }); + + it("never forwards anything outside the allowlist", () => { + // The guard for the whole point of this module: a header the API trusts + // (`origin`, `host`, `authorization`) must not be reachable through it. + expect( + Object.keys( + buildForwardedHeaders({ + captchaToken: "solved", + cookie: "vitnode_auth=abc", + forwardedFor: "203.0.113.7", + userAgent: "Mozilla/5.0", + }), + ).sort(), + ).toStrictEqual([ + "Cookie", + "user-agent", + "x-forwarded-for", + CAPTCHA_TOKEN_HEADER, + ]); + }); +}); diff --git a/packages/vitnode/src/lib/fetcher/request-context.ts b/packages/vitnode/src/lib/fetcher/request-context.ts new file mode 100644 index 000000000..057fb3289 --- /dev/null +++ b/packages/vitnode/src/lib/fetcher/request-context.ts @@ -0,0 +1,62 @@ +/** + * The request state a VitNode frontend forwards to the API - and nothing else. + * + * Deliberately an allowlist rather than a copy of the incoming headers. The API + * identifies the caller from `Cookie`, fingerprints their device from + * `user-agent`, and keys rate limiting and the audit trail off + * `x-forwarded-for`, so those three have to survive the hop. Everything else a + * browser or a proxy attached must not: `host` and `content-length` describe a + * different request than the one being made, and `origin`, `referer` or + * `authorization` would let a visitor hand the API state it trusts. + * + * Lives here, framework-free, because two frontends need the same contract - + * {@link fetcher} reads the request through `next/headers`, a TanStack Start app + * reads it through `@tanstack/react-start/server`. Only the reading differs; + * what gets sent must not. + */ + +/** Header the captcha middleware reads the client's solved token from. */ +export const CAPTCHA_TOKEN_HEADER = "x-vitnode-captcha-token"; + +/** Sent when no forwarded IP is known, so the API never has to handle an empty one. */ +export const FORWARDED_IP_FALLBACK = "0.0.0.0"; + +/** + * Sent when the caller has no `user-agent`, which is the normal case for a + * server-to-server call. Matches the API's own fallback, so `parseUserAgent` + * reports "Unknown" instead of inventing a browser. + */ +export const FORWARDED_USER_AGENT_FALLBACK = "node"; + +export interface ForwardedRequestContext { + /** Solved captcha token, when the route being called requires one. */ + captchaToken?: string; + /** The caller's full `Cookie` header - the session and device cookies live here. */ + cookie?: null | string; + /** + * The caller's IP, or the `x-forwarded-for` chain verbatim when the frontend + * itself sits behind a proxy. Only meaningful when that proxy is trusted; the + * API stores whatever arrives. + */ + forwardedFor?: null | string; + userAgent?: null | string; +} + +export const buildForwardedHeaders = ({ + captchaToken, + cookie, + forwardedFor, + userAgent, +}: ForwardedRequestContext): Record => { + const headers: Record = { + Cookie: cookie ?? "", + "user-agent": userAgent ?? FORWARDED_USER_AGENT_FALLBACK, + "x-forwarded-for": forwardedFor ?? FORWARDED_IP_FALLBACK, + }; + + if (captchaToken) { + headers[CAPTCHA_TOKEN_HEADER] = captchaToken; + } + + return headers; +}; diff --git a/packages/vitnode/src/lib/fetcher/set-cookie.test.ts b/packages/vitnode/src/lib/fetcher/set-cookie.test.ts new file mode 100644 index 000000000..d49011b89 --- /dev/null +++ b/packages/vitnode/src/lib/fetcher/set-cookie.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { parseSetCookies } from "./set-cookie"; + +describe("parseSetCookies", () => { + it("parses the session cookie the API mints", () => { + expect( + parseSetCookies([ + "vitnode_auth=token-value; Path=/; Domain=localhost; Expires=Sun, 24 Nov 2026 10:00:00 GMT; HttpOnly; Secure", + ]), + ).toStrictEqual([ + { + name: "vitnode_auth", + options: { + domain: "localhost", + expires: new Date("Sun, 24 Nov 2026 10:00:00 GMT"), + httpOnly: true, + path: "/", + sameSite: undefined, + secure: true, + }, + value: "token-value", + }, + ]); + }); + + it("parses every cookie in the response, not just the first", () => { + expect( + parseSetCookies([ + "vitnode_auth=a; Path=/", + "vitnode_device=b; Path=/", + ]).map(cookie => cookie.name), + ).toStrictEqual(["vitnode_auth", "vitnode_device"]); + }); + + it("treats a cookie with no Expires as a session cookie", () => { + expect(parseSetCookies(["vitnode_auth=a; Path=/"])[0].options.expires).toBe( + undefined, + ); + }); + + it("drops an unparseable Expires instead of passing on an invalid date", () => { + expect( + parseSetCookies(["vitnode_auth=a; Expires=not-a-date"])[0].options + .expires, + ).toBe(undefined); + }); + + it("normalizes SameSite to the casing a cookie store expects", () => { + expect( + parseSetCookies(["vitnode_auth=a; SameSite=Lax"])[0].options.sameSite, + ).toBe("lax"); + expect( + parseSetCookies(["vitnode_auth=a; SameSite=Nonsense"])[0].options + .sameSite, + ).toBe(undefined); + }); + + it("reports httpOnly and secure as absent when the flags are not set", () => { + expect(parseSetCookies(["vitnode_auth=a"])[0].options).toStrictEqual({ + domain: undefined, + expires: undefined, + httpOnly: false, + path: undefined, + sameSite: undefined, + secure: false, + }); + }); + + it("skips a header with no value to set", () => { + expect(parseSetCookies(["HttpOnly"])).toStrictEqual([]); + }); + + it("returns nothing for a response that set no cookies", () => { + expect(parseSetCookies([])).toStrictEqual([]); + }); +}); diff --git a/packages/vitnode/src/lib/fetcher/set-cookie.ts b/packages/vitnode/src/lib/fetcher/set-cookie.ts new file mode 100644 index 000000000..974f7a886 --- /dev/null +++ b/packages/vitnode/src/lib/fetcher/set-cookie.ts @@ -0,0 +1,91 @@ +import { cookieFromStringToObject } from "./cookie-from-string-to-object"; + +/** + * A `Set-Cookie` header from the API, split into the shape every cookie store + * takes: a name, a value, and the attributes. + * + * Framework-free on purpose. The API mints the session and device cookies, so + * whichever frontend made the call has to copy them onto its own response - + * Next through `cookies().set()`, TanStack Start through `setCookie()`. Parsing + * them twice is how the two drift apart. + */ +export interface ParsedSetCookie { + name: string; + options: { + domain?: string; + expires?: Date; + httpOnly?: boolean; + path?: string; + sameSite?: "lax" | "none" | "strict"; + secure?: boolean; + }; + value: string; +} + +const parseSameSite = ( + value: unknown, +): ParsedSetCookie["options"]["sameSite"] => { + if (typeof value !== "string") return undefined; + + const normalized = value.toLowerCase(); + + return normalized === "lax" || + normalized === "none" || + normalized === "strict" + ? normalized + : undefined; +}; + +/** + * An `Expires` the browser would honour, or nothing. A cookie sent without one + * is a session cookie, and passing an `Invalid Date` on to a cookie store + * serializes to a value browsers throw away - so the two cases are the same + * outcome reached by accident. This makes it the same outcome on purpose. + */ +const parseExpires = (value: unknown): Date | undefined => { + if (typeof value !== "string") return undefined; + + const expires = new Date(value); + + return Number.isNaN(expires.getTime()) ? undefined : expires; +}; + +const asString = (value: unknown): string | undefined => + typeof value === "string" ? value : undefined; + +/** + * A flag is present only when the attribute was there at all. Typed `unknown` + * because `cookieFromStringToObject` declares these as `boolean` while the key + * is simply missing when the attribute is absent. + */ +const asFlag = (value: unknown): boolean => value === true; + +/** + * Every cookie in a response's `Set-Cookie` headers, ready to be written to a + * cookie store. Pass `response.headers.getSetCookie()`. + */ +export const parseSetCookies = ( + setCookieHeaders: string[], +): ParsedSetCookie[] => + cookieFromStringToObject(setCookieHeaders).flatMap(cookie => { + // The name/value pair is the first entry; the rest are attributes. + const [name] = Object.keys(cookie); + const value = name === undefined ? undefined : cookie[name]; + + if (typeof name !== "string" || typeof value !== "string") return []; + + return [ + { + name, + options: { + domain: asString(cookie.Domain), + expires: parseExpires(cookie.Expires), + httpOnly: asFlag(cookie.HttpOnly), + path: asString(cookie.Path), + sameSite: parseSameSite(cookie.SameSite), + secure: asFlag(cookie.Secure), + }, + value, + }, + ]; + }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b730afd94..5be3cf137 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,6 +268,9 @@ importers: apps/web: dependencies: + '@hono/zod-openapi': + specifier: ^1.5.1 + version: 1.5.1(hono@4.12.31)(zod@4.4.3) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.3.3(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) @@ -283,9 +286,33 @@ importers: '@tanstack/react-start': specifier: latest version: 1.168.49(crossws@0.4.12(srvx@0.11.22))(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@vitnode/blog': + specifier: workspace:* + version: link:../../plugins/blog + '@vitnode/core': + specifier: workspace:* + version: link:../../packages/vitnode + '@vitnode/example': + specifier: workspace:* + version: link:../../plugins/example + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + drizzle-kit: + specifier: 1.0.0-rc.4 + version: 1.0.0-rc.4 + drizzle-orm: + specifier: 1.0.0-rc.4 + version: 1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.4.3) + hono: + specifier: ^4.12.31 + version: 4.12.31 + next-intl: + specifier: ^4.13.7 + version: 4.13.7(@swc/helpers@0.5.23)(next@16.3.1(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@22.20.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@6.0.3) nitro: specifier: 3.0.260610-beta - version: 3.0.260610-beta(chokidar@5.0.0)(dotenv@17.4.2)(jiti@2.7.0)(lru-cache@11.5.2)(rollup@4.62.2)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 3.0.260610-beta(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.4.3))(jiti@2.7.0)(lru-cache@11.5.2)(rollup@4.62.2)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) react: specifier: ^19.2.0 version: 19.2.8 @@ -295,6 +322,9 @@ importers: tailwindcss: specifier: ^4.1.18 version: 4.3.3 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@tanstack/devtools-vite': specifier: latest @@ -329,6 +359,9 @@ importers: vite: specifier: ^8.0.0 version: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) packages/config: dependencies: @@ -14278,7 +14311,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) '@vitest/expect@4.1.10': dependencies: @@ -14289,6 +14322,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 @@ -15082,7 +15123,9 @@ snapshots: dayjs@1.11.23: {} - db0@0.3.4: {} + db0@0.3.4(drizzle-orm@1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.4.3)): + optionalDependencies: + drizzle-orm: 1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.4.3) debounce-fn@4.0.0: dependencies: @@ -17622,6 +17665,23 @@ snapshots: next-intl-swc-plugin-extractor@4.13.7: {} + next-intl@4.13.7(@swc/helpers@0.5.23)(next@16.3.1(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@22.20.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@6.0.3): + dependencies: + '@formatjs/intl-localematcher': 0.8.13 + '@parcel/watcher': 2.6.0 + '@swc/core': 1.15.47(@swc/helpers@0.5.23) + icu-minify: 4.13.7 + negotiator: 1.0.0 + next: 16.3.1(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@22.20.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next-intl-swc-plugin-extractor: 4.13.7 + po-parser: 2.1.1 + react: 19.2.8 + use-intl: 4.13.7(react@19.2.8) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - '@swc/helpers' + next-intl@4.13.7(@swc/helpers@0.5.23)(next@16.3.1(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@6.0.3): dependencies: '@formatjs/intl-localematcher': 0.8.13 @@ -17699,13 +17759,41 @@ snapshots: - '@types/node' - babel-plugin-macros + next@16.3.1(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@22.20.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@next/env': 16.3.1 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.11.1 + caniuse-lite: 1.0.30001806 + postcss: 8.5.23 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.8) + optionalDependencies: + '@next/swc-darwin-arm64': 16.3.1 + '@next/swc-darwin-x64': 16.3.1 + '@next/swc-linux-arm64-gnu': 16.3.1 + '@next/swc-linux-arm64-musl': 16.3.1 + '@next/swc-linux-x64-gnu': 16.3.1 + '@next/swc-linux-x64-musl': 16.3.1 + '@next/swc-win32-arm64-msvc': 16.3.1 + '@next/swc-win32-x64-msvc': 16.3.1 + '@opentelemetry/api': 1.9.1 + '@playwright/test': 1.61.1 + babel-plugin-react-compiler: 1.0.0 + sharp: 0.35.3(@types/node@22.20.1) + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - babel-plugin-macros + nf3@0.3.24: {} - nitro@3.0.260610-beta(chokidar@5.0.0)(dotenv@17.4.2)(jiti@2.7.0)(lru-cache@11.5.2)(rollup@4.62.2)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + nitro@3.0.260610-beta(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.4.3))(jiti@2.7.0)(lru-cache@11.5.2)(rollup@4.62.2)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: consola: 3.4.2 crossws: 0.4.12(srvx@0.11.22) - db0: 0.3.4 + db0: 0.3.4(drizzle-orm@1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.4.3)) env-runner: 0.1.16 h3: 2.0.1-rc.22(crossws@0.4.12(srvx@0.11.22)) hookable: 6.1.1 @@ -17716,7 +17804,7 @@ snapshots: rolldown: 1.1.5 srvx: 0.11.22 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4)(lru-cache@11.5.2)(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(drizzle-orm@1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.4.3)))(lru-cache@11.5.2)(ofetch@2.0.0-alpha.3) optionalDependencies: dotenv: 17.4.2 jiti: 2.7.0 @@ -19013,6 +19101,40 @@ snapshots: '@img/sharp-win32-x64': 0.34.5 optional: true + sharp@0.35.3(@types/node@22.20.1): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 22.20.1 + optional: true + sharp@0.35.3(@types/node@26.1.1): dependencies: '@img/colour': 1.1.0 @@ -19721,10 +19843,10 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 - unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4)(lru-cache@11.5.2)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(drizzle-orm@1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.4.3)))(lru-cache@11.5.2)(ofetch@2.0.0-alpha.3): optionalDependencies: chokidar: 5.0.0 - db0: 0.3.4 + db0: 0.3.4(drizzle-orm@1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.4.3)) lru-cache: 11.5.2 ofetch: 2.0.0-alpha.3 @@ -19867,6 +19989,36 @@ snapshots: optionalDependencies: vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 22.20.1 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 diff --git a/turbo.json b/turbo.json index 721572f01..57c2f538c 100644 --- a/turbo.json +++ b/turbo.json @@ -10,6 +10,14 @@ "dependsOn": ["^test:types"], "cache": false }, + "web#test": { + "dependsOn": ["^build:plugins"], + "cache": false + }, + "web#test:types": { + "dependsOn": ["^build:plugins"], + "cache": false + }, "docker:dev": { "dependsOn": ["^docker:dev"], "cache": false, @@ -31,7 +39,13 @@ "build": { "dependsOn": ["^build:plugins", "^build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], - "outputs": [".next/**", "!.next/cache/**", "!.next/dev/**", "dist/**"], + "outputs": [ + ".next/**", + "!.next/cache/**", + "!.next/dev/**", + ".output/**", + "dist/**" + ], "env": ["POSTGRES_URL", "NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_WEB_URL"] }, "build:scripts": { From 8e6b4db837a6d0eb4af4bbd5d5ced749c029c010 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Wed, 26 Aug 2026 20:25:56 +0200 Subject: [PATCH 3/5] fix: improve api origin --- apps/web/.env.example | 17 +- apps/web/src/routes/api-check.tsx | 10 +- apps/web/src/routes/api/$.ts | 9 +- apps/web/src/server/fetcher.server.ts | 57 +++- apps/web/src/tests/api-origin.test.ts | 255 ++++++++++++++++++ apps/web/src/tests/api-server-route.test.ts | 246 +++++++++++++++++ apps/web/src/tests/start-runtime/handler.ts | 27 ++ apps/web/src/tests/start-runtime/manifest.ts | 9 + .../tests/start-runtime/plugin-adapters.ts | 7 + .../src/tests/start-runtime/router-entry.ts | 10 + .../src/tests/start-runtime/start-entry.ts | 8 + apps/web/vitest.config.ts | 21 ++ packages/vitnode/src/lib/fetcher/core.ts | 8 + packages/vitnode/src/lib/fetcher/raw.test.ts | 32 +++ packages/vitnode/src/lib/fetcher/raw.ts | 13 +- 15 files changed, 706 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/tests/api-origin.test.ts create mode 100644 apps/web/src/tests/api-server-route.test.ts create mode 100644 apps/web/src/tests/start-runtime/handler.ts create mode 100644 apps/web/src/tests/start-runtime/manifest.ts create mode 100644 apps/web/src/tests/start-runtime/plugin-adapters.ts create mode 100644 apps/web/src/tests/start-runtime/router-entry.ts create mode 100644 apps/web/src/tests/start-runtime/start-entry.ts diff --git a/apps/web/.env.example b/apps/web/.env.example index b5b5c0c77..63f5994a7 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,11 +1,18 @@ POSTGRES_URL=postgresql://root:root@localhost:5432/vitnode REDIS_URL=redis://localhost:6379 -# This app serves its own API at `/api/*`, so both point at the same origin. -# `@vitnode/core`'s fetcher builds absolute URLs from `NEXT_PUBLIC_API_URL`; -# leaving it equal to the web origin is what keeps API access same-origin. -NEXT_PUBLIC_WEB_URL=http://localhost:3000 -NEXT_PUBLIC_API_URL=http://localhost:3000 +# This app serves its own API at `/api/*`, so both name the same origin - and it +# has to be the port `pnpm dev` actually serves (`vite dev --port 3001`). Point +# either of them at 3000 and the browser talks to whatever else is on 3000, +# usually `apps/docs`, instead of the API mounted in this process. +# +# Server-side calls do not read these: `resolveApiOrigin()` in +# `src/server/fetcher.server.ts` takes the origin off the request being +# rendered, so a preview deployment on a generated hostname needs no config at +# all. These two are the copy inlined into the browser bundle, plus the fallback +# for code that runs outside a request. +NEXT_PUBLIC_WEB_URL=http://localhost:3001 +NEXT_PUBLIC_API_URL=http://localhost:3001 # === CRON Secret for Internal API Calls === CRON_SECRET=your-secure-cron-secret-key diff --git a/apps/web/src/routes/api-check.tsx b/apps/web/src/routes/api-check.tsx index d0bb558d6..e2fe8c4ab 100644 --- a/apps/web/src/routes/api-check.tsx +++ b/apps/web/src/routes/api-check.tsx @@ -1,6 +1,8 @@ import { createFileRoute } from '@tanstack/react-router' import { createServerFn } from '@tanstack/react-start' -import { getRequestHeader, getRequestUrl } from '@tanstack/react-start/server' +import { getRequestHeader } from '@tanstack/react-start/server' + +import { resolveApiOrigin } from '#/server/fetcher.server' interface ApiProbe { body: string @@ -30,9 +32,9 @@ const PROBES = [ const probeApi = createServerFn().handler(async (): Promise => { // Same-origin by construction: the API is mounted in this app, so the origin - // of the request being rendered is the origin to call. No `NEXT_PUBLIC_API_URL` - // and no second server involved. - const { origin } = getRequestUrl() + // of the request being rendered is the origin to call. The same resolver the + // fetcher uses, so this page cannot pass while real calls go elsewhere. + const origin = resolveApiOrigin() const cookie = getRequestHeader('cookie') const userAgent = getRequestHeader('user-agent') diff --git a/apps/web/src/routes/api/$.ts b/apps/web/src/routes/api/$.ts index bb7d76169..3b77cb64c 100644 --- a/apps/web/src/routes/api/$.ts +++ b/apps/web/src/routes/api/$.ts @@ -15,7 +15,14 @@ import { apiBridge } from '#/server/vitnode-api.server' * `ANY` rather than a handler per method: routing, OpenAPI, middleware, auth, * plugin mounting and error handling all stay inside Hono, exactly as they are * when the same app runs standalone in `apps/api` or under the Next.js catch-all - * in `apps/docs`. + * in `apps/docs`. `ANY` is part of the framework's `RouteMethod` union and is + * what Start falls back to for any method it was given no handler for - `HEAD` + * included, where it calls this handler and strips the response body itself. + * So the API keeps answering for methods this file has never heard of, which is + * the point: there is nothing here to keep in sync with the API's routes. + * + * `src/tests/api-server-route.test.ts` drives the real request handler over this + * route to hold that to every method the API needs. */ export const Route = createFileRoute('/api/$')({ server: { diff --git a/apps/web/src/server/fetcher.server.ts b/apps/web/src/server/fetcher.server.ts index c58b1eee1..83fc6b709 100644 --- a/apps/web/src/server/fetcher.server.ts +++ b/apps/web/src/server/fetcher.server.ts @@ -2,33 +2,63 @@ import '@tanstack/react-start/server-only' import { getRequestHeaders, getRequestIP, + getRequestUrl, setCookie, } from '@tanstack/react-start/server' +import { CONFIG } from '@vitnode/core/lib/config' import { coreFetcher } from '@vitnode/core/lib/fetcher/core' import { buildForwardedHeaders } from '@vitnode/core/lib/fetcher/request-context' import { parseSetCookies } from '@vitnode/core/lib/fetcher/set-cookie' import { config } from 'dotenv' /** - * `@vitnode/core`'s fetcher builds absolute URLs from - * `process.env.NEXT_PUBLIC_API_URL`, read lazily on every call, so that value - * has to be in `process.env` before the first request - not before the first - * import. + * `.env` into `process.env`, for anything that still reads it: the browser + * bundle's inlined `NEXT_PUBLIC_*` values, the database and Redis URLs the + * mounted API needs, and `resolveApiOrigin`'s fallback below. * - * Vite's config loads `.env` into `process.env` for `vite dev` and `vite build`. - * This covers `node .output/server/index.mjs`, where Vite is not involved, the - * same way `apps/api` does it. dotenv does not overwrite what is already set, so - * a platform that injects real environment variables still wins. + * Vite's config already does this for `vite dev` and `vite build`. This covers + * `node .output/server/index.mjs`, where Vite is not involved, the same way + * `apps/api` does it. dotenv does not overwrite what is already set, so a + * platform that injects real environment variables still wins. */ config({ quiet: true }) +/** + * The origin to call `/api/*` on. + * + * This app *serves* the API, so the answer is not configuration - it is + * whichever origin the request being rendered arrived on. Taking it from the + * request is what makes a preview deployment work: its hostname is generated + * per branch, so no `NEXT_PUBLIC_API_URL` could name it, and the old default of + * `http://localhost:3000` names a completely different app in development (this + * one is on 3001) or nothing at all in production. + * + * `getRequestUrl()` reads the `Host` header the request arrived with and honours + * `x-forwarded-proto`, so a TLS-terminating proxy in front of a plain-HTTP + * server still yields an `https:` origin. `x-forwarded-host` is deliberately + * *not* honoured: it is a header a visitor can set, and these calls carry that + * visitor's cookies, so trusting it would let a request point this server's + * API calls at a host of the caller's choosing. + * + * Outside a request - boot, a script, a cron job - there is nothing to read and + * `getRequestUrl()` throws, so `NEXT_PUBLIC_API_URL` remains the fallback. + */ +export const resolveApiOrigin = (): string => { + try { + return getRequestUrl().origin + } catch { + return CONFIG.api.origin + } +} + if (!process.env.NEXT_PUBLIC_API_URL && process.env.NODE_ENV === 'production') { - // The fallback is `http://localhost:3000`, which in production is either - // nothing at all or - worse - this very server, so the failure reads as a - // hanging page rather than a missing variable. + // Server-side calls no longer need it, but the browser bundle still does: + // `vitnode-env.ts` inlines this value, and with nothing to inline a + // client-side call falls back to `http://localhost:3000` - somebody else's + // machine, from the visitor's browser. // eslint-disable-next-line no-console console.warn( - '\x1b[34m[VitNode]\x1b[0m \x1b[33mNEXT_PUBLIC_API_URL is not set; API calls will fall back to http://localhost:3000\x1b[0m', + '\x1b[34m[VitNode]\x1b[0m \x1b[33mNEXT_PUBLIC_API_URL is not set; client-side API calls will fall back to http://localhost:3000\x1b[0m', ) } @@ -90,6 +120,9 @@ export const fetcherServer: typeof coreFetcher = async ( ...getForwardedApiHeaders(), ...options.additionalHeaders, }, + // Same-origin by construction, and ahead of `NEXT_PUBLIC_API_URL` - which + // an explicit `origin` on the call can still override. + origin: options.origin ?? resolveApiOrigin(), }) /** diff --git a/apps/web/src/tests/api-origin.test.ts b/apps/web/src/tests/api-origin.test.ts new file mode 100644 index 000000000..27d994709 --- /dev/null +++ b/apps/web/src/tests/api-origin.test.ts @@ -0,0 +1,255 @@ +import { requestHandler } from '@tanstack/react-start/server' +import { Hono } from 'hono' +import { readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { fetcherServer, resolveApiOrigin } from '#/server/fetcher.server' + +import { API_BASE, PLUGIN_ID } from './api-bridge-contract' + +const here = dirname(fileURLToPath(import.meta.url)) +const appRoot = resolve(here, '../..') +const repoRoot = resolve(appRoot, '../..') + +/** + * The port `pnpm dev` in this app actually listens on, read out of the script + * that starts it rather than restated here - restating it is how the two drift. + */ +const devPort = (app: string): string => { + const manifest = JSON.parse( + readFileSync(join(repoRoot, 'apps', app, 'package.json'), 'utf8'), + ) as { scripts?: Record } + + // `next dev` with no flag serves 3000; `vite dev` needs the flag to move off + // its own default, so an explicit `--port` is the only thing worth reading. + return /--port[= ](\d+)/.exec(manifest.scripts?.dev ?? '')?.[1] ?? '3000' +} + +const exampleEnv = (app: string): Record => + Object.fromEntries( + readFileSync(join(repoRoot, 'apps', app, '.env.example'), 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .map((line) => { + const at = line.indexOf('=') + + return [line.slice(0, at), line.slice(at + 1)] as const + }), + ) + +interface Recorded { + origin: string + path: string +} + +/** A stand-in for the mounted API that records the URL it was called on. */ +const createApi = (recorded: Recorded[]) => { + const plugin = new Hono() + + plugin.get('/users/session', (c) => { + const url = new URL(c.req.url) + recorded.push({ origin: url.origin, path: url.pathname }) + + return c.json({ user: null }) + }) + + const app = new Hono().basePath('/api') + app.route(`/${PLUGIN_ID}`, plugin) + + return app +} + +/** Runs `handler` inside a request, the way the server runtime runs one. */ +const withRequest = async ( + url: string, + headers: Record, + handler: () => Promise | T, +): Promise => { + let result!: T + + await requestHandler(async () => { + result = await handler() + + return new Response(null, { status: 204 }) + })(new Request(url, { headers }), {}) + + return result +} + +describe('development configuration', () => { + const env = exampleEnv('web') + + it('points both public URLs at the port this app serves', () => { + // The bug this pins: `vite dev --port 3001` with the URLs left on 3000. + // Every browser-side API call then leaves this app entirely. + const origin = `http://localhost:${devPort('web')}` + + expect(env.NEXT_PUBLIC_WEB_URL).toBe(origin) + expect(env.NEXT_PUBLIC_API_URL).toBe(origin) + }) + + it('keeps the API on the same origin as the web app', () => { + // The API is mounted at `/api/*` in this process, so a different origin here + // is always a mistake rather than a deployment choice. + expect(new URL(env.NEXT_PUBLIC_API_URL ?? '').origin).toBe( + new URL(env.NEXT_PUBLIC_WEB_URL ?? '').origin, + ) + }) + + it('does not share a development origin with the Next.js app', () => { + // `apps/docs` serves its own VitNode API on 3000 under a Next.js catch-all. + // Colliding with it means this app's calls are answered by that one - a + // different database connection, a different session store, and no error. + expect(env.NEXT_PUBLIC_API_URL).not.toBe( + exampleEnv('docs').NEXT_PUBLIC_API_URL, + ) + expect(devPort('web')).not.toBe(devPort('docs')) + }) +}) + +describe('resolveApiOrigin', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('is the origin of the request being handled', async () => { + vi.stubEnv('NEXT_PUBLIC_API_URL', 'http://localhost:3000') + + // Not the configured value: the API is served by this process, so the + // origin that reached it is the origin to call back on. + await expect( + withRequest('http://localhost:3001/session-check', {}, resolveApiOrigin), + ).resolves.toBe('http://localhost:3001') + }) + + it('works on a hostname nobody configured', async () => { + vi.stubEnv('NEXT_PUBLIC_API_URL', 'https://vitnode.com') + + // A preview deployment: the hostname is generated per branch, so no + // environment variable could have named it ahead of time. + await expect( + withRequest( + 'https://web-git-feat-tanstack-abc123.vercel.app/', + {}, + resolveApiOrigin, + ), + ).resolves.toBe('https://web-git-feat-tanstack-abc123.vercel.app') + }) + + it('follows x-forwarded-proto so a proxied http server calls itself over https', async () => { + // The production shape: TLS ends at the proxy and this server listens on + // plain HTTP. Calling `http://` back through that proxy is a redirect at + // best and a refused connection at worst. + await expect( + withRequest( + 'http://web.test/', + { 'x-forwarded-proto': 'https' }, + resolveApiOrigin, + ), + ).resolves.toBe('https://web.test') + }) + + it('ignores x-forwarded-host', async () => { + // A header the visitor can set, on calls that carry the visitor's cookies: + // honouring it would let a request send this server's API traffic - session + // cookie attached - to a host of the caller's choosing. + await expect( + withRequest( + 'https://web.test/', + { 'x-forwarded-host': 'attacker.test' }, + resolveApiOrigin, + ), + ).resolves.toBe('https://web.test') + }) + + it('falls back to NEXT_PUBLIC_API_URL outside a request', () => { + vi.stubEnv('NEXT_PUBLIC_API_URL', 'https://api.example.com') + + // Boot, a script, a cron job: there is no request to read, and the + // configured value is all there is. + expect(resolveApiOrigin()).toBe('https://api.example.com') + }) +}) + +describe('SSR calls through fetcherServer', () => { + let recorded: Recorded[] + const realFetch = globalThis.fetch + + beforeEach(() => { + recorded = [] + const api = createApi(recorded) + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => + api.fetch(new Request(input, init)) + }) + + afterEach(() => { + globalThis.fetch = realFetch + vi.unstubAllEnvs() + }) + + const callSession = async () => + ( + fetcherServer as unknown as ( + moduleReturn: { pluginId: string }, + options: { method: string; module: string; path: string }, + ) => Promise + )( + { pluginId: PLUGIN_ID }, + { + method: 'get', + module: 'users', + path: '/session', + }, + ) + + it('calls the origin the page was requested on', async () => { + await withRequest('https://web.test/session-check', {}, callSession) + + expect(recorded.at(0)).toStrictEqual({ + origin: 'https://web.test', + path: `${API_BASE}/users/session`, + }) + }) + + it('does not reach the Next.js app when the environment still names it', async () => { + // The regression: `NEXT_PUBLIC_API_URL` left on 3000, which in development + // is `apps/docs`. The call has to stay on 3001, where the mounted API is. + vi.stubEnv('NEXT_PUBLIC_API_URL', 'http://localhost:3000') + + await withRequest('http://localhost:3001/session-check', {}, callSession) + + expect(recorded.at(0)?.origin).toBe('http://localhost:3001') + }) + + it('lets an explicit origin on the call win', async () => { + // The escape hatch a genuinely separate API server needs. Nothing in this + // app passes it; the option exists so the request-derived default is a + // default rather than a hard-coding. + await withRequest('https://web.test/session-check', {}, async () => + ( + fetcherServer as unknown as ( + moduleReturn: { pluginId: string }, + options: { + method: string + module: string + origin: string + path: string + }, + ) => Promise + )( + { pluginId: PLUGIN_ID }, + { + method: 'get', + module: 'users', + origin: 'https://api.example.com', + path: '/session', + }, + ), + ) + + expect(recorded.at(0)?.origin).toBe('https://api.example.com') + }) +}) diff --git a/apps/web/src/tests/api-server-route.test.ts b/apps/web/src/tests/api-server-route.test.ts new file mode 100644 index 000000000..3b4789418 --- /dev/null +++ b/apps/web/src/tests/api-server-route.test.ts @@ -0,0 +1,246 @@ +/** + * `/api/*` as TanStack Start actually serves it. + * + * `hono-bridge.test.ts` and `stage-1-runtime.test.ts` call the bridge directly, + * which proves the forwarding is lossless but says nothing about whether the + * framework ever calls it. Everything here goes in through the real + * `createStartHandler` instead, so the parts only the framework owns are under + * test too: that `/api/$` is the route the path matches, that the single `ANY` + * handler is picked for every method, and that a request which finds nothing in + * the API fails as an API call rather than falling through to the app shell. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { getRouter } from '#/router' +import { createApiBridge } from '#/server/api-bridge' + +import type { + ApiBridge, + ApiBridgeFactory, + ApiFixture, +} from './api-bridge-contract' + +import { + API_BASE, + createApiFixture, + describeApiBridgeContract, +} from './api-bridge-contract' +import { SHELL_BODY, startHandler } from './start-runtime/handler' + +const WEB_ORIGIN = 'https://web.test' + +/** + * The methods the VitNode API needs. Its OpenAPI routes register `get`, `post`, + * `put`, `patch` and `delete`; `cors()` answers the `OPTIONS` preflight, and + * Hono answers `HEAD` from the matching `GET`. + */ +const METHODS = [ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'OPTIONS', + 'HEAD', +] as const + +let bridge: ApiBridge | undefined + +/** + * The route imports the API instance at module load, and building it opens + * Redis, starts the cron scheduler and needs Postgres. Only the bridge call is + * under test, so the module is replaced with one that forwards to whichever + * fixture app the running test installed - through the real `createApiBridge`, + * so this file never becomes a second forwarder. + */ +vi.mock('#/server/vitnode-api.server', () => ({ + apiBridge: async (request: Request) => { + if (!bridge) throw new Error('No Hono app installed for this test.') + + return bridge(request) + }, +})) + +const send = async (path: string, init?: RequestInit): Promise => + startHandler(new Request(new URL(path, WEB_ORIGIN), init)) + +/** + * The whole bridge contract, driven through the route instead of by calling the + * bridge. Method, body, query string, request headers, response status, + * `Set-Cookie` and the API's own 404s are all stated there once; running them + * here proves the framework delivers them rather than only that Hono would. + */ +const throughTheRoute: ApiBridgeFactory = (app) => { + bridge = createApiBridge(app) + + return async (request) => startHandler(request) +} + +describeApiBridgeContract('apps/web /api/$ server route', throughTheRoute) + +describe('the /api/$ server route', () => { + let fixture: ApiFixture + + beforeEach(() => { + fixture = createApiFixture() + bridge = createApiBridge(fixture.app) + }) + + describe('method dispatch', () => { + it.each(METHODS)('hands a %s request to Hono unchanged', async (method) => { + await send(`${API_BASE}/echo`, { method }) + + expect(fixture.received.at(-1)?.method).toBe(method) + }) + + it.each(METHODS.filter((method) => method !== 'HEAD'))( + 'answers a %s with what Hono returned', + async (method) => { + const res = await send(`${API_BASE}/echo`, { method }) + + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ method }) + }, + ) + + it('dispatches a method the framework has no name for', async () => { + // The route declares one `ANY` handler rather than a table of methods, so + // there is nothing to keep in sync with the API. Anything Hono is willing + // to route arrives; a per-method table would 404 here. + const res = await send(`${API_BASE}/echo`, { method: 'PROPFIND' }) + + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ method: 'PROPFIND' }) + }) + + it('lets Hono answer the CORS preflight', async () => { + // `VitNodeAPI` mounts `cors()`, so the preflight is the API's to answer. + // The framework has no `OPTIONS` handler of its own to answer it with. + await send(`${API_BASE}/echo`, { + method: 'OPTIONS', + headers: { + 'access-control-request-method': 'POST', + origin: 'https://app.test', + }, + }) + + const received = fixture.received.at(-1) + expect(received?.method).toBe('OPTIONS') + expect(received?.headers.origin).toBe('https://app.test') + }) + }) + + describe('HEAD', () => { + it('answers with the GET status and headers and no body', async () => { + const res = await send(`${API_BASE}/text`, { method: 'HEAD' }) + + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('text/plain') + // Start strips the body itself when a route has no `HEAD` handler of its + // own, which is what a HEAD response is supposed to look like. + expect(await res.text()).toBe('') + }) + + it('keeps a Hono 404 a 404', async () => { + const res = await send(`${API_BASE}/nope`, { method: 'HEAD' }) + + expect(res.status).toBe(404) + expect(await res.text()).toBe('') + }) + }) + + describe('request bodies', () => { + it.each(['POST', 'PUT', 'PATCH', 'DELETE'])( + 'forwards a %s body', + async (method) => { + const body = JSON.stringify({ name: 'VitNode' }) + await send(`${API_BASE}/echo`, { + method, + body, + headers: { 'content-type': 'application/json' }, + }) + + expect(fixture.received.at(-1)?.body).toBe(body) + }, + ) + + it('forwards a multipart upload', async () => { + // Avatars and attachments arrive this way. The body is a stream, so a + // bridge or a middleware that read it first would leave Hono nothing. + const form = new FormData() + form.set('name', 'VitNode') + form.set('file', new File(['hello'], 'a.txt', { type: 'text/plain' })) + + const res = await send(`${API_BASE}/echo`, { method: 'POST', body: form }) + const received = fixture.received.at(-1) + + expect(res.status).toBe(200) + expect(received?.headers['content-type']).toContain('multipart/form-data') + expect(received?.body).toContain('hello') + }) + }) + + describe('never the app shell', () => { + it('renders the shell for a page route', async () => { + // The control for everything below it: SSR is reachable in this harness, + // so an API path not reaching it is a decision rather than an accident. + const res = await send('/') + + expect(res.status).toBe(200) + expect(await res.text()).toBe(SHELL_BODY) + }) + + it.each([ + `${API_BASE}/nope`, + `${API_BASE}/echo/`, + '/api/@vitnode/unknown/echo', + '/api/nonsense/deep/path', + '/api', + ])('answers %s with the API 404 instead', async (path) => { + const res = await send(path) + + expect(res.status).toBe(404) + expect(await res.text()).not.toBe(SHELL_BODY) + }) + + it('answers a request that asks for JSON', async () => { + // The router refuses a non-HTML `Accept` with a 500, and every API client + // sends one - so this only passes while the server route answers first. + const res = await send(`${API_BASE}/echo`, { + headers: { accept: 'application/json' }, + }) + + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ method: 'GET' }) + }) + + it('is the only route under /api', () => { + // A page route added anywhere under `/api` outranks the splat for its own + // path, and that path would quietly start answering HTML. + const underApi = Object.keys(getRouter().routesById).filter( + (id) => id === '/api' || id.startsWith('/api/'), + ) + + expect(underApi).toEqual(['/api/$']) + }) + }) + + describe('the request the API is handed', () => { + it('is not screened by the framework before it gets there', async () => { + // Start's default request middleware is CSRF protection scoped to server + // functions. If a global one ever lands without that filter, every + // cross-origin API call starts failing - which is this assertion. + const res = await send(`${API_BASE}/body`, { + method: 'POST', + body: JSON.stringify({ ok: true }), + headers: { + 'content-type': 'application/json', + origin: 'https://other.test', + }, + }) + + expect(res.status).toBe(201) + expect(await res.json()).toEqual({ ok: true }) + }) + }) +}) diff --git a/apps/web/src/tests/start-runtime/handler.ts b/apps/web/src/tests/start-runtime/handler.ts new file mode 100644 index 000000000..405864185 --- /dev/null +++ b/apps/web/src/tests/start-runtime/handler.ts @@ -0,0 +1,27 @@ +import { createStartHandler } from '@tanstack/react-start/server' + +/** + * The app shell, stood in for. + * + * `createStartHandler` takes the SSR render callback, so anything that reaches + * the app router lands here instead of rendering React. That makes the shell + * identifiable in an assertion: a response carrying this body came from the + * router, and a response that does not came from a server route. + */ +export const SHELL_BODY = '' + +/** + * The real TanStack Start request handler, over the app's real route tree. + * + * This is the same function Nitro calls for every request in production - route + * matching, the request middleware chain, server-route method dispatch and the + * fall-through to SSR all run for real. Only the two things a test cannot have + * are replaced: the built asset manifest and the React render. + */ +export const startHandler = createStartHandler( + () => + new Response(SHELL_BODY, { + headers: { 'content-type': 'text/html; charset=utf-8' }, + status: 200, + }), +) diff --git a/apps/web/src/tests/start-runtime/manifest.ts b/apps/web/src/tests/start-runtime/manifest.ts new file mode 100644 index 000000000..71347b4de --- /dev/null +++ b/apps/web/src/tests/start-runtime/manifest.ts @@ -0,0 +1,9 @@ +/** + * `tanstack-start-manifest:v` for the test run. + * + * The manifest lists the built client assets per route, which only exist after + * a real build. Nothing under `/api/*` reaches it - a server route answers + * before the SSR pass begins - so an empty one is enough to let the shell + * render and act as the control the API assertions are measured against. + */ +export const tsrStartManifest = () => ({ routes: {} }) diff --git a/apps/web/src/tests/start-runtime/plugin-adapters.ts b/apps/web/src/tests/start-runtime/plugin-adapters.ts new file mode 100644 index 000000000..171d3e918 --- /dev/null +++ b/apps/web/src/tests/start-runtime/plugin-adapters.ts @@ -0,0 +1,7 @@ +/** + * `#tanstack-start-plugin-adapters` for the test run. No Start plugin in this + * app contributes serialization adapters, so there is nothing to register. + */ +export const hasPluginAdapters = false + +export const pluginSerializationAdapters = [] diff --git a/apps/web/src/tests/start-runtime/router-entry.ts b/apps/web/src/tests/start-runtime/router-entry.ts new file mode 100644 index 000000000..1b74b9dfb --- /dev/null +++ b/apps/web/src/tests/start-runtime/router-entry.ts @@ -0,0 +1,10 @@ +/** + * `#tanstack-router-entry` for the test run. + * + * The Start Vite plugin generates this module in a real build; here it is + * aliased in `vitest.config.ts` so `createStartHandler` can be driven without + * the plugin. It hands back the app's own router - the real + * `routeTree.gen.ts`, so the real `/api/$` route object - which is the whole + * point: the tests exercise the routes the app actually serves. + */ +export { getRouter } from '#/router' diff --git a/apps/web/src/tests/start-runtime/start-entry.ts b/apps/web/src/tests/start-runtime/start-entry.ts new file mode 100644 index 000000000..75fe716c0 --- /dev/null +++ b/apps/web/src/tests/start-runtime/start-entry.ts @@ -0,0 +1,8 @@ +/** + * `#tanstack-start-entry` for the test run. + * + * The app has no `src/start.ts`, so it has no start instance either and Start + * falls back to its own default request middleware. Mirroring that here keeps + * the middleware chain the tests run identical to production's. + */ +export const startInstance = undefined diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index d9dc4a9bd..6ce3905e4 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -10,6 +10,11 @@ export default defineConfig({ globals: true, environment: 'node', include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + server: { + deps: { + inline: [/@tanstack\/react-start/, /@tanstack\/start-server-core/], + }, + }, exclude: [ '**/node_modules/**', '**/dist/**', @@ -20,6 +25,22 @@ export default defineConfig({ }, resolve: { alias: { + 'tanstack-start-manifest:v': resolve( + import.meta.dirname, + './src/tests/start-runtime/manifest.ts', + ), + '#tanstack-router-entry': resolve( + import.meta.dirname, + './src/tests/start-runtime/router-entry.ts', + ), + '#tanstack-start-entry': resolve( + import.meta.dirname, + './src/tests/start-runtime/start-entry.ts', + ), + '#tanstack-start-plugin-adapters': resolve( + import.meta.dirname, + './src/tests/start-runtime/plugin-adapters.ts', + ), '#': resolve(import.meta.dirname, './src'), '@': resolve(import.meta.dirname, './src'), }, diff --git a/packages/vitnode/src/lib/fetcher/core.ts b/packages/vitnode/src/lib/fetcher/core.ts index 8187930f3..a5eccf83d 100644 --- a/packages/vitnode/src/lib/fetcher/core.ts +++ b/packages/vitnode/src/lib/fetcher/core.ts @@ -46,6 +46,12 @@ interface CoreFetcherOptions< method: Method; module: ModuleName; options?: Omit; + /** + * Origin to call, instead of the `NEXT_PUBLIC_API_URL` one. Set by a runtime + * that serves the API itself and knows the origin only per request; see + * `RawApiFetchArgs["origin"]`. + */ + origin?: string; path: SelectedPath; prefixPath?: string; withPagination?: boolean; @@ -76,6 +82,7 @@ export async function coreFetcher< withPagination = false, prefixPath = "", formData, + origin, }: CoreFetcherOptions, ): Promise< InferResponseType @@ -87,6 +94,7 @@ export async function coreFetcher< method, module, options, + origin, params: args && "params" in args ? (args.params as Record) diff --git a/packages/vitnode/src/lib/fetcher/raw.test.ts b/packages/vitnode/src/lib/fetcher/raw.test.ts index 2defa92e1..18aa01060 100644 --- a/packages/vitnode/src/lib/fetcher/raw.test.ts +++ b/packages/vitnode/src/lib/fetcher/raw.test.ts @@ -90,6 +90,20 @@ describe("buildApiUrl", () => { ).toBe("https://api.example.com"); }); + it("builds against the origin the caller passes instead of the env one", () => { + // What a runtime that serves the API itself needs: the origin is the one + // the request being handled arrived on, which is a per-request value and on + // a preview deployment a hostname nobody configured. + expect( + buildApiUrl({ + module: "middleware", + origin: "https://web-git-branch.vercel.app", + path: "/", + pluginId: PLUGIN_ID, + }).toString(), + ).toBe(`https://web-git-branch.vercel.app/api/${PLUGIN_ID}/middleware`); + }); + it("adds the pagination defaults only when asked", () => { const url = buildApiUrl({ module: "users", @@ -126,6 +140,24 @@ describe("rawApiFetch against the mounted API", () => { vi.restoreAllMocks(); }); + it("calls the origin passed on the call rather than the configured one", async () => { + // `rawApiFetch` forwards the override to the URL builder, so a caller that + // knows its origin per request never has to reach for `fetch` directly. + vi.stubEnv("NEXT_PUBLIC_API_URL", "http://localhost:3000"); + + await rawApiFetch({ + method: "get", + module: "middleware", + origin: "http://localhost:3001", + path: "/", + pluginId: PLUGIN_ID, + }); + + expect(new URL(api.seen.at(0)?.url ?? "").origin).toBe( + "http://localhost:3001", + ); + }); + it("reaches the route the URL builder addressed", async () => { const response = await rawApiFetch({ method: "get", diff --git a/packages/vitnode/src/lib/fetcher/raw.ts b/packages/vitnode/src/lib/fetcher/raw.ts index cfa2d2dfb..06896e71e 100644 --- a/packages/vitnode/src/lib/fetcher/raw.ts +++ b/packages/vitnode/src/lib/fetcher/raw.ts @@ -24,6 +24,15 @@ export interface RawApiFetchArgs { */ next?: { revalidate?: false | number; tags?: string[] }; }; + /** + * Origin to build the URL against, instead of `NEXT_PUBLIC_API_URL`. + * + * For a runtime that serves the API itself, the right origin is the one the + * request being handled arrived on: it is only knowable per request, and on a + * preview deployment it is a hostname nobody configured. Left unset + * everywhere else, so Next.js and the browser keep reading `CONFIG.api`. + */ + origin?: string; params?: Record; /** Route path within the module, e.g. `/` or `/{id}`. */ path: string; @@ -35,6 +44,7 @@ export interface RawApiFetchArgs { export const buildApiUrl = ({ module, + origin, params, path, pluginId, @@ -44,6 +54,7 @@ export const buildApiUrl = ({ }: Pick< RawApiFetchArgs, | "module" + | "origin" | "params" | "path" | "pluginId" @@ -65,7 +76,7 @@ export const buildApiUrl = ({ const url = new URL( `/api/${pluginId}${prefixPath}/${module}${formattedPath === "/" ? "" : formattedPath}`, - CONFIG.api.origin, + origin ?? CONFIG.api.origin, ); if (query) { From 6a1d90cf5285e95fb9dd1fd581936849e5c3299e Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Wed, 26 Aug 2026 21:06:19 +0200 Subject: [PATCH 4/5] chore: remove checker api --- apps/web/.env.example | 9 +- apps/web/package.json | 12 +- apps/web/src/routeTree.gen.ts | 42 +----- apps/web/src/routes/api-check.tsx | 131 ------------------ apps/web/src/routes/session-check.tsx | 60 -------- apps/web/src/server/fetcher.server.ts | 16 +-- apps/web/src/tests/api-origin.test.ts | 59 +++++++- .../src/tests/fetcher-request-context.test.ts | 46 +++++- packages/vitnode/src/lib/config.test.ts | 99 +++++++++++++ packages/vitnode/src/lib/config.ts | 33 ++++- .../fetcher/cookie-from-string-to-object.ts | 1 + .../src/lib/fetcher/helpers-server.test.ts | 103 ++++++++++++++ .../src/lib/fetcher/set-cookie.test.ts | 109 +++++++++++++++ .../vitnode/src/lib/fetcher/set-cookie.ts | 27 ++++ pnpm-lock.yaml | 12 +- 15 files changed, 496 insertions(+), 263 deletions(-) delete mode 100644 apps/web/src/routes/api-check.tsx delete mode 100644 apps/web/src/routes/session-check.tsx create mode 100644 packages/vitnode/src/lib/config.test.ts create mode 100644 packages/vitnode/src/lib/fetcher/helpers-server.test.ts diff --git a/apps/web/.env.example b/apps/web/.env.example index 63f5994a7..6a1dbbcf4 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -6,11 +6,12 @@ REDIS_URL=redis://localhost:6379 # either of them at 3000 and the browser talks to whatever else is on 3000, # usually `apps/docs`, instead of the API mounted in this process. # -# Server-side calls do not read these: `resolveApiOrigin()` in +# Neither side strictly needs them: `resolveApiOrigin()` in # `src/server/fetcher.server.ts` takes the origin off the request being -# rendered, so a preview deployment on a generated hostname needs no config at -# all. These two are the copy inlined into the browser bundle, plus the fallback -# for code that runs outside a request. +# rendered, and in the browser `CONFIG.api` falls back to the origin the page +# was served from - so a preview deployment on a generated hostname needs no +# config at all. Set `NEXT_PUBLIC_API_URL` only to point at a separate API +# server; `NEXT_PUBLIC_WEB_URL` is still what the API stamps cookies with. NEXT_PUBLIC_WEB_URL=http://localhost:3001 NEXT_PUBLIC_API_URL=http://localhost:3001 diff --git a/apps/web/package.json b/apps/web/package.json index ecd492437..7f699e9f8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,10 +22,10 @@ "dependencies": { "@hono/zod-openapi": "^1.5.1", "@tailwindcss/vite": "^4.1.18", - "@tanstack/react-devtools": "latest", - "@tanstack/react-router": "latest", - "@tanstack/react-router-devtools": "latest", - "@tanstack/react-start": "latest", + "@tanstack/react-devtools": "^0.10.12", + "@tanstack/react-router": "^1.170.32", + "@tanstack/react-router-devtools": "^1.167.1", + "@tanstack/react-start": "^1.168.49", "@vitnode/blog": "workspace:*", "@vitnode/core": "workspace:*", "@vitnode/example": "workspace:*", @@ -41,8 +41,8 @@ "zod": "^4.4.3" }, "devDependencies": { - "@tanstack/devtools-vite": "latest", - "@tanstack/eslint-config": "latest", + "@tanstack/devtools-vite": "^0.8.5", + "@tanstack/eslint-config": "^0.4.0", "@tanstack/router-cli": "^1.132.0", "@types/node": "^22.10.2", "@types/react": "^19.2.0", diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 25524f746..c9f46a291 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -10,8 +10,6 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' -import { Route as ApiCheckRouteImport } from './routes/api-check' -import { Route as SessionCheckRouteImport } from './routes/session-check' import { Route as ApiSplatRouteImport } from './routes/api/$' const IndexRoute = IndexRouteImport.update({ @@ -19,16 +17,6 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) -const ApiCheckRoute = ApiCheckRouteImport.update({ - id: '/api-check', - path: '/api-check', - getParentRoute: () => rootRouteImport, -} as any) -const SessionCheckRoute = SessionCheckRouteImport.update({ - id: '/session-check', - path: '/session-check', - getParentRoute: () => rootRouteImport, -} as any) const ApiSplatRoute = ApiSplatRouteImport.update({ id: '/api/$', path: '/api/$', @@ -37,35 +25,27 @@ const ApiSplatRoute = ApiSplatRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/api-check': typeof ApiCheckRoute - '/session-check': typeof SessionCheckRoute '/api/$': typeof ApiSplatRoute } export interface FileRoutesByTo { '/': typeof IndexRoute - '/api-check': typeof ApiCheckRoute - '/session-check': typeof SessionCheckRoute '/api/$': typeof ApiSplatRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute - '/api-check': typeof ApiCheckRoute - '/session-check': typeof SessionCheckRoute '/api/$': typeof ApiSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/api-check' | '/session-check' | '/api/$' + fullPaths: '/' | '/api/$' fileRoutesByTo: FileRoutesByTo - to: '/' | '/api-check' | '/session-check' | '/api/$' - id: '__root__' | '/' | '/api-check' | '/session-check' | '/api/$' + to: '/' | '/api/$' + id: '__root__' | '/' | '/api/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute - ApiCheckRoute: typeof ApiCheckRoute - SessionCheckRoute: typeof SessionCheckRoute ApiSplatRoute: typeof ApiSplatRoute } @@ -78,20 +58,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } - '/api-check': { - id: '/api-check' - path: '/api-check' - fullPath: '/api-check' - preLoaderRoute: typeof ApiCheckRouteImport - parentRoute: typeof rootRouteImport - } - '/session-check': { - id: '/session-check' - path: '/session-check' - fullPath: '/session-check' - preLoaderRoute: typeof SessionCheckRouteImport - parentRoute: typeof rootRouteImport - } '/api/$': { id: '/api/$' path: '/api/$' @@ -104,8 +70,6 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, - ApiCheckRoute: ApiCheckRoute, - SessionCheckRoute: SessionCheckRoute, ApiSplatRoute: ApiSplatRoute, } export const routeTree = rootRouteImport diff --git a/apps/web/src/routes/api-check.tsx b/apps/web/src/routes/api-check.tsx deleted file mode 100644 index e2fe8c4ab..000000000 --- a/apps/web/src/routes/api-check.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' -import { createServerFn } from '@tanstack/react-start' -import { getRequestHeader } from '@tanstack/react-start/server' - -import { resolveApiOrigin } from '#/server/fetcher.server' - -interface ApiProbe { - body: string - label: string - ok: boolean - path: string - status: number -} - -/** - * Two endpoints of the mounted API, both real: - * - * - the OpenAPI document, registered by `VitNodeAPI` itself, which answers - * without touching the database - so it isolates "is Hono mounted" from "is - * Postgres up"; - * - a plugin route, which runs the whole chain the API always runs: cors, csrf, - * rate limiter, `globalMiddleware` (session lookup included) and the plugin - * router the plugin id resolves to. - */ -const PROBES = [ - { label: 'OpenAPI document (no database)', path: '/api/swagger/doc' }, - { - label: 'Core plugin route (full middleware chain)', - path: '/api/@vitnode/core/middleware', - }, -] as const - -const probeApi = createServerFn().handler(async (): Promise => { - // Same-origin by construction: the API is mounted in this app, so the origin - // of the request being rendered is the origin to call. The same resolver the - // fetcher uses, so this page cannot pass while real calls go elsewhere. - const origin = resolveApiOrigin() - const cookie = getRequestHeader('cookie') - const userAgent = getRequestHeader('user-agent') - - return await Promise.all( - PROBES.map(async ({ label, path }) => { - const headers = new Headers() - // Forwarded so a signed-in SSR render is answered as that user. This is a - // verification page, not the fetcher - real calls go through - // `@vitnode/core`'s fetcher. - if (cookie) headers.set('cookie', cookie) - if (userAgent) headers.set('user-agent', userAgent) - - try { - const response = await fetch(new URL(path, origin), { headers }) - const body = await response.text() - - return { - body: body.slice(0, 600), - label, - ok: response.ok, - path, - status: response.status, - } - } catch (error) { - return { - body: error instanceof Error ? error.message : String(error), - label, - ok: false, - path, - status: 0, - } - } - }), - ) -}) - -export const Route = createFileRoute('/api-check')({ - loader: async () => probeApi(), - component: ApiCheck, -}) - -function ApiCheck() { - const probes = Route.useLoaderData() - - return ( -
-
-

- Hono API bridge -

-

- Rendered on the server. Each row is a same-origin request this app - made to /api/* during SSR, answered by the VitNode Hono - application mounted in this process. -

-
- -
    - {probes.map((probe) => ( -
  • -
    -

    - {probe.label} -

    - - - {probe.ok ? 'Succeeded with status ' : 'Failed with status '} - - {probe.status || 'no response'} - -
    - - GET {probe.path} - -
    -
    -                {probe.body || '(empty body)'}
    -              
    -
    -
  • - ))} -
-
- ) -} diff --git a/apps/web/src/routes/session-check.tsx b/apps/web/src/routes/session-check.tsx deleted file mode 100644 index dfc946c58..000000000 --- a/apps/web/src/routes/session-check.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' - -import { getSession } from '#/lib/session' - -/** - * Whether the API recognises the visitor rendering this page. - * - * The one thing Stage 1 has to be able to show: `@vitnode/core`'s fetcher, - * called during SSR, answered for the *browser's* session rather than for the - * server. Signed out it reads "anonymous"; sign in through any VitNode app on - * this host and it names the user - without this page knowing anything about - * authentication. - */ -export const Route = createFileRoute('/session-check')({ - component: SessionCheck, - loader: async () => getSession(), -}) - -function SessionCheck() { - const { user } = Route.useLoaderData() - - return ( -
-
-

- Session forwarding -

-

- Rendered on the server. The API was asked who is signed in through{' '} - @vitnode/core's fetcher, with this request's - cookies, user-agent and forwarded IP attached. -

-
- -
-
-
- Identified as -
-
- {user ? user.name : 'anonymous'} -
-
- - {user ? ( -
-
User ID
-
{user.id}
-
- ) : null} -
-
- ) -} diff --git a/apps/web/src/server/fetcher.server.ts b/apps/web/src/server/fetcher.server.ts index 83fc6b709..895f9714d 100644 --- a/apps/web/src/server/fetcher.server.ts +++ b/apps/web/src/server/fetcher.server.ts @@ -42,6 +42,11 @@ config({ quiet: true }) * * Outside a request - boot, a script, a cron job - there is nothing to read and * `getRequestUrl()` throws, so `NEXT_PUBLIC_API_URL` remains the fallback. + * + * The browser reaches the same conclusion on its own: with nothing configured, + * `CONFIG.api` reads the origin the document was served from, so a client-side + * call stays on this app too. `NEXT_PUBLIC_API_URL` is therefore optional here + * rather than load-bearing - set it only to point at a separate API server. */ export const resolveApiOrigin = (): string => { try { @@ -51,17 +56,6 @@ export const resolveApiOrigin = (): string => { } } -if (!process.env.NEXT_PUBLIC_API_URL && process.env.NODE_ENV === 'production') { - // Server-side calls no longer need it, but the browser bundle still does: - // `vitnode-env.ts` inlines this value, and with nothing to inline a - // client-side call falls back to `http://localhost:3000` - somebody else's - // machine, from the visitor's browser. - // eslint-disable-next-line no-console - console.warn( - '\x1b[34m[VitNode]\x1b[0m \x1b[33mNEXT_PUBLIC_API_URL is not set; client-side API calls will fall back to http://localhost:3000\x1b[0m', - ) -} - /** * The request state this app forwards to the API, read off the request being * rendered. diff --git a/apps/web/src/tests/api-origin.test.ts b/apps/web/src/tests/api-origin.test.ts index 27d994709..90cfdb9f1 100644 --- a/apps/web/src/tests/api-origin.test.ts +++ b/apps/web/src/tests/api-origin.test.ts @@ -1,4 +1,5 @@ import { requestHandler } from '@tanstack/react-start/server' +import { buildApiUrl } from '@vitnode/core/lib/fetcher/raw' import { Hono } from 'hono' import { readFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' @@ -121,7 +122,7 @@ describe('resolveApiOrigin', () => { // Not the configured value: the API is served by this process, so the // origin that reached it is the origin to call back on. await expect( - withRequest('http://localhost:3001/session-check', {}, resolveApiOrigin), + withRequest('http://localhost:3001/', {}, resolveApiOrigin), ).resolves.toBe('http://localhost:3001') }) @@ -206,7 +207,7 @@ describe('SSR calls through fetcherServer', () => { ) it('calls the origin the page was requested on', async () => { - await withRequest('https://web.test/session-check', {}, callSession) + await withRequest('https://web.test/', {}, callSession) expect(recorded.at(0)).toStrictEqual({ origin: 'https://web.test', @@ -219,7 +220,7 @@ describe('SSR calls through fetcherServer', () => { // is `apps/docs`. The call has to stay on 3001, where the mounted API is. vi.stubEnv('NEXT_PUBLIC_API_URL', 'http://localhost:3000') - await withRequest('http://localhost:3001/session-check', {}, callSession) + await withRequest('http://localhost:3001/', {}, callSession) expect(recorded.at(0)?.origin).toBe('http://localhost:3001') }) @@ -228,7 +229,7 @@ describe('SSR calls through fetcherServer', () => { // The escape hatch a genuinely separate API server needs. Nothing in this // app passes it; the option exists so the request-derived default is a // default rather than a hard-coding. - await withRequest('https://web.test/session-check', {}, async () => + await withRequest('https://web.test/', {}, async () => ( fetcherServer as unknown as ( moduleReturn: { pluginId: string }, @@ -253,3 +254,53 @@ describe('SSR calls through fetcherServer', () => { expect(recorded.at(0)?.origin).toBe('https://api.example.com') }) }) + +describe('browser-side calls', () => { + /** A client-side call, with the page served from `origin`. */ + const fromPageAt = (origin: string): string => { + vi.stubGlobal('location', { origin }) + + return buildApiUrl({ + module: 'users', + path: '/session', + pluginId: PLUGIN_ID, + }).toString() + } + + afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() + }) + + it('stays on the origin the page was served from', () => { + // The API is mounted in this app, so `https://example.com` serves + // `https://example.com/api/*` and the browser already knows where to call. + // Nothing has to name the origin for a client-side call to reach it. + vi.stubEnv('NEXT_PUBLIC_API_URL', undefined) + + expect(fromPageAt('https://example.com')).toBe( + `https://example.com${API_BASE}/users/session`, + ) + }) + + it('does not send the visitor to their own machine on a preview deployment', () => { + // The regression this closes: with `NEXT_PUBLIC_API_URL` unset the default + // was `http://localhost:3000`, so every client-side call from a visitor's + // browser went to that visitor's own machine. + vi.stubEnv('NEXT_PUBLIC_API_URL', undefined) + + expect(fromPageAt('https://web-git-feat-tanstack-abc123.vercel.app')).toBe( + `https://web-git-feat-tanstack-abc123.vercel.app${API_BASE}/users/session`, + ) + }) + + it('still lets a configured API origin win', () => { + // A genuinely separate API server: same-origin is the default, not a + // hard-coding. + vi.stubEnv('NEXT_PUBLIC_API_URL', 'https://api.example.com') + + expect(fromPageAt('https://example.com')).toBe( + `https://api.example.com${API_BASE}/users/session`, + ) + }) +}) diff --git a/apps/web/src/tests/fetcher-request-context.test.ts b/apps/web/src/tests/fetcher-request-context.test.ts index cfda10d4f..1e6b429db 100644 --- a/apps/web/src/tests/fetcher-request-context.test.ts +++ b/apps/web/src/tests/fetcher-request-context.test.ts @@ -1,5 +1,6 @@ import { requestHandler } from '@tanstack/react-start/server' import { Hono } from 'hono' +import { deleteCookie } from 'hono/cookie' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { @@ -59,6 +60,23 @@ const createApi = (recorded: Recorded[]) => { c.json({ message: 'slow down' }, 429), ) + plugin.get('/users/sign-out', (c) => { + // `hono/cookie`'s own helper rather than a hand-written header: the whole + // question is what the real API sends, and it sends `name=; Max-Age=0` with + // no `Expires` to fall back on. + deleteCookie(c, 'vitnode_auth', { path: '/' }) + + return c.json({ ok: true }) + }) + + plugin.get('/users/remember-device', (c) => { + c.header('set-cookie', 'vitnode_device=device; Path=/; Max-Age=31536000', { + append: true, + }) + + return c.json({ ok: true }) + }) + const app = new Hono().basePath('/api') app.route(`/${PLUGIN_ID}`, plugin) @@ -80,7 +98,7 @@ const withRequest = async ( result = await handler() return new Response(null, { status: 204 }) - })(new Request(`${WEB_ORIGIN}/session-check`, init), {}) + })(new Request(`${WEB_ORIGIN}/`, init), {}) return { result, setCookie: response.headers.getSetCookie() } } @@ -242,6 +260,9 @@ describe('SSR request context reaches the API', () => { }) describe('saveApiCookies', () => { + const call = async (path: string) => + await callFetcher(usersModule, { method: 'get', module: 'users', path }) + it('puts every cookie the API minted on this response', async () => { const { setCookie } = await withRequest({}, async () => { const response = await callFetcher(usersModule, { @@ -262,6 +283,29 @@ describe('SSR request context reaches the API', () => { ]) }) + it('carries a lifetime through instead of downgrading it to a session', async () => { + const { setCookie } = await withRequest({}, async () => { + saveApiCookies(await call('/remember-device')) + }) + + // Dropped, the device cookie lasts until the browser closes - and a new + // device row is written on the visitor's next visit. + expect(setCookie).toEqual([ + 'vitnode_device=device; Max-Age=31536000; Path=/', + ]) + }) + + it('forwards a sign-out as a deletion rather than an empty cookie', async () => { + const { setCookie } = await withRequest({}, async () => { + saveApiCookies(await call('/sign-out')) + }) + + // `Max-Age=0` is the entire instruction here. Without it the browser is + // told to hold an empty `vitnode_auth` for the rest of the session, so the + // cookie the visitor just signed out of survives the sign-out. + expect(setCookie).toEqual(['vitnode_auth=; Max-Age=0; Path=/']) + }) + it('writes nothing for a response that set no cookies', async () => { const { setCookie } = await withRequest({}, () => { saveApiCookies(new Response(null)) diff --git a/packages/vitnode/src/lib/config.test.ts b/packages/vitnode/src/lib/config.test.ts new file mode 100644 index 000000000..f823734ca --- /dev/null +++ b/packages/vitnode/src/lib/config.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { CONFIG } from "./config"; + +/** + * Runs `read` with the page served from `origin`, the way a browser would have + * it. `location` is a real object under jsdom, so it is replaced wholesale + * rather than assigned to. + */ +const inBrowserAt = (origin: string | undefined, read: () => T): T => { + vi.stubGlobal("location", origin === undefined ? undefined : { origin }); + + try { + return read(); + } finally { + vi.unstubAllGlobals(); + } +}; + +describe("CONFIG.api", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("is the configured origin when there is one", () => { + vi.stubEnv("NEXT_PUBLIC_API_URL", "https://api.example.com"); + + expect( + inBrowserAt("https://web.example.com", () => CONFIG.api.origin), + ).toBe("https://api.example.com"); + }); + + it("is the page's own origin in a browser when nothing is configured", () => { + // The mount is same-origin - this app serves `/api/*` itself - so the + // browser already knows the answer and no environment variable has to. + vi.stubEnv("NEXT_PUBLIC_API_URL", undefined); + + expect(inBrowserAt("https://vitnode.com", () => CONFIG.api.origin)).toBe( + "https://vitnode.com", + ); + }); + + it("works on a hostname nobody could have configured", () => { + // A preview deployment: the URL is generated per branch, so the old + // `http://localhost:3000` default pointed every visitor's browser at their + // own machine. + vi.stubEnv("NEXT_PUBLIC_API_URL", undefined); + + expect( + inBrowserAt( + "https://web-git-feat-abc123.vercel.app", + () => CONFIG.api.origin, + ), + ).toBe("https://web-git-feat-abc123.vercel.app"); + }); + + it("keeps the configured origin ahead of the page's own", () => { + // A genuinely separate API server stays reachable: same-origin is the + // default, not a hard-coding. + vi.stubEnv("NEXT_PUBLIC_API_URL", "https://api.example.com"); + + expect( + inBrowserAt("https://web-git-feat-abc123.vercel.app", () => + CONFIG.api.toString(), + ), + ).toBe("https://api.example.com/"); + }); + + it("falls back to localhost off a document, where there is no origin to read", () => { + // Node: the API server, a script, a build. Nothing to read, so the + // configured value - or its default - is all there is. + vi.stubEnv("NEXT_PUBLIC_API_URL", undefined); + + expect(inBrowserAt(undefined, () => CONFIG.api.origin)).toBe( + "http://localhost:3000", + ); + }); + + it("ignores an opaque origin rather than throwing on it", () => { + // A sandboxed iframe reports the string `"null"`, which `new URL()` would + // reject - and a throw here takes the whole render with it. + vi.stubEnv("NEXT_PUBLIC_API_URL", undefined); + + expect(inBrowserAt("null", () => CONFIG.api.origin)).toBe( + "http://localhost:3000", + ); + }); + + it("still throws on an empty NEXT_PUBLIC_API_URL", () => { + // Set-but-broken is a deployment mistake to surface, not an absence to + // paper over: `contentPreviewConfigProblems` reads this throw to report it. + vi.stubEnv("NEXT_PUBLIC_API_URL", ""); + + expect(() => + inBrowserAt("https://vitnode.com", () => CONFIG.api), + ).toThrow(); + }); +}); diff --git a/packages/vitnode/src/lib/config.ts b/packages/vitnode/src/lib/config.ts index bec34833e..9946d5186 100644 --- a/packages/vitnode/src/lib/config.ts +++ b/packages/vitnode/src/lib/config.ts @@ -6,6 +6,30 @@ export const INSECURE_DEFAULT_CRON_SECRET = "default-cron-secret-change-in-production"; +/** + * The origin the page itself was served from, when there is one. + * + * VitNode mounts its API on the app's own origin - `https://example.com` serving + * `https://example.com/api/*` - so in a browser "where is the API" answers + * itself, without configuration. That matters most exactly where configuration + * cannot help: a preview deployment's hostname is generated per branch, so no + * `NEXT_PUBLIC_API_URL` could have named it ahead of time, and the value it + * would otherwise fall back to names the visitor's own machine. + * + * The server half of the same answer is read off the request being handled; see + * `resolveApiOrigin` in the TanStack Start app. + * + * `undefined` wherever there is no document - Node, the API server, a build - so + * those keep falling through to the configured value. + */ +const browserOrigin = (): string | undefined => { + if (typeof location === "undefined") return undefined; + + // A sandboxed iframe or a `data:` document reports the string `"null"`, which + // is not a URL and would throw rather than fall through. + return location.origin.startsWith("http") ? location.origin : undefined; +}; + /** * Env is read lazily via getters, not captured at module load. The standalone * API loads its `.env` (dotenv) only when `vitnode.api.config.ts` runs, which can @@ -14,7 +38,14 @@ export const INSECURE_DEFAULT_CRON_SECRET = */ export const CONFIG = { get api(): URL { - return new URL(process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"); + // `??` rather than `||`, deliberately: an empty `NEXT_PUBLIC_API_URL` is a + // deployment that got it wrong, and `contentPreviewConfigProblems` reads the + // throw to say so. Only an absent one falls through. + return new URL( + process.env.NEXT_PUBLIC_API_URL ?? + browserOrigin() ?? + "http://localhost:3000", + ); }, get cronJobSecret(): string { return process.env.CRON_SECRET ?? INSECURE_DEFAULT_CRON_SECRET; diff --git a/packages/vitnode/src/lib/fetcher/cookie-from-string-to-object.ts b/packages/vitnode/src/lib/fetcher/cookie-from-string-to-object.ts index b526d353c..a05b12c14 100644 --- a/packages/vitnode/src/lib/fetcher/cookie-from-string-to-object.ts +++ b/packages/vitnode/src/lib/fetcher/cookie-from-string-to-object.ts @@ -6,6 +6,7 @@ export const cookieFromStringToObject = ( Domain: string; Expires: string; HttpOnly: boolean; + "Max-Age": string; Path: string; SameSite: "lax" | "none" | "strict" | boolean | undefined; Secure: boolean; diff --git a/packages/vitnode/src/lib/fetcher/helpers-server.test.ts b/packages/vitnode/src/lib/fetcher/helpers-server.test.ts new file mode 100644 index 000000000..00139c819 --- /dev/null +++ b/packages/vitnode/src/lib/fetcher/helpers-server.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// `server-only` throws on import outside a server component, and `next/headers` +// needs a request Next is not handling here. Both are mocked so the forwarding +// itself can be exercised; the cookie store below is Next's own serializer, not +// a stand-in, so what these assert is what a browser would receive. +vi.mock("server-only", () => ({})); + +const written = vi.hoisted(() => ({ headers: new Headers() })); + +vi.mock("next/headers", async () => { + const { ResponseCookies } = + await import("next/dist/server/web/spec-extension/cookies"); + + return { + cookies: async () => + await Promise.resolve(new ResponseCookies(written.headers)), + }; +}); + +const { handleSetCookiesFetcher } = await import("./helpers-server"); + +/** A response from the API carrying the `Set-Cookie` headers it just minted. */ +const apiResponse = (...setCookies: string[]): Response => { + const headers = new Headers(); + for (const value of setCookies) headers.append("set-cookie", value); + + return new Response(null, { headers }); +}; + +/** What Next would put on the page response once the forwarding has run. */ +const forwarded = async (...setCookies: string[]): Promise => { + await handleSetCookiesFetcher(apiResponse(...setCookies)); + + return written.headers.getSetCookie(); +}; + +describe("handleSetCookiesFetcher", () => { + beforeEach(() => { + written.headers = new Headers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("copies a persistent cookie onto the page response", async () => { + // Next derives an `Expires` of its own from `Max-Age`, so the clock has to + // stand still for the header to be assertable in full. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-26T00:00:00Z")); + + await expect( + forwarded( + "vitnode_device=d3v1c3; Path=/; Domain=localhost; Max-Age=31536000; HttpOnly; Secure; SameSite=Lax", + ), + ).resolves.toStrictEqual([ + "vitnode_device=d3v1c3; Path=/; Expires=Thu, 26 Aug 2027 00:00:00 GMT; Max-Age=31536000; Domain=localhost; Secure; HttpOnly; SameSite=lax", + ]); + }); + + it("keeps a session cookie a session cookie", async () => { + // No `Expires` and no `Max-Age` in, neither out: inventing either would + // outlive the browser session the API meant the cookie to last for. + await expect( + forwarded("vitnode_auth=token; Path=/; HttpOnly"), + ).resolves.toStrictEqual(["vitnode_auth=token; Path=/; HttpOnly"]); + }); + + it("copies every cookie of a response, not just the last", async () => { + await expect( + forwarded( + "vitnode_auth=token; Path=/; HttpOnly", + "vitnode_device=device; Path=/; HttpOnly", + ), + ).resolves.toStrictEqual([ + "vitnode_auth=token; Path=/; HttpOnly", + "vitnode_device=device; Path=/; HttpOnly", + ]); + }); + + it("forwards a sign-out as a deletion the browser acts on", async () => { + // The header `hono/cookie`'s `deleteCookie()` sends. With `Max-Age` dropped + // in the parse this arrives as a valueless *session* cookie instead, and the + // visitor keeps a `vitnode_auth` until they close the browser. + await expect( + forwarded("vitnode_auth=; Max-Age=0; Path=/"), + ).resolves.toStrictEqual(["vitnode_auth=; Path=/; Max-Age=0"]); + }); + + it("forwards a deletion written as an expiry in the past", async () => { + await expect( + forwarded("vitnode_auth=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT"), + ).resolves.toStrictEqual([ + "vitnode_auth=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT", + ]); + }); + + it("writes nothing for a response that set no cookies", async () => { + await expect(forwarded()).resolves.toStrictEqual([]); + }); +}); diff --git a/packages/vitnode/src/lib/fetcher/set-cookie.test.ts b/packages/vitnode/src/lib/fetcher/set-cookie.test.ts index d49011b89..7eab9e122 100644 --- a/packages/vitnode/src/lib/fetcher/set-cookie.test.ts +++ b/packages/vitnode/src/lib/fetcher/set-cookie.test.ts @@ -15,6 +15,7 @@ describe("parseSetCookies", () => { domain: "localhost", expires: new Date("Sun, 24 Nov 2026 10:00:00 GMT"), httpOnly: true, + maxAge: undefined, path: "/", sameSite: undefined, secure: true, @@ -33,6 +34,17 @@ describe("parseSetCookies", () => { ).toStrictEqual(["vitnode_auth", "vitnode_device"]); }); + it("keeps each cookie's own attributes when a response sets several", () => { + // One `Max-Age=0` in a response must not delete its neighbour, and one + // persistent cookie must not keep the other alive. + expect( + parseSetCookies([ + "vitnode_auth=; Path=/; Max-Age=0", + "vitnode_device=b; Path=/; Max-Age=31536000", + ]).map(cookie => cookie.options.maxAge), + ).toStrictEqual([0, 31536000]); + }); + it("treats a cookie with no Expires as a session cookie", () => { expect(parseSetCookies(["vitnode_auth=a; Path=/"])[0].options.expires).toBe( undefined, @@ -61,6 +73,7 @@ describe("parseSetCookies", () => { domain: undefined, expires: undefined, httpOnly: false, + maxAge: undefined, path: undefined, sameSite: undefined, secure: false, @@ -74,4 +87,100 @@ describe("parseSetCookies", () => { it("returns nothing for a response that set no cookies", () => { expect(parseSetCookies([])).toStrictEqual([]); }); + + describe("Max-Age", () => { + it("carries a lifetime in seconds through", () => { + expect( + parseSetCookies(["vitnode_device=b; Path=/; Max-Age=31536000"])[0] + .options.maxAge, + ).toBe(31536000); + }); + + it("keeps Max-Age=0 rather than dropping it as falsy", () => { + // The whole point of the attribute here: this is the header Hono's + // `deleteCookie()` sends, so a `0` read as "absent" is a sign-out that + // leaves the cookie in the browser. + expect( + parseSetCookies(["vitnode_auth=; Path=/; Max-Age=0"])[0].options.maxAge, + ).toBe(0); + }); + + it("parses the sign-out header the API actually sends", () => { + // Verbatim from `hono/cookie`'s `deleteCookie()`: an empty value, a + // `Max-Age` of 0, and no `Expires` to fall back on. + expect( + parseSetCookies(["vitnode_auth=; Max-Age=0; Path=/"]), + ).toStrictEqual([ + { + name: "vitnode_auth", + options: { + domain: undefined, + expires: undefined, + httpOnly: false, + maxAge: 0, + path: "/", + sameSite: undefined, + secure: false, + }, + value: "", + }, + ]); + }); + + it("keeps a negative Max-Age, which also means delete now", () => { + expect( + parseSetCookies(["vitnode_auth=; Max-Age=-1"])[0].options.maxAge, + ).toBe(-1); + }); + + it("ignores a Max-Age that is not a plain integer", () => { + // `Number()` would read every one of these as a number and hand a cookie + // store an attribute the API never sent. + for (const header of [ + "vitnode_auth=a; Max-Age=", + "vitnode_auth=a; Max-Age=soon", + "vitnode_auth=a; Max-Age=1e3", + "vitnode_auth=a; Max-Age=12.5", + "vitnode_auth=a; Max-Age= 12", + ]) { + expect(parseSetCookies([header])[0].options.maxAge).toBe(undefined); + } + }); + + it("ignores a bare Max-Age flag with no value", () => { + expect( + parseSetCookies(["vitnode_auth=a; Max-Age"])[0].options.maxAge, + ).toBe(undefined); + }); + + it("forwards both Max-Age and Expires when the API sends both", () => { + // Browsers give `Max-Age` precedence; dropping either here would only + // lose what the API said. + expect( + parseSetCookies([ + "vitnode_auth=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0", + ])[0].options, + ).toMatchObject({ + expires: new Date(0), + maxAge: 0, + }); + }); + }); + + describe("deletion", () => { + it("reads an expired cookie as the deletion it is", () => { + const [cookie] = parseSetCookies([ + "vitnode_auth=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT", + ]); + + expect(cookie.value).toBe(""); + expect(cookie.options.expires).toStrictEqual(new Date(0)); + }); + + it("keeps the empty value a deletion carries", () => { + // `vitnode_auth=` splits to an empty string, not to a missing value - so + // the cookie has to survive parsing rather than be skipped as unnamed. + expect(parseSetCookies(["vitnode_auth=; Max-Age=0"])[0].value).toBe(""); + }); + }); }); diff --git a/packages/vitnode/src/lib/fetcher/set-cookie.ts b/packages/vitnode/src/lib/fetcher/set-cookie.ts index 974f7a886..a7262dfa1 100644 --- a/packages/vitnode/src/lib/fetcher/set-cookie.ts +++ b/packages/vitnode/src/lib/fetcher/set-cookie.ts @@ -15,6 +15,11 @@ export interface ParsedSetCookie { domain?: string; expires?: Date; httpOnly?: boolean; + /** + * Lifetime in seconds. `0` is a value, not an absence: it is how the API + * deletes a cookie, so nothing downstream may treat it as falsy. + */ + maxAge?: number; path?: string; sameSite?: "lax" | "none" | "strict"; secure?: boolean; @@ -50,6 +55,25 @@ const parseExpires = (value: unknown): Date | undefined => { return Number.isNaN(expires.getTime()) ? undefined : expires; }; +/** + * A `Max-Age` in seconds, or nothing. + * + * This is the attribute the API deletes a cookie with: Hono's `deleteCookie()` + * answers with `name=; Max-Age=0` and no `Expires` at all, so dropping it turns + * every sign-out into an empty cookie that lingers until the browser closes + * rather than one the browser discards. + * + * `Number()` alone is too loose - it reads `""`, `" 12 "` and `"1e3"` as + * numbers, and a cookie store would then serialize an attribute the API never + * sent. RFC 6265 spells the value as an optionally-negative digit string and + * says to ignore anything else, which is exactly the test below. + */ +const parseMaxAge = (value: unknown): number | undefined => { + if (typeof value !== "string" || !/^-?\d+$/.test(value)) return undefined; + + return Number(value); +}; + const asString = (value: unknown): string | undefined => typeof value === "string" ? value : undefined; @@ -81,6 +105,9 @@ export const parseSetCookies = ( domain: asString(cookie.Domain), expires: parseExpires(cookie.Expires), httpOnly: asFlag(cookie.HttpOnly), + // Both are forwarded when the API sends both; browsers already give + // `Max-Age` precedence, so narrowing it here would only lose fidelity. + maxAge: parseMaxAge(cookie["Max-Age"]), path: asString(cookie.Path), sameSite: parseSameSite(cookie.SameSite), secure: asFlag(cookie.Secure), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5be3cf137..ac39c4c77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -275,16 +275,16 @@ importers: specifier: ^4.1.18 version: 4.3.3(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) '@tanstack/react-devtools': - specifier: latest + specifier: ^0.10.12 version: 0.10.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(solid-js@1.9.15) '@tanstack/react-router': - specifier: latest + specifier: ^1.170.32 version: 1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tanstack/react-router-devtools': - specifier: latest + specifier: ^1.167.1 version: 1.167.1(@tanstack/react-router@1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@tanstack/router-core@1.171.27)(csstype@3.2.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tanstack/react-start': - specifier: latest + specifier: ^1.168.49 version: 1.168.49(crossws@0.4.12(srvx@0.11.22))(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) '@vitnode/blog': specifier: workspace:* @@ -327,10 +327,10 @@ importers: version: 4.4.3 devDependencies: '@tanstack/devtools-vite': - specifier: latest + specifier: ^0.8.5 version: 0.8.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) '@tanstack/eslint-config': - specifier: latest + specifier: ^0.4.0 version: 0.4.0(@typescript-eslint/utils@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) '@tanstack/router-cli': specifier: ^1.132.0 From b05047fa4244a9a761ec2020c67bbc22a3b5684f Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Wed, 26 Aug 2026 21:24:57 +0200 Subject: [PATCH 5/5] perf: Improve cookie session for tanstack start --- apps/docs/content/docs/dev/advanced/auth.mdx | 43 +++ .../vitnode/src/api/lib/auth-cookie.test.ts | 195 +++++++++++ packages/vitnode/src/api/lib/auth-cookie.ts | 66 ++++ .../src/api/middlewares/global.middleware.ts | 5 + packages/vitnode/src/api/models/device.ts | 10 +- .../vitnode/src/api/models/session-admin.ts | 36 +- .../src/api/models/session-cookies.test.ts | 307 ++++++++++++++++++ packages/vitnode/src/api/models/session.ts | 16 +- packages/vitnode/src/api/models/sso.ts | 15 +- packages/vitnode/src/vitnode.config.ts | 19 ++ 10 files changed, 667 insertions(+), 45 deletions(-) create mode 100644 packages/vitnode/src/api/lib/auth-cookie.test.ts create mode 100644 packages/vitnode/src/api/lib/auth-cookie.ts create mode 100644 packages/vitnode/src/api/models/session-cookies.test.ts diff --git a/apps/docs/content/docs/dev/advanced/auth.mdx b/apps/docs/content/docs/dev/advanced/auth.mdx index 83e5104be..c9077f6a9 100644 --- a/apps/docs/content/docs/dev/advanced/auth.mdx +++ b/apps/docs/content/docs/dev/advanced/auth.mdx @@ -73,5 +73,48 @@ import { TypeTable } from 'fumadocs-ui/components/type-table'; type: 'boolean', default: 'true', }, + cookieDomain: { + description: + 'The Domain to stamp on the auth cookies. Leave it unset unless you share one session across subdomains - see below.', + type: 'string', + default: 'undefined (host-only)', + }, }} /> + +### Sharing a session across subdomains + +By default VitNode sends no `Domain` attribute at all, which makes every auth +cookie **host-only**: valid on exactly the host that issued it, and nowhere +else. That is what you want almost always, because the web app serves `/api/*` +on its own origin - there is no second host to share with. + +It is also the only setting that survives a hostname nobody configured. A +preview deployment gets a fresh URL per branch, and a cookie stamped with a +`Domain` the response did not come from is thrown away by the browser - so +nobody can sign in, and nothing says why. + +If you genuinely run VitNode across several subdomains - say `app.example.com` +and `admin.example.com` - opt in explicitly: + +```ts title="src/vitnode.api.config.ts" +VitNodeAPI({ + app, + plugins: [], + authorization: { + // [!code ++] + cookieDomain: '.example.com', + }, +}); +``` + + + The value has to be one the responding host falls under. `.example.com` works + for `app.example.com`; `example.com` does not work for `example.org`, and + nothing works for a preview URL on a hosting provider's domain. Get it wrong + and every browser silently drops the cookie. + + +Changing this later logs everyone out: the browser treats a host-only cookie and +a `Domain` cookie of the same name as two different cookies, so the old one is +no longer the one VitNode reads or removes. diff --git a/packages/vitnode/src/api/lib/auth-cookie.test.ts b/packages/vitnode/src/api/lib/auth-cookie.test.ts new file mode 100644 index 000000000..c2458cd3e --- /dev/null +++ b/packages/vitnode/src/api/lib/auth-cookie.test.ts @@ -0,0 +1,195 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { Hono } from "hono"; +import { describe, expect, it } from "vitest"; + +import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; + +import { parseSetCookies } from "@/lib/fetcher/set-cookie"; + +import { deleteAuthCookie, setAuthCookie } from "./auth-cookie"; + +type Authorization = EnvVariablesVitNode["core"]["authorization"]; + +const COOKIE = "vitnode_auth"; + +/** + * The `core` context these helpers read, with only the two fields they touch. + * + * Cast once, here: `ContextVariableMap` is augmented globally so `c.set("core")` + * wants the whole registry - plugins, content models, the cron metadata - none + * of which a cookie attribute depends on. + */ +const coreWith = ( + authorization: Partial, +): EnvVariablesVitNode["core"] => + ({ + authorization: { cookieSecure: true, ...authorization }, + }) as EnvVariablesVitNode["core"]; + +/** + * The `Set-Cookie` headers a request to `url` comes back with, once `write` has + * run against a real Hono response. Asserting the header rather than the options + * object is the point: it is what a browser would actually be handed. + */ +const setCookiesFrom = ({ + authorization = {}, + url = "https://vitnode.com/api/@vitnode/core/users/sign_in", + write, +}: { + authorization?: Partial; + url?: string; + write: (c: Context) => void; +}): string[] => { + const app = new Hono(); + + app.all("*", c => { + c.set("core", coreWith(authorization)); + write(c); + + return c.body(null, 204); + }); + + // `app.request` is synchronous enough here: the handler never awaits. + const response = app.request(url); + + if (!(response instanceof Response)) { + throw new Error("expected a synchronous response"); + } + + return response.headers.getSetCookie(); +}; + +const write = (c: Context) => { + setAuthCookie(c, COOKIE, "token-value", { + expires: new Date("2027-01-01T00:00:00Z"), + }); +}; + +const remove = (c: Context) => { + deleteAuthCookie(c, COOKIE); +}; + +describe("setAuthCookie", () => { + describe("host-only by default", () => { + // Every host VitNode is served from, including the one nobody configured. + it.each([ + ["localhost", "http://localhost:3001/api/x"], + ["a production hostname", "https://vitnode.com/api/x"], + [ + "a generated preview hostname", + "https://web-git-feat-tanstack-abc123.vercel.app/api/x", + ], + ])("sends no Domain on %s", (_label, url) => { + const [cookie] = setCookiesFrom({ url, write }); + + // A `Domain` naming anything the response did not come from is rejected + // outright, so the visitor is never signed in at all. + expect(cookie).not.toContain("Domain"); + expect(parseSetCookies([cookie])[0].options.domain).toBe(undefined); + }); + + it("still pins the path so a deletion can match it", () => { + expect(parseSetCookies(setCookiesFrom({ write }))[0].options.path).toBe( + "/", + ); + }); + + it("keeps the cookie unreadable to scripts and https-only", () => { + expect( + parseSetCookies(setCookiesFrom({ write }))[0].options, + ).toMatchObject({ httpOnly: true, secure: true }); + }); + + it("honours cookieSecure for a plain-http install", () => { + expect( + parseSetCookies( + setCookiesFrom({ authorization: { cookieSecure: false }, write }), + )[0].options.secure, + ).toBe(false); + }); + + it("writes a session cookie when no expiry is given", () => { + // The SSO state cookie: good for one round trip, not for a year. + const [cookie] = setCookiesFrom({ + write: c => { + setAuthCookie(c, COOKIE, "state"); + }, + }); + + expect(cookie).not.toContain("Expires"); + }); + }); + + describe("explicit cookieDomain", () => { + it("emits the Domain an install asked for", () => { + const [cookie] = setCookiesFrom({ + authorization: { cookieDomain: ".example.com" }, + url: "https://app.example.com/api/x", + write, + }); + + expect(parseSetCookies([cookie])[0].options.domain).toBe(".example.com"); + expect(cookie).toContain("Domain=.example.com"); + }); + + it("is opt-in, not derived from anything", () => { + // The regression this closes: a domain guessed from `NEXT_PUBLIC_WEB_URL` + // is `localhost` in development and the production domain on a preview + // deployment - wrong in both places, and silent. + expect( + setCookiesFrom({ url: "https://app.example.com/api/x", write })[0], + ).not.toContain("Domain"); + }); + }); +}); + +describe("deleteAuthCookie", () => { + /** A cookie is identified by name, domain and path; a deletion must match all three. */ + const domainAndPath = (header: string) => { + const [{ options }] = parseSetCookies([header]); + + return { domain: options.domain, path: options.path }; + }; + + it("targets the same cookie the write created, host-only", () => { + const [created] = setCookiesFrom({ write }); + const [deleted] = setCookiesFrom({ write: remove }); + + expect(domainAndPath(deleted)).toStrictEqual(domainAndPath(created)); + expect(deleted).not.toContain("Domain"); + }); + + it("targets the same cookie the write created, with an explicit domain", () => { + const authorization = { cookieDomain: ".example.com" }; + const [created] = setCookiesFrom({ authorization, write }); + const [deleted] = setCookiesFrom({ authorization, write: remove }); + + // The bug this closes: sign-out used to send no `Domain` against a cookie + // created with one, which removes nothing and reports nothing. + expect(domainAndPath(deleted)).toStrictEqual(domainAndPath(created)); + expect(domainAndPath(deleted).domain).toBe(".example.com"); + }); + + it("expires the cookie rather than merely blanking it", () => { + const [{ options, value }] = parseSetCookies( + setCookiesFrom({ write: remove }), + ); + + expect(value).toBe(""); + expect(options.maxAge).toBe(0); + }); + + it("deletes on every host the same way it creates", () => { + for (const url of [ + "http://localhost:3001/api/x", + "https://vitnode.com/api/x", + "https://web-git-feat-tanstack-abc123.vercel.app/api/x", + ]) { + expect( + domainAndPath(setCookiesFrom({ url, write: remove })[0]), + ).toStrictEqual(domainAndPath(setCookiesFrom({ url, write })[0])); + } + }); +}); diff --git a/packages/vitnode/src/api/lib/auth-cookie.ts b/packages/vitnode/src/api/lib/auth-cookie.ts new file mode 100644 index 000000000..f426a8a5e --- /dev/null +++ b/packages/vitnode/src/api/lib/auth-cookie.ts @@ -0,0 +1,66 @@ +import type { Context } from "hono"; +import type { CookieOptions } from "hono/utils/cookie"; + +import { deleteCookie, setCookie } from "hono/cookie"; + +/** + * The attributes every VitNode auth cookie is written with - and the ones a + * deletion has to repeat. + * + * A browser identifies a cookie by name, domain *and* path, so a `Set-Cookie` + * that removes one has to name the same three. Reading both sides from here is + * what stops them drifting: a sign-out sending no `Domain` against a cookie + * created with one deletes nothing at all, and says nothing while it happens. + * + * `domain` is absent unless an install explicitly asks for one. Left off, the + * cookie is *host-only* - bound to exactly the host that sent it - which is the + * right default for how VitNode deploys: the web app serves `/api/*` on its own + * origin, so there is no second host to share the cookie with. It is also the + * only default that works everywhere, because the host is not knowable ahead of + * time. A preview deployment's hostname is generated per branch, and a `Domain` + * naming anything the response did not come from - `localhost`, the production + * domain - is one the browser rejects outright, taking sign-in with it. + * + * Set `authorization.cookieDomain` to share a session across subdomains; see + * `VitNodeApiConfig`. + */ +const authCookieOptions = (c: Context): CookieOptions => { + const { cookieDomain, cookieSecure } = c.get("core").authorization; + + return { + // `undefined` emits no `Domain` attribute at all, which is the host-only + // default described above - not a domain of `"undefined"`. + domain: cookieDomain, + httpOnly: true, + path: "/", + secure: cookieSecure, + }; +}; + +/** + * Writes one of VitNode's auth cookies - the session, the admin session, the + * device id, the SSO state. `expires` is the only per-cookie attribute; the + * rest are shared so that {@link deleteAuthCookie} can mirror them. + * + * Omit `expires` for a session cookie the browser should drop when it closes. + */ +export const setAuthCookie = ( + c: Context, + name: string, + value: string, + { expires }: { expires?: Date } = {}, +): void => { + setCookie(c, name, value, { ...authCookieOptions(c), expires }); +}; + +/** + * Removes one of VitNode's auth cookies, with the attributes it was created + * with. + * + * Always use this rather than `deleteCookie` directly: a deletion whose `Domain` + * or `Path` does not match the cookie's leaves it in the browser, and the + * response looks identical either way. + */ +export const deleteAuthCookie = (c: Context, name: string): void => { + deleteCookie(c, name, authCookieOptions(c)); +}; diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 4d895fd47..cdf2f15d6 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -86,6 +86,8 @@ export interface EnvVariablesVitNode { adminCookieExpires: number; adminCookieName: string; cookie_expires: number; + /** Unset means host-only cookies; see `VitNodeApiConfig`. */ + cookieDomain: string | undefined; cookieName: string; cookieSecure: boolean; deviceCookieExpires: number; @@ -396,6 +398,9 @@ export const globalMiddleware = ({ adminCookieExpires: authorization?.adminCookieExpires ?? 1000 * 60 * 60 * 24 * 1, // 1 day cookieSecure: authorization?.cookieSecure ?? true, + // No default on purpose: absent means host-only, which is correct on + // localhost, on a generated preview hostname and in production alike. + cookieDomain: authorization?.cookieDomain, }, captcha, contentPreviewSecret, diff --git a/packages/vitnode/src/api/models/device.ts b/packages/vitnode/src/api/models/device.ts index 6ed9cef2e..d730c3c28 100644 --- a/packages/vitnode/src/api/models/device.ts +++ b/packages/vitnode/src/api/models/device.ts @@ -1,11 +1,11 @@ import type { Context } from "hono"; import { eq } from "drizzle-orm"; -import { getCookie, setCookie } from "hono/cookie"; +import { getCookie } from "hono/cookie"; import { randomBytes } from "node:crypto"; +import { setAuthCookie } from "@/api/lib/auth-cookie"; import { core_sessions_known_devices } from "@/database/sessions"; -import { CONFIG } from "@/lib/config"; export class DeviceModel { constructor(c: Context) { @@ -36,15 +36,11 @@ export class DeviceModel { } private setCookieDevice(publicDeviceId: string) { - setCookie( + setAuthCookie( this.c, this.c.get("core").authorization.deviceCookieName, publicDeviceId, { - httpOnly: true, - secure: this.c.get("core").authorization.cookieSecure, - path: "/", - domain: CONFIG.web.hostname, expires: new Date( Date.now() + this.c.get("core").authorization.deviceCookieExpires, ), diff --git a/packages/vitnode/src/api/models/session-admin.ts b/packages/vitnode/src/api/models/session-admin.ts index 8f6c395a9..7f5ea9b52 100644 --- a/packages/vitnode/src/api/models/session-admin.ts +++ b/packages/vitnode/src/api/models/session-admin.ts @@ -1,11 +1,11 @@ import type { Context } from "hono"; import { and, eq, gt, or } from "drizzle-orm"; -import { deleteCookie, getCookie, setCookie } from "hono/cookie"; +import { getCookie } from "hono/cookie"; import { HTTPException } from "hono/http-exception"; +import { deleteAuthCookie, setAuthCookie } from "@/api/lib/auth-cookie"; import { core_admin_permissions, core_admin_sessions } from "@/database/admins"; -import { CONFIG } from "@/lib/config"; import { DeviceModel } from "./device"; import { @@ -84,15 +84,16 @@ export class SessionAdminModel { deviceId: device.id, }); - setCookie(this.c, this.c.get("core").authorization.adminCookieName, token, { - httpOnly: true, - secure: this.c.get("core").authorization.cookieSecure, - path: "/", - expires: new Date( - Date.now() + this.c.get("core").authorization.adminCookieExpires, - ), - domain: CONFIG.web.hostname, - }); + setAuthCookie( + this.c, + this.c.get("core").authorization.adminCookieName, + token, + { + expires: new Date( + Date.now() + this.c.get("core").authorization.adminCookieExpires, + ), + }, + ); return { token }; } @@ -118,10 +119,7 @@ export class SessionAdminModel { .get("cache") .deleteSystem(adminSessionCacheKey(hashedToken, device.id)); - deleteCookie(this.c, this.c.get("core").authorization.adminCookieName, { - path: "/", - domain: CONFIG.web.hostname, - }); + deleteAuthCookie(this.c, this.c.get("core").authorization.adminCookieName); } async getUser() { @@ -168,10 +166,10 @@ export class SessionAdminModel { .limit(1); if (!session) { - deleteCookie(this.c, this.c.get("core").authorization.adminCookieName, { - path: "/", - domain: CONFIG.web.hostname, - }); + deleteAuthCookie( + this.c, + this.c.get("core").authorization.adminCookieName, + ); return null; } diff --git a/packages/vitnode/src/api/models/session-cookies.test.ts b/packages/vitnode/src/api/models/session-cookies.test.ts new file mode 100644 index 000000000..ecf08453e --- /dev/null +++ b/packages/vitnode/src/api/models/session-cookies.test.ts @@ -0,0 +1,307 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { Hono } from "hono"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, vi } from "vitest"; + +import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; + +import { core_admin_permissions } from "@/database/admins"; +import { core_sessions_known_devices } from "@/database/sessions"; +import { parseSetCookies } from "@/lib/fetcher/set-cookie"; + +// `checkIfUserIsAdmin` resolves the user before it looks at permissions, and +// that path is a module of its own with no bearing on cookie attributes. +vi.mock("./user", () => ({ + UserModel: class { + getUserById = async () => + await Promise.resolve({ id: 7, roleId: 1, name: "Test" }); + }, +})); + +const { DeviceModel } = await import("./device"); +const { SessionModel } = await import("./session"); +const { SessionAdminModel } = await import("./session-admin"); + +type Authorization = EnvVariablesVitNode["core"]["authorization"]; + +const AUTHORIZATION: Authorization = { + adminCookieExpires: 1000 * 60 * 60 * 24, + adminCookieName: "vitnode_auth_admin", + cookieDomain: undefined, + cookie_expires: 1000 * 60 * 60 * 24 * 90, + cookieName: "vitnode_auth", + cookieSecure: true, + deviceCookieExpires: 1000 * 60 * 60 * 24 * 365, + deviceCookieName: "vitnode_device", + ssoAdapters: [], +}; + +/** + * A Drizzle stand-in: every builder method returns the same chainable object, + * and awaiting it hands back rows chosen by the operation and table. + * + * These tests are about the `Set-Cookie` a model emits, so the queries only have + * to resolve - not to be right. + */ +const fakeDb = () => { + const chain = (kind: string, table: unknown) => { + const op = { kind, table }; + const rows = (): unknown[] => { + if (op.kind === "insert" && op.table === core_sessions_known_devices) { + return [{ id: 1 }]; + } + if (op.kind === "select" && op.table === core_sessions_known_devices) { + // No stored device: the model mints one, which is the case that writes + // the device cookie. + return []; + } + if (op.kind === "select" && op.table === core_admin_permissions) { + return [{ id: 1 }]; + } + + return []; + }; + + const self = { + from: (table: unknown) => { + op.table = table; + + return self; + }, + limit: () => self, + returning: () => self, + set: () => self, + then: async (onFulfilled: (value: unknown[]) => unknown) => + await Promise.resolve(onFulfilled(rows())), + values: () => self, + where: () => self, + }; + + return self; + }; + + return { + delete: (table: unknown) => chain("delete", table), + insert: (table: unknown) => chain("insert", table), + select: () => chain("select", undefined), + update: (table: unknown) => chain("update", table), + }; +}; + +const fakeCache = () => ({ + deleteSystem: async () => await Promise.resolve(), + getSystem: async () => await Promise.resolve(null), + setSystem: async () => await Promise.resolve(), +}); + +/** + * Runs `act` inside a real request, and hands back every `Set-Cookie` the + * response carries - what the browser is actually told to do. + */ +const setCookiesFrom = async ({ + authorization = {}, + cookie, + url = "https://web-git-feat-abc123.vercel.app/api/@vitnode/core/users/sign_in", + act, +}: { + act: (c: Context) => Promise; + authorization?: Partial; + cookie?: string; + url?: string; +}): Promise => { + const app = new Hono(); + + app.all("*", async c => { + c.set("core", { + authorization: { ...AUTHORIZATION, ...authorization }, + } as EnvVariablesVitNode["core"]); + c.set("db", fakeDb() as unknown as EnvVariablesVitNode["db"]); + c.set("cache", fakeCache() as unknown as EnvVariablesVitNode["cache"]); + c.set("ipAddress", "203.0.113.7"); + + await act(c); + + return c.body(null, 204); + }); + + const response = await app.request(url, { + headers: cookie === undefined ? {} : { cookie }, + }); + + return response.headers.getSetCookie(); +}; + +/** The cookie named `name`, parsed, out of everything the response set. */ +const named = (headers: string[], name: string) => { + const found = parseSetCookies(headers).find(entry => entry.name === name); + if (!found) throw new Error(`no ${name} cookie in ${headers.join(" | ")}`); + + return found; +}; + +const HOSTS = [ + ["localhost", "http://localhost:3001/api/x"], + ["a production hostname", "https://vitnode.com/api/x"], + [ + "a generated preview hostname", + "https://web-git-feat-abc123.vercel.app/api/x", + ], +] as const; + +describe("session cookie", () => { + const create = async (c: Context) => + await new SessionModel(c).createSessionByUserId(7); + const remove = async (c: Context) => + await new SessionModel(c).deleteSession(); + + it.each(HOSTS)("is host-only on %s", async (_label, url) => { + const cookie = named( + await setCookiesFrom({ act: create, url }), + AUTHORIZATION.cookieName, + ); + + expect(cookie.options.domain).toBe(undefined); + expect(cookie.options.path).toBe("/"); + }); + + it("is deleted with the attributes it was created with", async () => { + const created = named( + await setCookiesFrom({ act: create }), + AUTHORIZATION.cookieName, + ); + const deleted = named( + await setCookiesFrom({ act: remove, cookie: "vitnode_auth=token" }), + AUTHORIZATION.cookieName, + ); + + expect(deleted.options.domain).toBe(created.options.domain); + expect(deleted.options.path).toBe(created.options.path); + // And it is a deletion, not a blanking: `Max-Age=0` is what makes the + // browser drop it rather than hold an empty value for the session. + expect(deleted.options.maxAge).toBe(0); + expect(deleted.value).toBe(""); + }); + + it("carries an explicit cookieDomain on both sides", async () => { + const authorization = { cookieDomain: ".example.com" }; + const created = named( + await setCookiesFrom({ act: create, authorization }), + AUTHORIZATION.cookieName, + ); + const deleted = named( + await setCookiesFrom({ + act: remove, + authorization, + cookie: "vitnode_auth=token", + }), + AUTHORIZATION.cookieName, + ); + + expect(created.options.domain).toBe(".example.com"); + expect(deleted.options.domain).toBe(".example.com"); + }); +}); + +describe("admin session cookie", () => { + const create = async (c: Context) => + await new SessionAdminModel(c).createSessionByUserId(7); + const remove = async (c: Context) => + await new SessionAdminModel(c).deleteSession(); + + it.each(HOSTS)("is host-only on %s", async (_label, url) => { + const cookie = named( + await setCookiesFrom({ act: create, url }), + AUTHORIZATION.adminCookieName, + ); + + expect(cookie.options.domain).toBe(undefined); + expect(cookie.options.path).toBe("/"); + }); + + it("is deleted with the attributes it was created with", async () => { + const created = named( + await setCookiesFrom({ act: create }), + AUTHORIZATION.adminCookieName, + ); + const deleted = named( + await setCookiesFrom({ + act: remove, + cookie: "vitnode_auth_admin=token", + }), + AUTHORIZATION.adminCookieName, + ); + + expect(deleted.options.domain).toBe(created.options.domain); + expect(deleted.options.path).toBe(created.options.path); + expect(deleted.options.maxAge).toBe(0); + expect(deleted.value).toBe(""); + }); + + it("carries an explicit cookieDomain on both sides", async () => { + const authorization = { cookieDomain: ".example.com" }; + const created = named( + await setCookiesFrom({ act: create, authorization }), + AUTHORIZATION.adminCookieName, + ); + const deleted = named( + await setCookiesFrom({ + act: remove, + authorization, + cookie: "vitnode_auth_admin=token", + }), + AUTHORIZATION.adminCookieName, + ); + + expect(created.options.domain).toBe(".example.com"); + expect(deleted.options.domain).toBe(".example.com"); + }); +}); + +describe("device cookie", () => { + const create = async (c: Context) => await new DeviceModel(c).getDeviceId(); + + it.each(HOSTS)("is host-only on %s", async (_label, url) => { + const cookie = named( + await setCookiesFrom({ act: create, url }), + AUTHORIZATION.deviceCookieName, + ); + + expect(cookie.options.domain).toBe(undefined); + expect(cookie.options.path).toBe("/"); + }); + + it("carries an explicit cookieDomain", async () => { + const cookie = named( + await setCookiesFrom({ + act: create, + authorization: { cookieDomain: ".example.com" }, + }), + AUTHORIZATION.deviceCookieName, + ); + + expect(cookie.options.domain).toBe(".example.com"); + }); +}); + +describe("every auth cookie goes through the shared helper", () => { + // The invariant the rest of this file rests on. A raw `setCookie` reintroduces + // the `domain` argument these tests exist to keep out, and a raw + // `deleteCookie` reintroduces the create/delete mismatch that leaves a cookie + // in the browser - neither of which changes a response's shape enough to fail + // an assertion elsewhere. + const here = dirname(fileURLToPath(import.meta.url)); + + it.each(["session.ts", "session-admin.ts", "device.ts", "sso.ts"])( + "%s writes no cookie of its own", + file => { + const source = readFileSync(resolve(here, file), "utf8"); + + expect(source).not.toMatch(/\bsetCookie\(/); + expect(source).not.toMatch(/\bdeleteCookie\(/); + }, + ); +}); diff --git a/packages/vitnode/src/api/models/session.ts b/packages/vitnode/src/api/models/session.ts index 6df9a5e98..811aa26d5 100644 --- a/packages/vitnode/src/api/models/session.ts +++ b/packages/vitnode/src/api/models/session.ts @@ -1,10 +1,10 @@ import type { Context } from "hono"; import { and, eq, gt } from "drizzle-orm"; -import { deleteCookie, getCookie, setCookie } from "hono/cookie"; +import { getCookie } from "hono/cookie"; +import { deleteAuthCookie, setAuthCookie } from "@/api/lib/auth-cookie"; import { core_sessions } from "@/database/sessions"; -import { CONFIG } from "@/lib/config"; import { DeviceModel } from "./device"; import { @@ -60,17 +60,13 @@ export class SessionModel { deviceId: device.id, }); - setCookie(this.c, this.c.get("core").authorization.cookieName, token, { - httpOnly: true, - secure: this.c.get("core").authorization.cookieSecure, - path: "/", + setAuthCookie(this.c, this.c.get("core").authorization.cookieName, token, { expires: this.c.get("core").authorization.cookie_expires > 0 ? new Date( Date.now() + this.c.get("core").authorization.cookie_expires, ) : undefined, - domain: CONFIG.web.hostname, }); return { token }; @@ -85,7 +81,7 @@ export class SessionModel { // Ensure both token and deviceId exist before proceeding if (!(token && device.id)) { - deleteCookie(this.c, this.c.get("core").authorization.cookieName); + deleteAuthCookie(this.c, this.c.get("core").authorization.cookieName); return; } @@ -107,7 +103,7 @@ export class SessionModel { .get("cache") .deleteSystem(sessionCacheKey(hashedToken, device.id)); - deleteCookie(this.c, this.c.get("core").authorization.cookieName); + deleteAuthCookie(this.c, this.c.get("core").authorization.cookieName); } async getUser() { @@ -146,7 +142,7 @@ export class SessionModel { .limit(1); if (!session) { - deleteCookie(this.c, this.c.get("core").authorization.cookieName); + deleteAuthCookie(this.c, this.c.get("core").authorization.cookieName); return null; } diff --git a/packages/vitnode/src/api/models/sso.ts b/packages/vitnode/src/api/models/sso.ts index 6a460c032..db25a9a93 100644 --- a/packages/vitnode/src/api/models/sso.ts +++ b/packages/vitnode/src/api/models/sso.ts @@ -1,10 +1,11 @@ import type { Context } from "hono"; import { and, eq } from "drizzle-orm"; -import { deleteCookie, getCookie, setCookie } from "hono/cookie"; +import { getCookie } from "hono/cookie"; import { HTTPException } from "hono/http-exception"; import crypto from "node:crypto"; +import { deleteAuthCookie, setAuthCookie } from "@/api/lib/auth-cookie"; import { core_users, core_users_sso } from "@/database/users"; import { CONFIG } from "@/lib/config"; import { removeSpecialCharacters } from "@/lib/special-characters"; @@ -145,16 +146,12 @@ export class SSOModel { }); }); - setCookie( + // No `expires`: the state is only good for the round trip to the provider + // and back, so it should not outlive the browser session. + setAuthCookie( this.c, `${this.c.get("core").authorization.cookieName}--state-sso`, encryptedState, - { - httpOnly: true, - secure: this.c.get("core").authorization.cookieSecure, - path: "/", - domain: CONFIG.web.hostname, - }, ); return state; @@ -195,7 +192,7 @@ export class SSOModel { }); } - deleteCookie( + deleteAuthCookie( this.c, `${this.c.get("core").authorization.cookieName}--state-sso`, ); diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index 6166f6756..bd6421dc8 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -52,6 +52,25 @@ export interface VitNodeApiConfig { authorization?: { adminCookieExpires?: number; adminCookieName?: string; + /** + * `Domain` to stamp on the session, admin, device and SSO cookies. + * + * Leave it unset - the default - and no `Domain` is sent at all, making the + * cookies *host-only*: valid on exactly the host that issued them. That is + * what a normal VitNode install wants, because the web app serves `/api/*` + * on its own origin, and it is the only setting that survives a hostname + * nobody configured, such as a per-branch preview deployment. + * + * Set it only to share one session across subdomains - `".example.com"` for + * `app.example.com` and `admin.example.com`. A value the response's own host + * does not fall under is rejected by the browser, so an install that gets + * this wrong cannot sign anybody in. + * + * Deliberately not derived from `NEXT_PUBLIC_WEB_URL`: that names where the + * front end lives, which is not the same question, and guessing it is how a + * preview deployment ends up sending `Domain=localhost`. + */ + cookieDomain?: string; cookieExpires?: number; cookieName?: string; cookieSecure?: boolean;