diff --git a/.changeset/start-response-state.md b/.changeset/start-response-state.md new file mode 100644 index 0000000000..a3abf65933 --- /dev/null +++ b/.changeset/start-response-state.md @@ -0,0 +1,10 @@ +--- +'@tanstack/react-start': patch +'@tanstack/solid-start': patch +'@tanstack/vue-start': patch +'@tanstack/start-client-core': patch +'@tanstack/start-plugin-core': patch +'@tanstack/start-server-core': patch +--- + +Improve Start response reconciliation, session cookie handling, and server-entry error recovery. Server entries now recover Start error responses with `handleStartError`, response helpers reconcile across server routes, SSR, server functions, redirects, and error responses, and serialized server-function transport headers are protected from helper overrides. diff --git a/docs/start/framework/react/guide/server-entry-point.md b/docs/start/framework/react/guide/server-entry-point.md index 5acc8ee550..ebed40b470 100644 --- a/docs/start/framework/react/guide/server-entry-point.md +++ b/docs/start/framework/react/guide/server-entry-point.md @@ -27,14 +27,60 @@ TanStack Start exposes a wrapper to make creation type-safe. This is done in the import handler, { createServerEntry } from '@tanstack/react-start/server-entry' export default createServerEntry({ - fetch(request) { - return handler.fetch(request) + fetch(request, opts) { + return handler.fetch(request, opts) }, }) ``` Whether we are statically generating our app or serving it dynamically, the `server.ts` file is the entry point for doing all SSR-related work as well as for handling server routes and server function requests. +## Configuring a Custom Server Entry + +By default, Start uses the generated server entry. If you provide your own server entry at a non-standard path, configure it in your bundler plugin. The `server.entry` path is resolved relative to `srcDirectory`, which defaults to `src`. + + + +# Vite + +```ts title="vite.config.ts" +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [ + tanstackStart({ + server: { + entry: './server-entry.ts', + }, + }), + viteReact(), + ], +}) +``` + +# Rsbuild + +```ts title="rsbuild.config.ts" +import { defineConfig } from '@rsbuild/core' +import { pluginReact } from '@rsbuild/plugin-react' +import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild' + +export default defineConfig({ + plugins: [ + pluginReact(), + tanstackStart({ + server: { + entry: './server-entry.ts', + }, + }), + ], +}) +``` + + + ## Custom Server Handlers You can create custom server handlers to modify how your application is rendered: @@ -53,13 +99,101 @@ const customHandler = defineHandlerCallback((ctx) => { return defaultStreamHandler(ctx) }) -const fetch = createStartHandler(customHandler) +const startHandler = createStartHandler(customHandler) export default createServerEntry({ - fetch, + fetch(request, opts) { + return startHandler(request, opts) + }, }) ``` +## Error Handling + +The generated server entry catches uncaught errors and calls `handleStartError(error)`. Response helpers such as `setResponseStatus`, `setResponseHeader`, and `setCookie` are reconciled onto the error response. + +This default behavior means Start owns the uncaught-error boundary. If a loader, server route, or middleware throws after setting response state, Start still returns a valid `Response` with the intended status, headers, and cookies. Server function RPC errors are handled before this top-level boundary so the client protocol response stays intact. + +For example, this should return `401`, not a generic `500`: + +```tsx +import { createMiddleware } from '@tanstack/react-start' +import { + setResponseHeader, + setResponseStatus, +} from '@tanstack/react-start/server' + +const authMiddleware = createMiddleware().server(() => { + setResponseStatus(401, 'Unauthorized') + setResponseHeader('www-authenticate', 'Bearer') + throw new Error('Unauthorized') +}) +``` + +This conversion does more than create a plain `new Response(...)`. It preserves response state that Start captured during the request: + +- Status and status text from `setResponseStatus` +- Headers from `setResponseHeader` and `setResponseHeaders` +- Cookies from `setCookie`, including multiple `Set-Cookie` headers +- HTTP-style error metadata such as `error.status`, `error.statusText`, `error.headers`, and `error.cause.headers` +- Protocol headers used by server function responses +- Null-body response rules for `HEAD`, `204`, `205`, and `304` + +`createStartHandler(...)` rethrows uncaught errors after associating them with the current Start request. If you write a custom server entry and need custom top-level error handling, catch around the returned fetch handler and call `handleStartError(error)` yourself: + +```tsx +// src/server.ts +import { + createStartHandler, + defaultStreamHandler, + handleStartError, +} from '@tanstack/react-start/server' +import { createServerEntry } from '@tanstack/react-start/server-entry' + +const startHandler = createStartHandler(defaultStreamHandler) + +export default createServerEntry({ + async fetch(request, opts) { + try { + return await startHandler(request, opts) + } catch (error) { + // Add logging, reporting, or custom branching here. + return handleStartError(error) + } + }, +}) +``` + +Use `handleStartError(error)` when you want custom top-level logic, such as logging or reporting, but still want Start to produce the same error response as the generated server entry. + +`handleStartError(error)` is useful because the error is caught outside the active Start handler. It recovers the Start request state associated with non-primitive thrown errors and reconciles that state onto the error response. Primitive throws cannot carry that association. + +If you do not want to preserve response helpers that ran before the error, do not call `handleStartError`. Return your own response instead: + +```tsx +// src/server.ts +import { + createStartHandler, + defaultStreamHandler, +} from '@tanstack/react-start/server' +import { createServerEntry } from '@tanstack/react-start/server-entry' + +const startHandler = createStartHandler(defaultStreamHandler) + +export default createServerEntry({ + async fetch(request, opts) { + try { + return await startHandler(request, opts) + } catch (error) { + console.error(error) + return new Response('Internal Server Error', { status: 500 }) + } + }, +}) +``` + +Use this pattern when your server entry or deployment platform should fully own the error response and should ignore any status, headers, or cookies set earlier in the Start request. + ## Request context When your server needs to pass additional, typed data into request handlers (for example, authenticated user info, a database connection, or per-request flags), register a request context type via TypeScript module augmentation. The registered context is delivered as the second argument to the server `fetch` handler and is available throughout the server-side middleware chain — including global middleware, request/function middleware, server routes, server functions, and the router itself. @@ -83,8 +217,11 @@ declare module '@tanstack/react-router' { } export default createServerEntry({ - async fetch(request) { - return handler.fetch(request, { context: { hello: 'world', foo: 123 } }) + fetch(request, opts) { + return handler.fetch(request, { + ...opts, + context: { hello: 'world', foo: 123 }, + }) }, }) ``` diff --git a/e2e/react-start/response-reconciliation/.gitignore b/e2e/react-start/response-reconciliation/.gitignore new file mode 100644 index 0000000000..6ccfab35a6 --- /dev/null +++ b/e2e/react-start/response-reconciliation/.gitignore @@ -0,0 +1,22 @@ +node_modules +package-lock.json +yarn.lock + +.DS_Store +.cache +.tanstack +.env +.vercel +.output +/build/ +/api/ +/server/build +/public/build# Sentry Config File +.env.sentry-build-plugin +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/dist/ +/dist-*/ +/port-*.txt diff --git a/e2e/react-start/response-reconciliation/.prettierignore b/e2e/react-start/response-reconciliation/.prettierignore new file mode 100644 index 0000000000..40a96e8819 --- /dev/null +++ b/e2e/react-start/response-reconciliation/.prettierignore @@ -0,0 +1,4 @@ +**/build +**/public +pnpm-lock.yaml +routeTree.gen.ts diff --git a/e2e/react-start/response-reconciliation/package.json b/e2e/react-start/response-reconciliation/package.json new file mode 100644 index 0000000000..a8b5657d8d --- /dev/null +++ b/e2e/react-start/response-reconciliation/package.json @@ -0,0 +1,84 @@ +{ + "name": "tanstack-react-start-e2e-response-reconciliation", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "pnpm dev:vite --port 3000", + "dev:e2e": "pnpm dev:vite", + "dev:vite": "vite dev", + "dev:rsbuild": "rsbuild dev", + "build": "pnpm build:vite", + "build:vite": "vite build && tsc --noEmit", + "build:rsbuild": "rsbuild build && tsc --noEmit", + "preview": "vite preview", + "start": "node server.js", + "test:e2e:local": "pnpm build && playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:*", + "@tanstack/react-start": "workspace:*", + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@playwright/test": "^1.57.0", + "@rsbuild/core": "^2.0.8", + "@rsbuild/plugin-react": "^2.0.0", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "25.0.9", + "@types/react": "^19.2.8", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "srvx": "^0.11.9", + "typescript": "^6.0.2", + "vite": "^8.0.14" + }, + "nx": { + "targets": { + "test:e2e": { + "parallelism": false + }, + "test:e2e--vite-ssr": { + "parallelism": false + }, + "test:e2e--vite-ssr--custom-entry": { + "parallelism": false + }, + "test:e2e--rsbuild-ssr": { + "parallelism": false + }, + "test:e2e--rsbuild-ssr--custom-entry": { + "parallelism": false + } + }, + "metadata": { + "playwrightModes": [ + { + "toolchain": "vite", + "mode": "ssr" + }, + { + "toolchain": "vite", + "mode": "ssr", + "name": "custom-entry", + "env": { + "TSS_E2E_SERVER_ENTRY": "server-entry" + } + }, + { + "toolchain": "rsbuild", + "mode": "ssr" + }, + { + "toolchain": "rsbuild", + "mode": "ssr", + "name": "custom-entry", + "env": { + "TSS_E2E_SERVER_ENTRY": "server-entry" + } + } + ] + } + } +} diff --git a/e2e/react-start/response-reconciliation/playwright.config.ts b/e2e/react-start/response-reconciliation/playwright.config.ts new file mode 100644 index 0000000000..20cc15565f --- /dev/null +++ b/e2e/react-start/response-reconciliation/playwright.config.ts @@ -0,0 +1,41 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const e2ePortKey = process.env.E2E_PORT_KEY ?? packageJson.name +const toolchain = process.env.E2E_TOOLCHAIN ?? 'vite' +const distDir = process.env.E2E_DIST_DIR ?? 'dist' +const serverEntry = process.env.TSS_E2E_SERVER_ENTRY ?? '' +const serverEntryMode = serverEntry ? 'custom' : 'default' + +export const PORT = await getTestServerPort(`${e2ePortKey}-${serverEntryMode}`) +const baseURL = `http://localhost:${PORT}` + +export default defineConfig({ + testDir: './tests', + workers: 1, + reporter: [['line']], + use: { + baseURL, + }, + webServer: { + command: `pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + env: { + PORT: String(PORT), + VITE_SERVER_PORT: String(PORT), + E2E_DIST_DIR: distDir, + E2E_TOOLCHAIN: toolchain, + E2E_PORT_KEY: e2ePortKey, + TSS_E2E_SERVER_ENTRY: serverEntry, + }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-start/response-reconciliation/rsbuild.config.ts b/e2e/react-start/response-reconciliation/rsbuild.config.ts new file mode 100644 index 0000000000..38f44ef9fb --- /dev/null +++ b/e2e/react-start/response-reconciliation/rsbuild.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from '@rsbuild/core' +import { pluginReact } from '@rsbuild/plugin-react' +import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild' + +const serverEntry = process.env.TSS_E2E_SERVER_ENTRY +const outDir = process.env.E2E_DIST_DIR ?? 'dist' + +export default defineConfig({ + plugins: [ + pluginReact(), + tanstackStart({ + server: serverEntry ? { entry: serverEntry } : undefined, + }), + ], + output: { + distPath: { + root: outDir, + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/server.js b/e2e/react-start/response-reconciliation/server.js new file mode 100644 index 0000000000..324a462bec --- /dev/null +++ b/e2e/react-start/response-reconciliation/server.js @@ -0,0 +1,62 @@ +import fs from 'node:fs' +import path from 'node:path' +import { spawn } from 'node:child_process' +import { pathToFileURL } from 'node:url' + +const distDir = process.env.E2E_DIST_DIR || 'dist' + +function resolveDistClientDir() { + return path.resolve(distDir, 'client') +} + +function resolveDistServerEntryPath() { + if (process.env.TSS_E2E_SERVER_ENTRY) { + const customEntryPath = path.resolve( + distDir, + 'server', + `${process.env.TSS_E2E_SERVER_ENTRY}.js`, + ) + if (fs.existsSync(customEntryPath)) { + return customEntryPath + } + } + + const serverJsPath = path.resolve(distDir, 'server', 'server.js') + if (fs.existsSync(serverJsPath)) { + return serverJsPath + } + + const indexJsPath = path.resolve(distDir, 'server', 'index.js') + if (fs.existsSync(indexJsPath)) { + return indexJsPath + } + + return serverJsPath +} + +export function start() { + const child = spawn( + 'srvx', + ['--prod', '-s', resolveDistClientDir(), resolveDistServerEntryPath()], + { + stdio: 'inherit', + shell: process.platform === 'win32', + }, + ) + + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal) + return + } + + process.exit(code ?? 0) + }) +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + start() +} diff --git a/e2e/react-start/response-reconciliation/src/components/DefaultCatchBoundary.tsx b/e2e/react-start/response-reconciliation/src/components/DefaultCatchBoundary.tsx new file mode 100644 index 0000000000..f3d691814f --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/components/DefaultCatchBoundary.tsx @@ -0,0 +1,20 @@ +import { ErrorComponent, Link, useRouter } from '@tanstack/react-router' +import type { ErrorComponentProps } from '@tanstack/react-router' + +export function DefaultCatchBoundary({ error }: ErrorComponentProps) { + const router = useRouter() + + return ( +
+ + + Home +
+ ) +} diff --git a/e2e/react-start/response-reconciliation/src/components/NotFound.tsx b/e2e/react-start/response-reconciliation/src/components/NotFound.tsx new file mode 100644 index 0000000000..0b720f0e37 --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/components/NotFound.tsx @@ -0,0 +1,10 @@ +import { Link } from '@tanstack/react-router' + +export function NotFound({ children }: { children?: React.ReactNode }) { + return ( +
+ {children ||

The page you are looking for does not exist.

} + Start Over +
+ ) +} diff --git a/e2e/react-start/response-reconciliation/src/routeTree.gen.ts b/e2e/react-start/response-reconciliation/src/routeTree.gen.ts new file mode 100644 index 0000000000..9785134d2e --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routeTree.gen.ts @@ -0,0 +1,520 @@ +/* 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 SsrRouteImport } from './routes/ssr' +import { Route as ServerFunctionsRouteImport } from './routes/server-functions' +import { Route as IndexRouteImport } from './routes/index' +import { Route as ApiTwoReturnedResponsesRouteImport } from './routes/api/two-returned-responses' +import { Route as ApiThrowAfterStatusRouteImport } from './routes/api/throw-after-status' +import { Route as ApiSameBoundaryConflictRouteImport } from './routes/api/same-boundary-conflict' +import { Route as ApiRouteBeforeNextRouteImport } from './routes/api/route-before-next' +import { Route as ApiRouteAfterNextRouteImport } from './routes/api/route-after-next' +import { Route as ApiReplaceExplicitSetCookieRouteImport } from './routes/api/replace-explicit-set-cookie' +import { Route as ApiReplaceAfterDirectMutationRouteImport } from './routes/api/replace-after-direct-mutation' +import { Route as ApiRemoveReturnedHeaderRouteImport } from './routes/api/remove-returned-header' +import { Route as ApiRedirectWithCookiesRouteImport } from './routes/api/redirect-with-cookies' +import { Route as ApiReadonlyAfterNextRouteImport } from './routes/api/readonly-after-next' +import { Route as ApiNullBodyStatusRouteImport } from './routes/api/null-body-status' +import { Route as ApiMultipleCookiesRouteImport } from './routes/api/multiple-cookies' +import { Route as ApiGetResponseHeadersHelperRouteImport } from './routes/api/get-response-headers-helper' +import { Route as ApiGetResponseHeaderHelperRouteImport } from './routes/api/get-response-header-helper' +import { Route as ApiExplicitSetCookieHeaderRouteImport } from './routes/api/explicit-set-cookie-header' +import { Route as ApiDirectMutationVisibleRouteImport } from './routes/api/direct-mutation-visible' +import { Route as ApiClearReturnedHeadersRouteImport } from './routes/api/clear-returned-headers' +import { Route as ApiBulkHeadersRouteImport } from './routes/api/bulk-headers' +import { Route as ApiBaseRouteImport } from './routes/api/base' + +const SsrRoute = SsrRouteImport.update({ + id: '/ssr', + path: '/ssr', + getParentRoute: () => rootRouteImport, +} as any) +const ServerFunctionsRoute = ServerFunctionsRouteImport.update({ + id: '/server-functions', + path: '/server-functions', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const ApiTwoReturnedResponsesRoute = ApiTwoReturnedResponsesRouteImport.update({ + id: '/api/two-returned-responses', + path: '/api/two-returned-responses', + getParentRoute: () => rootRouteImport, +} as any) +const ApiThrowAfterStatusRoute = ApiThrowAfterStatusRouteImport.update({ + id: '/api/throw-after-status', + path: '/api/throw-after-status', + getParentRoute: () => rootRouteImport, +} as any) +const ApiSameBoundaryConflictRoute = ApiSameBoundaryConflictRouteImport.update({ + id: '/api/same-boundary-conflict', + path: '/api/same-boundary-conflict', + getParentRoute: () => rootRouteImport, +} as any) +const ApiRouteBeforeNextRoute = ApiRouteBeforeNextRouteImport.update({ + id: '/api/route-before-next', + path: '/api/route-before-next', + getParentRoute: () => rootRouteImport, +} as any) +const ApiRouteAfterNextRoute = ApiRouteAfterNextRouteImport.update({ + id: '/api/route-after-next', + path: '/api/route-after-next', + getParentRoute: () => rootRouteImport, +} as any) +const ApiReplaceExplicitSetCookieRoute = + ApiReplaceExplicitSetCookieRouteImport.update({ + id: '/api/replace-explicit-set-cookie', + path: '/api/replace-explicit-set-cookie', + getParentRoute: () => rootRouteImport, + } as any) +const ApiReplaceAfterDirectMutationRoute = + ApiReplaceAfterDirectMutationRouteImport.update({ + id: '/api/replace-after-direct-mutation', + path: '/api/replace-after-direct-mutation', + getParentRoute: () => rootRouteImport, + } as any) +const ApiRemoveReturnedHeaderRoute = ApiRemoveReturnedHeaderRouteImport.update({ + id: '/api/remove-returned-header', + path: '/api/remove-returned-header', + getParentRoute: () => rootRouteImport, +} as any) +const ApiRedirectWithCookiesRoute = ApiRedirectWithCookiesRouteImport.update({ + id: '/api/redirect-with-cookies', + path: '/api/redirect-with-cookies', + getParentRoute: () => rootRouteImport, +} as any) +const ApiReadonlyAfterNextRoute = ApiReadonlyAfterNextRouteImport.update({ + id: '/api/readonly-after-next', + path: '/api/readonly-after-next', + getParentRoute: () => rootRouteImport, +} as any) +const ApiNullBodyStatusRoute = ApiNullBodyStatusRouteImport.update({ + id: '/api/null-body-status', + path: '/api/null-body-status', + getParentRoute: () => rootRouteImport, +} as any) +const ApiMultipleCookiesRoute = ApiMultipleCookiesRouteImport.update({ + id: '/api/multiple-cookies', + path: '/api/multiple-cookies', + getParentRoute: () => rootRouteImport, +} as any) +const ApiGetResponseHeadersHelperRoute = + ApiGetResponseHeadersHelperRouteImport.update({ + id: '/api/get-response-headers-helper', + path: '/api/get-response-headers-helper', + getParentRoute: () => rootRouteImport, + } as any) +const ApiGetResponseHeaderHelperRoute = + ApiGetResponseHeaderHelperRouteImport.update({ + id: '/api/get-response-header-helper', + path: '/api/get-response-header-helper', + getParentRoute: () => rootRouteImport, + } as any) +const ApiExplicitSetCookieHeaderRoute = + ApiExplicitSetCookieHeaderRouteImport.update({ + id: '/api/explicit-set-cookie-header', + path: '/api/explicit-set-cookie-header', + getParentRoute: () => rootRouteImport, + } as any) +const ApiDirectMutationVisibleRoute = + ApiDirectMutationVisibleRouteImport.update({ + id: '/api/direct-mutation-visible', + path: '/api/direct-mutation-visible', + getParentRoute: () => rootRouteImport, + } as any) +const ApiClearReturnedHeadersRoute = ApiClearReturnedHeadersRouteImport.update({ + id: '/api/clear-returned-headers', + path: '/api/clear-returned-headers', + getParentRoute: () => rootRouteImport, +} as any) +const ApiBulkHeadersRoute = ApiBulkHeadersRouteImport.update({ + id: '/api/bulk-headers', + path: '/api/bulk-headers', + getParentRoute: () => rootRouteImport, +} as any) +const ApiBaseRoute = ApiBaseRouteImport.update({ + id: '/api/base', + path: '/api/base', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/server-functions': typeof ServerFunctionsRoute + '/ssr': typeof SsrRoute + '/api/base': typeof ApiBaseRoute + '/api/bulk-headers': typeof ApiBulkHeadersRoute + '/api/clear-returned-headers': typeof ApiClearReturnedHeadersRoute + '/api/direct-mutation-visible': typeof ApiDirectMutationVisibleRoute + '/api/explicit-set-cookie-header': typeof ApiExplicitSetCookieHeaderRoute + '/api/get-response-header-helper': typeof ApiGetResponseHeaderHelperRoute + '/api/get-response-headers-helper': typeof ApiGetResponseHeadersHelperRoute + '/api/multiple-cookies': typeof ApiMultipleCookiesRoute + '/api/null-body-status': typeof ApiNullBodyStatusRoute + '/api/readonly-after-next': typeof ApiReadonlyAfterNextRoute + '/api/redirect-with-cookies': typeof ApiRedirectWithCookiesRoute + '/api/remove-returned-header': typeof ApiRemoveReturnedHeaderRoute + '/api/replace-after-direct-mutation': typeof ApiReplaceAfterDirectMutationRoute + '/api/replace-explicit-set-cookie': typeof ApiReplaceExplicitSetCookieRoute + '/api/route-after-next': typeof ApiRouteAfterNextRoute + '/api/route-before-next': typeof ApiRouteBeforeNextRoute + '/api/same-boundary-conflict': typeof ApiSameBoundaryConflictRoute + '/api/throw-after-status': typeof ApiThrowAfterStatusRoute + '/api/two-returned-responses': typeof ApiTwoReturnedResponsesRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/server-functions': typeof ServerFunctionsRoute + '/ssr': typeof SsrRoute + '/api/base': typeof ApiBaseRoute + '/api/bulk-headers': typeof ApiBulkHeadersRoute + '/api/clear-returned-headers': typeof ApiClearReturnedHeadersRoute + '/api/direct-mutation-visible': typeof ApiDirectMutationVisibleRoute + '/api/explicit-set-cookie-header': typeof ApiExplicitSetCookieHeaderRoute + '/api/get-response-header-helper': typeof ApiGetResponseHeaderHelperRoute + '/api/get-response-headers-helper': typeof ApiGetResponseHeadersHelperRoute + '/api/multiple-cookies': typeof ApiMultipleCookiesRoute + '/api/null-body-status': typeof ApiNullBodyStatusRoute + '/api/readonly-after-next': typeof ApiReadonlyAfterNextRoute + '/api/redirect-with-cookies': typeof ApiRedirectWithCookiesRoute + '/api/remove-returned-header': typeof ApiRemoveReturnedHeaderRoute + '/api/replace-after-direct-mutation': typeof ApiReplaceAfterDirectMutationRoute + '/api/replace-explicit-set-cookie': typeof ApiReplaceExplicitSetCookieRoute + '/api/route-after-next': typeof ApiRouteAfterNextRoute + '/api/route-before-next': typeof ApiRouteBeforeNextRoute + '/api/same-boundary-conflict': typeof ApiSameBoundaryConflictRoute + '/api/throw-after-status': typeof ApiThrowAfterStatusRoute + '/api/two-returned-responses': typeof ApiTwoReturnedResponsesRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/server-functions': typeof ServerFunctionsRoute + '/ssr': typeof SsrRoute + '/api/base': typeof ApiBaseRoute + '/api/bulk-headers': typeof ApiBulkHeadersRoute + '/api/clear-returned-headers': typeof ApiClearReturnedHeadersRoute + '/api/direct-mutation-visible': typeof ApiDirectMutationVisibleRoute + '/api/explicit-set-cookie-header': typeof ApiExplicitSetCookieHeaderRoute + '/api/get-response-header-helper': typeof ApiGetResponseHeaderHelperRoute + '/api/get-response-headers-helper': typeof ApiGetResponseHeadersHelperRoute + '/api/multiple-cookies': typeof ApiMultipleCookiesRoute + '/api/null-body-status': typeof ApiNullBodyStatusRoute + '/api/readonly-after-next': typeof ApiReadonlyAfterNextRoute + '/api/redirect-with-cookies': typeof ApiRedirectWithCookiesRoute + '/api/remove-returned-header': typeof ApiRemoveReturnedHeaderRoute + '/api/replace-after-direct-mutation': typeof ApiReplaceAfterDirectMutationRoute + '/api/replace-explicit-set-cookie': typeof ApiReplaceExplicitSetCookieRoute + '/api/route-after-next': typeof ApiRouteAfterNextRoute + '/api/route-before-next': typeof ApiRouteBeforeNextRoute + '/api/same-boundary-conflict': typeof ApiSameBoundaryConflictRoute + '/api/throw-after-status': typeof ApiThrowAfterStatusRoute + '/api/two-returned-responses': typeof ApiTwoReturnedResponsesRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/server-functions' + | '/ssr' + | '/api/base' + | '/api/bulk-headers' + | '/api/clear-returned-headers' + | '/api/direct-mutation-visible' + | '/api/explicit-set-cookie-header' + | '/api/get-response-header-helper' + | '/api/get-response-headers-helper' + | '/api/multiple-cookies' + | '/api/null-body-status' + | '/api/readonly-after-next' + | '/api/redirect-with-cookies' + | '/api/remove-returned-header' + | '/api/replace-after-direct-mutation' + | '/api/replace-explicit-set-cookie' + | '/api/route-after-next' + | '/api/route-before-next' + | '/api/same-boundary-conflict' + | '/api/throw-after-status' + | '/api/two-returned-responses' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/server-functions' + | '/ssr' + | '/api/base' + | '/api/bulk-headers' + | '/api/clear-returned-headers' + | '/api/direct-mutation-visible' + | '/api/explicit-set-cookie-header' + | '/api/get-response-header-helper' + | '/api/get-response-headers-helper' + | '/api/multiple-cookies' + | '/api/null-body-status' + | '/api/readonly-after-next' + | '/api/redirect-with-cookies' + | '/api/remove-returned-header' + | '/api/replace-after-direct-mutation' + | '/api/replace-explicit-set-cookie' + | '/api/route-after-next' + | '/api/route-before-next' + | '/api/same-boundary-conflict' + | '/api/throw-after-status' + | '/api/two-returned-responses' + id: + | '__root__' + | '/' + | '/server-functions' + | '/ssr' + | '/api/base' + | '/api/bulk-headers' + | '/api/clear-returned-headers' + | '/api/direct-mutation-visible' + | '/api/explicit-set-cookie-header' + | '/api/get-response-header-helper' + | '/api/get-response-headers-helper' + | '/api/multiple-cookies' + | '/api/null-body-status' + | '/api/readonly-after-next' + | '/api/redirect-with-cookies' + | '/api/remove-returned-header' + | '/api/replace-after-direct-mutation' + | '/api/replace-explicit-set-cookie' + | '/api/route-after-next' + | '/api/route-before-next' + | '/api/same-boundary-conflict' + | '/api/throw-after-status' + | '/api/two-returned-responses' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ServerFunctionsRoute: typeof ServerFunctionsRoute + SsrRoute: typeof SsrRoute + ApiBaseRoute: typeof ApiBaseRoute + ApiBulkHeadersRoute: typeof ApiBulkHeadersRoute + ApiClearReturnedHeadersRoute: typeof ApiClearReturnedHeadersRoute + ApiDirectMutationVisibleRoute: typeof ApiDirectMutationVisibleRoute + ApiExplicitSetCookieHeaderRoute: typeof ApiExplicitSetCookieHeaderRoute + ApiGetResponseHeaderHelperRoute: typeof ApiGetResponseHeaderHelperRoute + ApiGetResponseHeadersHelperRoute: typeof ApiGetResponseHeadersHelperRoute + ApiMultipleCookiesRoute: typeof ApiMultipleCookiesRoute + ApiNullBodyStatusRoute: typeof ApiNullBodyStatusRoute + ApiReadonlyAfterNextRoute: typeof ApiReadonlyAfterNextRoute + ApiRedirectWithCookiesRoute: typeof ApiRedirectWithCookiesRoute + ApiRemoveReturnedHeaderRoute: typeof ApiRemoveReturnedHeaderRoute + ApiReplaceAfterDirectMutationRoute: typeof ApiReplaceAfterDirectMutationRoute + ApiReplaceExplicitSetCookieRoute: typeof ApiReplaceExplicitSetCookieRoute + ApiRouteAfterNextRoute: typeof ApiRouteAfterNextRoute + ApiRouteBeforeNextRoute: typeof ApiRouteBeforeNextRoute + ApiSameBoundaryConflictRoute: typeof ApiSameBoundaryConflictRoute + ApiThrowAfterStatusRoute: typeof ApiThrowAfterStatusRoute + ApiTwoReturnedResponsesRoute: typeof ApiTwoReturnedResponsesRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/ssr': { + id: '/ssr' + path: '/ssr' + fullPath: '/ssr' + preLoaderRoute: typeof SsrRouteImport + parentRoute: typeof rootRouteImport + } + '/server-functions': { + id: '/server-functions' + path: '/server-functions' + fullPath: '/server-functions' + preLoaderRoute: typeof ServerFunctionsRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/api/two-returned-responses': { + id: '/api/two-returned-responses' + path: '/api/two-returned-responses' + fullPath: '/api/two-returned-responses' + preLoaderRoute: typeof ApiTwoReturnedResponsesRouteImport + parentRoute: typeof rootRouteImport + } + '/api/throw-after-status': { + id: '/api/throw-after-status' + path: '/api/throw-after-status' + fullPath: '/api/throw-after-status' + preLoaderRoute: typeof ApiThrowAfterStatusRouteImport + parentRoute: typeof rootRouteImport + } + '/api/same-boundary-conflict': { + id: '/api/same-boundary-conflict' + path: '/api/same-boundary-conflict' + fullPath: '/api/same-boundary-conflict' + preLoaderRoute: typeof ApiSameBoundaryConflictRouteImport + parentRoute: typeof rootRouteImport + } + '/api/route-before-next': { + id: '/api/route-before-next' + path: '/api/route-before-next' + fullPath: '/api/route-before-next' + preLoaderRoute: typeof ApiRouteBeforeNextRouteImport + parentRoute: typeof rootRouteImport + } + '/api/route-after-next': { + id: '/api/route-after-next' + path: '/api/route-after-next' + fullPath: '/api/route-after-next' + preLoaderRoute: typeof ApiRouteAfterNextRouteImport + parentRoute: typeof rootRouteImport + } + '/api/replace-explicit-set-cookie': { + id: '/api/replace-explicit-set-cookie' + path: '/api/replace-explicit-set-cookie' + fullPath: '/api/replace-explicit-set-cookie' + preLoaderRoute: typeof ApiReplaceExplicitSetCookieRouteImport + parentRoute: typeof rootRouteImport + } + '/api/replace-after-direct-mutation': { + id: '/api/replace-after-direct-mutation' + path: '/api/replace-after-direct-mutation' + fullPath: '/api/replace-after-direct-mutation' + preLoaderRoute: typeof ApiReplaceAfterDirectMutationRouteImport + parentRoute: typeof rootRouteImport + } + '/api/remove-returned-header': { + id: '/api/remove-returned-header' + path: '/api/remove-returned-header' + fullPath: '/api/remove-returned-header' + preLoaderRoute: typeof ApiRemoveReturnedHeaderRouteImport + parentRoute: typeof rootRouteImport + } + '/api/redirect-with-cookies': { + id: '/api/redirect-with-cookies' + path: '/api/redirect-with-cookies' + fullPath: '/api/redirect-with-cookies' + preLoaderRoute: typeof ApiRedirectWithCookiesRouteImport + parentRoute: typeof rootRouteImport + } + '/api/readonly-after-next': { + id: '/api/readonly-after-next' + path: '/api/readonly-after-next' + fullPath: '/api/readonly-after-next' + preLoaderRoute: typeof ApiReadonlyAfterNextRouteImport + parentRoute: typeof rootRouteImport + } + '/api/null-body-status': { + id: '/api/null-body-status' + path: '/api/null-body-status' + fullPath: '/api/null-body-status' + preLoaderRoute: typeof ApiNullBodyStatusRouteImport + parentRoute: typeof rootRouteImport + } + '/api/multiple-cookies': { + id: '/api/multiple-cookies' + path: '/api/multiple-cookies' + fullPath: '/api/multiple-cookies' + preLoaderRoute: typeof ApiMultipleCookiesRouteImport + parentRoute: typeof rootRouteImport + } + '/api/get-response-headers-helper': { + id: '/api/get-response-headers-helper' + path: '/api/get-response-headers-helper' + fullPath: '/api/get-response-headers-helper' + preLoaderRoute: typeof ApiGetResponseHeadersHelperRouteImport + parentRoute: typeof rootRouteImport + } + '/api/get-response-header-helper': { + id: '/api/get-response-header-helper' + path: '/api/get-response-header-helper' + fullPath: '/api/get-response-header-helper' + preLoaderRoute: typeof ApiGetResponseHeaderHelperRouteImport + parentRoute: typeof rootRouteImport + } + '/api/explicit-set-cookie-header': { + id: '/api/explicit-set-cookie-header' + path: '/api/explicit-set-cookie-header' + fullPath: '/api/explicit-set-cookie-header' + preLoaderRoute: typeof ApiExplicitSetCookieHeaderRouteImport + parentRoute: typeof rootRouteImport + } + '/api/direct-mutation-visible': { + id: '/api/direct-mutation-visible' + path: '/api/direct-mutation-visible' + fullPath: '/api/direct-mutation-visible' + preLoaderRoute: typeof ApiDirectMutationVisibleRouteImport + parentRoute: typeof rootRouteImport + } + '/api/clear-returned-headers': { + id: '/api/clear-returned-headers' + path: '/api/clear-returned-headers' + fullPath: '/api/clear-returned-headers' + preLoaderRoute: typeof ApiClearReturnedHeadersRouteImport + parentRoute: typeof rootRouteImport + } + '/api/bulk-headers': { + id: '/api/bulk-headers' + path: '/api/bulk-headers' + fullPath: '/api/bulk-headers' + preLoaderRoute: typeof ApiBulkHeadersRouteImport + parentRoute: typeof rootRouteImport + } + '/api/base': { + id: '/api/base' + path: '/api/base' + fullPath: '/api/base' + preLoaderRoute: typeof ApiBaseRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ServerFunctionsRoute: ServerFunctionsRoute, + SsrRoute: SsrRoute, + ApiBaseRoute: ApiBaseRoute, + ApiBulkHeadersRoute: ApiBulkHeadersRoute, + ApiClearReturnedHeadersRoute: ApiClearReturnedHeadersRoute, + ApiDirectMutationVisibleRoute: ApiDirectMutationVisibleRoute, + ApiExplicitSetCookieHeaderRoute: ApiExplicitSetCookieHeaderRoute, + ApiGetResponseHeaderHelperRoute: ApiGetResponseHeaderHelperRoute, + ApiGetResponseHeadersHelperRoute: ApiGetResponseHeadersHelperRoute, + ApiMultipleCookiesRoute: ApiMultipleCookiesRoute, + ApiNullBodyStatusRoute: ApiNullBodyStatusRoute, + ApiReadonlyAfterNextRoute: ApiReadonlyAfterNextRoute, + ApiRedirectWithCookiesRoute: ApiRedirectWithCookiesRoute, + ApiRemoveReturnedHeaderRoute: ApiRemoveReturnedHeaderRoute, + ApiReplaceAfterDirectMutationRoute: ApiReplaceAfterDirectMutationRoute, + ApiReplaceExplicitSetCookieRoute: ApiReplaceExplicitSetCookieRoute, + ApiRouteAfterNextRoute: ApiRouteAfterNextRoute, + ApiRouteBeforeNextRoute: ApiRouteBeforeNextRoute, + ApiSameBoundaryConflictRoute: ApiSameBoundaryConflictRoute, + ApiThrowAfterStatusRoute: ApiThrowAfterStatusRoute, + ApiTwoReturnedResponsesRoute: ApiTwoReturnedResponsesRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { startInstance } from './start.ts' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + config: Awaited> + } +} diff --git a/e2e/react-start/response-reconciliation/src/router.tsx b/e2e/react-start/response-reconciliation/src/router.tsx new file mode 100644 index 0000000000..c5c2209e5a --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/router.tsx @@ -0,0 +1,13 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +import { DefaultCatchBoundary } from './components/DefaultCatchBoundary' +import { NotFound } from './components/NotFound' + +export function getRouter() { + return createRouter({ + routeTree, + defaultErrorComponent: DefaultCatchBoundary, + defaultNotFoundComponent: () => , + scrollRestoration: true, + }) +} diff --git a/e2e/react-start/response-reconciliation/src/routes/__root.tsx b/e2e/react-start/response-reconciliation/src/routes/__root.tsx new file mode 100644 index 0000000000..a562991887 --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/__root.tsx @@ -0,0 +1,57 @@ +/// +import * as React from 'react' +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' +import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary' +import { NotFound } from '~/components/NotFound' +import '~/styles/app.css' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + ], + }), + errorComponent: (props) => { + return ( + + + + ) + }, + notFoundComponent: () => , + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + ) +} + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + + {children} + + + + ) +} diff --git a/e2e/react-start/response-reconciliation/src/routes/api/-response.ts b/e2e/react-start/response-reconciliation/src/routes/api/-response.ts new file mode 100644 index 0000000000..54082c10f6 --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/-response.ts @@ -0,0 +1,85 @@ +import { createMiddleware } from '@tanstack/react-start' +import { + getResponseHeader, + setResponseHeader, + setResponseStatus, +} from '@tanstack/react-start/server' + +function getScenario(request: Request) { + const url = new URL(request.url) + return url.pathname.split('/').pop() || '' +} + +export function baseResponse(scenario: string) { + return new Response(scenario, { + headers: { 'x-handler': scenario, 'x-base': 'yes' }, + }) +} + +export const routeBoundaryMiddleware = createMiddleware().server( + async ({ next, request }) => { + const scenario = getScenario(request) + + if (scenario === 'route-before-next') { + setResponseHeader('x-route-before', 'yes') + setResponseStatus(234, 'route-before') + return next() + } + + if (scenario === 'route-after-next') { + const result = await next() + setResponseHeader('x-route-after', 'yes') + setResponseStatus(233, 'route-after') + return result + } + + if (scenario === 'direct-mutation-visible') { + const result = await next() + result.response.headers.set('x-direct-visible', 'yes') + setResponseHeader( + 'x-direct-read', + getResponseHeader('x-direct-visible') || 'missing', + ) + return result + } + + if (scenario === 'replace-after-direct-mutation') { + const result = await next() + result.response.headers.set('x-direct-a', 'yes') + setResponseHeader('x-helper-delta', 'yes') + return new Response('replacement', { + status: 202, + headers: { 'x-response-b': 'yes' }, + }) + } + + if (scenario === 'two-returned-responses') { + await next() + setResponseHeader('x-helper-after-a', 'yes') + return new Response('response-b', { + headers: { 'x-response-b': 'yes' }, + }) + } + + if (scenario === 'readonly-after-next') { + const result = await next() + setResponseHeader('x-readonly-after', 'yes') + return result + } + + return next() + }, +) + +export const returnedResponseMiddleware = createMiddleware().server( + async ({ next, request }) => { + if (getScenario(request) === 'two-returned-responses') { + await next() + return new Response('response-a', { + headers: { 'x-response-a': 'yes' }, + }) + } + + return next() + }, +) diff --git a/e2e/react-start/response-reconciliation/src/routes/api/base.ts b/e2e/react-start/response-reconciliation/src/routes/api/base.ts new file mode 100644 index 0000000000..62f706a9f1 --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/base.ts @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/react-router' +import { baseResponse } from './-response' + +export const Route = createFileRoute('/api/base')({ + server: { + handlers: { + GET: () => baseResponse('base'), + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/routes/api/bulk-headers.ts b/e2e/react-start/response-reconciliation/src/routes/api/bulk-headers.ts new file mode 100644 index 0000000000..db7ded6c54 --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/bulk-headers.ts @@ -0,0 +1,19 @@ +import { createFileRoute } from '@tanstack/react-router' +import { setResponseHeaders } from '@tanstack/react-start/server' + +export const Route = createFileRoute('/api/bulk-headers')({ + server: { + handlers: { + GET: () => { + const headers = new Headers({ + 'x-bulk-one': '1', + 'x-bulk-two': '2', + }) + headers.append('set-cookie', 'bulk-one=1; Path=/') + headers.append('set-cookie', 'bulk-two=2; Path=/') + setResponseHeaders(headers) + return new Response('bulk', { headers: { 'x-keep': 'yes' } }) + }, + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/routes/api/clear-returned-headers.ts b/e2e/react-start/response-reconciliation/src/routes/api/clear-returned-headers.ts new file mode 100644 index 0000000000..fd3b88c172 --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/clear-returned-headers.ts @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/react-router' +import { clearResponseHeaders } from '@tanstack/react-start/server' + +export const Route = createFileRoute('/api/clear-returned-headers')({ + server: { + handlers: { + GET: () => { + clearResponseHeaders() + return new Response('clear', { + headers: { 'x-clear-one': '1', 'x-clear-two': '2' }, + }) + }, + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/routes/api/direct-mutation-visible.ts b/e2e/react-start/response-reconciliation/src/routes/api/direct-mutation-visible.ts new file mode 100644 index 0000000000..96823887e5 --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/direct-mutation-visible.ts @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router' +import { baseResponse, routeBoundaryMiddleware } from './-response' + +export const Route = createFileRoute('/api/direct-mutation-visible')({ + server: { + middleware: [routeBoundaryMiddleware], + handlers: { + GET: () => baseResponse('direct-mutation-visible'), + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/routes/api/explicit-set-cookie-header.ts b/e2e/react-start/response-reconciliation/src/routes/api/explicit-set-cookie-header.ts new file mode 100644 index 0000000000..3ea2bc6d75 --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/explicit-set-cookie-header.ts @@ -0,0 +1,16 @@ +import { createFileRoute } from '@tanstack/react-router' +import { setResponseHeader } from '@tanstack/react-start/server' + +export const Route = createFileRoute('/api/explicit-set-cookie-header')({ + server: { + handlers: { + GET: () => { + setResponseHeader('set-cookie', [ + 'explicit-one=1; Path=/', + 'explicit-two=2; Path=/', + ]) + return new Response('explicit') + }, + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/routes/api/get-response-header-helper.ts b/e2e/react-start/response-reconciliation/src/routes/api/get-response-header-helper.ts new file mode 100644 index 0000000000..b597882ff6 --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/get-response-header-helper.ts @@ -0,0 +1,17 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + getResponseHeader, + setResponseHeader, +} from '@tanstack/react-start/server' + +export const Route = createFileRoute('/api/get-response-header-helper')({ + server: { + handlers: { + GET: () => { + setResponseHeader('x-helper-visible', 'yes') + const value = getResponseHeader('x-helper-visible') || 'missing' + return new Response(value, { headers: { 'x-read-after-set': value } }) + }, + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/routes/api/get-response-headers-helper.ts b/e2e/react-start/response-reconciliation/src/routes/api/get-response-headers-helper.ts new file mode 100644 index 0000000000..07eaacbaa0 --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/get-response-headers-helper.ts @@ -0,0 +1,19 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + getResponseHeaders, + setResponseHeader, +} from '@tanstack/react-start/server' + +export const Route = createFileRoute('/api/get-response-headers-helper')({ + server: { + handlers: { + GET: () => { + setResponseHeader('x-headers-helper-visible', 'yes') + const headers = getResponseHeaders() + const value = headers.get('x-headers-helper-visible') || 'missing' + headers.set('x-headers-snapshot-write', 'ignored') + return new Response(value, { headers: { 'x-headers-read': value } }) + }, + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/routes/api/multiple-cookies.ts b/e2e/react-start/response-reconciliation/src/routes/api/multiple-cookies.ts new file mode 100644 index 0000000000..eebf7a167e --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/multiple-cookies.ts @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router' +import { setCookie } from '@tanstack/react-start/server' + +export const Route = createFileRoute('/api/multiple-cookies')({ + server: { + handlers: { + GET: () => { + setCookie('route-one', '1', { path: '/' }) + setCookie('route-two', '2', { path: '/' }) + return new Response('cookies') + }, + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/routes/api/null-body-status.ts b/e2e/react-start/response-reconciliation/src/routes/api/null-body-status.ts new file mode 100644 index 0000000000..d11056e7a8 --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/null-body-status.ts @@ -0,0 +1,18 @@ +import { createFileRoute } from '@tanstack/react-router' +import { setResponseStatus } from '@tanstack/react-start/server' + +export const Route = createFileRoute('/api/null-body-status')({ + server: { + handlers: { + GET: ({ request }) => { + const statusParam = new URL(request.url).searchParams.get('status') + const status = + statusParam === '205' ? 205 : statusParam === '304' ? 304 : 204 + setResponseStatus(status) + return new Response('should-drop', { + headers: { 'x-null-body': 'yes' }, + }) + }, + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/routes/api/readonly-after-next.ts b/e2e/react-start/response-reconciliation/src/routes/api/readonly-after-next.ts new file mode 100644 index 0000000000..7b42955052 --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/readonly-after-next.ts @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router' +import { routeBoundaryMiddleware } from './-response' + +export const Route = createFileRoute('/api/readonly-after-next')({ + server: { + middleware: [routeBoundaryMiddleware], + handlers: { + GET: () => fetch('data:text/plain,readonly'), + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/routes/api/redirect-with-cookies.ts b/e2e/react-start/response-reconciliation/src/routes/api/redirect-with-cookies.ts new file mode 100644 index 0000000000..5b0072f35a --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/redirect-with-cookies.ts @@ -0,0 +1,18 @@ +import { createFileRoute } from '@tanstack/react-router' +import { setCookie } from '@tanstack/react-start/server' + +export const Route = createFileRoute('/api/redirect-with-cookies')({ + server: { + handlers: { + GET: () => { + setCookie('redirect-one', '1', { path: '/' }) + setCookie('redirect-two', '2', { path: '/' }) + setCookie('redirect-three', '3', { path: '/' }) + return new Response(null, { + status: 307, + headers: { Location: '/api/base' }, + }) + }, + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/routes/api/remove-returned-header.ts b/e2e/react-start/response-reconciliation/src/routes/api/remove-returned-header.ts new file mode 100644 index 0000000000..96f82bf98d --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/remove-returned-header.ts @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/react-router' +import { removeResponseHeader } from '@tanstack/react-start/server' + +export const Route = createFileRoute('/api/remove-returned-header')({ + server: { + handlers: { + GET: () => { + removeResponseHeader('x-remove-me') + return new Response('remove', { + headers: { 'x-remove-me': 'response', 'x-keep': 'yes' }, + }) + }, + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/routes/api/replace-after-direct-mutation.ts b/e2e/react-start/response-reconciliation/src/routes/api/replace-after-direct-mutation.ts new file mode 100644 index 0000000000..890280483d --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/replace-after-direct-mutation.ts @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router' +import { baseResponse, routeBoundaryMiddleware } from './-response' + +export const Route = createFileRoute('/api/replace-after-direct-mutation')({ + server: { + middleware: [routeBoundaryMiddleware], + handlers: { + GET: () => baseResponse('replace-after-direct-mutation'), + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/routes/api/replace-explicit-set-cookie.ts b/e2e/react-start/response-reconciliation/src/routes/api/replace-explicit-set-cookie.ts new file mode 100644 index 0000000000..97df27d915 --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/replace-explicit-set-cookie.ts @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/react-router' +import { setResponseHeader } from '@tanstack/react-start/server' + +export const Route = createFileRoute('/api/replace-explicit-set-cookie')({ + server: { + handlers: { + GET: () => { + setResponseHeader('set-cookie', 'explicit-new=1; Path=/') + return new Response('explicit', { + headers: { 'set-cookie': 'explicit-old=1; Path=/' }, + }) + }, + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/routes/api/route-after-next.ts b/e2e/react-start/response-reconciliation/src/routes/api/route-after-next.ts new file mode 100644 index 0000000000..7d3650147f --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/route-after-next.ts @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router' +import { baseResponse, routeBoundaryMiddleware } from './-response' + +export const Route = createFileRoute('/api/route-after-next')({ + server: { + middleware: [routeBoundaryMiddleware], + handlers: { + GET: () => baseResponse('route-after-next'), + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/routes/api/route-before-next.ts b/e2e/react-start/response-reconciliation/src/routes/api/route-before-next.ts new file mode 100644 index 0000000000..b52c7642af --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/route-before-next.ts @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router' +import { baseResponse, routeBoundaryMiddleware } from './-response' + +export const Route = createFileRoute('/api/route-before-next')({ + server: { + middleware: [routeBoundaryMiddleware], + handlers: { + GET: () => baseResponse('route-before-next'), + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/routes/api/same-boundary-conflict.ts b/e2e/react-start/response-reconciliation/src/routes/api/same-boundary-conflict.ts new file mode 100644 index 0000000000..7ac086d652 --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/same-boundary-conflict.ts @@ -0,0 +1,21 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + setResponseHeader, + setResponseStatus, +} from '@tanstack/react-start/server' + +export const Route = createFileRoute('/api/same-boundary-conflict')({ + server: { + handlers: { + GET: () => { + setResponseStatus(235, 'same-boundary') + setResponseHeader('x-conflict', 'helper') + return new Response('conflict', { + status: 201, + statusText: 'response-status', + headers: { 'x-conflict': 'response' }, + }) + }, + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/routes/api/throw-after-status.ts b/e2e/react-start/response-reconciliation/src/routes/api/throw-after-status.ts new file mode 100644 index 0000000000..9d93d75b61 --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/throw-after-status.ts @@ -0,0 +1,26 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + setCookie, + setResponseHeader, + setResponseStatus, +} from '@tanstack/react-start/server' + +export const Route = createFileRoute('/api/throw-after-status')({ + server: { + handlers: { + GET: ({ request }) => { + const url = new URL(request.url) + setResponseStatus(401, 'Unauthorized') + setResponseHeader('x-error-helper', 'yes') + setCookie('throw-after-status', '1', { path: '/' }) + if (url.searchParams.get('throw') === 'response') { + throw new Response('Unauthorized response', { + status: 200, + headers: { 'x-thrown-response': 'yes' }, + }) + } + throw new Error('Unauthorized route') + }, + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/routes/api/two-returned-responses.ts b/e2e/react-start/response-reconciliation/src/routes/api/two-returned-responses.ts new file mode 100644 index 0000000000..f433778fd8 --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/api/two-returned-responses.ts @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + baseResponse, + returnedResponseMiddleware, + routeBoundaryMiddleware, +} from './-response' + +export const Route = createFileRoute('/api/two-returned-responses')({ + server: { + middleware: [routeBoundaryMiddleware, returnedResponseMiddleware], + handlers: { + GET: () => baseResponse('two-returned-responses'), + }, + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/routes/index.tsx b/e2e/react-start/response-reconciliation/src/routes/index.tsx new file mode 100644 index 0000000000..1379ced274 --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/index.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

Response Reconciliation E2E

+

This app contains HTTP-level regression tests.

+
+ ) +} diff --git a/e2e/react-start/response-reconciliation/src/routes/server-functions.tsx b/e2e/react-start/response-reconciliation/src/routes/server-functions.tsx new file mode 100644 index 0000000000..a2d27406fe --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/server-functions.tsx @@ -0,0 +1,211 @@ +import { ClientOnly, createFileRoute } from '@tanstack/react-router' +import { + createMiddleware, + createServerFn, + useServerFn, +} from '@tanstack/react-start' +import * as React from 'react' +import { + getResponseHeader, + setCookie, + setResponseHeader, + setResponseHeaders, + setResponseStatus, +} from '@tanstack/react-start/server' + +const functionAfterNextMiddleware = createMiddleware({ + type: 'function', +}).server(async ({ next }) => { + const result = await next() + setResponseHeader('x-function-after', 'yes') + setResponseStatus(236, 'function-after') + return result +}) + +const rawResponseAfterNextMiddleware = createMiddleware({ + type: 'function', +}).server(async ({ next }) => { + const result = await next() + setResponseHeader('x-function-raw-after', 'yes') + setResponseStatus(237, 'function-raw-after') + return result +}) + +const throwStatusMiddleware = createMiddleware({ + type: 'function', +}).server(() => { + setResponseStatus(401, 'Unauthorized') + setResponseHeader('x-function-error-middleware', 'yes') + throw new Error('Unauthorized function middleware') +}) + +const replacementMiddleware = createMiddleware({ + type: 'function', +}).server(async ({ next }) => { + const result = await next() + const resultWithResponse = result as typeof result & { result?: unknown } + if (!(resultWithResponse.result instanceof Response)) { + throw new Error('Expected replacement test to observe a Response') + } + resultWithResponse.result.headers.set('x-function-a', 'yes') + setResponseHeader('x-function-helper-delta', 'yes') + return new Response('function-b', { + headers: { 'x-function-b': 'yes' }, + }) as any +}) + +const globalSerializedFn = createServerFn().handler(() => { + return { ok: true } +}) + +const functionAfterNextFn = createServerFn() + .middleware([functionAfterNextMiddleware]) + .handler(() => { + return { ok: true } + }) + +const rawResponseAfterNextFn = createServerFn() + .middleware([rawResponseAfterNextMiddleware]) + .handler(() => { + return new Response('raw function body', { + headers: { 'x-function-raw': 'yes' }, + }) + }) + +const replacementFn = createServerFn() + .middleware([replacementMiddleware]) + .handler(() => { + return new Response('function-a') + }) + +const multipleCookiesFn = createServerFn().handler(() => { + setCookie('fn-one', '1', { path: '/' }) + setCookie('fn-two', '2', { path: '/' }) + return { ok: true } +}) + +const rawMultipleCookiesFn = createServerFn().handler(() => { + setCookie('fn-raw-helper-one', '1', { path: '/' }) + setCookie('fn-raw-helper-two', '2', { path: '/' }) + const headers = new Headers() + headers.append('set-cookie', 'fn-raw-returned-one=1; Path=/') + headers.append('set-cookie', 'fn-raw-returned-two=2; Path=/') + return new Response('raw cookie body', { headers }) +}) + +const explicitCookieHeaderFn = createServerFn().handler(() => { + setResponseHeader('set-cookie', [ + 'fn-explicit-one=1; Path=/', + 'fn-explicit-two=2; Path=/', + ]) + return { ok: true } +}) + +const throwAfterStatusFn = createServerFn().handler(() => { + setResponseStatus(401, 'Unauthorized') + setResponseHeader('x-function-error', 'yes') + throw new Error('Unauthorized function') +}) + +const throwAfterMiddlewareStatusFn = createServerFn() + .middleware([throwStatusMiddleware]) + .handler(() => { + return { ok: true } + }) + +const transportProtectedFn = createServerFn().handler(() => { + setResponseHeaders( + new Headers({ + 'content-type': 'text/plain', + 'x-tss-serialized': 'false', + 'x-user-header': 'yes', + }), + ) + return { ok: true } +}) + +const readAfterSetFn = createServerFn().handler(() => { + setResponseHeader('x-read-after-set', 'yes') + setResponseHeader( + 'x-read-after-set-value', + getResponseHeader('x-read-after-set') || 'missing', + ) + return { ok: true } +}) + +export const Route = createFileRoute('/server-functions')({ + component: ServerFunctions, +}) + +function ServerFunctions() { + return ( +
+

Server Functions

+ + + + + + + + + + + + + +
+ ) +} + +function ServerFunctionButton({ + name, + fn, +}: { + name: string + fn: (...args: Array) => Promise +}) { + const serverFn = useServerFn(fn) + const [result, setResult] = React.useState('idle') + + return ( +
+ + {result} +
+ ) +} diff --git a/e2e/react-start/response-reconciliation/src/routes/ssr.tsx b/e2e/react-start/response-reconciliation/src/routes/ssr.tsx new file mode 100644 index 0000000000..c0cb69c8ec --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/routes/ssr.tsx @@ -0,0 +1,29 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { + setCookie, + setResponseHeader, + setResponseStatus, +} from '@tanstack/react-start/server' + +const setSsrResponseFn = createServerFn().handler(() => { + setResponseStatus(238, 'ssr-loader') + setResponseHeader('x-ssr-loader', 'yes') + setCookie('ssr-one', '1', { path: '/' }) + setCookie('ssr-two', '2', { path: '/' }) + return { message: 'ssr response' } +}) + +export const Route = createFileRoute('/ssr')({ + loader: () => setSsrResponseFn(), + component: SsrRoute, +}) + +function SsrRoute() { + const data = Route.useLoaderData() + return ( +
+

{data.message}

+
+ ) +} diff --git a/e2e/react-start/response-reconciliation/src/server-entry.ts b/e2e/react-start/response-reconciliation/src/server-entry.ts new file mode 100644 index 0000000000..cde46981d5 --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/server-entry.ts @@ -0,0 +1,58 @@ +import { + createStartHandler, + defaultStreamHandler, + handleStartError, +} from '@tanstack/react-start/server' +import handler, { createServerEntry } from '@tanstack/react-start/server-entry' + +const uncaughtFetch = createStartHandler(defaultStreamHandler) + +function markCustomServerEntry(response: Response): Response { + try { + response.headers.set('x-custom-server-entry', 'yes') + return response + } catch { + const headers = new Headers(response.headers) + headers.set('x-custom-server-entry', 'yes') + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) + } +} + +export default createServerEntry({ + async fetch(request, opts) { + if (request.headers.get('x-custom-handle-errors') === 'false') { + try { + return markCustomServerEntry(await uncaughtFetch(request, opts)) + } catch (error) { + if (request.headers.get('x-custom-handle-start-error') === 'true') { + return markCustomServerEntry(handleStartError(error)) + } + return markCustomServerEntry( + new Response(error instanceof Error ? error.message : 'error', { + status: 555, + headers: { + 'x-custom-entry-catch': 'yes', + }, + }), + ) + } + } + + try { + return markCustomServerEntry(await handler.fetch(request, opts)) + } catch (error) { + return markCustomServerEntry( + new Response(error instanceof Error ? error.message : 'error', { + status: 556, + headers: { + 'x-custom-entry-catch': 'unexpected', + }, + }), + ) + } + }, +}) diff --git a/e2e/react-start/response-reconciliation/src/start.ts b/e2e/react-start/response-reconciliation/src/start.ts new file mode 100644 index 0000000000..bd3e72c35e --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/start.ts @@ -0,0 +1,53 @@ +import { createMiddleware, createStart } from '@tanstack/react-start' +import { + setCookie, + getCookie, + setResponseHeader, + setResponseHeaders, + setResponseStatus, +} from '@tanstack/react-start/server' + +const globalResponseMiddleware = createMiddleware().server( + async ({ next, request }) => { + const scenario = + request.headers.get('x-reconciliation-scenario') || + getCookie('reconciliation-scenario') + + if (scenario === 'global-before') { + setResponseHeaders( + new Headers({ + 'x-global-before': 'yes', + 'x-global-common': 'before', + }), + ) + setResponseStatus(231, 'global-before') + return next() + } + + if (scenario === 'global-after') { + const result = await next() + setResponseHeader('x-global-after', 'yes') + setResponseHeader('x-global-common', 'after') + setResponseStatus(232, 'global-after') + return result + } + + if (scenario === 'global-multiple-cookies') { + setCookie('global-one', '1', { path: '/' }) + setCookie('global-two', '2', { path: '/' }) + return next() + } + + if (scenario === 'global-throw') { + setResponseStatus(401, 'Unauthorized') + setResponseHeader('x-global-error', 'yes') + throw new Error('Unauthorized global middleware') + } + + return next() + }, +) + +export const startInstance = createStart(() => ({ + requestMiddleware: [globalResponseMiddleware], +})) diff --git a/e2e/react-start/response-reconciliation/src/styles/app.css b/e2e/react-start/response-reconciliation/src/styles/app.css new file mode 100644 index 0000000000..a38fb439a6 --- /dev/null +++ b/e2e/react-start/response-reconciliation/src/styles/app.css @@ -0,0 +1,12 @@ +html { + color-scheme: light dark; + font-family: system-ui, sans-serif; +} + +body { + margin: 0; +} + +main { + padding: 16px; +} diff --git a/e2e/react-start/response-reconciliation/tests/response-reconciliation.spec.ts b/e2e/react-start/response-reconciliation/tests/response-reconciliation.spec.ts new file mode 100644 index 0000000000..2d6ea02260 --- /dev/null +++ b/e2e/react-start/response-reconciliation/tests/response-reconciliation.spec.ts @@ -0,0 +1,648 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import type { APIResponse, Page, Response } from '@playwright/test' + +type TestResponse = APIResponse | Response + +function header(response: TestResponse, name: string) { + return response.headers()[name.toLowerCase()] ?? null +} + +async function setCookieValues(response: TestResponse) { + return (await Promise.resolve(response.headersArray())) + .filter((item) => item.name.toLowerCase() === 'set-cookie') + .map((item) => item.value) +} + +function expectCookie(cookies: Array, name: string, value: string) { + expect( + cookies.filter((cookie) => cookie.startsWith(`${name}=${value};`)), + ).toHaveLength(1) +} + +async function invokeServerFunction( + page: Page, + name: string, + scenario?: string, +) { + await page.goto( + scenario + ? `/server-functions?scenario=${encodeURIComponent(scenario)}` + : '/server-functions', + ) + await expect(page.getByTestId('server-functions-hydrated')).toBeAttached() + await page.evaluate((value) => { + document.cookie = value + ? `reconciliation-scenario=${value}; Path=/` + : 'reconciliation-scenario=; Max-Age=0; Path=/' + }, scenario) + const responsePromise = page.waitForResponse((response) => + response.url().includes('/_serverFn/'), + ) + await page.getByTestId(`server-function-${name}`).click() + const response = await responsePromise + await expect( + page.getByTestId(`server-function-${name}-result`), + ).not.toHaveText('pending') + return response +} + +async function invokeJsonServerFunction( + page: Page, + name: string, + scenario?: string, +) { + const response = await invokeServerFunction(page, name, scenario) + const request = response.request() + expect(request.headers()['x-tsr-serverfn']).toBe('true') + const postData = request.postData() + if (postData) { + expect(postData).not.toContain('x-reconciliation-scenario') + } + return response +} + +async function expectServerFunctionResult( + page: Page, + name: string, + result: string | RegExp, +) { + await expect(page.getByTestId(`server-function-${name}-result`)).toHaveText( + result, + ) +} + +test.describe('server routes', () => { + test('uses expected server entry mode', async ({ request }) => { + const response = await request.get('/api/base') + + if (process.env.TSS_E2E_SERVER_ENTRY) { + expect(header(response, 'x-custom-server-entry')).toBe('yes') + } else { + expect(header(response, 'x-custom-server-entry')).toBe(null) + } + }) + + test('global request middleware cookies are preserved on server routes', async ({ + request, + }) => { + const response = await request.get('/api/base', { + headers: { 'x-reconciliation-scenario': 'global-multiple-cookies' }, + }) + const cookies = await setCookieValues(response) + + expectCookie(cookies, 'global-one', '1') + expectCookie(cookies, 'global-two', '2') + }) + + test('regression #5407: global middleware setResponseHeaders and status apply before next', async ({ + request, + }) => { + const response = await request.get('/api/base', { + headers: { 'x-reconciliation-scenario': 'global-before' }, + }) + + expect(response.status()).toBe(231) + expect(response.statusText()).toBe('global-before') + expect(header(response, 'x-global-before')).toBe('yes') + expect(header(response, 'x-global-common')).toBe('before') + expect(header(response, 'x-base')).toBe('yes') + }) + + test('global middleware helper changes apply after next', async ({ + request, + }) => { + const response = await request.get('/api/base', { + headers: { 'x-reconciliation-scenario': 'global-after' }, + }) + + expect(response.status()).toBe(232) + expect(response.statusText()).toBe('global-after') + expect(header(response, 'x-global-after')).toBe('yes') + expect(header(response, 'x-global-common')).toBe('after') + expect(header(response, 'x-base')).toBe('yes') + }) + + test('route middleware helper changes apply before and after next', async ({ + request, + }) => { + const before = await request.get('/api/route-before-next') + expect(before.status()).toBe(234) + expect(before.statusText()).toBe('route-before') + expect(header(before, 'x-route-before')).toBe('yes') + expect(header(before, 'x-handler')).toBe('route-before-next') + + const after = await request.get('/api/route-after-next') + expect(after.status()).toBe(233) + expect(after.statusText()).toBe('route-after') + expect(header(after, 'x-route-after')).toBe('yes') + expect(header(after, 'x-handler')).toBe('route-after-next') + }) + + test('helper changes overlay same-boundary returned response conflicts', async ({ + request, + }) => { + const response = await request.get('/api/same-boundary-conflict') + + expect(response.status()).toBe(235) + expect(response.statusText()).toBe('same-boundary') + expect(header(response, 'x-conflict')).toBe('helper') + await expect(response.text()).resolves.toBe('conflict') + }) + + test('setResponseHeaders accepts Headers and preserves multiple cookies', async ({ + request, + }) => { + const response = await request.get('/api/bulk-headers') + const cookies = await setCookieValues(response) + + expect(header(response, 'x-bulk-one')).toBe('1') + expect(header(response, 'x-bulk-two')).toBe('2') + expect(header(response, 'x-keep')).toBe('yes') + expectCookie(cookies, 'bulk-one', '1') + expectCookie(cookies, 'bulk-two', '2') + }) + + test('remove and clear helpers can remove returned response headers', async ({ + request, + }) => { + const removed = await request.get('/api/remove-returned-header') + expect(header(removed, 'x-remove-me')).toBe(null) + expect(header(removed, 'x-keep')).toBe('yes') + + const cleared = await request.get('/api/clear-returned-headers') + expect(header(cleared, 'x-clear-one')).toBe(null) + expect(header(cleared, 'x-clear-two')).toBe(null) + }) + + test('getResponseHeader and getResponseHeaders read helper writes immediately', async ({ + request, + }) => { + const headerResponse = await request.get('/api/get-response-header-helper') + expect(header(headerResponse, 'x-read-after-set')).toBe('yes') + await expect(headerResponse.text()).resolves.toBe('yes') + + const headersResponse = await request.get( + '/api/get-response-headers-helper', + ) + expect(header(headersResponse, 'x-headers-read')).toBe('yes') + expect(header(headersResponse, 'x-headers-snapshot-write')).toBe(null) + await expect(headersResponse.text()).resolves.toBe('yes') + }) + + test('getResponseHeader sees direct current response header mutation', async ({ + request, + }) => { + const response = await request.get('/api/direct-mutation-visible') + + expect(header(response, 'x-direct-visible')).toBe('yes') + expect(header(response, 'x-direct-read')).toBe('yes') + }) + + test('response replacement carries helper deltas but not old response mutations', async ({ + request, + }) => { + const response = await request.get('/api/replace-after-direct-mutation') + + expect(response.status()).toBe(202) + expect(header(response, 'x-direct-a')).toBe(null) + expect(header(response, 'x-helper-delta')).toBe('yes') + expect(header(response, 'x-response-b')).toBe('yes') + await expect(response.text()).resolves.toBe('replacement') + }) + + test('later returned response replaces earlier response metadata', async ({ + request, + }) => { + const response = await request.get('/api/two-returned-responses') + + expect(header(response, 'x-response-a')).toBe(null) + expect(header(response, 'x-response-b')).toBe('yes') + expect(header(response, 'x-helper-after-a')).toBe('yes') + await expect(response.text()).resolves.toBe('response-b') + }) + + test('readonly responses reconcile through clone fallback', async ({ + request, + }) => { + const response = await request.get('/api/readonly-after-next') + + expect(header(response, 'x-readonly-after')).toBe('yes') + await expect(response.text()).resolves.toBe('readonly') + }) + + for (const status of [204, 205, 304]) { + test(`null-body status ${status} drops the response body`, async ({ + request, + }) => { + const response = await request.get( + `/api/null-body-status?status=${status}`, + ) + + expect(response.status()).toBe(status) + expect(header(response, 'x-null-body')).toBe('yes') + await expect(response.text()).resolves.toBe('') + }) + } + + test('regression #5107: thrown errors preserve explicit response status and headers', async ({ + request, + }) => { + const response = await request.get('/api/throw-after-status') + + expect(response.status()).toBe(401) + expect(response.statusText()).toBe('Unauthorized') + expect(header(response, 'x-error-helper')).toBe('yes') + expect(header(response, 'content-type')).toContain('application/json') + if (process.env.TSS_E2E_SERVER_ENTRY) { + expect(header(response, 'x-custom-server-entry')).toBe('yes') + expect(header(response, 'x-custom-entry-catch')).toBe(null) + } + await expect(response.json()).resolves.toMatchObject({ status: 401 }) + }) + + test('thrown responses preserve explicit response status and headers', async ({ + request, + }) => { + const response = await request.get('/api/throw-after-status?throw=response') + + expect(response.status()).toBe(401) + expect(response.statusText()).toBe('Unauthorized') + expect(header(response, 'x-error-helper')).toBe('yes') + expect(header(response, 'x-thrown-response')).toBe('yes') + if (process.env.TSS_E2E_SERVER_ENTRY) { + expect(header(response, 'x-custom-server-entry')).toBe('yes') + expect(header(response, 'x-custom-entry-catch')).toBe(null) + } + await expect(response.text()).resolves.toBe('Unauthorized response') + }) + + test('custom server entry can opt out of automatic error responses', async ({ + request, + }) => { + test.skip(!process.env.TSS_E2E_SERVER_ENTRY, 'custom server entry only') + + const response = await request.get('/api/throw-after-status', { + headers: { 'x-custom-handle-errors': 'false' }, + }) + + expect(response.status()).toBe(555) + expect(header(response, 'x-custom-server-entry')).toBe('yes') + expect(header(response, 'x-custom-entry-catch')).toBe('yes') + await expect(response.text()).resolves.toBe('Unauthorized route') + }) + + test('custom server entry can recover Start error response state', async ({ + request, + }) => { + test.skip(!process.env.TSS_E2E_SERVER_ENTRY, 'custom server entry only') + + const response = await request.get('/api/throw-after-status', { + headers: { + 'x-custom-handle-errors': 'false', + 'x-custom-handle-start-error': 'true', + }, + }) + const cookies = await setCookieValues(response) + + expect(response.status()).toBe(401) + expect(response.statusText()).toBe('Unauthorized') + expect(header(response, 'x-error-helper')).toBe('yes') + expect(header(response, 'x-custom-server-entry')).toBe('yes') + expect(header(response, 'x-custom-entry-catch')).toBe(null) + expectCookie(cookies, 'throw-after-status', '1') + await expect(response.json()).resolves.toMatchObject({ status: 401 }) + }) + + test('custom server entry can recover thrown response state', async ({ + request, + }) => { + test.skip(!process.env.TSS_E2E_SERVER_ENTRY, 'custom server entry only') + + const response = await request.get( + '/api/throw-after-status?throw=response', + { + headers: { + 'x-custom-handle-errors': 'false', + 'x-custom-handle-start-error': 'true', + }, + }, + ) + const cookies = await setCookieValues(response) + + expect(response.status()).toBe(401) + expect(response.statusText()).toBe('Unauthorized') + expect(header(response, 'x-error-helper')).toBe('yes') + expect(header(response, 'x-thrown-response')).toBe('yes') + expect(header(response, 'x-custom-server-entry')).toBe('yes') + expect(header(response, 'x-custom-entry-catch')).toBe(null) + expectCookie(cookies, 'throw-after-status', '1') + await expect(response.text()).resolves.toBe('Unauthorized response') + }) + + test('custom server entry can opt out of SSR request middleware error responses', async ({ + request, + }) => { + test.skip(!process.env.TSS_E2E_SERVER_ENTRY, 'custom server entry only') + + const response = await request.get('/ssr', { + headers: { + 'x-custom-handle-errors': 'false', + 'x-reconciliation-scenario': 'global-throw', + }, + }) + + expect(response.status()).toBe(555) + expect(header(response, 'x-custom-server-entry')).toBe('yes') + expect(header(response, 'x-custom-entry-catch')).toBe('yes') + await expect(response.text()).resolves.toBe( + 'Unauthorized global middleware', + ) + }) + + test('regression #5464: multiple Set-Cookie headers are preserved', async ({ + request, + }) => { + const response = await request.get('/api/multiple-cookies') + const cookies = await setCookieValues(response) + + expectCookie(cookies, 'route-one', '1') + expectCookie(cookies, 'route-two', '2') + }) + + test('explicit Set-Cookie header values are preserved and replace response values', async ({ + request, + }) => { + const explicit = await request.get('/api/explicit-set-cookie-header') + const explicitCookies = await setCookieValues(explicit) + expectCookie(explicitCookies, 'explicit-one', '1') + expectCookie(explicitCookies, 'explicit-two', '2') + + const replaced = await request.get('/api/replace-explicit-set-cookie') + const replacedCookies = await setCookieValues(replaced) + expectCookie(replacedCookies, 'explicit-new', '1') + expect( + replacedCookies.some((cookie) => cookie.startsWith('explicit-old=1;')), + ).toBe(false) + }) + + test('cookies survive redirect responses', async ({ request }) => { + const response = await request.get('/api/redirect-with-cookies', { + maxRedirects: 0, + }) + const cookies = await setCookieValues(response) + + expect(response.status()).toBe(307) + expect(header(response, 'location')).toBe('/api/base') + expectCookie(cookies, 'redirect-one', '1') + expectCookie(cookies, 'redirect-two', '2') + expectCookie(cookies, 'redirect-three', '3') + }) +}) + +test.describe('SSR document responses', () => { + test('loader helper headers, status, and cookies reconcile onto document response', async ({ + request, + }) => { + const response = await request.get('/ssr') + const cookies = await setCookieValues(response) + + expect(response.status()).toBe(238) + expect(response.statusText()).toBe('ssr-loader') + expect(header(response, 'x-ssr-loader')).toBe('yes') + expect(header(response, 'content-type')).toContain('text/html') + expectCookie(cookies, 'ssr-one', '1') + expectCookie(cookies, 'ssr-two', '2') + await expect(response.text()).resolves.toContain('ssr response') + }) + + test('regression #5464: SSR document responses preserve multiple Set-Cookie headers', async ({ + request, + }) => { + const response = await request.get('/ssr', { + headers: { 'x-reconciliation-scenario': 'global-multiple-cookies' }, + }) + const cookies = await setCookieValues(response) + + expectCookie(cookies, 'global-one', '1') + expectCookie(cookies, 'global-two', '2') + expectCookie(cookies, 'ssr-one', '1') + expectCookie(cookies, 'ssr-two', '2') + }) + + test('global middleware after next reconciles onto document response', async ({ + request, + }) => { + const response = await request.get('/ssr', { + headers: { 'x-reconciliation-scenario': 'global-after' }, + }) + + expect(response.status()).toBe(232) + expect(response.statusText()).toBe('global-after') + expect(header(response, 'x-global-after')).toBe('yes') + expect(header(response, 'x-ssr-loader')).toBe('yes') + }) +}) + +test.describe('server functions', () => { + test('global request middleware helper changes reconcile onto serialized responses', async ({ + page, + }) => { + const response = await invokeJsonServerFunction( + page, + 'globalSerialized', + 'global-before', + ) + + expect(response.status()).toBe(231) + expect(response.statusText()).toBe('global-before') + expect(header(response, 'x-global-before')).toBe('yes') + expect(header(response, 'x-tss-serialized')).toBe('true') + await expectServerFunctionResult(page, 'globalSerialized', '{"ok":true}') + }) + + test('global request middleware helper changes after next reconcile onto serialized responses', async ({ + page, + }) => { + const response = await invokeJsonServerFunction( + page, + 'globalSerialized', + 'global-after', + ) + + expect(response.status()).toBe(232) + expect(response.statusText()).toBe('global-after') + expect(header(response, 'x-global-after')).toBe('yes') + expect(header(response, 'x-tss-serialized')).toBe('true') + await expectServerFunctionResult(page, 'globalSerialized', '{"ok":true}') + }) + + test('function middleware helper changes after next reconcile onto serialized responses', async ({ + page, + }) => { + const response = await invokeJsonServerFunction(page, 'functionAfterNext') + + expect(response.status()).toBe(236) + expect(response.statusText()).toBe('function-after') + expect(header(response, 'x-function-after')).toBe('yes') + expect(header(response, 'x-tss-serialized')).toBe('true') + await expectServerFunctionResult(page, 'functionAfterNext', '{"ok":true}') + }) + + test('raw server function responses reconcile helper changes after next', async ({ + page, + }) => { + const response = await invokeJsonServerFunction( + page, + 'rawResponseAfterNext', + ) + + expect(response.status()).toBe(237) + expect(response.statusText()).toBe('function-raw-after') + expect(header(response, 'x-function-raw')).toBe('yes') + expect(header(response, 'x-function-raw-after')).toBe('yes') + expect(header(response, 'x-tss-raw')).toBe('true') + await expect(response.text()).resolves.toBe('raw function body') + await expectServerFunctionResult( + page, + 'rawResponseAfterNext', + '237:raw function body', + ) + }) + + test('function response replacement carries helper deltas only', async ({ + page, + }) => { + const response = await invokeJsonServerFunction(page, 'replacement') + + expect(header(response, 'x-function-a')).toBe(null) + expect(header(response, 'x-function-b')).toBe('yes') + expect(header(response, 'x-function-helper-delta')).toBe('yes') + await expect(response.text()).resolves.toBe('function-b') + await expectServerFunctionResult(page, 'replacement', '200:function-b') + }) + + test('server function preserves multiple Set-Cookie headers', async ({ + page, + }) => { + const response = await invokeJsonServerFunction(page, 'multipleCookies') + const cookies = await setCookieValues(response) + + expectCookie(cookies, 'fn-one', '1') + expectCookie(cookies, 'fn-two', '2') + await expectServerFunctionResult(page, 'multipleCookies', '{"ok":true}') + }) + + test('raw server function responses preserve multiple Set-Cookie headers', async ({ + page, + }) => { + const response = await invokeJsonServerFunction(page, 'rawMultipleCookies') + const cookies = await setCookieValues(response) + + expect(header(response, 'x-tss-raw')).toBe('true') + expectCookie(cookies, 'fn-raw-returned-one', '1') + expectCookie(cookies, 'fn-raw-returned-two', '2') + expectCookie(cookies, 'fn-raw-helper-one', '1') + expectCookie(cookies, 'fn-raw-helper-two', '2') + await expect(response.text()).resolves.toBe('raw cookie body') + await expectServerFunctionResult( + page, + 'rawMultipleCookies', + '200:raw cookie body', + ) + }) + + test('server function explicit Set-Cookie header arrays are preserved', async ({ + page, + }) => { + const response = await invokeJsonServerFunction( + page, + 'explicitCookieHeader', + ) + const cookies = await setCookieValues(response) + + expectCookie(cookies, 'fn-explicit-one', '1') + expectCookie(cookies, 'fn-explicit-two', '2') + await expectServerFunctionResult( + page, + 'explicitCookieHeader', + '{"ok":true}', + ) + }) + + test.describe('expected 401 responses', () => { + test.use({ + whitelistErrors: [ + /Failed to load resource: the server responded with a status of 401 \(Unauthorized\)/, + ], + }) + + test('server function thrown errors preserve explicit response status', async ({ + page, + }) => { + const response = await invokeJsonServerFunction(page, 'throwAfterStatus') + + expect(response.status()).toBe(401) + expect(response.statusText()).toBe('Unauthorized') + expect(header(response, 'x-function-error')).toBe('yes') + expect(header(response, 'x-tss-serialized')).toBe('true') + await expectServerFunctionResult(page, 'throwAfterStatus', /Unauthorized/) + }) + + test('server function request middleware errors are serialized', async ({ + page, + }) => { + const response = await invokeJsonServerFunction( + page, + 'globalSerialized', + 'global-throw', + ) + + expect(response.status()).toBe(401) + expect(response.statusText()).toBe('Unauthorized') + expect(header(response, 'x-global-error')).toBe('yes') + expect(header(response, 'x-tss-serialized')).toBe('true') + await expectServerFunctionResult(page, 'globalSerialized', /Unauthorized/) + }) + + test('regression #5107: function middleware thrown errors preserve explicit response status', async ({ + page, + }) => { + const response = await invokeJsonServerFunction( + page, + 'throwAfterMiddlewareStatus', + ) + + expect(response.status()).toBe(401) + expect(response.statusText()).toBe('Unauthorized') + expect(header(response, 'x-function-error-middleware')).toBe('yes') + expect(header(response, 'x-tss-serialized')).toBe('true') + await expectServerFunctionResult( + page, + 'throwAfterMiddlewareStatus', + /Unauthorized/, + ) + }) + }) + + test('transport headers win over user helpers while user headers persist', async ({ + page, + }) => { + const response = await invokeJsonServerFunction(page, 'transportProtected') + + expect(header(response, 'x-user-header')).toBe('yes') + expect(header(response, 'x-tss-serialized')).toBe('true') + expect(header(response, 'content-type')).toContain('application/json') + await expectServerFunctionResult(page, 'transportProtected', '{"ok":true}') + }) + + test('server function getResponseHeader sees helper writes immediately', async ({ + page, + }) => { + const response = await invokeJsonServerFunction(page, 'readAfterSet') + + expect(header(response, 'x-read-after-set')).toBe('yes') + expect(header(response, 'x-read-after-set-value')).toBe('yes') + await expectServerFunctionResult(page, 'readAfterSet', '{"ok":true}') + }) +}) diff --git a/e2e/react-start/response-reconciliation/tsconfig.json b/e2e/react-start/response-reconciliation/tsconfig.json new file mode 100644 index 0000000000..3824b148e1 --- /dev/null +++ b/e2e/react-start/response-reconciliation/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["**/*.ts", "**/*.tsx", "public/script*.js"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true, + "types": ["vite/client"] + } +} diff --git a/e2e/react-start/response-reconciliation/vite.config.ts b/e2e/react-start/response-reconciliation/vite.config.ts new file mode 100644 index 0000000000..355e12a2f7 --- /dev/null +++ b/e2e/react-start/response-reconciliation/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' + +const serverEntry = process.env.TSS_E2E_SERVER_ENTRY +const outDir = process.env.E2E_DIST_DIR ?? 'dist' + +export default defineConfig({ + resolve: { tsconfigPaths: true }, + build: { + outDir, + }, + plugins: [ + tanstackStart({ + server: serverEntry ? { entry: serverEntry } : undefined, + }), + viteReact(), + ], +}) diff --git a/e2e/react-start/session-handling/.gitignore b/e2e/react-start/session-handling/.gitignore new file mode 100644 index 0000000000..6ccfab35a6 --- /dev/null +++ b/e2e/react-start/session-handling/.gitignore @@ -0,0 +1,22 @@ +node_modules +package-lock.json +yarn.lock + +.DS_Store +.cache +.tanstack +.env +.vercel +.output +/build/ +/api/ +/server/build +/public/build# Sentry Config File +.env.sentry-build-plugin +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/dist/ +/dist-*/ +/port-*.txt diff --git a/e2e/react-start/session-handling/.prettierignore b/e2e/react-start/session-handling/.prettierignore new file mode 100644 index 0000000000..40a96e8819 --- /dev/null +++ b/e2e/react-start/session-handling/.prettierignore @@ -0,0 +1,4 @@ +**/build +**/public +pnpm-lock.yaml +routeTree.gen.ts diff --git a/e2e/react-start/session-handling/package.json b/e2e/react-start/session-handling/package.json new file mode 100644 index 0000000000..39e0ff0cdf --- /dev/null +++ b/e2e/react-start/session-handling/package.json @@ -0,0 +1,62 @@ +{ + "name": "tanstack-react-start-e2e-session-handling", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "pnpm dev:vite --port 3000", + "dev:e2e": "pnpm dev:vite", + "dev:vite": "vite dev", + "dev:rsbuild": "rsbuild dev", + "build": "pnpm build:vite", + "build:vite": "vite build && tsc --noEmit", + "build:rsbuild": "rsbuild build && tsc --noEmit", + "preview": "vite preview", + "start": "node server.js", + "test:e2e:local": "pnpm build && playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:*", + "@tanstack/react-start": "workspace:*", + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@playwright/test": "^1.57.0", + "@rsbuild/core": "^2.0.8", + "@rsbuild/plugin-react": "^2.0.0", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "25.0.9", + "@types/react": "^19.2.8", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "srvx": "^0.11.9", + "typescript": "^6.0.2", + "vite": "^8.0.14" + }, + "nx": { + "targets": { + "test:e2e": { + "parallelism": false + }, + "test:e2e--vite-ssr": { + "parallelism": false + }, + "test:e2e--rsbuild-ssr": { + "parallelism": false + } + }, + "metadata": { + "playwrightModes": [ + { + "toolchain": "vite", + "mode": "ssr" + }, + { + "toolchain": "rsbuild", + "mode": "ssr" + } + ] + } + } +} diff --git a/e2e/react-start/session-handling/playwright.config.ts b/e2e/react-start/session-handling/playwright.config.ts new file mode 100644 index 0000000000..c291adaabf --- /dev/null +++ b/e2e/react-start/session-handling/playwright.config.ts @@ -0,0 +1,38 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const e2ePortKey = process.env.E2E_PORT_KEY ?? packageJson.name +const toolchain = process.env.E2E_TOOLCHAIN ?? 'vite' +const distDir = process.env.E2E_DIST_DIR ?? 'dist' + +export const PORT = await getTestServerPort(e2ePortKey) +const baseURL = `http://localhost:${PORT}` + +export default defineConfig({ + testDir: './tests', + workers: 1, + reporter: [['line']], + use: { + baseURL, + }, + webServer: { + command: `pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + env: { + PORT: String(PORT), + VITE_SERVER_PORT: String(PORT), + E2E_DIST_DIR: distDir, + E2E_TOOLCHAIN: toolchain, + E2E_PORT_KEY: e2ePortKey, + }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-start/session-handling/rsbuild.config.ts b/e2e/react-start/session-handling/rsbuild.config.ts new file mode 100644 index 0000000000..af8c3df7d2 --- /dev/null +++ b/e2e/react-start/session-handling/rsbuild.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from '@rsbuild/core' +import { pluginReact } from '@rsbuild/plugin-react' +import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild' + +const outDir = process.env.E2E_DIST_DIR ?? 'dist' + +export default defineConfig({ + plugins: [pluginReact(), tanstackStart()], + output: { + distPath: { + root: outDir, + }, + }, +}) diff --git a/e2e/react-start/session-handling/server.js b/e2e/react-start/session-handling/server.js new file mode 100644 index 0000000000..da874e85c7 --- /dev/null +++ b/e2e/react-start/session-handling/server.js @@ -0,0 +1,51 @@ +import fs from 'node:fs' +import path from 'node:path' +import { spawn } from 'node:child_process' +import { pathToFileURL } from 'node:url' + +const distDir = process.env.E2E_DIST_DIR || 'dist' + +function resolveDistClientDir() { + return path.resolve(distDir, 'client') +} + +function resolveDistServerEntryPath() { + const serverJsPath = path.resolve(distDir, 'server', 'server.js') + if (fs.existsSync(serverJsPath)) { + return serverJsPath + } + + const indexJsPath = path.resolve(distDir, 'server', 'index.js') + if (fs.existsSync(indexJsPath)) { + return indexJsPath + } + + return serverJsPath +} + +export function start() { + const child = spawn( + 'srvx', + ['--prod', '-s', resolveDistClientDir(), resolveDistServerEntryPath()], + { + stdio: 'inherit', + shell: process.platform === 'win32', + }, + ) + + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal) + return + } + + process.exit(code ?? 0) + }) +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + start() +} diff --git a/e2e/react-start/session-handling/src/components/DefaultCatchBoundary.tsx b/e2e/react-start/session-handling/src/components/DefaultCatchBoundary.tsx new file mode 100644 index 0000000000..f3d691814f --- /dev/null +++ b/e2e/react-start/session-handling/src/components/DefaultCatchBoundary.tsx @@ -0,0 +1,20 @@ +import { ErrorComponent, Link, useRouter } from '@tanstack/react-router' +import type { ErrorComponentProps } from '@tanstack/react-router' + +export function DefaultCatchBoundary({ error }: ErrorComponentProps) { + const router = useRouter() + + return ( +
+ + + Home +
+ ) +} diff --git a/e2e/react-start/session-handling/src/components/NotFound.tsx b/e2e/react-start/session-handling/src/components/NotFound.tsx new file mode 100644 index 0000000000..0b720f0e37 --- /dev/null +++ b/e2e/react-start/session-handling/src/components/NotFound.tsx @@ -0,0 +1,10 @@ +import { Link } from '@tanstack/react-router' + +export function NotFound({ children }: { children?: React.ReactNode }) { + return ( +
+ {children ||

The page you are looking for does not exist.

} + Start Over +
+ ) +} diff --git a/e2e/react-start/session-handling/src/routeTree.gen.ts b/e2e/react-start/session-handling/src/routeTree.gen.ts new file mode 100644 index 0000000000..2c878687f9 --- /dev/null +++ b/e2e/react-start/session-handling/src/routeTree.gen.ts @@ -0,0 +1,284 @@ +/* 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 SsrRouteImport } from './routes/ssr' +import { Route as ServerFunctionsRouteImport } from './routes/server-functions' +import { Route as IndexRouteImport } from './routes/index' +import { Route as ApiSessionSealRouteImport } from './routes/api/session-seal' +import { Route as ApiSessionNamedRouteImport } from './routes/api/session-named' +import { Route as ApiSessionMiddlewareRouteImport } from './routes/api/session-middleware' +import { Route as ApiSessionHelperCookieRouteImport } from './routes/api/session-helper-cookie' +import { Route as ApiSessionHeaderRouteImport } from './routes/api/session-header' +import { Route as ApiSessionCookieDisabledRouteImport } from './routes/api/session-cookie-disabled' +import { Route as ApiSessionClearRouteImport } from './routes/api/session-clear' +import { Route as ApiSessionRouteImport } from './routes/api/session' + +const SsrRoute = SsrRouteImport.update({ + id: '/ssr', + path: '/ssr', + getParentRoute: () => rootRouteImport, +} as any) +const ServerFunctionsRoute = ServerFunctionsRouteImport.update({ + id: '/server-functions', + path: '/server-functions', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const ApiSessionSealRoute = ApiSessionSealRouteImport.update({ + id: '/api/session-seal', + path: '/api/session-seal', + getParentRoute: () => rootRouteImport, +} as any) +const ApiSessionNamedRoute = ApiSessionNamedRouteImport.update({ + id: '/api/session-named', + path: '/api/session-named', + getParentRoute: () => rootRouteImport, +} as any) +const ApiSessionMiddlewareRoute = ApiSessionMiddlewareRouteImport.update({ + id: '/api/session-middleware', + path: '/api/session-middleware', + getParentRoute: () => rootRouteImport, +} as any) +const ApiSessionHelperCookieRoute = ApiSessionHelperCookieRouteImport.update({ + id: '/api/session-helper-cookie', + path: '/api/session-helper-cookie', + getParentRoute: () => rootRouteImport, +} as any) +const ApiSessionHeaderRoute = ApiSessionHeaderRouteImport.update({ + id: '/api/session-header', + path: '/api/session-header', + getParentRoute: () => rootRouteImport, +} as any) +const ApiSessionCookieDisabledRoute = + ApiSessionCookieDisabledRouteImport.update({ + id: '/api/session-cookie-disabled', + path: '/api/session-cookie-disabled', + getParentRoute: () => rootRouteImport, + } as any) +const ApiSessionClearRoute = ApiSessionClearRouteImport.update({ + id: '/api/session-clear', + path: '/api/session-clear', + getParentRoute: () => rootRouteImport, +} as any) +const ApiSessionRoute = ApiSessionRouteImport.update({ + id: '/api/session', + path: '/api/session', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/server-functions': typeof ServerFunctionsRoute + '/ssr': typeof SsrRoute + '/api/session': typeof ApiSessionRoute + '/api/session-clear': typeof ApiSessionClearRoute + '/api/session-cookie-disabled': typeof ApiSessionCookieDisabledRoute + '/api/session-header': typeof ApiSessionHeaderRoute + '/api/session-helper-cookie': typeof ApiSessionHelperCookieRoute + '/api/session-middleware': typeof ApiSessionMiddlewareRoute + '/api/session-named': typeof ApiSessionNamedRoute + '/api/session-seal': typeof ApiSessionSealRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/server-functions': typeof ServerFunctionsRoute + '/ssr': typeof SsrRoute + '/api/session': typeof ApiSessionRoute + '/api/session-clear': typeof ApiSessionClearRoute + '/api/session-cookie-disabled': typeof ApiSessionCookieDisabledRoute + '/api/session-header': typeof ApiSessionHeaderRoute + '/api/session-helper-cookie': typeof ApiSessionHelperCookieRoute + '/api/session-middleware': typeof ApiSessionMiddlewareRoute + '/api/session-named': typeof ApiSessionNamedRoute + '/api/session-seal': typeof ApiSessionSealRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/server-functions': typeof ServerFunctionsRoute + '/ssr': typeof SsrRoute + '/api/session': typeof ApiSessionRoute + '/api/session-clear': typeof ApiSessionClearRoute + '/api/session-cookie-disabled': typeof ApiSessionCookieDisabledRoute + '/api/session-header': typeof ApiSessionHeaderRoute + '/api/session-helper-cookie': typeof ApiSessionHelperCookieRoute + '/api/session-middleware': typeof ApiSessionMiddlewareRoute + '/api/session-named': typeof ApiSessionNamedRoute + '/api/session-seal': typeof ApiSessionSealRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/server-functions' + | '/ssr' + | '/api/session' + | '/api/session-clear' + | '/api/session-cookie-disabled' + | '/api/session-header' + | '/api/session-helper-cookie' + | '/api/session-middleware' + | '/api/session-named' + | '/api/session-seal' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/server-functions' + | '/ssr' + | '/api/session' + | '/api/session-clear' + | '/api/session-cookie-disabled' + | '/api/session-header' + | '/api/session-helper-cookie' + | '/api/session-middleware' + | '/api/session-named' + | '/api/session-seal' + id: + | '__root__' + | '/' + | '/server-functions' + | '/ssr' + | '/api/session' + | '/api/session-clear' + | '/api/session-cookie-disabled' + | '/api/session-header' + | '/api/session-helper-cookie' + | '/api/session-middleware' + | '/api/session-named' + | '/api/session-seal' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ServerFunctionsRoute: typeof ServerFunctionsRoute + SsrRoute: typeof SsrRoute + ApiSessionRoute: typeof ApiSessionRoute + ApiSessionClearRoute: typeof ApiSessionClearRoute + ApiSessionCookieDisabledRoute: typeof ApiSessionCookieDisabledRoute + ApiSessionHeaderRoute: typeof ApiSessionHeaderRoute + ApiSessionHelperCookieRoute: typeof ApiSessionHelperCookieRoute + ApiSessionMiddlewareRoute: typeof ApiSessionMiddlewareRoute + ApiSessionNamedRoute: typeof ApiSessionNamedRoute + ApiSessionSealRoute: typeof ApiSessionSealRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/ssr': { + id: '/ssr' + path: '/ssr' + fullPath: '/ssr' + preLoaderRoute: typeof SsrRouteImport + parentRoute: typeof rootRouteImport + } + '/server-functions': { + id: '/server-functions' + path: '/server-functions' + fullPath: '/server-functions' + preLoaderRoute: typeof ServerFunctionsRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/api/session-seal': { + id: '/api/session-seal' + path: '/api/session-seal' + fullPath: '/api/session-seal' + preLoaderRoute: typeof ApiSessionSealRouteImport + parentRoute: typeof rootRouteImport + } + '/api/session-named': { + id: '/api/session-named' + path: '/api/session-named' + fullPath: '/api/session-named' + preLoaderRoute: typeof ApiSessionNamedRouteImport + parentRoute: typeof rootRouteImport + } + '/api/session-middleware': { + id: '/api/session-middleware' + path: '/api/session-middleware' + fullPath: '/api/session-middleware' + preLoaderRoute: typeof ApiSessionMiddlewareRouteImport + parentRoute: typeof rootRouteImport + } + '/api/session-helper-cookie': { + id: '/api/session-helper-cookie' + path: '/api/session-helper-cookie' + fullPath: '/api/session-helper-cookie' + preLoaderRoute: typeof ApiSessionHelperCookieRouteImport + parentRoute: typeof rootRouteImport + } + '/api/session-header': { + id: '/api/session-header' + path: '/api/session-header' + fullPath: '/api/session-header' + preLoaderRoute: typeof ApiSessionHeaderRouteImport + parentRoute: typeof rootRouteImport + } + '/api/session-cookie-disabled': { + id: '/api/session-cookie-disabled' + path: '/api/session-cookie-disabled' + fullPath: '/api/session-cookie-disabled' + preLoaderRoute: typeof ApiSessionCookieDisabledRouteImport + parentRoute: typeof rootRouteImport + } + '/api/session-clear': { + id: '/api/session-clear' + path: '/api/session-clear' + fullPath: '/api/session-clear' + preLoaderRoute: typeof ApiSessionClearRouteImport + parentRoute: typeof rootRouteImport + } + '/api/session': { + id: '/api/session' + path: '/api/session' + fullPath: '/api/session' + preLoaderRoute: typeof ApiSessionRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ServerFunctionsRoute: ServerFunctionsRoute, + SsrRoute: SsrRoute, + ApiSessionRoute: ApiSessionRoute, + ApiSessionClearRoute: ApiSessionClearRoute, + ApiSessionCookieDisabledRoute: ApiSessionCookieDisabledRoute, + ApiSessionHeaderRoute: ApiSessionHeaderRoute, + ApiSessionHelperCookieRoute: ApiSessionHelperCookieRoute, + ApiSessionMiddlewareRoute: ApiSessionMiddlewareRoute, + ApiSessionNamedRoute: ApiSessionNamedRoute, + ApiSessionSealRoute: ApiSessionSealRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { startInstance } from './start.ts' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + config: Awaited> + } +} diff --git a/e2e/react-start/session-handling/src/router.tsx b/e2e/react-start/session-handling/src/router.tsx new file mode 100644 index 0000000000..c5c2209e5a --- /dev/null +++ b/e2e/react-start/session-handling/src/router.tsx @@ -0,0 +1,13 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +import { DefaultCatchBoundary } from './components/DefaultCatchBoundary' +import { NotFound } from './components/NotFound' + +export function getRouter() { + return createRouter({ + routeTree, + defaultErrorComponent: DefaultCatchBoundary, + defaultNotFoundComponent: () => , + scrollRestoration: true, + }) +} diff --git a/e2e/react-start/session-handling/src/routes/__root.tsx b/e2e/react-start/session-handling/src/routes/__root.tsx new file mode 100644 index 0000000000..e5e5f4cd24 --- /dev/null +++ b/e2e/react-start/session-handling/src/routes/__root.tsx @@ -0,0 +1,59 @@ +/// +import * as React from 'react' +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' +import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary' +import { NotFound } from '~/components/NotFound' +import '~/styles/app.css' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + ], + }), + errorComponent: (props) => { + return ( + + + + ) + }, + notFoundComponent: () => , + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + ) +} + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + + {children} + + + + ) +} diff --git a/e2e/react-start/session-handling/src/routes/api/session-clear.ts b/e2e/react-start/session-handling/src/routes/api/session-clear.ts new file mode 100644 index 0000000000..54e82270fd --- /dev/null +++ b/e2e/react-start/session-handling/src/routes/api/session-clear.ts @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router' +import { clearSession } from '@tanstack/react-start/server' + +export const Route = createFileRoute('/api/session-clear')({ + server: { + handlers: { + POST: async () => { + await clearSession({}) + return Response.json({ cleared: true }) + }, + }, + }, +}) diff --git a/e2e/react-start/session-handling/src/routes/api/session-cookie-disabled.ts b/e2e/react-start/session-handling/src/routes/api/session-cookie-disabled.ts new file mode 100644 index 0000000000..33b41dd173 --- /dev/null +++ b/e2e/react-start/session-handling/src/routes/api/session-cookie-disabled.ts @@ -0,0 +1,19 @@ +import { createFileRoute } from '@tanstack/react-router' +import { readJson, readSession, updateSessionData } from '~/session' + +const cookieDisabled = { cookie: false } + +export const Route = createFileRoute('/api/session-cookie-disabled')({ + server: { + handlers: { + GET: async () => { + return Response.json(await readSession(cookieDisabled)) + }, + POST: async ({ request }) => { + return Response.json( + await updateSessionData(await readJson(request), cookieDisabled), + ) + }, + }, + }, +}) diff --git a/e2e/react-start/session-handling/src/routes/api/session-header.ts b/e2e/react-start/session-handling/src/routes/api/session-header.ts new file mode 100644 index 0000000000..210a0f4360 --- /dev/null +++ b/e2e/react-start/session-handling/src/routes/api/session-header.ts @@ -0,0 +1,16 @@ +import { createFileRoute } from '@tanstack/react-router' +import { readSession } from '~/session' + +export const Route = createFileRoute('/api/session-header')({ + server: { + handlers: { + GET: async ({ request }) => { + const url = new URL(request.url) + const sessionHeader = url.searchParams.get('name') || undefined + return Response.json( + await readSession({ cookie: false, sessionHeader }), + ) + }, + }, + }, +}) diff --git a/e2e/react-start/session-handling/src/routes/api/session-helper-cookie.ts b/e2e/react-start/session-handling/src/routes/api/session-helper-cookie.ts new file mode 100644 index 0000000000..95235d5fea --- /dev/null +++ b/e2e/react-start/session-handling/src/routes/api/session-helper-cookie.ts @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router' +import { readJson, updateSessionWithHelperCookie } from '~/session' + +export const Route = createFileRoute('/api/session-helper-cookie')({ + server: { + handlers: { + POST: async ({ request }) => { + return Response.json( + await updateSessionWithHelperCookie(await readJson(request)), + ) + }, + }, + }, +}) diff --git a/e2e/react-start/session-handling/src/routes/api/session-middleware.ts b/e2e/react-start/session-handling/src/routes/api/session-middleware.ts new file mode 100644 index 0000000000..b9368963c9 --- /dev/null +++ b/e2e/react-start/session-handling/src/routes/api/session-middleware.ts @@ -0,0 +1,12 @@ +import { createFileRoute } from '@tanstack/react-router' +import { readSession } from '~/session' + +export const Route = createFileRoute('/api/session-middleware')({ + server: { + handlers: { + GET: async () => { + return Response.json(await readSession()) + }, + }, + }, +}) diff --git a/e2e/react-start/session-handling/src/routes/api/session-named.ts b/e2e/react-start/session-handling/src/routes/api/session-named.ts new file mode 100644 index 0000000000..1a56e7f306 --- /dev/null +++ b/e2e/react-start/session-handling/src/routes/api/session-named.ts @@ -0,0 +1,23 @@ +import { createFileRoute } from '@tanstack/react-router' +import { readJson, readSession, updateSessionData } from '~/session' + +function getName(request: Request) { + return new URL(request.url).searchParams.get('name') || 'named' +} + +export const Route = createFileRoute('/api/session-named')({ + server: { + handlers: { + GET: async ({ request }) => { + return Response.json(await readSession({ name: getName(request) })) + }, + POST: async ({ request }) => { + return Response.json( + await updateSessionData(await readJson(request), { + name: getName(request), + }), + ) + }, + }, + }, +}) diff --git a/e2e/react-start/session-handling/src/routes/api/session-seal.ts b/e2e/react-start/session-handling/src/routes/api/session-seal.ts new file mode 100644 index 0000000000..65bb66670e --- /dev/null +++ b/e2e/react-start/session-handling/src/routes/api/session-seal.ts @@ -0,0 +1,20 @@ +import { createFileRoute } from '@tanstack/react-router' +import { sealSession, useSession } from '@tanstack/react-start/server' +import { readJson, sessionConfig } from '~/session' + +export const Route = createFileRoute('/api/session-seal')({ + server: { + handlers: { + POST: async ({ request }) => { + const config = sessionConfig({ cookie: false }) + const session = await useSession>(config) + await session.update(await readJson(request)) + return Response.json({ + id: session.id, + data: session.data, + sealed: await sealSession(config), + }) + }, + }, + }, +}) diff --git a/e2e/react-start/session-handling/src/routes/api/session.ts b/e2e/react-start/session-handling/src/routes/api/session.ts new file mode 100644 index 0000000000..d75451858f --- /dev/null +++ b/e2e/react-start/session-handling/src/routes/api/session.ts @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/react-router' +import { readJson, readSession, updateSessionData } from '~/session' + +export const Route = createFileRoute('/api/session')({ + server: { + handlers: { + GET: async () => { + return Response.json(await readSession()) + }, + POST: async ({ request }) => { + return Response.json(await updateSessionData(await readJson(request))) + }, + }, + }, +}) diff --git a/e2e/react-start/session-handling/src/routes/index.tsx b/e2e/react-start/session-handling/src/routes/index.tsx new file mode 100644 index 0000000000..9dc929d5bb --- /dev/null +++ b/e2e/react-start/session-handling/src/routes/index.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

Session Handling E2E

+

This app contains HTTP-level session tests.

+
+ ) +} diff --git a/e2e/react-start/session-handling/src/routes/server-functions.tsx b/e2e/react-start/session-handling/src/routes/server-functions.tsx new file mode 100644 index 0000000000..4d470366e1 --- /dev/null +++ b/e2e/react-start/session-handling/src/routes/server-functions.tsx @@ -0,0 +1,70 @@ +import { ClientOnly, createFileRoute } from '@tanstack/react-router' +import { createServerFn, useServerFn } from '@tanstack/react-start' +import { useSession } from '@tanstack/react-start/server' +import * as React from 'react' +import { sessionConfig } from '~/session' + +const updateSessionFn = createServerFn().handler(async () => { + const session = await useSession>(sessionConfig()) + await session.update({ serverFn: 'updated' }) + return { + id: session.id, + data: session.data, + } +}) + +const readSessionFn = createServerFn().handler(async () => { + const session = await useSession>(sessionConfig()) + return { + id: session.id, + data: session.data, + } +}) + +export const Route = createFileRoute('/server-functions')({ + component: ServerFunctions, +}) + +function ServerFunctions() { + return ( +
+

Server Functions

+ + + + +
+ ) +} + +function ServerFunctionButton({ + name, + fn, +}: { + name: string + fn: (...args: Array) => Promise +}) { + const serverFn = useServerFn(fn) + const [result, setResult] = React.useState('idle') + + return ( +
+ + {result} +
+ ) +} diff --git a/e2e/react-start/session-handling/src/routes/ssr.tsx b/e2e/react-start/session-handling/src/routes/ssr.tsx new file mode 100644 index 0000000000..fd67d12390 --- /dev/null +++ b/e2e/react-start/session-handling/src/routes/ssr.tsx @@ -0,0 +1,30 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { useSession } from '@tanstack/react-start/server' +import { sessionConfig } from '~/session' + +const loadSession = createServerFn().handler(async () => { + const session = await useSession>(sessionConfig()) + const count = Number(session.data.ssrCount ?? 0) + 1 + await session.update({ ssrCount: count }) + return { + id: session.id, + data: session.data, + } +}) + +export const Route = createFileRoute('/ssr')({ + loader: () => loadSession(), + component: SsrRoute, +}) + +function SsrRoute() { + const data = Route.useLoaderData() + return ( +
+

SSR Session

+

{data.id}

+

{String(data.data.ssrCount)}

+
+ ) +} diff --git a/e2e/react-start/session-handling/src/session.ts b/e2e/react-start/session-handling/src/session.ts new file mode 100644 index 0000000000..d8f354aef8 --- /dev/null +++ b/e2e/react-start/session-handling/src/session.ts @@ -0,0 +1,53 @@ +import { setCookie, useSession } from '@tanstack/react-start/server' + +export const sessionPassword = 'x'.repeat(64) + +let sessionId = 0 + +export function sessionConfig(overrides: Record = {}) { + return { + password: sessionPassword, + generateId: () => `session-${++sessionId}`, + ...overrides, + } +} + +export async function readJson(request: Request) { + try { + return (await request.json()) as Record + } catch { + return {} + } +} + +export async function readSession(overrides: Record = {}) { + const session = await useSession>( + sessionConfig(overrides), + ) + return { + id: session.id, + data: session.data, + } +} + +export async function updateSessionData( + update: Record, + overrides: Record = {}, +) { + const session = await useSession>( + sessionConfig(overrides), + ) + await session.update(update) + return { + id: session.id, + data: session.data, + } +} + +export async function updateSessionWithHelperCookie( + update: Record, +) { + const session = await updateSessionData(update) + setCookie('helper-session', '1', { path: '/' }) + return session +} diff --git a/e2e/react-start/session-handling/src/start.ts b/e2e/react-start/session-handling/src/start.ts new file mode 100644 index 0000000000..8797cc45c6 --- /dev/null +++ b/e2e/react-start/session-handling/src/start.ts @@ -0,0 +1,30 @@ +import { createMiddleware, createStart } from '@tanstack/react-start' +import { updateSession } from '@tanstack/react-start/server' +import { sessionConfig } from './session' + +const sessionMiddleware = createMiddleware().server( + async ({ next, request }) => { + const scenario = request.headers.get('x-session-middleware') + + if (scenario === 'before') { + await updateSession>(sessionConfig(), { + middleware: 'before', + }) + return next() + } + + if (scenario === 'after') { + const result = await next() + await updateSession>(sessionConfig(), { + middleware: 'after', + }) + return result + } + + return next() + }, +) + +export const startInstance = createStart(() => ({ + requestMiddleware: [sessionMiddleware], +})) diff --git a/e2e/react-start/session-handling/src/styles/app.css b/e2e/react-start/session-handling/src/styles/app.css new file mode 100644 index 0000000000..a38fb439a6 --- /dev/null +++ b/e2e/react-start/session-handling/src/styles/app.css @@ -0,0 +1,12 @@ +html { + color-scheme: light dark; + font-family: system-ui, sans-serif; +} + +body { + margin: 0; +} + +main { + padding: 16px; +} diff --git a/e2e/react-start/session-handling/tests/session-handling.spec.ts b/e2e/react-start/session-handling/tests/session-handling.spec.ts new file mode 100644 index 0000000000..74fa31b0e3 --- /dev/null +++ b/e2e/react-start/session-handling/tests/session-handling.spec.ts @@ -0,0 +1,314 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import type { APIResponse, Page, Response } from '@playwright/test' + +type TestResponse = APIResponse | Response + +async function setCookieValues(response: TestResponse) { + return (await Promise.resolve(response.headersArray())) + .filter((item) => item.name.toLowerCase() === 'set-cookie') + .map((item) => item.value) +} + +function cookieName(cookie: string) { + return cookie.split('=', 1)[0]! +} + +function cookiePair(cookie: string) { + return cookie.split(';', 1)[0]! +} + +function cookieHeader(...cookieSets: Array>) { + const cookies = new Map() + + for (const cookie of cookieSets.flat()) { + const name = cookieName(cookie) + if (/Max-Age=0/i.test(cookie)) { + cookies.delete(name) + } else { + cookies.set(name, cookiePair(cookie)) + } + } + + return Array.from(cookies.values()).join('; ') +} + +function expectCookie(cookies: Array, name: string) { + expect(cookies.some((cookie) => cookie.startsWith(`${name}=`))).toBe(true) +} + +function expectNoCookies(cookies: Array) { + expect(cookies).toEqual([]) +} + +async function expectServerFunctionResult( + page: Page, + name: string, + text: string | RegExp, +) { + await expect( + page.getByTestId(`server-function-${name}-result`), + ).toContainText(text) +} + +test.describe('server route sessions', () => { + test('creates and reuses the default cookie-backed session', async ({ + request, + }) => { + const first = await request.get('/api/session') + const firstBody = await first.json() + const firstCookies = await setCookieValues(first) + + expect(firstBody.id).toMatch(/^session-/) + expect(firstBody.data).toEqual({}) + expectCookie(firstCookies, 'start') + expect(firstCookies[0]).toContain('HttpOnly') + expect(firstCookies[0]).toContain('Secure') + expect(firstCookies[0]).toContain('Path=/') + + const second = await request.get('/api/session', { + headers: { cookie: cookieHeader(firstCookies) }, + }) + const secondBody = await second.json() + + expect(secondBody.id).toBe(firstBody.id) + expect(secondBody.data).toEqual({}) + expectNoCookies(await setCookieValues(second)) + }) + + test('updates session data and persists it to the next request', async ({ + request, + }) => { + const update = await request.post('/api/session', { + data: { user: 'tanner' }, + }) + const updateBody = await update.json() + const updateCookies = await setCookieValues(update) + + expect(updateBody.data).toEqual({ user: 'tanner' }) + expectCookie(updateCookies, 'start') + + const next = await request.get('/api/session', { + headers: { cookie: cookieHeader(updateCookies) }, + }) + const nextBody = await next.json() + + expect(nextBody.id).toBe(updateBody.id) + expect(nextBody.data).toEqual({ user: 'tanner' }) + }) + + test('clears the default session with a deletion cookie', async ({ + request, + }) => { + const update = await request.post('/api/session', { + data: { user: 'clear-me' }, + }) + const updateCookies = await setCookieValues(update) + + const clear = await request.post('/api/session-clear', { + headers: { cookie: cookieHeader(updateCookies) }, + }) + const clearCookies = await setCookieValues(clear) + + expect(await clear.json()).toEqual({ cleared: true }) + expect( + clearCookies.some( + (cookie) => cookie.startsWith('start=') && /Max-Age=0/i.test(cookie), + ), + ).toBe(true) + }) + + test('stores large sessions in chunks and removes stale chunks when shrinking', async ({ + request, + }) => { + const token = 'x'.repeat(5000) + const large = await request.post('/api/session', { + data: { token }, + }) + const largeCookies = await setCookieValues(large) + + expect( + largeCookies.some((cookie) => cookie.startsWith('start=__chunked__')), + ).toBe(true) + expectCookie(largeCookies, 'start.1') + + const small = await request.post('/api/session', { + headers: { cookie: cookieHeader(largeCookies) }, + data: { token: 'tiny' }, + }) + const smallCookies = await setCookieValues(small) + + expect( + smallCookies.some( + (cookie) => cookie.startsWith('start.1=') && /Max-Age=0/i.test(cookie), + ), + ).toBe(true) + expect( + smallCookies.some( + (cookie) => + cookie.startsWith('start=') && + !cookie.startsWith('start=__chunked__'), + ), + ).toBe(true) + + const next = await request.get('/api/session', { + headers: { cookie: cookieHeader(largeCookies, smallCookies) }, + }) + + expect((await next.json()).data).toEqual({ token: 'tiny' }) + }) + + test('keeps named sessions independent from the default session', async ({ + request, + }) => { + const defaultResponse = await request.post('/api/session', { + data: { scope: 'default' }, + }) + const defaultCookies = await setCookieValues(defaultResponse) + const namedResponse = await request.post( + '/api/session-named?name=account', + { + data: { role: 'admin' }, + }, + ) + const namedCookies = await setCookieValues(namedResponse) + const cookies = cookieHeader(defaultCookies, namedCookies) + + expectCookie(defaultCookies, 'start') + expectCookie(namedCookies, 'account') + + const defaultRead = await request.get('/api/session', { + headers: { cookie: cookies }, + }) + const namedRead = await request.get('/api/session-named?name=account', { + headers: { cookie: cookies }, + }) + + expect((await defaultRead.json()).data).toEqual({ scope: 'default' }) + expect((await namedRead.json()).data).toEqual({ role: 'admin' }) + }) + + test('reads sessions from default and custom headers without Set-Cookie', async ({ + request, + }) => { + const sealedResponse = await request.post('/api/session-seal', { + data: { source: 'header' }, + }) + const sealedBody = await sealedResponse.json() + + expectNoCookies(await setCookieValues(sealedResponse)) + expect(typeof sealedBody.sealed).toBe('string') + + const defaultHeader = await request.get('/api/session-header', { + headers: { 'x-start-session': sealedBody.sealed }, + }) + const customHeader = await request.get( + '/api/session-header?name=x-custom-session', + { + headers: { 'x-custom-session': sealedBody.sealed }, + }, + ) + + expect((await defaultHeader.json()).data).toEqual({ source: 'header' }) + expect((await customHeader.json()).data).toEqual({ source: 'header' }) + expectNoCookies(await setCookieValues(defaultHeader)) + expectNoCookies(await setCookieValues(customHeader)) + }) + + test('supports cookie:false sessions without persistence cookies', async ({ + request, + }) => { + const update = await request.post('/api/session-cookie-disabled', { + data: { transient: true }, + }) + const next = await request.get('/api/session-cookie-disabled') + + expect((await update.json()).data).toEqual({ transient: true }) + expectNoCookies(await setCookieValues(update)) + expect((await next.json()).data).toEqual({}) + expectNoCookies(await setCookieValues(next)) + }) + + test('falls back to a new session for tampered cookies', async ({ + request, + }) => { + const response = await request.get('/api/session', { + headers: { cookie: 'start=not-a-valid-seal' }, + }) + const body = await response.json() + const cookies = await setCookieValues(response) + + expect(response.status()).toBe(200) + expect(body.id).toMatch(/^session-/) + expect(body.data).toEqual({}) + expectCookie(cookies, 'start') + }) + + test('merges session cookies with helper cookies', async ({ request }) => { + const response = await request.post('/api/session-helper-cookie', { + data: { helper: true }, + }) + const cookies = await setCookieValues(response) + + expectCookie(cookies, 'start') + expect( + cookies.some((cookie) => cookie.startsWith('helper-session=1;')), + ).toBe(true) + }) + + test('request middleware can update sessions before and after next', async ({ + request, + }) => { + const before = await request.get('/api/session-middleware', { + headers: { 'x-session-middleware': 'before' }, + }) + const beforeBody = await before.json() + + expect(beforeBody.data).toEqual({ middleware: 'before' }) + + const after = await request.get('/api/session-middleware', { + headers: { 'x-session-middleware': 'after' }, + }) + const afterCookies = await setCookieValues(after) + const afterRead = await request.get('/api/session', { + headers: { cookie: cookieHeader(afterCookies) }, + }) + + expect((await after.json()).data).toEqual({ middleware: 'before' }) + expect((await afterRead.json()).data).toEqual({ middleware: 'after' }) + }) +}) + +test.describe('document and server function sessions', () => { + test('SSR loader session updates persist across navigations', async ({ + page, + }) => { + await page.goto('/ssr') + await expect(page.getByTestId('ssr-session-count')).toHaveText('1') + const firstId = await page.getByTestId('ssr-session-id').textContent() + + await page.reload() + await expect(page.getByTestId('ssr-session-count')).toHaveText('2') + await expect(page.getByTestId('ssr-session-id')).toHaveText(firstId ?? '') + }) + + test('server functions update and read the browser session', async ({ + page, + }) => { + await page.goto('/server-functions') + await expect(page.getByTestId('server-functions-hydrated')).toBeAttached() + + const responsePromise = page.waitForResponse((response) => + response.url().includes('/_serverFn/'), + ) + await page.getByTestId('server-function-update').click() + const response = await responsePromise + const cookies = await setCookieValues(response) + + expectCookie(cookies, 'start') + await expectServerFunctionResult(page, 'update', '"serverFn":"updated"') + + await page.getByTestId('server-function-read').click() + await expectServerFunctionResult(page, 'read', '"serverFn":"updated"') + }) +}) diff --git a/e2e/react-start/session-handling/tsconfig.json b/e2e/react-start/session-handling/tsconfig.json new file mode 100644 index 0000000000..c7a0597e4b --- /dev/null +++ b/e2e/react-start/session-handling/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true, + "types": ["vite/client"] + } +} diff --git a/e2e/react-start/session-handling/vite.config.ts b/e2e/react-start/session-handling/vite.config.ts new file mode 100644 index 0000000000..ef9695579b --- /dev/null +++ b/e2e/react-start/session-handling/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' + +const outDir = process.env.E2E_DIST_DIR ?? 'dist' + +export default defineConfig({ + resolve: { tsconfigPaths: true }, + build: { + outDir, + }, + plugins: [tanstackStart(), viteReact()], +}) diff --git a/packages/react-start/src/default-entry/server.ts b/packages/react-start/src/default-entry/server.ts index 8e734a7e49..26142334ad 100644 --- a/packages/react-start/src/default-entry/server.ts +++ b/packages/react-start/src/default-entry/server.ts @@ -1,6 +1,7 @@ import { createStartHandler, defaultStreamHandler, + handleStartError, } from '@tanstack/react-start/server' import type { Register } from '@tanstack/react-router' import type { RequestHandler } from '@tanstack/react-start/server' @@ -13,7 +14,11 @@ export type ServerEntry = { fetch: RequestHandler } export function createServerEntry(entry: ServerEntry): ServerEntry { return { async fetch(...args) { - return await entry.fetch(...args) + try { + return await entry.fetch(...args) + } catch (error) { + return handleStartError(error) + } }, } } diff --git a/packages/solid-start/src/default-entry/server.ts b/packages/solid-start/src/default-entry/server.ts index b8f3105c6a..5561c6aa07 100644 --- a/packages/solid-start/src/default-entry/server.ts +++ b/packages/solid-start/src/default-entry/server.ts @@ -1,6 +1,7 @@ import { createStartHandler, defaultStreamHandler, + handleStartError, } from '@tanstack/solid-start/server' import type { Register } from '@tanstack/solid-router' import type { RequestHandler } from '@tanstack/solid-start/server' @@ -13,7 +14,11 @@ export type ServerEntry = { fetch: RequestHandler } export function createServerEntry(entry: ServerEntry): ServerEntry { return { async fetch(...args) { - return await entry.fetch(...args) + try { + return await entry.fetch(...args) + } catch (error) { + return handleStartError(error) + } }, } } diff --git a/packages/start-plugin-core/src/vite/plugin.ts b/packages/start-plugin-core/src/vite/plugin.ts index 5fd9356f1f..c2a02a504e 100644 --- a/packages/start-plugin-core/src/vite/plugin.ts +++ b/packages/start-plugin-core/src/vite/plugin.ts @@ -124,10 +124,9 @@ export function tanStackStartVite( routerBasepath, serverFnBase: startConfig.serverFns.base, }) - const resolvedEntryPlan = configContext.resolveEntries() const entryAliases = createViteResolvedEntryAliases({ - entryPaths: resolvedEntryPlan.entryPaths, + entryPaths: configContext.resolveEntries().entryPaths, }) const startPackageName = diff --git a/packages/start-plugin-core/tests/entry-planning.test.ts b/packages/start-plugin-core/tests/entry-planning.test.ts new file mode 100644 index 0000000000..8256ae79ae --- /dev/null +++ b/packages/start-plugin-core/tests/entry-planning.test.ts @@ -0,0 +1,115 @@ +import { + mkdtempSync, + mkdirSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, test } from 'vitest' +import { resolveStartEntryPlan } from '../src/planning' +import { parseStartConfig as parseRsbuildStartConfig } from '../src/rsbuild/schema' +import { parseStartConfig as parseViteStartConfig } from '../src/vite/schema' + +const corePluginOpts = { framework: 'react' as const } +const defaultEntryPaths = { + client: '/virtual/default-client', + server: '/virtual/default-server', + start: '/virtual/default-start', +} +const roots = new Set() + +function createRoot() { + const root = mkdtempSync(join(tmpdir(), 'tanstack-start-entry-')) + roots.add(root) + mkdirSync(join(root, 'src')) + writeFileSync(join(root, 'src', 'router.ts'), 'export {}\n') + return root +} + +afterEach(() => { + for (const root of roots) { + rmSync(root, { recursive: true, force: true }) + } + roots.clear() +}) + +describe('server entry planning', () => { + test.each([ + ['vite', parseViteStartConfig], + ['rsbuild', parseRsbuildStartConfig], + ])( + 'uses the default server entry for %s when none is configured', + (_, parse) => { + const root = createRoot() + const startConfig = parse({}, corePluginOpts, root) + + const plan = resolveStartEntryPlan({ + root, + startConfig, + defaultEntryPaths, + }) + + expect(plan.entryPaths.server).toBe(defaultEntryPaths.server) + }, + ) + + test.each([ + ['vite', parseViteStartConfig], + ['rsbuild', parseRsbuildStartConfig], + ])('resolves a custom server entry for %s from srcDirectory', (_, parse) => { + const root = createRoot() + const serverEntry = join(root, 'src', 'server-entry.ts') + writeFileSync(serverEntry, 'export default {}\n') + const startConfig = parse( + { + server: { + entry: './server-entry.ts', + }, + }, + corePluginOpts, + root, + ) + + const plan = resolveStartEntryPlan({ + root, + startConfig, + defaultEntryPaths, + }) + + expect(plan.entryPaths.server).toBe(realpathSync(serverEntry)) + }) + + test.each([ + ['vite', parseViteStartConfig], + ['rsbuild', parseRsbuildStartConfig], + ])( + 'resolves a custom server entry for %s from a custom srcDirectory', + (_, parse) => { + const root = createRoot() + mkdirSync(join(root, 'app')) + writeFileSync(join(root, 'app', 'router.ts'), 'export {}\n') + const serverEntry = join(root, 'app', 'server-entry.ts') + writeFileSync(serverEntry, 'export default {}\n') + const startConfig = parse( + { + srcDirectory: 'app', + server: { + entry: './server-entry.ts', + }, + }, + corePluginOpts, + root, + ) + + const plan = resolveStartEntryPlan({ + root, + startConfig, + defaultEntryPaths, + }) + + expect(plan.entryPaths.server).toBe(realpathSync(serverEntry)) + }, + ) +}) diff --git a/packages/start-server-core/docs/RECONCILIATION.md b/packages/start-server-core/docs/RECONCILIATION.md new file mode 100644 index 0000000000..6506d59bbd --- /dev/null +++ b/packages/start-server-core/docs/RECONCILIATION.md @@ -0,0 +1,163 @@ +# Response Reconciliation + +This package owns request and response state for TanStack Start server runtime. `h3` is only used by session helpers and is created lazily when a session helper runs. + +## Request State + +Every Start request runs inside a shared `AsyncLocalStorage` event. The event contains: + +- `request`: the original platform `Request` +- `response`: mutable Start response state +- `responseMeta`: metadata used to reconcile helper changes +- `h3Event`: optional lazy session-only `H3Event` + +The ALS instance is stored on a global symbol so separately bundled copies of this module share one request context. + +Native helpers read directly from this event: + +- `getRequest` +- `getRequestHeaders` +- `getRequestHeader` +- `getRequestHost` +- `getRequestProtocol` +- `getRequestUrl` +- `getRequestIP` +- `getCookies` +- `getCookie` + +These helpers do not construct or read from an `H3Event`. + +## Response State + +Start response helpers mutate event-owned state, not `h3` state: + +- `setResponseStatus` +- `setResponseHeader` +- `setResponseHeaders` +- `removeResponseHeader` +- `clearResponseHeaders` +- `setCookie` +- `deleteCookie` + +`getResponseHeader` and `getResponseHeaders` read the current returned response plus helper overlay. Helper writes are visible immediately, even before a final `Response` exists. Treat `getResponseHeaders()` as a read-only snapshot: mutating the returned `Headers` object does not mutate outgoing response state. Use `setResponseHeader`, `setResponseHeaders`, `removeResponseHeader`, or `clearResponseHeaders` for writes. + +Internal code can use `getResponse` to access event-owned mutable response state during serialization. This is not public API; user code should use the status/header/cookie helpers above. + +## Reconciliation Model + +Route handlers, middleware, SSR handlers, and server functions can all return `Response` objects. Start reconciles those responses with helper mutations before the response leaves the request boundary. + +Reconciliation rules: + +- Helper state overlays returned `Response` state. +- Status and status text from `setResponseStatus` win over returned response status. +- Headers from response helpers win over returned response headers. +- `removeResponseHeader` and `clearResponseHeaders` can remove headers from a returned response. +- Helper deltas survive when a later middleware replaces the response. +- Direct mutations to an old `response.headers` object do not survive response replacement. +- Direct mutations to the current response are visible while that response remains current. +- If nothing changed, reconciliation returns the original `Response` instance. +- If only writable headers changed, reconciliation mutates the existing `Response` headers. +- If status/body shape changed or headers are readonly, reconciliation creates a new `Response` using the same body. + +The implementation avoids cloning `Response` objects unless mutation is impossible or the response shape must change. + +## Header Tracking + +The event response headers are wrapped to track helper intent: + +- `set` records a helper write. +- `append` records a helper write. +- `delete` records a helper removal. +- `clearResponseHeaders()` records a full clear. + +This metadata matters because a plain `Headers` object cannot distinguish between "not set" and "explicitly removed". + +## Cookies + +Cookies use native `cookie-es` parsing and serialization. + +`setCookie` appends `Set-Cookie` values and dedupes by cookie identity: + +- name +- domain +- path + +If domain is absent, identity uses an empty domain. `setCookie` defaults to `Path=/`, but returned `Set-Cookie` headers without `Path` keep an empty path identity so they do not dedupe against explicit-path cookies. + +Cookie behavior has two modes: + +- `setCookie`/`deleteCookie`: merge with existing returned response cookies. +- `setResponseHeader('set-cookie', ...)`: replace returned response cookies. + +This preserves multiple `Set-Cookie` headers and lets explicit header APIs replace cookie state when requested. + +## Protected Transport Headers + +Server function protocol headers are protected after the protocol response is created: + +- `content-type` +- `x-tss-serialized` +- `x-tss-raw` + +Helper headers still reconcile onto server function responses, but protected transport headers win over user helper writes. This prevents user code from corrupting the client/server function protocol. + +## Null Body Responses + +Reconciliation drops bodies for response shapes that cannot carry one: + +- `HEAD` requests +- status `204` +- status `205` +- status `304` + +Informational statuses like `101` cannot be used to construct Fetch responses, so they are sanitized before reconciliation. + +If a middleware sets one of these statuses after a streamed SSR response is produced, the middleware executor treats that as response replacement and disposes the original SSR stream owner. + +## Stream Ownership + +SSR streaming responses carry cleanup ownership metadata. Middleware reconciliation preserves or disposes that ownership based on the final body: + +- Same response: ownership is preserved. +- Wrapper response with the same body: ownership moves to the wrapper. +- Different response or dropped body: original stream owner is disposed. +- Middleware error after `next()`: original stream owner is disposed. + +This prevents cleanup leaks while still allowing middleware to wrap streamed responses without prematurely disposing the stream. + +## Error Handling + +`requestHandler` rethrows uncaught errors. Object/function errors are associated with the current Start event in a `WeakMap` so server entries can catch outside the ALS continuation and call `handleStartError(error)`. + +Generated server entries wrap `entry.fetch(...args)` in `try/catch` and call `handleStartError(error)`. Custom server entries that call `createStartHandler(...)` directly should do the same if they want Start's default conversion. + +Server function RPC errors are converted before the top-level server-entry boundary so serialized protocol headers and bodies are preserved. + +`handleStartError` behavior: + +- If ALS is still active, it uses the current event. +- If the error was remembered, it uses and deletes the remembered event. +- If the error is a `Response`, it returns that response when no Start event exists. +- Otherwise it returns a generic JSON error response. + +Primitive throws are rethrown as-is. Without an associated Start event, `handleStartError(error)` can still return a generic error response, but it cannot recover request-specific state. + +## h3 Session Bridge + +Session helpers lazily create an `H3Event` only when needed: + +- `useSession` +- `getSession` +- `updateSession` +- `sealSession` +- `unsealSession` +- `clearSession` + +Before a session helper runs, Start response state is copied to `h3Event.res`. After the helper returns, `h3Event.res.headers` is merged back into Start response state. `Set-Cookie` values from session helpers merge through the same cookie dedupe path as native `setCookie`. + +No request helper or non-session response helper depends on `h3`. + +## Malformed URLs + +`requestHandler` validates `request.url` before entering ALS. A malformed URL that throws `URIError` or `TypeError` becomes `400 Bad Request`. Other URL construction errors are rethrown. diff --git a/packages/start-server-core/package.json b/packages/start-server-core/package.json index 906c25f2fe..4c8f2a9eff 100644 --- a/packages/start-server-core/package.json +++ b/packages/start-server-core/package.json @@ -91,13 +91,13 @@ "@tanstack/router-core": "workspace:*", "@tanstack/start-client-core": "workspace:*", "@tanstack/start-storage-context": "workspace:*", + "cookie-es": "^3.0.0", "fetchdts": "^0.1.6", "h3-v2": "npm:h3@2.0.1-rc.20", "seroval": "^1.5.4" }, "devDependencies": { "@standard-schema/spec": "^1.0.0", - "cookie-es": "^3.0.0", "vite": "*", "@types/node": ">=20" } diff --git a/packages/start-server-core/skills/start-server-core/SKILL.md b/packages/start-server-core/skills/start-server-core/SKILL.md index 7dc16d517f..269960a151 100644 --- a/packages/start-server-core/skills/start-server-core/SKILL.md +++ b/packages/start-server-core/skills/start-server-core/SKILL.md @@ -102,6 +102,8 @@ const serverFn = createServerFn({ method: 'POST' }).handler(async () => { }) ``` +`getResponseHeader` and `getResponseHeaders` are read helpers. Treat the `Headers` returned by `getResponseHeaders()` as a snapshot; mutating it does not change the outgoing response. Use the setter/removal helpers for writes. + ## Cookie Management ```ts @@ -190,29 +192,6 @@ await session.update({ userId: '123' }) // Persist session data await session.clear() // Clear session data ``` -## Query Validation - -Validate query string parameters using a Standard Schema: - -```ts -// Use @tanstack/-start for your framework (react, solid, vue) -import { getValidatedQuery } from '@tanstack/react-start/server' -import { z } from 'zod' - -const serverFn = createServerFn({ method: 'GET' }).handler(async () => { - const query = await getValidatedQuery( - z.object({ - page: z.coerce.number().default(1), - limit: z.coerce.number().default(20), - }), - ) - - return { page: query.page } -}) -``` - -> Note: `getValidatedQuery` accepts a Standard Schema validator, not a callback function. - ## How Request Handling Works `createStartHandler` processes requests in three phases: diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 378ed50d2d..be2561e2ee 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -25,9 +25,12 @@ import { getStartContext, runWithStartContext, } from '@tanstack/start-storage-context' -import { requestHandler } from './request-response' +import { reconcileResponse, requestHandler } from './internal-request-response' import { getStartManifest } from './router-manifest' -import { handleServerAction } from './server-functions-handler' +import { + createServerFnErrorResponse, + handleServerAction, +} from './server-functions-handler' import { createEarlyHintsCollector } from './early-hints' import { createCachedBaseManifestLoader, @@ -47,6 +50,7 @@ import type { } from '@tanstack/start-client-core' import type { RequestHandler } from './request-handler' import type { + AnyRedirect, AnyRoute, AnyRouter, AnySerializationAdapter, @@ -69,16 +73,15 @@ export interface CreateStartHandlerOptions extends FinalManifestOptions { handler: HandlerCallback } -function getStartResponseHeaders(opts: { router: AnyRouter }) { - const headers = mergeHeaders( +function getStartResponseHeaders(router: AnyRouter) { + return mergeHeaders( { 'Content-Type': 'text/html; charset=utf-8', }, - ...opts.router.stores.matches.get().map((match) => { + ...router.stores.matches.get().map((match) => { return match.headers }), ) - return headers } interface PluginAdaptersEntry { @@ -141,7 +144,9 @@ function hasCsrfMiddleware( } function warnMissingCsrfMiddlewareOnce() { - if (hasWarnedMissingCsrfMiddleware) return + if (hasWarnedMissingCsrfMiddleware) { + return + } hasWarnedMissingCsrfMiddleware = true console.warn(`TanStack Start server functions are not protected by the CSRF middleware. @@ -198,23 +203,13 @@ function isSpecialResponse(value: unknown): value is Response { return value instanceof Response || isRedirect(value) } -/** - * Normalize middleware result to context shape - */ -function handleCtxResult(result: TODO) { - if (isSsrResponse(result) || isSpecialResponse(result)) { - return { response: result } - } - return result -} - /** * Execute a middleware chain */ async function executeMiddleware( middlewares: Array, ctx: TODO, -): Promise<{ ctx: TODO; response: HandlerCallbackResult }> { +): Promise { let index = -1 let streamResponse: | Extract @@ -225,11 +220,12 @@ async function executeMiddleware( if (response.serverSsrCleanup === 'stream') { streamResponse = response } - ctx.response = response.response + ctx.response = reconcileResponse(response.response) return } - ctx.response = response + ctx.response = + response instanceof Response ? reconcileResponse(response) : response } const disposeStreamResponse = async (reason: string) => { @@ -252,11 +248,16 @@ async function executeMiddleware( } const getFinalResponse = async (): Promise => { - const response = ctx.response + let response = ctx.response if (!response) { throwRouteHandlerError() } + if (response instanceof Response) { + response = reconcileResponse(response) + ctx.response = response + } + if (!streamResponse) { return response } @@ -294,7 +295,9 @@ async function executeMiddleware( index++ const middleware = middlewares[index] - if (!middleware) return ctx + if (!middleware) { + return ctx + } let result: TODO try { @@ -308,7 +311,10 @@ async function executeMiddleware( throw err } - const normalized = handleCtxResult(result) + const normalized = + isSsrResponse(result) || isSpecialResponse(result) + ? { response: result } + : result if (normalized) { if (normalized.response !== undefined) { setResponse(normalized.response) @@ -321,8 +327,13 @@ async function executeMiddleware( return ctx } - await next() - return { ctx, response: await getFinalResponse() } + try { + await next() + return await getFinalResponse() + } catch (error) { + await disposeStreamResponse('middleware error') + throw error + } } /** @@ -400,8 +411,8 @@ export function createStartHandler( request, requestOpts, ) => { - let router: AnyRouter | null = null as AnyRouter | null - let responseOwnsCleanup = false as boolean + let router: AnyRouter | undefined + let responseOwnsCleanup = false try { // normalizing and sanitizing the pathname here for server, so we always deal with the same format during SSR. @@ -447,10 +458,15 @@ export function createStartHandler( const executedRequestMiddlewares = new Set( flattenedRequestMiddlewares, ) + const requestMiddlewares = flattenedRequestMiddlewares.map( + (d) => d.options.server, + ) // Memoized router getter const getRouter = async (): Promise => { - if (router) return router + if (router) { + return router + } router = await entries.routerEntry.getRouter() @@ -481,6 +497,24 @@ export function createStartHandler( return router } + const runStartContext = ( + handlerType: 'router' | 'serverFn', + context: TODO, + fn: () => TResult | Promise, + ) => { + return runWithStartContext( + { + getRouter, + startOptions: requestStartOptions, + contextAfterGlobalMiddlewares: context, + request, + executedRequestMiddlewares, + handlerType, + }, + fn, + ) + } + // Check for server function requests first (early exit) if (SERVER_FN_BASE && url.pathname.startsWith(SERVER_FN_BASE)) { if ( @@ -499,37 +533,41 @@ export function createStartHandler( throw new Error('Invalid server action param for serverFnId') } + const middlewareCtx = { + request, + pathname: url.pathname, + handlerType: 'serverFn', + context: createNullProtoObject(requestOpts?.context), + } + const serverFnHandler = async ({ context }: TODO) => { - return runWithStartContext( - { - getRouter, - startOptions: requestStartOptions, - contextAfterGlobalMiddlewares: context, + return runStartContext('serverFn', context, () => + handleServerAction({ request, - executedRequestMiddlewares, - handlerType: 'serverFn', - }, - () => - handleServerAction({ - request, - context: requestOpts?.context, - serverFnId, - }), + context, + serverFnId, + }), ) } - const middlewares = flattenedRequestMiddlewares.map( - (d) => d.options.server, - ) - const { response: middlewareResponse } = await executeMiddleware( - [...middlewares, serverFnHandler], - { - request, - pathname: url.pathname, - handlerType: 'serverFn', - context: createNullProtoObject(requestOpts?.context), - }, - ) + const handleServerFnMiddlewareError = async ( + error: unknown, + ): Promise => { + return runStartContext('serverFn', middlewareCtx.context, () => + createServerFnErrorResponse(error), + ) + } + + let middlewareResponse: HandlerCallbackResult + try { + middlewareResponse = await executeMiddleware( + [...requestMiddlewares, serverFnHandler], + middlewareCtx, + ) + } catch (error) { + // Request middleware can throw before serverFnHandler runs. + middlewareResponse = await handleServerFnMiddlewareError(error) + } const result = await handleRedirectResponse( middlewareResponse, @@ -599,9 +637,7 @@ export function createStartHandler( requestAssets: ctx?.requestAssets, }) - const responseHeaders = getStartResponseHeaders({ - router: routerInstance, - }) + const responseHeaders = getStartResponseHeaders(routerInstance) earlyHints?.appendResponseHeaders(responseHeaders) const response = await cb({ request, @@ -613,40 +649,20 @@ export function createStartHandler( // Main request handler const requestHandlerMiddleware = async ({ context }: TODO) => { - return runWithStartContext( - { + return runStartContext('router', context, () => + handleServerRoutes({ getRouter, - startOptions: requestStartOptions, - contextAfterGlobalMiddlewares: context, request, + url, + executeRouter, + context, executedRequestMiddlewares, - handlerType: 'router', - }, - async () => { - try { - return await handleServerRoutes({ - getRouter, - request, - url, - executeRouter, - context, - executedRequestMiddlewares, - }) - } catch (err) { - if (err instanceof Response) { - return err - } - throw err - } - }, + }), ) } - const middlewares = flattenedRequestMiddlewares.map( - (d) => d.options.server, - ) - const { response: middlewareResponse } = await executeMiddleware( - [...middlewares, requestHandlerMiddleware], + const middlewareResponse = await executeMiddleware( + [...requestMiddlewares, requestHandlerMiddleware], { request, pathname: url.pathname, @@ -669,7 +685,7 @@ export function createStartHandler( // Transformed streaming response bodies clean up when consumed/cancelled. router.serverSsr.cleanup() } - router = null + router = undefined } } @@ -685,55 +701,51 @@ async function handleRedirectResponse( if (!isRedirect(ssrResponse.response)) { return ssrResponse } + const redirectResponse: AnyRedirect = ssrResponse.response + const isServerFn = request.headers.get('x-tsr-serverFn') === 'true' + const serializeServerFnRedirect = () => { + return replaceSsrResponse( + ssrResponse, + Response.json( + { ...redirectResponse.options, isSerializedRedirect: true }, + { headers: redirectResponse.headers }, + ), + 'redirect response replaced', + ) + } - if (isResolvedRedirect(ssrResponse.response)) { - if (request.headers.get('x-tsr-serverFn') === 'true') { - return replaceSsrResponse( - ssrResponse, - Response.json( - { ...ssrResponse.response.options, isSerializedRedirect: true }, - { headers: ssrResponse.response.headers }, - ), - 'redirect response replaced', - ) + if (isResolvedRedirect(redirectResponse)) { + if (isServerFn) { + return serializeServerFnRedirect() } return ssrResponse } - const opts = ssrResponse.response.options + const opts = redirectResponse.options if (opts.to && typeof opts.to === 'string' && !opts.to.startsWith('/')) { throw new Error( `Server side redirects must use absolute paths via the 'href' or 'to' options. The redirect() method's "to" property accepts an internal path only. Use the "href" property to provide an external URL. Received: ${JSON.stringify(opts)}`, ) } - if ( - ['params', 'search', 'hash'].some( - (d) => typeof (opts as TODO)[d] === 'function', - ) - ) { + const functionalRedirectOptionNames = ( + ['params', 'search', 'hash'] as const + ).filter((key) => typeof opts[key] === 'function') + + if (functionalRedirectOptionNames.length) { throw new Error( - `Server side redirects must use static search, params, and hash values and do not support functional values. Received functional values for: ${Object.keys( - opts, - ) - .filter((d) => typeof (opts as TODO)[d] === 'function') + `Server side redirects must use static search, params, and hash values and do not support functional values. Received functional values for: ${functionalRedirectOptionNames .map((d) => `"${d}"`) .join(', ')}`, ) } const router = await getRouter() - const redirect = router.resolveRedirect(ssrResponse.response) + // resolveRedirect mutates redirectResponse so serverFn serialization includes href/Location. + const redirect = router.resolveRedirect(redirectResponse) - if (request.headers.get('x-tsr-serverFn') === 'true') { - return replaceSsrResponse( - ssrResponse, - Response.json( - { ...ssrResponse.response.options, isSerializedRedirect: true }, - { headers: ssrResponse.response.headers }, - ), - 'redirect response replaced', - ) + if (isServerFn) { + return serializeServerFnRedirect() } return replaceSsrResponse(ssrResponse, redirect, 'redirect response replaced') @@ -766,7 +778,7 @@ async function handleServerRoutes({ const { matchedRoutes, foundRoute, routeParams } = router.getMatchedRoutes(pathname) - const isExactMatch = foundRoute && routeParams['**'] === undefined + const isExactMatch = !!foundRoute && routeParams['**'] === undefined // Collect and dedupe route middlewares const routeMiddlewares: Array = [] @@ -803,8 +815,7 @@ async function handleServerRoutes({ requestMethod === 'HEAD' ? (handlers['HEAD'] ?? handlers['GET'] ?? handlers['ANY']) : (handlers[requestMethod] ?? handlers['ANY']) - isHeadFallback = - requestMethod === 'HEAD' && handler !== undefined && !handlers['HEAD'] + isHeadFallback = requestMethod === 'HEAD' && !!handler && !handlers['HEAD'] if (handler) { const mayDefer = !!foundRoute.options.component @@ -829,7 +840,7 @@ async function handleServerRoutes({ routeMiddlewares.push(((ctx: TODO) => executeRouter(ctx.context, matchedRoutes)) as TODO) - const { ctx, response } = await executeMiddleware(routeMiddlewares, { + const response = await executeMiddleware(routeMiddlewares, { request, context, params: routeParams, @@ -839,11 +850,11 @@ async function handleServerRoutes({ // RFC 9110 §9.3.2: HEAD must carry the same header fields as GET but no body. // Resolve any redirect before stripping so the Location header survives. + // Keep this SsrResponse-aware strip here instead of relying only on final + // reconciliation. At this point we still have stream ownership metadata, so + // stripSsrResponseBody can dispose SSR streams when HEAD falls back to GET/ANY. + // Generic reconciliation still handles plain Response body dropping later. if (isHeadFallback) { - if (!ctx.response) { - throwRouteHandlerError() - } - const resolved = await handleRedirectResponse(response, request, getRouter) return stripSsrResponseBody(resolved, 'HEAD body stripped') } diff --git a/packages/start-server-core/src/headers.ts b/packages/start-server-core/src/headers.ts new file mode 100644 index 0000000000..8632fd5724 --- /dev/null +++ b/packages/start-server-core/src/headers.ts @@ -0,0 +1,38 @@ +import { splitSetCookieString } from 'cookie-es' + +type HeadersWithGetSetCookie = Headers & { + getSetCookie?: () => Array +} + +export function getSetCookieValues(headers: Headers): Array { + const headersWithSetCookie = headers as HeadersWithGetSetCookie + if (typeof headersWithSetCookie.getSetCookie === 'function') { + return headersWithSetCookie.getSetCookie() + } + const value = headers.get('set-cookie') + return value ? splitSetCookieString(value) : [] +} + +export function cloneHeaders(headers: Headers): Headers { + const cloned = new Headers() + applyHeaders(headers, cloned) + return cloned +} + +export function applyHeaders(source: Headers, target: Headers): void { + for (const [name, value] of source) { + if (name !== 'set-cookie') { + target.set(name, value) + } + } + for (const cookie of getSetCookieValues(source)) { + target.append('set-cookie', cookie) + } +} + +export function copyHeaders(source: Headers, target: Headers): void { + for (const name of Array.from(target.keys())) { + target.delete(name) + } + applyHeaders(source, target) +} diff --git a/packages/start-server-core/src/internal-request-response.ts b/packages/start-server-core/src/internal-request-response.ts new file mode 100644 index 0000000000..ae8f1a6d41 --- /dev/null +++ b/packages/start-server-core/src/internal-request-response.ts @@ -0,0 +1,1233 @@ +import { AsyncLocalStorage } from 'node:async_hooks' + +import { + H3Event, + clearSession as h3_clearSession, + getSession as h3_getSession, + sealSession as h3_sealSession, + unsealSession as h3_unsealSession, + updateSession as h3_updateSession, + useSession as h3_useSession, +} from 'h3-v2' +import { parseCookie, parseSetCookie, serializeCookie } from 'cookie-es' +import { cloneHeaders, copyHeaders, getSetCookieValues } from './headers' +import type { + RequestHeaderMap, + RequestHeaderName, + ResponseHeaderMap, + ResponseHeaderName, + TypedHeaders, +} from 'fetchdts' + +import type { CookieSerializeOptions } from 'cookie-es' +import type { + Session, + SessionConfig, + SessionData, + SessionManager, + SessionUpdate, +} from './session' +import type { RequestHandler } from './request-handler' + +interface StartEvent { + request: Request + requestUrl?: URL + h3Event?: H3Event + h3SessionQueue?: Promise + response: StartResponse + responseMeta: ResponseMeta +} + +interface StartResponse { + status?: number + statusText?: string + headers: Headers +} + +// Use a global symbol to ensure the same AsyncLocalStorage instance is shared +// across different bundles that may each bundle this module. +const GLOBAL_EVENT_STORAGE_KEY = Symbol.for('tanstack-start:event-storage') + +const globalObj = globalThis as typeof globalThis & { + [GLOBAL_EVENT_STORAGE_KEY]?: AsyncLocalStorage +} + +if (!globalObj[GLOBAL_EVENT_STORAGE_KEY]) { + globalObj[GLOBAL_EVENT_STORAGE_KEY] = new AsyncLocalStorage() +} + +const eventStorage = globalObj[GLOBAL_EVENT_STORAGE_KEY] + +export type { ResponseHeaderName, RequestHeaderName } + +type ProtectedHeaders = Map + +type MaybePromise = T | Promise + +interface ResponseMeta { + currentResponse?: Response + removedHeaders?: Set + clearHeaders: boolean + settingCookie: boolean + setCookieBehavior?: 'merge' | 'replace' +} + +const protectedResponseHeaders = new WeakMap() +const startErrorEvents = new WeakMap() + +function normalizeHeaderName(name: string): string { + return name.toLowerCase() +} + +function sanitizeStatusMessage(statusMessage = ''): string { + let sanitized = '' + for (const char of statusMessage) { + const code = char.charCodeAt(0) + if (code === 0x09 || (code >= 0x20 && code <= 0x7e)) { + sanitized += char + } + } + return sanitized +} + +function sanitizeStatusCode( + statusCode: string | number | undefined, + defaultStatusCode: number | undefined = 200, +): number { + const fallbackStatusCode = defaultStatusCode + if (!statusCode) { + return fallbackStatusCode + } + const code = typeof statusCode === 'string' ? Number(statusCode) : statusCode + if (!Number.isInteger(code) || code < 200 || code > 599) { + return fallbackStatusCode + } + return code +} + +function getObjectProperty(value: unknown, key: string): unknown { + if ((typeof value === 'object' && value) || typeof value === 'function') { + return (value as Record)[key] + } + return undefined +} + +function getStatusCodeProperty( + value: unknown, + key: 'status' | 'statusCode', +): string | number | undefined { + const property = getObjectProperty(value, key) + if (typeof property === 'string' || typeof property === 'number') { + return property + } + return undefined +} + +export function getErrorStatus(error: unknown): number | undefined { + const cause = getObjectProperty(error, 'cause') + const status = sanitizeStatusCode( + getStatusCodeProperty(error, 'status') ?? + getStatusCodeProperty(error, 'statusCode') ?? + getStatusCodeProperty(cause, 'status') ?? + getStatusCodeProperty(cause, 'statusCode'), + 0, + ) + return status || undefined +} + +export function getErrorStatusText(error: unknown): string | undefined { + const cause = getObjectProperty(error, 'cause') + const statusText = + getObjectProperty(error, 'statusText') ?? + getObjectProperty(error, 'statusMessage') ?? + getObjectProperty(cause, 'statusText') ?? + getObjectProperty(cause, 'statusMessage') + if (typeof statusText === 'string') { + return sanitizeStatusMessage(statusText) + } + return undefined +} + +export function getErrorHeaders(error: unknown): Headers | undefined { + const cause = getObjectProperty(error, 'cause') + const headers = + getObjectProperty(error, 'headers') ?? getObjectProperty(cause, 'headers') + if (!headers) { + return undefined + } + try { + return new Headers(headers as HeadersInit) + } catch { + return undefined + } +} + +function getRemovedHeaders(meta: ResponseMeta): Set { + return (meta.removedHeaders ||= new Set()) +} + +function markHeaderSet(meta: ResponseMeta, name: string): void { + const normalizedName = normalizeHeaderName(name) + meta.removedHeaders?.delete(normalizedName) + if (normalizedName === 'set-cookie' && meta.setCookieBehavior !== 'replace') { + meta.setCookieBehavior = 'merge' + } +} + +function markHeaderDeleted(meta: ResponseMeta, name: string): void { + const normalizedName = normalizeHeaderName(name) + getRemovedHeaders(meta).add(normalizedName) + if (normalizedName === 'set-cookie' && !meta.settingCookie) { + meta.setCookieBehavior = 'replace' + } +} + +function trackResponseHeaders(headers: Headers, meta: ResponseMeta): void { + const set = headers.set.bind(headers) + const append = headers.append.bind(headers) + const del = headers.delete.bind(headers) + + headers.set = (name, value) => { + if (normalizeHeaderName(name) === 'set-cookie') { + meta.setCookieBehavior = 'replace' + } + markHeaderSet(meta, name) + return set(name, value) + } + + headers.append = (name, value) => { + markHeaderSet(meta, name) + return append(name, value) + } + + headers.delete = (name) => { + markHeaderDeleted(meta, name) + return del(name) + } +} + +function trackStartResponse(response: StartResponse, meta: ResponseMeta): void { + let status = response.status + let statusText = response.statusText + + Object.defineProperties(response, { + status: { + configurable: true, + enumerable: true, + get() { + return status + }, + set(value: number | undefined) { + status = + value === undefined ? undefined : sanitizeStatusCode(value, status) + }, + }, + statusText: { + configurable: true, + enumerable: true, + get() { + return statusText + }, + set(value: string | undefined) { + statusText = + value === undefined ? undefined : sanitizeStatusMessage(value) + }, + }, + }) + + trackResponseHeaders(response.headers, meta) +} + +function createResponseMeta(): ResponseMeta { + return { + clearHeaders: false, + settingCookie: false, + } +} + +function createStartResponse(): StartResponse { + return { + headers: new Headers(), + } +} + +function isPromiseLike(value: MaybePromise): value is Promise { + return typeof (value as Promise).then === 'function' +} + +function getDistinctCookieKey( + name: string, + options: { domain?: string; path?: string }, + defaultPath = '', +): string { + return [name, options.domain || '', options.path ?? defaultPath].join(';') +} + +function getDistinctCookieKeyFromHeader(cookie: string): string | undefined { + const parsed = parseSetCookie(cookie) + if (!parsed) { + return undefined + } + return getDistinctCookieKey(parsed.name, parsed) +} + +function replaceSetCookieValues( + headers: Headers, + cookies: Array, +): void { + headers.delete('set-cookie') + for (const cookie of cookies) { + headers.append('set-cookie', cookie) + } +} + +function mergeSetCookieValues( + headers: Headers, + cookiesToMerge: Array, +): void { + if (cookiesToMerge.length === 0) { + return + } + + const cookieKeysToMerge = new Set( + cookiesToMerge.map(getDistinctCookieKeyFromHeader).filter(Boolean), + ) + const currentCookies = getSetCookieValues(headers).filter((cookie) => { + const cookieKey = getDistinctCookieKeyFromHeader(cookie) + return !cookieKey || !cookieKeysToMerge.has(cookieKey) + }) + replaceSetCookieValues(headers, currentCookies) + for (const cookie of cookiesToMerge) { + headers.append('set-cookie', cookie) + } +} + +function hasProtectedHeaderChanges( + response: Response, + protectedHeaders?: ProtectedHeaders, +): boolean { + if (!protectedHeaders) { + return false + } + + for (const [name, value] of protectedHeaders) { + if (response.headers.get(name) !== value) { + return true + } + } + return false +} + +function applyProtectedHeaders( + protectedHeaders: ProtectedHeaders | undefined, + headers: Headers, +): void { + if (!protectedHeaders) { + return + } + + for (const [name, value] of protectedHeaders) { + if (value === null) { + headers.delete(name) + } else { + headers.set(name, value) + } + } +} + +function applyHeaderState( + target: Headers, + eventHeaders: Headers, + meta: ResponseMeta, + protectedHeaders?: ProtectedHeaders, +): void { + if (meta.clearHeaders) { + for (const name of Array.from(target.keys())) { + if (!protectedHeaders?.has(name)) { + target.delete(name) + } + } + } + + if (!meta.clearHeaders && meta.removedHeaders) { + for (const name of meta.removedHeaders) { + if (!protectedHeaders?.has(name)) { + target.delete(name) + } + } + } + + for (const [name, value] of eventHeaders) { + if (name !== 'set-cookie' && !protectedHeaders?.has(name)) { + target.set(name, value) + } + } + + if (meta.setCookieBehavior && !protectedHeaders?.has('set-cookie')) { + const eventSetCookies = getSetCookieValues(eventHeaders) + if (meta.setCookieBehavior === 'replace') { + replaceSetCookieValues(target, eventSetCookies) + } else { + mergeSetCookieValues(target, eventSetCookies) + } + } +} + +function hasHeaders(headers: Headers): boolean { + return !headers.keys().next().done +} + +function hasHeaderState(headers: Headers, meta: ResponseMeta): boolean { + return ( + meta.clearHeaders || + !!meta.removedHeaders?.size || + !!meta.setCookieBehavior || + hasHeaders(headers) + ) +} + +export function canHaveBody(method: string, status: number): boolean { + return ( + method !== 'HEAD' && + status !== 101 && + status !== 204 && + status !== 205 && + status !== 304 + ) +} + +function cancelDroppedBody(response: Response): void { + try { + response.body + ?.cancel('Response body dropped by Start reconciliation') + .catch(() => {}) + } catch { + // Ignore locked or already-consumed bodies. + } +} + +function createReconciledResponse( + response: Response, + event: StartEvent, + status: number, + statusText: string, + headers: Headers, +): Response { + const shouldKeepBody = canHaveBody(event.request.method, status) + if (!shouldKeepBody) { + cancelDroppedBody(response) + } + const body = shouldKeepBody ? response.body : null + + try { + return new Response(body, { + status, + statusText, + headers, + }) + } catch (cause) { + throw new Error( + 'Unable to reconcile response because its body has already been consumed or locked.', + { cause }, + ) + } +} + +function reconcileResponseWithEvent(response: Response, event: StartEvent) { + const { response: eventResponse, responseMeta } = event + const status = eventResponse.status ?? response.status + const statusText = eventResponse.statusText ?? response.statusText + const statusChanged = status !== response.status + const statusTextChanged = statusText !== response.statusText + const mustDropBody = + response.body !== null && !canHaveBody(event.request.method, status) + const headersChanged = hasHeaderState(eventResponse.headers, responseMeta) + const protectedHeaders = protectedResponseHeaders.get(response) + const protectedHeadersChanged = hasProtectedHeaderChanges( + response, + protectedHeaders, + ) + + if ( + !statusChanged && + !statusTextChanged && + !mustDropBody && + !headersChanged && + !protectedHeadersChanged + ) { + responseMeta.currentResponse = response + return response + } + + if (!statusChanged && !statusTextChanged && !mustDropBody) { + try { + if (headersChanged) { + applyHeaderState( + response.headers, + eventResponse.headers, + responseMeta, + protectedHeaders, + ) + } + if (protectedHeadersChanged) { + applyProtectedHeaders(protectedHeaders, response.headers) + } + responseMeta.currentResponse = response + return response + } catch { + // Readonly response headers require cloning below. + } + } + + const headers = cloneHeaders(response.headers) + if (headersChanged) { + applyHeaderState( + headers, + eventResponse.headers, + responseMeta, + protectedHeaders, + ) + } + if (protectedHeadersChanged) { + applyProtectedHeaders(protectedHeaders, headers) + } + + const reconciled = createReconciledResponse( + response, + event, + status, + statusText, + headers, + ) + if (protectedHeaders) { + protectedResponseHeaders.set(reconciled, protectedHeaders) + } + responseMeta.currentResponse = reconciled + return reconciled +} + +function createErrorResponse(error: unknown, event: StartEvent): Response { + const eventResponse = event.response + const errorStatus = getErrorStatus(error) + const status = eventResponse.status ?? errorStatus ?? 500 + const statusText = eventResponse.statusText ?? getErrorStatusText(error) ?? '' + const body = canHaveBody(event.request.method, status) + ? JSON.stringify({ + status, + statusText, + unhandled: true, + message: 'HTTPError', + data: undefined, + }) + : null + const headers = getErrorHeaders(error) ?? new Headers() + + if (!headers.has('content-type')) { + headers.set('content-type', 'application/json') + } + + if (eventResponse.status === undefined && errorStatus === undefined) { + console.error(error) + } + + return new Response(body, { + status, + statusText, + headers, + }) +} + +function finalizeError(error: unknown, event: StartEvent): Response { + if (error instanceof Response) { + return reconcileResponseWithEvent(error, event) + } + return reconcileResponseWithEvent(createErrorResponse(error, event), event) +} + +function rememberStartError(error: unknown, event: StartEvent): never { + if ((typeof error === 'object' && error) || typeof error === 'function') { + startErrorEvents.set(error, event) + throw error + } + + throw error +} + +export function handleStartError(error: unknown): Response { + let event = eventStorage.getStore() + if ( + !event && + ((typeof error === 'object' && error) || typeof error === 'function') + ) { + event = startErrorEvents.get(error) + startErrorEvents.delete(error) + } + if (event) { + return finalizeError(error, event) + } + if (error instanceof Response) { + return error + } + const response = createStartResponse() + const responseMeta = createResponseMeta() + trackStartResponse(response, responseMeta) + // No request context exists for primitive errors, so this fallback cannot + // recover the original method for HEAD/null-body handling. + return createErrorResponse(error, { + request: new Request('http://localhost'), + response, + responseMeta, + }) +} + +export function reconcileResponse(response: Response): Response { + const event = eventStorage.getStore() + if (!event) { + return response + } + return reconcileResponseWithEvent(response, event) +} + +export function protectResponseHeaders( + response: Response, + headerNames: Array, +): void { + const protectedHeaders = protectedResponseHeaders.get(response) ?? new Map() + for (const name of headerNames) { + const normalizedName = normalizeHeaderName(name) + if (normalizedName === 'set-cookie') { + throw new Error('Set-Cookie headers cannot be protected.') + } + protectedHeaders.set(normalizedName, response.headers.get(normalizedName)) + } + protectedResponseHeaders.set(response, protectedHeaders) +} + +function setResponseHeaderOnResponse( + response: Response, + name: string, + value: string, +): Response { + try { + response.headers.set(name, value) + return response + } catch { + const headers = cloneHeaders(response.headers) + headers.set(name, value) + let nextResponse: Response + try { + nextResponse = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) + } catch (cause) { + throw new Error( + 'Unable to set response header because the response body has already been consumed or locked.', + { cause }, + ) + } + const protectedHeaders = protectedResponseHeaders.get(response) + if (protectedHeaders) { + protectedResponseHeaders.set(nextResponse, protectedHeaders) + } + return nextResponse + } +} + +/** + * Atomically sets a response header AND marks it as protected from + * reconciliation overwrites. + * + * Ownership: the input `response` MUST NOT be referenced after this call. + * When the input has immutable headers (e.g. an opaque fetched Response) the + * helper clones it, transferring ownership of the body stream to the returned + * Response — accessing the original body afterwards will throw "body locked". + * + * All code that needs to layer a header onto an arbitrary user Response should + * go through this single chokepoint so the body-reuse hazard stays contained. + */ +export function setProtectedResponseHeader( + response: Response, + name: string, + value: string, +): Response { + const next = setResponseHeaderOnResponse(response, name, value) + protectResponseHeaders(next, [name]) + return next +} + +export function requestHandler( + handler: RequestHandler, +) { + return (request: Request, requestOpts: any): Promise | Response => { + let requestUrl: URL + try { + requestUrl = new URL(request.url) + } catch (error) { + if (error instanceof URIError || error instanceof TypeError) { + return new Response(null, { + status: 400, + statusText: 'Bad Request', + }) + } + throw error + } + + const response = createStartResponse() + const responseMeta = createResponseMeta() + trackStartResponse(response, responseMeta) + + const startEvent = { request, requestUrl, response, responseMeta } + return eventStorage.run(startEvent, () => { + try { + const response = handler(request, requestOpts) + if (isPromiseLike(response)) { + return response.then( + (resolved) => reconcileResponseWithEvent(resolved, startEvent), + (error) => { + return rememberStartError(error, startEvent) + }, + ) + } + return reconcileResponseWithEvent(response, startEvent) + } catch (error) { + return rememberStartError(error, startEvent) + } + }) + } +} + +function getStartEvent() { + const event = eventStorage.getStore() + if (!event) { + throw new Error( + `No StartEvent found in AsyncLocalStorage. Make sure you are using the function within the server runtime.`, + ) + } + return event +} + +function getStartRequestUrl(event: StartEvent): URL { + return (event.requestUrl ||= new URL(event.request.url)) +} + +function getSessionH3Event(event: StartEvent): H3Event { + return (event.h3Event ||= new H3Event(event.request)) +} + +function syncStartResponseToH3(event: StartEvent): H3Event { + const h3Event = getSessionH3Event(event) + h3Event.res.status = event.response.status + h3Event.res.statusText = event.response.statusText + copyHeaders(event.response.headers, h3Event.res.headers) + return h3Event +} + +function syncH3ResponseToStart(event: StartEvent): void { + const h3Event = event.h3Event + if (!h3Event) { + return + } + + if (h3Event.res.status !== undefined) { + event.response.status = h3Event.res.status + } + if (h3Event.res.statusText !== undefined) { + event.response.statusText = h3Event.res.statusText + } + + for (const [name, value] of h3Event.res.headers) { + if (name !== 'set-cookie') { + event.response.headers.set(name, value) + } + } + mergeStartSetCookieValues(event, getSetCookieValues(h3Event.res.headers)) +} + +function mergeStartSetCookieValues( + event: StartEvent, + cookies: Array, +): void { + if (cookies.length === 0) { + return + } + + const wasSettingCookie = event.responseMeta.settingCookie + event.responseMeta.settingCookie = true + if (event.responseMeta.setCookieBehavior !== 'replace') { + event.responseMeta.setCookieBehavior = 'merge' + } + try { + mergeSetCookieValues(event.response.headers, cookies) + } finally { + event.responseMeta.settingCookie = wasSettingCookie + } +} + +async function withH3SessionResponse( + event: StartEvent, + fn: (h3Event: H3Event) => T | Promise, +): Promise { + const previousSession = event.h3SessionQueue + let releaseSession!: () => void + event.h3SessionQueue = new Promise((resolve) => { + releaseSession = resolve + }) + + await previousSession + + let h3Event: H3Event | undefined + try { + h3Event = syncStartResponseToH3(event) + return await fn(h3Event) + } finally { + try { + if (h3Event) { + syncH3ResponseToStart(event) + } + } finally { + releaseSession() + } + } +} + +export function getRequest(): Request { + return getStartEvent().request +} + +export function getRequestHeaders(): TypedHeaders { + return getStartEvent().request.headers as TypedHeaders +} + +export function getRequestHeader(name: RequestHeaderName): string | undefined { + return getRequestHeaders().get(name) ?? undefined +} + +export function getRequestIP(opts?: { + /** + * Use the X-Forwarded-For HTTP header set by proxies. + * + * Note: Make sure that this header can be trusted (your application running behind a CDN or reverse proxy) before enabling. + */ + xForwardedFor?: boolean +}) { + const request = getRequest() + if (opts?.xForwardedFor) { + const forwardedFor = request.headers.get('x-forwarded-for') + const forwardedIp = forwardedFor?.split(',')[0]?.trim() + if (forwardedIp) { + return forwardedIp + } + } + + return ( + (request as Request & { context?: { clientAddress?: string }; ip?: string }) + .context?.clientAddress || + (request as Request & { ip?: string }).ip || + undefined + ) +} + +/** + * Get the request hostname. + * + * If `xForwardedHost` is `true`, it will use the `x-forwarded-host` header if it exists. + * + * If no host header is found, it will default to "localhost". + */ +export function getRequestHost(opts?: { xForwardedHost?: boolean }) { + const headers = getRequestHeaders() + if (opts?.xForwardedHost) { + const forwardedHost = headers.get('x-forwarded-host') + const host = forwardedHost?.split(',')[0]?.trim() + if (host) { + return host + } + } + return headers.get('host') || 'localhost' +} + +/** + * Get the full incoming request URL. + * + * If `xForwardedHost` is `true`, it will use the `x-forwarded-host` header if it exists. + * + * If `xForwardedProto` is `false`, it will not use the `x-forwarded-proto` header. + */ +export function getRequestUrl(opts?: { + xForwardedHost?: boolean + xForwardedProto?: boolean +}) { + const event = getStartEvent() + const url = new URL(getStartRequestUrl(event)) + url.protocol = getRequestProtocol(opts) + if (opts?.xForwardedHost) { + const host = getRequestHost(opts) + if (host) { + url.host = host + if (!/:\d+$/.test(host)) { + url.port = '' + } + } + } + return url +} + +/** + * Get the request protocol. + * + * If `x-forwarded-proto` header is set to "https", it will return "https". You can disable this behavior by setting `xForwardedProto` to `false`. + * + * If protocol cannot be determined, it will default to "http". + */ +export function getRequestProtocol(opts?: { + xForwardedProto?: boolean +}): 'http' | 'https' | (string & {}) { + const request = getRequest() + if (opts?.xForwardedProto !== false) { + const forwardedProto = request.headers + .get('x-forwarded-proto') + ?.split(',')[0] + ?.trim() + .toLowerCase() + if (forwardedProto === 'https') { + return 'https' + } + if (forwardedProto === 'http') { + return 'http' + } + } + const url = getStartRequestUrl(getStartEvent()) + return url.protocol.slice(0, -1) as 'http' | 'https' | (string & {}) +} + +export function setResponseHeaders( + headers: TypedHeaders, +): void { + if (headers instanceof Headers) { + for (const [name, value] of headers) { + if (name !== 'set-cookie') { + setResponseHeader(name as ResponseHeaderName, value) + } + } + + const cookies = getSetCookieValues(headers) + if (cookies.length > 0) { + setResponseHeader( + 'set-cookie', + cookies.length === 1 ? cookies[0]! : cookies, + ) + } + return + } + + if (!Array.isArray(headers)) { + for (const [name, value] of Object.entries( + headers as unknown as Record>, + )) { + setResponseHeader(name as ResponseHeaderName, value) + } + return + } + + const groupedHeaders = new Map< + string, + { name: ResponseHeaderName; values: Array } + >() + const addHeader = (name: string, value: string) => { + const normalizedName = normalizeHeaderName(name) + let header = groupedHeaders.get(normalizedName) + if (!header) { + header = { name: name as ResponseHeaderName, values: [] } + groupedHeaders.set(normalizedName, header) + } + header.values.push(value) + } + + for (const [name, value] of headers) { + addHeader(name, value) + } + + for (const { name, values } of groupedHeaders.values()) { + setResponseHeader(name, values.length === 1 ? values[0]! : values) + } +} + +export function getResponseHeaders(): TypedHeaders { + const event = getStartEvent() + const currentResponse = event.responseMeta.currentResponse + if (!currentResponse) { + return cloneHeaders( + event.response.headers, + ) as TypedHeaders + } + const protectedHeaders = protectedResponseHeaders.get(currentResponse) + const headers = cloneHeaders(currentResponse.headers) + if (hasHeaderState(event.response.headers, event.responseMeta)) { + applyHeaderState( + headers, + event.response.headers, + event.responseMeta, + protectedHeaders, + ) + } + if (protectedHeaders) { + applyProtectedHeaders(protectedHeaders, headers) + } + return headers as TypedHeaders +} + +export function getResponseHeader( + name: ResponseHeaderName, +): string | undefined { + const event = getStartEvent() + const normalizedName = normalizeHeaderName(name) + const currentResponse = event.responseMeta.currentResponse + const protectedHeaders = currentResponse + ? protectedResponseHeaders.get(currentResponse) + : undefined + if (protectedHeaders?.has(normalizedName)) { + return protectedHeaders.get(normalizedName) ?? undefined + } + + const eventHeaders = event.response.headers + const eventValue = eventHeaders.get(name) + if (eventValue !== null) { + return eventValue + } + + if (event.responseMeta.clearHeaders) { + return undefined + } + + if (event.responseMeta.removedHeaders?.has(normalizedName)) { + return undefined + } + + return currentResponse?.headers.get(name) ?? undefined +} + +export function setResponseHeader( + name: ResponseHeaderName, + value: string | Array, +): void { + const startEvent = getStartEvent() + if (Array.isArray(value)) { + startEvent.response.headers.delete(name) + for (const valueItem of value) { + startEvent.response.headers.append(name, valueItem) + } + } else { + startEvent.response.headers.set(name, value) + } +} +export function removeResponseHeader(name: ResponseHeaderName): void { + const startEvent = getStartEvent() + startEvent.response.headers.delete(name) +} + +export function clearResponseHeaders( + headerNames?: Array, +): void { + const event = getStartEvent() + if (headerNames && headerNames.length > 0) { + for (const name of headerNames) { + event.response.headers.delete(name) + } + return + } + + event.responseMeta.clearHeaders = true + for (const name of Array.from(event.response.headers.keys())) { + event.response.headers.delete(name) + } +} + +export function getResponseStatus(): number { + return getStartEvent().response.status || 200 +} + +export function setResponseStatus(code?: number, text?: string): void { + const event = getStartEvent().response + if (code) { + event.status = sanitizeStatusCode(code, event.status) + } + if (text) { + event.statusText = sanitizeStatusMessage(text) + } +} + +/** + * Parse the request to get HTTP Cookie header string and return an object of all cookie name-value pairs. + * @returns Object of cookie name-value pairs + * ```ts + * const cookies = getCookies() + * ``` + */ +export function getCookies(): Record { + const cookies = parseCookie(getRequestHeaders().get('cookie') || '') + const definedCookies: Record = Object.create(null) + + for (const [name, value] of Object.entries(cookies)) { + if (value !== undefined) { + definedCookies[name] = value + } + } + + return definedCookies +} + +/** + * Get a cookie value by name. + * @param name Name of the cookie to get + * @returns {*} Value of the cookie (String or undefined) + * ```ts + * const authorization = getCookie('Authorization') + * ``` + */ +export function getCookie(name: string): string | undefined { + return getCookies()[name] +} + +/** + * Set a cookie value by name. + * @param name Name of the cookie to set + * @param value Value of the cookie to set + * @param options {CookieSerializeOptions} Options for serializing the cookie + * ```ts + * setCookie('Authorization', '1234567') + * ``` + */ +export function setCookie( + name: string, + value: string, + options?: CookieSerializeOptions, +): void { + const { encode, stringify, ...attrs } = options ?? {} + mergeStartSetCookieValues(getStartEvent(), [ + serializeCookie( + { name, value, path: '/', ...attrs }, + { encode, stringify }, + ), + ]) +} + +/** + * Remove a cookie by name. + * @param name Name of the cookie to delete + * @param serializeOptions {CookieSerializeOptions} Cookie options + * ```ts + * deleteCookie('SessionId') + * ``` + */ +export function deleteCookie( + name: string, + options?: CookieSerializeOptions, +): void { + setCookie(name, '', { + ...options, + maxAge: 0, + }) +} + +function getDefaultSessionConfig(config: SessionConfig): SessionConfig { + return { + name: 'start', + ...config, + } +} + +/** + * Create a session manager for the current request. + */ +export function useSession( + config: SessionConfig, +): Promise> { + const event = getStartEvent() + return withH3SessionResponse(event, (h3Event) => { + return h3_useSession(h3Event, getDefaultSessionConfig(config)) + }).then((manager) => { + const wrappedManager: SessionManager = { + get id() { + return manager.id + }, + get data() { + return manager.data + }, + update: async (update) => { + await withH3SessionResponse(event, () => manager.update(update)) + return wrappedManager + }, + clear: async () => { + await withH3SessionResponse(event, () => manager.clear()) + return wrappedManager + }, + } + return wrappedManager + }) +} +/** + * Get the session for the current request + */ +export function getSession( + config: SessionConfig, +): Promise> { + const event = getStartEvent() + return withH3SessionResponse(event, (h3Event) => { + return h3_getSession(h3Event, getDefaultSessionConfig(config)) + }) +} + +/** + * Update the session data for the current request. + */ +export function updateSession( + config: SessionConfig, + update?: SessionUpdate, +): Promise> { + const event = getStartEvent() + return withH3SessionResponse(event, (h3Event) => { + return h3_updateSession( + h3Event, + getDefaultSessionConfig(config), + update, + ) + }) +} + +/** + * Encrypt and sign the session data for the current request. + */ +export function sealSession(config: SessionConfig): Promise { + const event = getStartEvent() + return withH3SessionResponse(event, (h3Event) => { + return h3_sealSession(h3Event, getDefaultSessionConfig(config)) + }) +} +/** + * Decrypt and verify the session data for the current request. + */ +export function unsealSession( + config: SessionConfig, + sealed: string, +): Promise> { + const event = getStartEvent() + return withH3SessionResponse(event, (h3Event) => { + return h3_unsealSession(h3Event, getDefaultSessionConfig(config), sealed) + }) +} + +/** + * Clear the session data for the current request. + */ +export function clearSession(config: Partial): Promise { + const event = getStartEvent() + return withH3SessionResponse(event, (h3Event) => { + return h3_clearSession(h3Event, { name: 'start', ...config }) + }) +} + +export function getResponse() { + return getStartEvent().response +} diff --git a/packages/start-server-core/src/request-response.ts b/packages/start-server-core/src/request-response.ts index e0ad8a336a..58ef8dd4a0 100644 --- a/packages/start-server-core/src/request-response.ts +++ b/packages/start-server-core/src/request-response.ts @@ -1,428 +1,33 @@ -import { AsyncLocalStorage } from 'node:async_hooks' - -import { - H3Event, - clearSession as h3_clearSession, - deleteCookie as h3_deleteCookie, - getRequestHost as h3_getRequestHost, - getRequestIP as h3_getRequestIP, - getRequestProtocol as h3_getRequestProtocol, - getRequestURL as h3_getRequestURL, - getSession as h3_getSession, - getValidatedQuery as h3_getValidatedQuery, - parseCookies as h3_parseCookies, - sanitizeStatusCode as h3_sanitizeStatusCode, - sanitizeStatusMessage as h3_sanitizeStatusMessage, - sealSession as h3_sealSession, - setCookie as h3_setCookie, - toResponse as h3_toResponse, - unsealSession as h3_unsealSession, - updateSession as h3_updateSession, - useSession as h3_useSession, -} from 'h3-v2' -import type { - RequestHeaderMap, +export { + clearResponseHeaders, + clearSession, + deleteCookie, + getCookie, + getCookies, + getRequest, + getRequestHeader, + getRequestHeaders, + getRequestHost, + getRequestIP, + getRequestProtocol, + getRequestUrl, + getResponseHeader, + getResponseHeaders, + getResponseStatus, + getSession, + handleStartError, + removeResponseHeader, + sealSession, + setCookie, + setResponseHeader, + setResponseHeaders, + setResponseStatus, + unsealSession, + updateSession, + useSession, +} from './internal-request-response' + +export type { RequestHeaderName, - ResponseHeaderMap, ResponseHeaderName, - TypedHeaders, -} from 'fetchdts' - -import type { CookieSerializeOptions } from 'cookie-es' -import type { - Session, - SessionConfig, - SessionData, - SessionManager, - SessionUpdate, -} from './session' -import type { StandardSchemaV1 } from '@standard-schema/spec' -import type { RequestHandler } from './request-handler' - -interface StartEvent { - h3Event: H3Event -} - -// Use a global symbol to ensure the same AsyncLocalStorage instance is shared -// across different bundles that may each bundle this module. -const GLOBAL_EVENT_STORAGE_KEY = Symbol.for('tanstack-start:event-storage') - -const globalObj = globalThis as typeof globalThis & { - [GLOBAL_EVENT_STORAGE_KEY]?: AsyncLocalStorage -} - -if (!globalObj[GLOBAL_EVENT_STORAGE_KEY]) { - globalObj[GLOBAL_EVENT_STORAGE_KEY] = new AsyncLocalStorage() -} - -const eventStorage = globalObj[GLOBAL_EVENT_STORAGE_KEY] - -export type { ResponseHeaderName, RequestHeaderName } - -type HeadersWithGetSetCookie = Headers & { - getSetCookie?: () => Array -} - -type MaybePromise = T | Promise - -function isPromiseLike(value: MaybePromise): value is Promise { - return typeof (value as Promise).then === 'function' -} - -function getSetCookieValues(headers: Headers): Array { - const headersWithSetCookie = headers as HeadersWithGetSetCookie - if (typeof headersWithSetCookie.getSetCookie === 'function') { - return headersWithSetCookie.getSetCookie() - } - const value = headers.get('set-cookie') - return value ? [value] : [] -} - -function mergeEventResponseHeaders(response: Response, event: H3Event): void { - if (response.ok) { - return - } - - const eventSetCookies = getSetCookieValues(event.res.headers) - if (eventSetCookies.length === 0) { - return - } - - const responseSetCookies = getSetCookieValues(response.headers) - response.headers.delete('set-cookie') - for (const cookie of responseSetCookies) { - response.headers.append('set-cookie', cookie) - } - for (const cookie of eventSetCookies) { - response.headers.append('set-cookie', cookie) - } -} - -function attachResponseHeaders( - value: MaybePromise, - event: H3Event, -): MaybePromise { - if (isPromiseLike(value)) { - return value.then((resolved) => { - if (resolved instanceof Response) { - mergeEventResponseHeaders(resolved, event) - } - return resolved - }) - } - - if (value instanceof Response) { - mergeEventResponseHeaders(value, event) - } - - return value -} - -export function requestHandler( - handler: RequestHandler, -) { - return (request: Request, requestOpts: any): Promise | Response => { - let h3Event: H3Event - try { - h3Event = new H3Event(request) - } catch (error) { - if (error instanceof URIError) { - return new Response(null, { - status: 400, - statusText: 'Bad Request', - }) - } - throw error - } - - const response = eventStorage.run({ h3Event }, () => - handler(request, requestOpts), - ) - return h3_toResponse(attachResponseHeaders(response, h3Event), h3Event) - } -} - -function getH3Event() { - const event = eventStorage.getStore() - if (!event) { - throw new Error( - `No StartEvent found in AsyncLocalStorage. Make sure you are using the function within the server runtime.`, - ) - } - return event.h3Event -} - -export function getRequest(): Request { - const event = getH3Event() - return event.req -} - -export function getRequestHeaders(): TypedHeaders { - return getH3Event().req.headers -} - -export function getRequestHeader(name: RequestHeaderName): string | undefined { - return getRequestHeaders().get(name) || undefined -} - -export function getRequestIP(opts?: { - /** - * Use the X-Forwarded-For HTTP header set by proxies. - * - * Note: Make sure that this header can be trusted (your application running behind a CDN or reverse proxy) before enabling. - */ - xForwardedFor?: boolean -}) { - return h3_getRequestIP(getH3Event(), opts) -} - -/** - * Get the request hostname. - * - * If `xForwardedHost` is `true`, it will use the `x-forwarded-host` header if it exists. - * - * If no host header is found, it will default to "localhost". - */ -export function getRequestHost(opts?: { xForwardedHost?: boolean }) { - return h3_getRequestHost(getH3Event(), opts) -} - -/** - * Get the full incoming request URL. - * - * If `xForwardedHost` is `true`, it will use the `x-forwarded-host` header if it exists. - * - * If `xForwardedProto` is `false`, it will not use the `x-forwarded-proto` header. - */ -export function getRequestUrl(opts?: { - xForwardedHost?: boolean - xForwardedProto?: boolean -}) { - return h3_getRequestURL(getH3Event(), opts) -} - -/** - * Get the request protocol. - * - * If `x-forwarded-proto` header is set to "https", it will return "https". You can disable this behavior by setting `xForwardedProto` to `false`. - * - * If protocol cannot be determined, it will default to "http". - */ -export function getRequestProtocol(opts?: { - xForwardedProto?: boolean -}): 'http' | 'https' | (string & {}) { - return h3_getRequestProtocol(getH3Event(), opts) -} - -export function setResponseHeaders( - headers: TypedHeaders, -): void { - const event = getH3Event() - for (const [name, value] of Object.entries(headers)) { - event.res.headers.set(name, value) - } -} - -export function getResponseHeaders(): TypedHeaders { - const event = getH3Event() - return event.res.headers -} - -export function getResponseHeader( - name: ResponseHeaderName, -): string | undefined { - const event = getH3Event() - return event.res.headers.get(name) || undefined -} - -export function setResponseHeader( - name: ResponseHeaderName, - value: string | Array, -): void { - const event = getH3Event() - if (Array.isArray(value)) { - event.res.headers.delete(name) - for (const valueItem of value) { - event.res.headers.append(name, valueItem) - } - } else { - event.res.headers.set(name, value) - } -} -export function removeResponseHeader(name: ResponseHeaderName): void { - const event = getH3Event() - event.res.headers.delete(name) -} - -export function clearResponseHeaders( - headerNames?: Array, -): void { - const event = getH3Event() - // If headerNames is provided, clear only those headers - if (headerNames && headerNames.length > 0) { - for (const name of headerNames) { - event.res.headers.delete(name) - } - // Otherwise, clear all headers - } else { - for (const name of event.res.headers.keys()) { - event.res.headers.delete(name) - } - } -} - -export function getResponseStatus(): number { - return getH3Event().res.status || 200 -} - -export function setResponseStatus(code?: number, text?: string): void { - const event = getH3Event() - if (code) { - event.res.status = h3_sanitizeStatusCode(code, event.res.status) - } - if (text) { - event.res.statusText = h3_sanitizeStatusMessage(text) - } -} - -/** - * Parse the request to get HTTP Cookie header string and return an object of all cookie name-value pairs. - * @returns Object of cookie name-value pairs - * ```ts - * const cookies = getCookies() - * ``` - */ -export function getCookies(): Record { - const event = getH3Event() - const cookies = h3_parseCookies(event) - const definedCookies: Record = Object.create(null) - - for (const [name, value] of Object.entries(cookies)) { - if (value !== undefined) { - definedCookies[name] = value - } - } - - return definedCookies -} - -/** - * Get a cookie value by name. - * @param name Name of the cookie to get - * @returns {*} Value of the cookie (String or undefined) - * ```ts - * const authorization = getCookie('Authorization') - * ``` - */ -export function getCookie(name: string): string | undefined { - return getCookies()[name] -} - -/** - * Set a cookie value by name. - * @param name Name of the cookie to set - * @param value Value of the cookie to set - * @param options {CookieSerializeOptions} Options for serializing the cookie - * ```ts - * setCookie('Authorization', '1234567') - * ``` - */ -export function setCookie( - name: string, - value: string, - options?: CookieSerializeOptions, -): void { - const event = getH3Event() - h3_setCookie(event, name, value, options) -} - -/** - * Remove a cookie by name. - * @param name Name of the cookie to delete - * @param serializeOptions {CookieSerializeOptions} Cookie options - * ```ts - * deleteCookie('SessionId') - * ``` - */ -export function deleteCookie( - name: string, - options?: CookieSerializeOptions, -): void { - const event = getH3Event() - h3_deleteCookie(event, name, options) -} - -function getDefaultSessionConfig(config: SessionConfig): SessionConfig { - return { - name: 'start', - ...config, - } -} - -/** - * Create a session manager for the current request. - */ -export function useSession( - config: SessionConfig, -): Promise> { - const event = getH3Event() - return h3_useSession(event, getDefaultSessionConfig(config)) -} -/** - * Get the session for the current request - */ -export function getSession( - config: SessionConfig, -): Promise> { - const event = getH3Event() - return h3_getSession(event, getDefaultSessionConfig(config)) -} - -/** - * Update the session data for the current request. - */ -export function updateSession( - config: SessionConfig, - update?: SessionUpdate, -): Promise> { - const event = getH3Event() - return h3_updateSession(event, getDefaultSessionConfig(config), update) -} - -/** - * Encrypt and sign the session data for the current request. - */ -export function sealSession(config: SessionConfig): Promise { - const event = getH3Event() - return h3_sealSession(event, getDefaultSessionConfig(config)) -} -/** - * Decrypt and verify the session data for the current request. - */ -export function unsealSession( - config: SessionConfig, - sealed: string, -): Promise> { - const event = getH3Event() - return h3_unsealSession(event, getDefaultSessionConfig(config), sealed) -} - -/** - * Clear the session data for the current request. - */ -export function clearSession(config: Partial): Promise { - const event = getH3Event() - return h3_clearSession(event, { name: 'start', ...config }) -} - -export function getResponse() { - const event = getH3Event() - return event.res -} - -// not public API (yet) -export function getValidatedQuery( - schema: StandardSchemaV1, -): Promise> { - return h3_getValidatedQuery(getH3Event(), schema) -} +} from './internal-request-response' diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts index e7d961decf..7adbc6ba43 100644 --- a/packages/start-server-core/src/server-functions-handler.ts +++ b/packages/start-server-core/src/server-functions-handler.ts @@ -12,7 +12,16 @@ import { safeObjectMerge, } from '@tanstack/start-client-core' import { fromJSON, toCrossJSONAsync, toCrossJSONStream } from 'seroval' -import { getResponse } from './request-response' +import { + canHaveBody, + getErrorHeaders, + getErrorStatus, + getErrorStatusText, + getRequest, + getResponse, + protectResponseHeaders, + setProtectedResponseHeader, +} from './internal-request-response' import { getServerFnById } from './getServerFnById' import { TSS_CONTENT_TYPE_FRAMED_VERSIONED, @@ -21,17 +30,58 @@ import { import type { LateStreamRegistration } from './frame-protocol' import type { Plugin as SerovalPlugin } from 'seroval' -// Cache serovalPlugins at module level to avoid repeated calls -let serovalPlugins: Array> | undefined = undefined - // Known FormData 'Content-Type' header values - module-level constant const FORM_DATA_CONTENT_TYPES = [ 'multipart/form-data', 'application/x-www-form-urlencoded', ] +const PROTECTED_SERIALIZED_HEADERS = ['content-type', X_TSS_SERIALIZED] // Maximum payload size for GET requests (1MB) const MAX_PAYLOAD_SIZE = 1_000_000 +export async function createServerFnErrorResponse( + error: unknown, + serovalPlugins?: Array>, +) { + const request = getRequest() + const response = getResponse() + if (isNotFound(error)) { + return isNotFoundResponse(error) + } + + const errorStatus = getErrorStatus(error) + if (response.status === undefined && errorStatus === undefined) { + console.error(error) + } + const headers = getErrorHeaders(error) ?? new Headers() + headers.set('Content-Type', 'application/json') + headers.set(X_TSS_SERIALIZED, 'true') + const status = response.status ?? errorStatus ?? 500 + const statusText = response.statusText ?? getErrorStatusText(error) + if (!canHaveBody(request.method, status)) { + const errorResponse = new Response(null, { + status, + statusText, + headers, + }) + protectResponseHeaders(errorResponse, PROTECTED_SERIALIZED_HEADERS) + return errorResponse + } + + const serializedError = JSON.stringify( + await toCrossJSONAsync(error, { + refs: new Map(), + plugins: serovalPlugins ?? getDefaultSerovalPlugins(), + }), + ) + const errorResponse = new Response(serializedError, { + status, + statusText, + headers, + }) + protectResponseHeaders(errorResponse, PROTECTED_SERIALIZED_HEADERS) + return errorResponse +} export const handleServerAction = async ({ request, @@ -42,205 +92,256 @@ export const handleServerAction = async ({ context: any serverFnId: string }) => { - const method = request.method - const methodUpper = method.toUpperCase() + const method = request.method.toUpperCase() const url = new URL(request.url) const action = await getServerFnById(serverFnId, { origin: 'client' }) // Early method check: reject mismatched HTTP methods before parsing // the request payload (FormData, JSON, query string, etc.) - if (action.method && methodUpper !== action.method) { - return new Response( - `expected ${action.method} method. Got ${methodUpper}`, - { - status: 405, - headers: { - Allow: action.method, - }, + if (action.method && method !== action.method) { + return new Response(`expected ${action.method} method. Got ${method}`, { + status: 405, + headers: { + Allow: action.method, }, - ) + }) } const isServerFn = request.headers.get('x-tsr-serverFn') === 'true' - // Initialize serovalPlugins lazily (cached at module level) - if (!serovalPlugins) { - serovalPlugins = getDefaultSerovalPlugins() + let serovalPlugins: Array> | undefined + const getRequestSerovalPlugins = () => { + return (serovalPlugins ??= getDefaultSerovalPlugins()) } const contentType = request.headers.get('Content-Type') - function parsePayload(payload: any) { - const parsedPayload = fromJSON(payload, { plugins: serovalPlugins }) - return parsedPayload as any + function parsePayload(payload: any): any { + return fromJSON(payload, { + plugins: getRequestSerovalPlugins(), + }) } - const response = await (async () => { - try { - let res = await (async () => { - // FormData - if ( - FORM_DATA_CONTENT_TYPES.some( - (type) => contentType && contentType.includes(type), + async function executeAction() { + // FormData + if ( + contentType && + FORM_DATA_CONTENT_TYPES.some((type) => contentType.includes(type)) + ) { + // We don't support GET requests with FormData payloads... that seems impossible + if (method === 'GET') { + if (process.env.NODE_ENV !== 'production') { + throw new Error( + 'Invariant failed: GET requests with FormData payloads are not supported', ) - ) { - // We don't support GET requests with FormData payloads... that seems impossible - if (methodUpper === 'GET') { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - 'Invariant failed: GET requests with FormData payloads are not supported', - ) - } + } - invariant() - } - const formData = await request.formData() - const serializedContext = formData.get(TSS_FORMDATA_CONTEXT) - formData.delete(TSS_FORMDATA_CONTEXT) - - const params = { - context, - data: formData, - method: methodUpper, + invariant() + } + const formData = await request.formData() + const serializedContext = formData.get(TSS_FORMDATA_CONTEXT) + formData.delete(TSS_FORMDATA_CONTEXT) + + const params = { + context, + data: formData, + method, + } + if (typeof serializedContext === 'string') { + try { + const parsedContext = JSON.parse(serializedContext) + const deserializedContext = fromJSON(parsedContext, { + plugins: getRequestSerovalPlugins(), + }) + if (typeof deserializedContext === 'object' && deserializedContext) { + params.context = safeObjectMerge( + deserializedContext as Record, + context, + ) } - if (typeof serializedContext === 'string') { - try { - const parsedContext = JSON.parse(serializedContext) - const deserializedContext = fromJSON(parsedContext, { - plugins: serovalPlugins, - }) - if ( - typeof deserializedContext === 'object' && - deserializedContext - ) { - params.context = safeObjectMerge( - deserializedContext as Record, - context, - ) - } - } catch (e) { - // Log warning for debugging but don't expose to client - if (process.env.NODE_ENV === 'development') { - console.warn('Failed to parse FormData context:', e) - } - } + } catch (e) { + // Log warning for debugging but don't expose to client + if (process.env.NODE_ENV === 'development') { + console.warn('Failed to parse FormData context:', e) } - - return await action(params) } + } - // Get requests use the query string - if (methodUpper === 'GET') { - // Get payload directly from searchParams - const payloadParam = url.searchParams.get('payload') - // Reject oversized payloads to prevent DoS - if (payloadParam && payloadParam.length > MAX_PAYLOAD_SIZE) { - throw new Error('Payload too large') - } - // If there's a payload, we should try to parse it - const payload: any = payloadParam - ? parsePayload(JSON.parse(payloadParam)) - : {} - payload.context = safeObjectMerge(payload.context, context) - payload.method = methodUpper - // Send it through! - return await action(payload) - } + return action(params) + } - let jsonPayload - if (contentType?.includes('application/json')) { - jsonPayload = await request.json() - } + // Get requests use the query string + if (method === 'GET') { + // Get payload directly from searchParams + const payloadParam = url.searchParams.get('payload') + // Reject oversized payloads to prevent DoS + if (payloadParam && payloadParam.length > MAX_PAYLOAD_SIZE) { + throw new Error('Payload too large') + } + // If there's a payload, we should try to parse it + const payload: any = payloadParam + ? parsePayload(JSON.parse(payloadParam)) + : {} + payload.context = safeObjectMerge(payload.context, context) + payload.method = method + // Send it through! + return action(payload) + } - const payload = jsonPayload ? parsePayload(jsonPayload) : {} - payload.context = safeObjectMerge(payload.context, context) - payload.method = methodUpper - return await action(payload) - })() + const jsonPayload = contentType?.includes('application/json') + ? await request.json() + : undefined - const unwrapped = res.result || res.error + const payload = jsonPayload ? parsePayload(jsonPayload) : {} + payload.context = safeObjectMerge(payload.context, context) + payload.method = method + return action(payload) + } - if (isNotFound(res)) { - res = isNotFoundResponse(res) - } + try { + let res = await executeAction() + + const unwrapped = res.result || res.error - if (!isServerFn) { + if (isNotFound(res)) { + res = isNotFoundResponse(res) + } + + if (!isServerFn) { + return unwrapped + } + + if (unwrapped instanceof Response) { + if (isRedirect(unwrapped)) { return unwrapped } + return setProtectedResponseHeader(unwrapped, X_TSS_RAW_RESPONSE, 'true') + } - if (unwrapped instanceof Response) { - if (isRedirect(unwrapped)) { - return unwrapped - } - unwrapped.headers.set(X_TSS_RAW_RESPONSE, 'true') - return unwrapped + return serializeResult(res) + + function serializeResult(res: unknown): Response { + const nonStreamingBodies: Array = [] + + const alsResponse = getResponse() + const status = alsResponse.status || 200 + + if (!canHaveBody(request.method, status)) { + const response = new Response(null, { + status, + statusText: alsResponse.statusText, + headers: { + 'Content-Type': 'application/json', + [X_TSS_SERIALIZED]: 'true', + }, + }) + protectResponseHeaders(response, PROTECTED_SERIALIZED_HEADERS) + return response } - return serializeResult(res) - - function serializeResult(res: unknown): Response { - let nonStreamingBody: any = undefined - - const alsResponse = getResponse() - if (res !== undefined) { - // Collect raw streams encountered during initial synchronous serialization - const rawStreams = new Map>() - - // Track whether we're still in the initial synchronous phase - // After initial phase, new RawStreams go to lateStreamWriter - let initialPhase = true - - // Late stream registration for RawStreams discovered after initial pass - // (e.g., from resolved Promises) - let lateStreamWriter: - | WritableStreamDefaultWriter - | undefined - let lateStreamReadable: - | ReadableStream - | undefined = undefined - const pendingLateStreams: Array = [] - - const rawStreamPlugin = createRawStreamRPCPlugin( - (id: number, stream: ReadableStream) => { - if (initialPhase) { - rawStreams.set(id, stream) - return - } + if (res !== undefined) { + // Collect raw streams encountered during initial synchronous serialization + const rawStreams = new Map>() + + // Track whether we're still in the initial synchronous phase + // After initial phase, new RawStreams go to lateStreamWriter + let initialPhase = true + + // Late stream registration for RawStreams discovered after initial pass + // (e.g., from resolved Promises) + let lateStreamWriter: + | WritableStreamDefaultWriter + | undefined + const pendingLateStreams: Array = [] + let lateStreamsClosed = false + + const cancelRawStream = ( + stream: ReadableStream, + reason: unknown, + ) => { + try { + stream.cancel(reason).catch(() => {}) + } catch { + // Ignore locked or already-cancelled streams. + } + } - if (lateStreamWriter) { - // Late stream - write to the late stream channel - lateStreamWriter.write({ id, stream }).catch(() => { - // Ignore write errors - stream may be closed - }) - return - } + const cancelPendingLateStreams = (reason: unknown) => { + for (const registration of pendingLateStreams) { + cancelRawStream(registration.stream, reason) + } + pendingLateStreams.length = 0 + } - // Discovered after initial phase but before writer exists. - pendingLateStreams.push({ id, stream }) - }, - ) + const cancelInitialRawStreams = (reason: unknown) => { + for (const stream of rawStreams.values()) { + cancelRawStream(stream, reason) + } + rawStreams.clear() + } - // Build plugins with RawStreamRPCPlugin first (before default SSR plugin) - const plugins = [rawStreamPlugin, ...(serovalPlugins || [])] - - // first run without the stream in case `result` does not need streaming - let done = false as boolean - const callbacks: { - onParse: (value: any) => void - onDone: () => void - onError: (error: any) => void - } = { - onParse: (value) => { - nonStreamingBody = value - }, - onDone: () => { - done = true - }, - onError: (error) => { - throw error - }, + const writeLateStream = (registration: LateStreamRegistration) => { + if (lateStreamsClosed || !lateStreamWriter) { + cancelRawStream( + registration.stream, + 'Late raw stream channel is closed', + ) + return } + + lateStreamWriter.write(registration).catch(() => { + cancelRawStream( + registration.stream, + 'Late raw stream channel write failed', + ) + }) + } + + const rawStreamPlugin = createRawStreamRPCPlugin( + (id: number, stream: ReadableStream) => { + if (initialPhase) { + rawStreams.set(id, stream) + return + } + + if (lateStreamWriter) { + // Late stream - write to the late stream channel + writeLateStream({ id, stream }) + return + } + + if (lateStreamsClosed) { + cancelRawStream(stream, 'Late raw stream channel is closed') + return + } + + // Discovered after initial phase but before writer exists. + pendingLateStreams.push({ id, stream }) + }, + ) + + // Build plugins with RawStreamRPCPlugin first (before default SSR plugin) + const plugins = [rawStreamPlugin, ...getRequestSerovalPlugins()] + + // first run without the stream in case `result` does not need streaming + let done = false as boolean + const callbacks: { + onParse: (value: any) => void + onDone: () => void + onError: (error: any) => void + } = { + onParse: (value) => { + nonStreamingBodies.push(value) + }, + onDone: () => { + done = true + }, + onError: (error) => { + throw error + }, + } + try { toCrossJSONStream(res, { refs: new Map(), plugins, @@ -254,178 +355,167 @@ export const handleServerAction = async ({ callbacks.onError(error) }, }) + } catch (error) { + cancelInitialRawStreams(error) + cancelPendingLateStreams(error) + throw error + } - // End of initial synchronous phase - any new RawStreams are "late" - initialPhase = false - - // If any RawStreams are discovered after this point but before the - // late-stream writer exists, we buffer them and flush once the writer - // is ready. This avoids an occasional missed-stream race. - - // If no raw streams and done synchronously, return simple JSON - if (done && rawStreams.size === 0) { - return new Response( - nonStreamingBody ? JSON.stringify(nonStreamingBody) : undefined, - { - status: alsResponse.status, - statusText: alsResponse.statusText, - headers: { - 'Content-Type': 'application/json', - [X_TSS_SERIALIZED]: 'true', - }, - }, - ) - } + // End of initial synchronous phase - any new RawStreams are "late" + initialPhase = false - // Not done synchronously or has raw streams - use framed protocol - // This supports late RawStreams from resolved Promises - const { readable, writable } = - new TransformStream() - lateStreamReadable = readable - lateStreamWriter = writable.getWriter() + // If any RawStreams are discovered after this point but before the + // late-stream writer exists, we buffer them and flush once the writer + // is ready. This avoids an occasional missed-stream race. - // Flush any late streams that were discovered in the small window - // between end of initial serialization and writer setup. - for (const registration of pendingLateStreams) { - lateStreamWriter.write(registration).catch(() => { - // Ignore write errors - stream may be closed - }) + // If no raw streams and done synchronously, return simple JSON + if (done && rawStreams.size === 0) { + if (nonStreamingBodies.length > 1) { + throw new Error( + 'Expected Seroval to emit one synchronous root value.', + ) } - pendingLateStreams.length = 0 + lateStreamsClosed = true + cancelPendingLateStreams( + 'Response serialization completed without framed stream', + ) + const response = new Response( + nonStreamingBodies.length === 1 + ? JSON.stringify(nonStreamingBodies[0]) + : undefined, + { + status, + statusText: alsResponse.statusText, + headers: { + 'Content-Type': 'application/json', + [X_TSS_SERIALIZED]: 'true', + }, + }, + ) + protectResponseHeaders(response, PROTECTED_SERIALIZED_HEADERS) + return response + } - // Create a stream of JSON chunks - const jsonStream = new ReadableStream({ - start(controller) { - callbacks.onParse = (value) => { - controller.enqueue(JSON.stringify(value) + '\n') - } - callbacks.onDone = () => { - try { - controller.close() - } catch { - // Already closed - } - // Close late stream writer when JSON serialization is done - // Any RawStreams not yet discovered won't be sent - lateStreamWriter - ?.close() - .catch(() => { - // Ignore close errors - }) - .finally(() => { - lateStreamWriter = undefined - }) - } + // Not done synchronously or has raw streams - use framed protocol + // This supports late RawStreams from resolved Promises + const { readable: lateStreamReadable, writable } = + new TransformStream() + lateStreamWriter = writable.getWriter() - callbacks.onError = (error) => { - controller.error(error) - lateStreamWriter - ?.abort(error) - .catch(() => { - // Ignore abort errors - }) - .finally(() => { - lateStreamWriter = undefined - }) - } + // Flush any late streams that were discovered in the small window + // between end of initial serialization and writer setup. + for (const registration of pendingLateStreams) { + writeLateStream(registration) + } + pendingLateStreams.length = 0 - // Emit initial body if we have one - if (nonStreamingBody !== undefined) { - callbacks.onParse(nonStreamingBody) - } - // If serialization already completed synchronously, close now - // This handles the case where onDone was called during toCrossJSONStream - // before we overwrote callbacks.onDone - if (done) { - callbacks.onDone() + // Create a stream of JSON chunks + const jsonStream = new ReadableStream({ + start(controller) { + callbacks.onParse = (value) => { + controller.enqueue(JSON.stringify(value) + '\n') + } + callbacks.onDone = () => { + try { + controller.close() + } catch { + // Already closed } - }, - cancel() { - lateStreamWriter?.abort().catch(() => {}) - lateStreamWriter = undefined - }, - }) + // Close late stream writer when JSON serialization is done + // Any RawStreams not yet discovered won't be sent + lateStreamsClosed = true + cancelPendingLateStreams('Response serialization completed') + lateStreamWriter + ?.close() + .catch(() => { + // Ignore close errors + }) + .finally(() => { + lateStreamWriter = undefined + }) + } - // Create multiplexed stream with JSON, initial raw streams, and late streams - const multiplexedStream = createMultiplexedStream( - jsonStream, - rawStreams, - lateStreamReadable, - ) + callbacks.onError = (error) => { + controller.error(error) + lateStreamsClosed = true + cancelPendingLateStreams(error) + lateStreamWriter + ?.abort(error) + .catch(() => { + // Ignore abort errors + }) + .finally(() => { + lateStreamWriter = undefined + }) + } - return new Response(multiplexedStream, { - status: alsResponse.status, - statusText: alsResponse.statusText, - headers: { - 'Content-Type': TSS_CONTENT_TYPE_FRAMED_VERSIONED, - [X_TSS_SERIALIZED]: 'true', - }, - }) - } + // Emit initial body if we have one + for (const nonStreamingBody of nonStreamingBodies) { + callbacks.onParse(nonStreamingBody) + } + // If serialization already completed synchronously, close now + // This handles the case where onDone was called during toCrossJSONStream + // before we overwrote callbacks.onDone + if (done) { + callbacks.onDone() + } + }, + cancel() { + lateStreamsClosed = true + cancelPendingLateStreams('Response stream cancelled') + lateStreamWriter?.abort().catch(() => {}) + lateStreamWriter = undefined + }, + }) - return new Response(undefined, { - status: alsResponse.status, + // Create multiplexed stream with JSON, initial raw streams, and late streams + const multiplexedStream = createMultiplexedStream( + jsonStream, + rawStreams, + lateStreamReadable, + ) + + const response = new Response(multiplexedStream, { + status, statusText: alsResponse.statusText, + headers: { + 'Content-Type': TSS_CONTENT_TYPE_FRAMED_VERSIONED, + [X_TSS_SERIALIZED]: 'true', + }, }) - } - } catch (error: any) { - if (error instanceof Response) { - return error - } - // else if ( - // isPlainObject(error) && - // 'result' in error && - // error.result instanceof Response - // ) { - // return error.result - // } - - // Currently this server-side context has no idea how to - // build final URLs, so we need to defer that to the client. - // The client will check for __redirect and __notFound keys, - // and if they exist, it will handle them appropriately. - - if (isNotFound(error)) { - return isNotFoundResponse(error) + protectResponseHeaders(response, PROTECTED_SERIALIZED_HEADERS) + return response } - console.info() - console.info('Server Fn Error!') - console.info() - console.error(error) - console.info() - - const serializedError = JSON.stringify( - await Promise.resolve( - toCrossJSONAsync(error, { - refs: new Map(), - plugins: serovalPlugins, - }), - ), - ) - const response = getResponse() - return new Response(serializedError, { - status: response.status ?? 500, - statusText: response.statusText, - headers: { - 'Content-Type': 'application/json', - [X_TSS_SERIALIZED]: 'true', - }, + const response = new Response(undefined, { + status, + statusText: alsResponse.statusText, }) + return response + } + } catch (error: any) { + if (error instanceof Response) { + return error } - })() - return response + // Currently this server-side context has no idea how to + // build final URLs, so we need to defer that to the client. + // The client will check for __redirect and __notFound keys, + // and if they exist, it will handle them appropriately. + + return createServerFnErrorResponse(error, serovalPlugins) + } } function isNotFoundResponse(error: any) { const { headers, ...rest } = error + const responseHeaders = new Headers(headers || {}) + responseHeaders.set('Content-Type', 'application/json') - return new Response(JSON.stringify(rest), { + const response = new Response(JSON.stringify(rest), { status: 404, - headers: { - 'Content-Type': 'application/json', - ...(headers || {}), - }, + headers: responseHeaders, }) + protectResponseHeaders(response, PROTECTED_SERIALIZED_HEADERS) + return response } diff --git a/packages/start-server-core/tests/createStartHandler.test.ts b/packages/start-server-core/tests/createStartHandler.test.ts index 5b7c7d3a7a..f3ba9a7d8f 100644 --- a/packages/start-server-core/tests/createStartHandler.test.ts +++ b/packages/start-server-core/tests/createStartHandler.test.ts @@ -2,6 +2,7 @@ import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { createMiddleware } from '@tanstack/start-client-core' import { BaseRootRoute, BaseRoute, RouterCore } from '@tanstack/router-core' +import { splitSetCookieString } from 'cookie-es' import { createNonReactiveMutableStore, createNonReactiveReadonlyStore, @@ -11,6 +12,27 @@ import { createSsrStreamResponse, } from '@tanstack/router-core/ssr/server' import { createStartHandler } from '../src/createStartHandler' +import { + clearResponseHeaders, + getCookie, + getCookies, + getRequestHost, + getRequestIP, + getRequestProtocol, + getRequestUrl, + getResponse, + getResponseHeader, + getResponseHeaders, + handleStartError, + protectResponseHeaders, + reconcileResponse, + requestHandler, + setCookie, + setResponseHeader, + setResponseStatus, + updateSession, + useSession, +} from '../src/internal-request-response' import { getStaticHandlerInlineCssDefault, resolveInlineCssForRequest, @@ -23,6 +45,7 @@ const startMocks = vi.hoisted(() => { previousServerFnBase, requestMiddleware: [] as Array, serverFnResult: undefined as undefined | Response | object, + serverFnCalls: [] as Array<{ context?: unknown }>, router: undefined as undefined | ReturnType, } }) @@ -41,7 +64,21 @@ vi.mock('#tanstack-router-entry', () => ({ })) vi.mock('../src/server-functions-handler', () => ({ - handleServerAction: () => startMocks.serverFnResult, + createServerFnErrorResponse: () => { + return new Response(JSON.stringify({ message: 'middleware failed' }), { + status: 500, + headers: { + 'content-type': 'application/json', + 'x-tss-serialized': 'true', + }, + }) + }, + handleServerAction: (opts: { context?: unknown }) => { + startMocks.serverFnCalls.push({ + context: opts.context, + }) + return startMocks.serverFnResult + }, })) const getStoreConfig = () => ({ @@ -81,9 +118,46 @@ function makeStreamResponse(router: ReturnType) { return createSsrStreamResponse(router as any, new Response(stream)) } +function getSetCookieValues(headers: Headers): Array { + const headersWithSetCookie = headers as Headers & { + getSetCookie?: () => Array + } + if (typeof headersWithSetCookie.getSetCookie === 'function') { + return headersWithSetCookie.getSetCookie() + } + const value = headers.get('set-cookie') + if (value) { + return splitSetCookieString(value) + } + return [] +} + +async function handleThrownStartError( + fn: () => Promise | Response, +): Promise { + try { + await fn() + } catch (error) { + return handleStartError(error) + } + + throw new Error('Expected request handler to throw') +} + +function expectSetCookie( + cookies: Array, + name: string, + value: string, +): void { + expect( + cookies.filter((cookie) => cookie.startsWith(`${name}=${value};`)), + ).toHaveLength(1) +} + afterEach(() => { startMocks.requestMiddleware = [] startMocks.serverFnResult = undefined + startMocks.serverFnCalls = [] startMocks.router = undefined vi.unstubAllEnvs() }) @@ -96,6 +170,584 @@ afterAll(() => { } }) +describe('requestHandler response reconciliation', () => { + it('preserves returned response cookies when getSetCookie is unavailable', async () => { + const handler = requestHandler(() => { + setCookie('helper', '1', { path: '/' }) + const headers = new Headers() + headers.append('set-cookie', 'returned-one=1; Path=/') + headers.append('set-cookie', 'returned-two=2; Path=/') + const response = new Response('ok', { headers }) + const responseHeaders = response.headers as unknown as { + getSetCookie?: unknown + } + responseHeaders.getSetCookie = undefined + return response + }) + + const response = await handler(new Request('http://localhost/'), {}) + const cookies = getSetCookieValues(response.headers) + + expectSetCookie(cookies, 'returned-one', '1') + expectSetCookie(cookies, 'returned-two', '2') + expectSetCookie(cookies, 'helper', '1') + }) + + it('does not dedupe absent-path cookies against explicit-path cookies', async () => { + const handler = requestHandler(() => { + setCookie('same', 'helper', { path: '/' }) + return new Response('ok', { + headers: { 'set-cookie': 'same=returned' }, + }) + }) + + const response = await handler(new Request('http://localhost/nested'), {}) + const cookies = getSetCookieValues(response.headers) + + expect(cookies.filter((cookie) => cookie.startsWith('same='))).toEqual([ + 'same=returned', + 'same=helper; Path=/', + ]) + }) + + it('merges session cookies with helper cookies', async () => { + const handler = requestHandler(async () => { + setCookie('helper', '1', { path: '/' }) + const session = await useSession({ password: 'x'.repeat(32) }) + await session.update({ user: 'tanner' }) + return new Response('ok') + }) + + const response = await handler(new Request('http://localhost/'), {}) + const cookies = getSetCookieValues(response.headers) + + expectSetCookie(cookies, 'helper', '1') + expect(cookies.some((cookie) => cookie.startsWith('start='))).toBe(true) + }) + + it('serializes concurrent session updates through one response bridge', async () => { + const handler = requestHandler(async () => { + const password = 'x'.repeat(32) + const first = updateSession<{ count: number }>({ password }, (data) => ({ + count: (data.count ?? 0) + 1, + })) + const second = updateSession<{ count: number }>({ password }, (data) => ({ + count: (data.count ?? 0) + 1, + })) + const [, secondSession] = await Promise.all([first, second]) + + expect(secondSession.data.count).toBe(2) + return new Response('ok') + }) + + const response = await handler(new Request('http://localhost/'), {}) + const cookies = getSetCookieValues(response.headers) + + expect(cookies.some((cookie) => cookie.startsWith('start='))).toBe(true) + }) + + it('releases queued session operations after update failures', async () => { + const handler = requestHandler(async () => { + const password = 'x'.repeat(32) + const failed = updateSession({ password }, () => { + throw new Error('session failed') + }).catch((error) => error) + const next = updateSession<{ ok: boolean }>( + { name: 'next', password }, + { ok: true }, + ) + const [error, nextSession] = await Promise.all([failed, next]) + + expect(error).toBeInstanceOf(Error) + expect(nextSession.data.ok).toBe(true) + return new Response('ok') + }) + + const response = await handler(new Request('http://localhost/'), {}) + const cookies = getSetCookieValues(response.headers) + + expect(cookies.some((cookie) => cookie.startsWith('next='))).toBe(true) + }) + + it('restores protected headers after direct response mutation', async () => { + const handler = requestHandler(() => { + const response = new Response('ok', { + headers: { + 'content-type': 'application/json', + 'x-tss-serialized': 'true', + }, + }) + protectResponseHeaders(response, ['content-type', 'x-tss-serialized']) + response.headers.set('content-type', 'text/plain') + response.headers.set('x-tss-serialized', 'false') + return response + }) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(response.headers.get('content-type')).toBe('application/json') + expect(response.headers.get('x-tss-serialized')).toBe('true') + }) + + it('merges repeated protected header snapshots', async () => { + const handler = requestHandler(() => { + const response = new Response('ok', { + headers: { + 'content-type': 'application/json', + 'x-tss-raw': 'true', + }, + }) + protectResponseHeaders(response, ['content-type']) + protectResponseHeaders(response, ['x-tss-raw']) + response.headers.set('content-type', 'text/plain') + response.headers.set('x-tss-raw', 'false') + return response + }) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(response.headers.get('content-type')).toBe('application/json') + expect(response.headers.get('x-tss-raw')).toBe('true') + }) + + it('rejects protecting Set-Cookie snapshots', () => { + expect(() => { + protectResponseHeaders(new Response('ok'), ['set-cookie']) + }).toThrow('Set-Cookie headers cannot be protected.') + }) + + it('reads protected header snapshots after direct response mutation', async () => { + let seenHeader: string | undefined + let seenHeadersHeader: string | null | undefined + const handler = requestHandler(() => { + const response = new Response('ok', { + headers: { 'x-transport': 'original' }, + }) + protectResponseHeaders(response, ['x-transport']) + const reconciled = reconcileResponse(response) + reconciled.headers.set('x-transport', 'mutated') + seenHeader = getResponseHeader('x-transport') + seenHeadersHeader = getResponseHeaders().get('x-transport') + return reconciled + }) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(seenHeader).toBe('original') + expect(seenHeadersHeader).toBe('original') + expect(response.headers.get('x-transport')).toBe('original') + }) + + it('returns getResponseHeaders as a read-only snapshot after reconciliation', async () => { + const handler = requestHandler(() => { + const response = reconcileResponse( + new Response('ok', { + headers: { + 'x-keep': 'yes', + 'x-remove': 'remove-me', + }, + }), + ) + const headers = getResponseHeaders() + expect(headers.get('x-remove')).toBe('remove-me') + headers.set('x-added', 'yes') + headers.delete('x-remove') + return response + }) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(response.headers.get('x-keep')).toBe('yes') + expect(response.headers.get('x-added')).toBe(null) + expect(response.headers.get('x-remove')).toBe('remove-me') + }) + + it('preserves empty string response header values', async () => { + let seenHeader: string | undefined + const handler = requestHandler(() => { + setResponseHeader('x-empty', '') + seenHeader = getResponseHeader('x-empty') + return new Response('ok') + }) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(seenHeader).toBe('') + expect(response.headers.get('x-empty')).toBe('') + }) + + it('keeps helper headers set after clearing response headers', async () => { + const handler = requestHandler(() => { + clearResponseHeaders() + setResponseHeader('x-after-clear', 'yes') + return new Response('ok', { + headers: { + 'x-returned': 'removed', + }, + }) + }) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(response.headers.get('x-returned')).toBe(null) + expect(response.headers.get('x-after-clear')).toBe('yes') + }) + + it('replaces Set-Cookie arrays and keeps later semantic cookie updates', async () => { + const handler = requestHandler(() => { + setResponseHeader('set-cookie', ['array=1; Path=/', 'same=first; Path=/']) + setCookie('same', 'second', { path: '/' }) + return new Response('ok', { + headers: { + 'set-cookie': 'returned=1; Path=/', + }, + }) + }) + + const response = await handler(new Request('http://localhost/'), {}) + const cookies = getSetCookieValues(response.headers) + + expect(cookies).toEqual(['array=1; Path=/', 'same=second; Path=/']) + }) + + it('returns the same response after repeated clean reconciliation', async () => { + let firstResponse: Response | undefined + let secondResponse: Response | undefined + const handler = requestHandler(() => { + const response = new Response('ok') + firstResponse = reconcileResponse(response) + secondResponse = reconcileResponse(firstResponse) + return secondResponse + }) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(secondResponse).toBe(firstResponse) + expect(response).toBe(firstResponse) + }) + + it('applies helper mutations after the same response was reconciled', async () => { + let reconciledResponse: Response | undefined + const handler = requestHandler(() => { + const response = new Response('ok') + reconciledResponse = reconcileResponse(response) + setResponseHeader('x-late-helper', 'true') + return reconciledResponse + }) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(response).toBe(reconciledResponse) + expect(response.headers.get('x-late-helper')).toBe('true') + }) + + it('reapplies helper headers after direct mutation of a reconciled response', async () => { + const handler = requestHandler(() => { + setResponseHeader('x-helper', 'true') + const response = reconcileResponse(new Response('ok')) + response.headers.delete('x-helper') + return response + }) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(response.headers.get('x-helper')).toBe('true') + }) + + it('applies earlier helper state to later replacement responses', async () => { + let firstResponse: Response | undefined + const handler = requestHandler(() => { + setResponseHeader('x-helper', 'true') + firstResponse = reconcileResponse(new Response('first')) + return new Response('second') + }) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(response).not.toBe(firstResponse) + expect(response.headers.get('x-helper')).toBe('true') + await expect(response.text()).resolves.toBe('second') + }) + + it('checks protected headers after the same response was reconciled', async () => { + let firstResponse: Response | undefined + let secondResponse: Response | undefined + const handler = requestHandler(() => { + const response = new Response('ok', { + headers: { 'x-transport': 'original' }, + }) + protectResponseHeaders(response, ['x-transport']) + firstResponse = reconcileResponse(response) + firstResponse.headers.set('x-transport', 'mutated') + secondResponse = reconcileResponse(firstResponse) + return secondResponse + }) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(secondResponse).toBe(firstResponse) + expect(response.headers.get('x-transport')).toBe('original') + }) + + it('tracks direct Start response status assignment', async () => { + const handler = requestHandler(() => { + const response = getResponse() + response.status = 201 + response.statusText = 'Created' + return new Response('ok') + }) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(response.status).toBe(201) + expect(response.statusText).toBe('Created') + }) + + it('sanitizes direct response statusText assignment', async () => { + const handler = requestHandler(() => { + const response = getResponse() + response.status = 418 + response.statusText = 'Bad\nTeapot' + return new Response('ok') + }) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(response.status).toBe(418) + expect(response.statusText).toBe('BadTeapot') + }) + + it('returns 400 for malformed URLs that throw TypeError', async () => { + const handler = requestHandler(() => new Response('unused')) + const response = await handler({ url: 'http://%' } as Request, {}) + + expect(response.status).toBe(400) + expect(response.statusText).toBe('Bad Request') + }) + + it('parses request helpers from forwarded headers and cookies', async () => { + const handler = requestHandler(() => { + return Response.json({ + host: getRequestHost(), + forwardedHost: getRequestHost({ xForwardedHost: true }), + protocol: getRequestProtocol(), + originalProtocol: getRequestProtocol({ xForwardedProto: false }), + url: getRequestUrl({ xForwardedHost: true }).toString(), + ip: getRequestIP() ?? null, + forwardedIp: getRequestIP({ xForwardedFor: true }), + cookies: getCookies(), + encodedCookie: getCookie('encoded'), + }) + }) + + const response = await handler( + new Request('http://internal.local:3000/path?x=1', { + headers: { + cookie: 'plain=value; encoded=hello%20world', + host: 'origin.example:8080', + 'x-forwarded-for': '203.0.113.10, 10.0.0.1', + 'x-forwarded-host': 'public.example, proxy.example', + 'x-forwarded-proto': 'https, http', + }, + }), + {}, + ) + + await expect(response.json()).resolves.toEqual({ + host: 'origin.example:8080', + forwardedHost: 'public.example', + protocol: 'https', + originalProtocol: 'http', + url: 'https://public.example/path?x=1', + ip: null, + forwardedIp: '203.0.113.10', + cookies: { + plain: 'value', + encoded: 'hello world', + }, + encodedCookie: 'hello world', + }) + }) + + it('preserves HTTP-style error status, statusText, and headers', async () => { + const handler = requestHandler(() => { + const error = new Error('handled') as Error & { + status: number + statusText: string + headers: HeadersInit + } + error.status = 418 + error.statusText = 'Teapot' + error.headers = { 'x-error': 'handled' } + throw error + }) + + const response = await handleThrownStartError(() => + handler(new Request('http://localhost/'), {}), + ) + + expect(response.status).toBe(418) + expect(response.statusText).toBe('Teapot') + expect(response.headers.get('x-error')).toBe('handled') + expect(response.headers.get('content-type')).toBe('application/json') + }) + + it('converts errors through handleStartError', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const handler = requestHandler(() => { + throw new Error('handled by default') + }) + + try { + const response = await handleThrownStartError(() => + handler(new Request('http://localhost/'), {}), + ) + + expect(response.status).toBe(500) + expect(response.headers.get('content-type')).toBe('application/json') + expect(consoleError).toHaveBeenCalledOnce() + } finally { + consoleError.mockRestore() + } + }) + + it('preserves HTTP-style error headers from cause', async () => { + const handler = requestHandler(() => { + throw new Error('wrapped', { + cause: { + status: 409, + headers: { 'x-error-cause': 'handled' }, + }, + }) + }) + + const response = await handleThrownStartError(() => + handler(new Request('http://localhost/'), {}), + ) + + expect(response.status).toBe(409) + expect(response.headers.get('x-error-cause')).toBe('handled') + }) + + it('keeps helper status over HTTP-style error status', async () => { + const handler = requestHandler(() => { + setResponseStatus(401, 'Unauthorized') + const error = new Error('handled') as Error & { status: number } + error.status = 418 + throw error + }) + + const response = await handleThrownStartError(() => + handler(new Request('http://localhost/'), {}), + ) + + expect(response.status).toBe(401) + expect(response.statusText).toBe('Unauthorized') + }) + + it('rethrows primitive errors', async () => { + const handler = requestHandler(() => { + return Promise.reject('primitive failure') + }) + + await expect(handler(new Request('http://localhost/'), {})).rejects.toBe( + 'primitive failure', + ) + }) + + it('recovers Start error state outside the active event', async () => { + const error = Object.assign(new Error('outside'), { + status: 409, + headers: { 'x-error': 'yes' }, + }) + const handler = requestHandler(() => { + setResponseHeader('x-helper', 'yes') + throw error + }) + + let caught: unknown + try { + await handler(new Request('http://localhost/'), {}) + } catch (error) { + caught = error + } + + const response = handleStartError(caught) + + expect(response.status).toBe(409) + expect(response.headers.get('x-error')).toBe('yes') + expect(response.headers.get('x-helper')).toBe('yes') + }) + + it('returns response errors verbatim outside the active event', () => { + const response = new Response('handled', { status: 418 }) + + expect(handleStartError(response)).toBe(response) + }) + + it('returns a generic response for primitive errors outside the active event', () => { + const response = handleStartError('primitive failure') + + expect(response.status).toBe(500) + expect(response.headers.get('content-type')).toBe('application/json') + }) + + it('cancels returned response bodies when reconciliation drops them', async () => { + let cancelledReason: unknown + let resolveCancel!: () => void + const cancelled = new Promise((resolve) => { + resolveCancel = resolve + }) + const handler = requestHandler(() => { + setResponseStatus(204) + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1])) + }, + cancel(reason) { + cancelledReason = reason + resolveCancel() + }, + }), + ) + }) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(response.status).toBe(204) + expect(response.body).toBe(null) + await expect(cancelled).resolves.toBeUndefined() + expect(cancelledReason).toBe( + 'Response body dropped by Start reconciliation', + ) + }) + + it('falls back from informational statuses that Fetch responses cannot use', async () => { + const handler = requestHandler(() => { + setResponseStatus(101) + return new Response('ok') + }) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(response.status).toBe(200) + }) + + it('sanitizes direct response status assignment', async () => { + const handler = requestHandler(() => { + const response = getResponse() + response.status = 418 + response.status = 700 + return new Response('ok') + }) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(response.status).toBe(418) + }) +}) + describe('createStartHandler SSR cleanup ownership', () => { it('preserves serverFn stream cleanup ownership through early return', async () => { startMocks.requestMiddleware = [] @@ -209,6 +861,34 @@ describe('createStartHandler SSR cleanup ownership', () => { expect(router.serverSsr).toBeUndefined() }) + it('does not duplicate helper cookies across repeated reconciliation', async () => { + startMocks.serverFnResult = new Response('ok') + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + setCookie('first', '1', { path: '/' }) + const result = await next() + setCookie('second', '2', { path: '/' }) + return result + }), + ] + + const handler = createStartHandler(() => new Response('unused')) + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + const cookies = getSetCookieValues(response.headers) + + expect( + cookies.filter((cookie) => cookie.startsWith('first=1;')), + ).toHaveLength(1) + expect( + cookies.filter((cookie) => cookie.startsWith('second=2;')), + ).toHaveLength(1) + }) + it('preserves stream ownership when middleware wraps same body', async () => { const router = makeRouter() startMocks.router = router @@ -243,7 +923,35 @@ describe('createStartHandler SSR cleanup ownership', () => { expect(router.serverSsr).toBeUndefined() }) - it('disposes stream response on middleware error after next', async () => { + it('disposes stream response when later reconciliation drops the body', async () => { + const router = makeRouter() + startMocks.router = router + const ssrResponse = makeStreamResponse(router) + startMocks.serverFnResult = ssrResponse + const dispose = vi.spyOn(ssrResponse as any, 'dispose') + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + const result = await next() + setResponseStatus(204) + return result + }), + ] + + const handler = createStartHandler(() => new Response('unused')) + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + expect(response.status).toBe(204) + expect(response.body).toBe(null) + expect(dispose).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + }) + + it('converts middleware errors by default', async () => { const router = makeRouter() startMocks.router = router const ssrResponse = makeStreamResponse(router) @@ -265,10 +973,33 @@ describe('createStartHandler SSR cleanup ownership', () => { ) expect(response.status).toBe(500) + expect(response.headers.get('x-tss-serialized')).toBe('true') expect(dispose).toHaveBeenCalledOnce() expect(router.serverSsr).toBeUndefined() }) + it('passes request middleware context to server functions', async () => { + startMocks.serverFnResult = new Response('ok') + startMocks.requestMiddleware = [ + createMiddleware().server(({ next }) => { + return next({ context: { middleware: 'yes' } }) + }), + ] + + const handler = createStartHandler(() => new Response('unused')) + await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + expect(startMocks.serverFnCalls).toHaveLength(1) + expect(startMocks.serverFnCalls[0]?.context).toMatchObject({ + middleware: 'yes', + }) + }) + it('disposes stream response replaced by thrown response', async () => { const router = makeRouter() startMocks.router = router diff --git a/packages/start-server-core/tests/server-functions-handler.test.ts b/packages/start-server-core/tests/server-functions-handler.test.ts new file mode 100644 index 0000000000..f4e572b1ad --- /dev/null +++ b/packages/start-server-core/tests/server-functions-handler.test.ts @@ -0,0 +1,273 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RawStream } from '@tanstack/router-core' +import { TSS_FORMDATA_CONTEXT } from '@tanstack/start-client-core' +import { runWithStartContext } from '@tanstack/start-storage-context' +import { handleServerAction } from '../src/server-functions-handler' +import { + FRAME_HEADER_SIZE, + FrameType, + TSS_CONTENT_TYPE_FRAMED_VERSIONED, +} from '../src/frame-protocol' +import { + getResponse, + requestHandler, + setResponseHeader, + setResponseStatus, +} from '../src/internal-request-response' + +const serverFnMocks = vi.hoisted(() => ({ + action: undefined as + | undefined + | (ReturnType & { method: string }), +})) + +vi.mock('../src/getServerFnById', () => ({ + getServerFnById: () => serverFnMocks.action, +})) + +function createServerFunctionRequest( + input = 'http://localhost/_serverFn/test', + init?: RequestInit, +) { + const headers = new Headers(init?.headers) + headers.set('x-tsr-serverFn', 'true') + return new Request(input, { ...init, headers }) +} + +function createAction(method = 'GET') { + return Object.assign(vi.fn(), { method }) +} + +function createHandler() { + return requestHandler((request) => + runWithStartContext( + { + getRouter: async () => undefined as any, + request, + startOptions: { serializationAdapters: [] }, + contextAfterGlobalMiddlewares: {}, + executedRequestMiddlewares: new Set(), + handlerType: 'serverFn', + }, + () => + handleServerAction({ + request, + context: {}, + serverFnId: 'test', + }), + ), + ) +} + +async function readFrames(response: Response) { + const reader = response.body!.getReader() + const frames: Array<{ + type: FrameType + streamId: number + payload: Uint8Array + }> = [] + + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + const view = new DataView(value.buffer, value.byteOffset) + const length = view.getUint32(5, false) + frames.push({ + type: view.getUint8(0) as FrameType, + streamId: view.getUint32(1, false), + payload: value.slice(FRAME_HEADER_SIZE, FRAME_HEADER_SIZE + length), + }) + } + + return frames +} + +afterEach(() => { + serverFnMocks.action = undefined +}) + +describe('handleServerAction error handling', () => { + it('preserves HTTP-style action error metadata', async () => { + const action = createAction() + const error = Object.assign(new Error('conflict'), { + status: 409, + statusText: 'Conflict', + headers: { 'x-error': 'yes' }, + }) + action.mockImplementation(() => { + throw error + }) + serverFnMocks.action = action + const handler = createHandler() + + const response = await handler(createServerFunctionRequest(), {}) + + expect(response.status).toBe(409) + expect(response.statusText).toBe('Conflict') + expect(response.headers.get('x-error')).toBe('yes') + expect(response.headers.get('content-type')).toBe('application/json') + expect(response.headers.get('x-tss-serialized')).toBe('true') + }) + + it('applies helper headers to serialized action errors', async () => { + const action = createAction() + action.mockImplementation(() => { + const response = getResponse() + response.status = 500 + setResponseHeader('x-error-header', 'yes') + throw new Error('server function failed') + }) + serverFnMocks.action = action + const handler = createHandler() + + const response = await handler(createServerFunctionRequest(), {}) + + expect(response.status).toBe(500) + expect(response.headers.get('x-error-header')).toBe('yes') + expect(response.headers.get('x-tss-serialized')).toBe('true') + }) + + it('protects not-found transport headers from helper overrides', async () => { + const action = createAction() + action.mockImplementation(() => { + setResponseHeader('content-type', 'text/plain') + setResponseHeader('x-tss-serialized', 'true') + throw { isNotFound: true, data: 'missing' } + }) + serverFnMocks.action = action + const handler = createHandler() + + const response = await handler(createServerFunctionRequest(), {}) + + expect(response.status).toBe(404) + expect(response.headers.get('content-type')).toBe('application/json') + expect(response.headers.get('x-tss-serialized')).toBe(null) + await expect(response.json()).resolves.toEqual({ + isNotFound: true, + data: 'missing', + }) + }) + + it.each([204, 205, 304])( + 'drops serialized success body for %s responses', + async (status) => { + const action = createAction() + action.mockImplementation(() => { + setResponseStatus(status) + return { result: { ok: true } } + }) + serverFnMocks.action = action + const handler = createHandler() + + const response = await handler(createServerFunctionRequest(), {}) + + expect(response.status).toBe(status) + expect(response.body).toBe(null) + expect(await response.text()).toBe('') + }, + ) + + it('converts oversized GET payload errors', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const action = createAction() + serverFnMocks.action = action + const handler = createHandler() + + try { + const response = await handler( + createServerFunctionRequest( + `http://localhost/_serverFn/test?payload=${'x'.repeat(1_000_001)}`, + ), + {}, + ) + + expect(response.status).toBe(500) + expect(response.headers.get('x-tss-serialized')).toBe('true') + expect(action).not.toHaveBeenCalled() + } finally { + consoleError.mockRestore() + } + }) + + it('falls back to default context for malformed FormData context', async () => { + const action = createAction('POST') + action.mockImplementation((payload) => { + return { result: { context: payload.context, method: payload.method } } + }) + serverFnMocks.action = action + const handler = createHandler() + const formData = new FormData() + formData.set(TSS_FORMDATA_CONTEXT, '{not json') + formData.set('field', 'value') + + const response = await handler( + createServerFunctionRequest('http://localhost/_serverFn/test', { + method: 'POST', + body: formData, + }), + {}, + ) + + expect(response.status).toBe(200) + expect(action).toHaveBeenCalledOnce() + expect(action.mock.calls[0]?.[0].context).toEqual({}) + }) + + it('streams RawStreams discovered after initial serialization', async () => { + const action = createAction() + action.mockImplementation(() => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('late')) + controller.close() + }, + }) + return { + result: { + stream: Promise.resolve(new RawStream(stream)), + }, + } + }) + serverFnMocks.action = action + const handler = createHandler() + + const response = await handler(createServerFunctionRequest(), {}) + const frames = await readFrames(response) + const chunkFrame = frames.find((frame) => frame.type === FrameType.CHUNK) + + expect(response.headers.get('content-type')).toBe( + TSS_CONTENT_TYPE_FRAMED_VERSIONED, + ) + expect(frames.some((frame) => frame.type === FrameType.JSON)).toBe(true) + expect(chunkFrame).toBeDefined() + expect(new TextDecoder().decode(chunkFrame?.payload)).toBe('late') + expect( + frames.some( + (frame) => + frame.type === FrameType.END && + frame.streamId === chunkFrame?.streamId, + ), + ).toBe(true) + }) + + it.each([204, 205, 304])( + 'drops serialized error body for %s responses', + async (status) => { + const action = createAction() + action.mockImplementation(() => { + setResponseStatus(status) + throw new Error('no body') + }) + serverFnMocks.action = action + const handler = createHandler() + + const response = await handler(createServerFunctionRequest(), {}) + + expect(response.status).toBe(status) + expect(response.body).toBe(null) + expect(await response.text()).toBe('') + }, + ) +}) diff --git a/packages/start-server-core/vite.config.ts b/packages/start-server-core/vite.config.ts index aaece761fb..223f0d8f74 100644 --- a/packages/start-server-core/vite.config.ts +++ b/packages/start-server-core/vite.config.ts @@ -40,6 +40,7 @@ export default mergeConfig( './src/empty-plugin-adapters.ts', ], externalDeps: [ + 'cookie-es', ...Object.values(VIRTUAL_MODULES), '#tanstack-start-entry', '#tanstack-router-entry', diff --git a/packages/vue-start/src/default-entry/server.ts b/packages/vue-start/src/default-entry/server.ts index 5729a469d3..ef9e144be9 100644 --- a/packages/vue-start/src/default-entry/server.ts +++ b/packages/vue-start/src/default-entry/server.ts @@ -1,6 +1,7 @@ import { createStartHandler, defaultStreamHandler, + handleStartError, } from '@tanstack/vue-start/server' import type { Register } from '@tanstack/vue-router' import type { RequestHandler } from '@tanstack/vue-start/server' @@ -13,7 +14,11 @@ export type ServerEntry = { fetch: RequestHandler } export function createServerEntry(entry: ServerEntry): ServerEntry { return { async fetch(...args) { - return await entry.fetch(...args) + try { + return await entry.fetch(...args) + } catch (error) { + return handleStartError(error) + } }, } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7938cd51f4..dcbc0f9107 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2021,7 +2021,7 @@ importers: version: 2.0.1 '@rsbuild/plugin-react': specifier: ^2.0.0 - version: 2.0.0(@rsbuild/core@2.0.1)(@rspack/core@2.0.5(@swc/helpers@0.5.23)) + version: 2.0.0(@rsbuild/core@2.0.1)(@rspack/core@2.0.5(@swc/helpers@0.5.21)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -2457,6 +2457,55 @@ importers: specifier: ^8.0.14 version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + e2e/react-start/response-reconciliation: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-start': + specifier: workspace:* + version: link:../../../packages/react-start + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + devDependencies: + '@playwright/test': + specifier: ^1.57.0 + version: 1.58.0 + '@rsbuild/core': + specifier: ^2.0.8 + version: 2.0.8 + '@rsbuild/plugin-react': + specifier: ^2.0.0 + version: 2.0.0(@rsbuild/core@2.0.8)(@rspack/core@2.0.5(@swc/helpers@0.5.23)) + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 25.0.9 + version: 25.0.9 + '@types/react': + specifier: ^19.2.8 + version: 19.2.9 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.9) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + srvx: + specifier: ^0.11.9 + version: 0.11.15 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + vite: + specifier: ^8.0.14 + version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + e2e/react-start/rsc: dependencies: '@tanstack/react-router': @@ -3095,6 +3144,55 @@ importers: specifier: ^8.0.14 version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + e2e/react-start/session-handling: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-start': + specifier: workspace:* + version: link:../../../packages/react-start + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + devDependencies: + '@playwright/test': + specifier: ^1.57.0 + version: 1.58.0 + '@rsbuild/core': + specifier: ^2.0.8 + version: 2.0.8 + '@rsbuild/plugin-react': + specifier: ^2.0.0 + version: 2.0.0(@rsbuild/core@2.0.8)(@rspack/core@2.0.5(@swc/helpers@0.5.23)) + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 25.0.9 + version: 25.0.9 + '@types/react': + specifier: ^19.2.8 + version: 19.2.9 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.9) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + srvx: + specifier: ^0.11.9 + version: 0.11.15 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + vite: + specifier: ^8.0.14 + version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + e2e/react-start/spa-mode: dependencies: '@tanstack/react-router': @@ -13269,6 +13367,9 @@ importers: '@tanstack/start-storage-context': specifier: workspace:* version: link:../start-storage-context + cookie-es: + specifier: ^3.0.0 + version: 3.1.1 fetchdts: specifier: ^0.1.6 version: 0.1.7 @@ -13285,9 +13386,6 @@ importers: '@types/node': specifier: 25.0.9 version: 25.0.9 - cookie-es: - specifier: ^3.0.0 - version: 3.1.1 vite: specifier: ^8.0.14 version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) @@ -31446,9 +31544,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@rsbuild/plugin-react@2.0.0(@rsbuild/core@2.0.1)(@rspack/core@2.0.5(@swc/helpers@0.5.23))': + '@rsbuild/plugin-react@2.0.0(@rsbuild/core@2.0.1)(@rspack/core@2.0.5(@swc/helpers@0.5.21))': dependencies: - '@rspack/plugin-react-refresh': 2.0.0(@rspack/core@2.0.5(@swc/helpers@0.5.23))(react-refresh@0.18.0) + '@rspack/plugin-react-refresh': 2.0.0(@rspack/core@2.0.5(@swc/helpers@0.5.21))(react-refresh@0.18.0) react-refresh: 0.18.0 optionalDependencies: '@rsbuild/core': 2.0.1 @@ -31639,6 +31737,13 @@ snapshots: optionalDependencies: '@swc/helpers': 0.5.23 + '@rspack/core@2.0.5(@swc/helpers@0.5.21)': + dependencies: + '@rspack/binding': 2.0.5 + optionalDependencies: + '@swc/helpers': 0.5.21 + optional: true + '@rspack/core@2.0.5(@swc/helpers@0.5.23)': dependencies: '@rspack/binding': 2.0.5 @@ -31647,6 +31752,12 @@ snapshots: '@rspack/lite-tapable@1.1.0': {} + '@rspack/plugin-react-refresh@2.0.0(@rspack/core@2.0.5(@swc/helpers@0.5.21))(react-refresh@0.18.0)': + dependencies: + react-refresh: 0.18.0 + optionalDependencies: + '@rspack/core': 2.0.5(@swc/helpers@0.5.21) + '@rspack/plugin-react-refresh@2.0.0(@rspack/core@2.0.5(@swc/helpers@0.5.23))(react-refresh@0.18.0)': dependencies: react-refresh: 0.18.0