diff --git a/e2e/react-start/server-functions-global-middleware/.gitignore b/e2e/react-start/server-functions-global-middleware/.gitignore new file mode 100644 index 00000000000..ea6591949b9 --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.nitro +.output +port*.txt +test-results diff --git a/e2e/react-start/server-functions-global-middleware/package.json b/e2e/react-start/server-functions-global-middleware/package.json new file mode 100644 index 00000000000..8060481fad0 --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/package.json @@ -0,0 +1,34 @@ +{ + "name": "tanstack-react-start-e2e-server-functions-global-middleware", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "pnpx srvx --prod -s ../client dist/server/server.js", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-start": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^7.1.7" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tailwindcss/vite": "^4.1.18", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "srvx": "^0.9.8", + "tailwindcss": "^4.1.18", + "typescript": "^5.7.2", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/e2e/react-start/server-functions-global-middleware/playwright.config.ts b/e2e/react-start/server-functions-global-middleware/playwright.config.ts new file mode 100644 index 00000000000..8330e023eea --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +export const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + workers: 1, + + reporter: [['line']], + + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL, + }, + + webServer: { + command: `VITE_SERVER_PORT=${PORT} pnpm build && PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-start/server-functions-global-middleware/src/routeTree.gen.ts b/e2e/react-start/server-functions-global-middleware/src/routeTree.gen.ts new file mode 100644 index 00000000000..bcc17d676ef --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/src/routeTree.gen.ts @@ -0,0 +1,105 @@ +/* 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 SimpleRouteImport } from './routes/simple' +import { Route as MultipleServerFunctionsRouteImport } from './routes/multiple-server-functions' +import { Route as IndexRouteImport } from './routes/index' + +const SimpleRoute = SimpleRouteImport.update({ + id: '/simple', + path: '/simple', + getParentRoute: () => rootRouteImport, +} as any) +const MultipleServerFunctionsRoute = MultipleServerFunctionsRouteImport.update({ + id: '/multiple-server-functions', + path: '/multiple-server-functions', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/multiple-server-functions': typeof MultipleServerFunctionsRoute + '/simple': typeof SimpleRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/multiple-server-functions': typeof MultipleServerFunctionsRoute + '/simple': typeof SimpleRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/multiple-server-functions': typeof MultipleServerFunctionsRoute + '/simple': typeof SimpleRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/multiple-server-functions' | '/simple' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/multiple-server-functions' | '/simple' + id: '__root__' | '/' | '/multiple-server-functions' | '/simple' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + MultipleServerFunctionsRoute: typeof MultipleServerFunctionsRoute + SimpleRoute: typeof SimpleRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/simple': { + id: '/simple' + path: '/simple' + fullPath: '/simple' + preLoaderRoute: typeof SimpleRouteImport + parentRoute: typeof rootRouteImport + } + '/multiple-server-functions': { + id: '/multiple-server-functions' + path: '/multiple-server-functions' + fullPath: '/multiple-server-functions' + preLoaderRoute: typeof MultipleServerFunctionsRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + MultipleServerFunctionsRoute: MultipleServerFunctionsRoute, + SimpleRoute: SimpleRoute, +} +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/server-functions-global-middleware/src/router.tsx b/e2e/react-start/server-functions-global-middleware/src/router.tsx new file mode 100644 index 00000000000..6ee5705ca9b --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/src/router.tsx @@ -0,0 +1,12 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + scrollRestoration: true, + }) + + return router +} diff --git a/e2e/react-start/server-functions-global-middleware/src/routes/__root.tsx b/e2e/react-start/server-functions-global-middleware/src/routes/__root.tsx new file mode 100644 index 00000000000..de9dedae745 --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/src/routes/__root.tsx @@ -0,0 +1,52 @@ +/// +import * as React from 'react' +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' +import appCss from '~/styles/app.css?url' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { + charSet: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + ], + links: [{ rel: 'stylesheet', href: appCss }], + }), + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + +
+ + Home + +
+
+ + + + + ) +} diff --git a/e2e/react-start/server-functions-global-middleware/src/routes/index.tsx b/e2e/react-start/server-functions-global-middleware/src/routes/index.tsx new file mode 100644 index 00000000000..509affdf046 --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/src/routes/index.tsx @@ -0,0 +1,32 @@ +import { Link, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

+ Global Middleware Deduplication E2E Tests +

+

+ Tests for issue #5239: global request middleware is executed multiple + times for single request +

+
    +
  • + + Simple test - single server function with global middleware + +
  • +
  • + + Complex test - multiple server functions in loader with shared + global middleware + +
  • +
+
+ ) +} diff --git a/e2e/react-start/server-functions-global-middleware/src/routes/multiple-server-functions.tsx b/e2e/react-start/server-functions-global-middleware/src/routes/multiple-server-functions.tsx new file mode 100644 index 00000000000..276fd9a94c8 --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/src/routes/multiple-server-functions.tsx @@ -0,0 +1,374 @@ +import { createFileRoute, Link } from '@tanstack/react-router' +import { createServerFn, createMiddleware } from '@tanstack/react-start' +import { + globalFunctionMiddleware, + loggingMiddleware, + getMiddlewareExecutionCounts, + trackMiddlewareExecution, +} from '~/start' + +// Local middleware that should also be tracked +const localMiddleware = createMiddleware({ type: 'function' }).server( + async ({ next }) => { + trackMiddlewareExecution('localMiddleware') + return next({ + context: { + localMiddlewareExecuted: true, + }, + }) + }, +) + +// Server function 1 - uses global middleware (implicit from start.ts) +// Plus explicitly adds globalFunctionMiddleware again (should be deduped) +const serverFn1 = createServerFn() + .middleware([globalFunctionMiddleware]) + .handler(async ({ context }) => { + const counts = getMiddlewareExecutionCounts() + return { + fn: 'serverFn1', + globalMiddlewareExecuted: (context as any).globalMiddlewareExecuted, + // Return counts for this specific request + counts, + } + }) + +// Server function 2 - uses global middleware plus local middleware +const serverFn2 = createServerFn() + .middleware([globalFunctionMiddleware, localMiddleware]) + .handler(async ({ context }) => { + const counts = getMiddlewareExecutionCounts() + return { + fn: 'serverFn2', + globalMiddlewareExecuted: (context as any).globalMiddlewareExecuted, + localMiddlewareExecuted: (context as any).localMiddlewareExecuted, + counts, + } + }) + +// Server function 3 - uses loggingMiddleware (which is in requestMiddleware) +// plus local middleware +const serverFn3 = createServerFn() + .middleware([loggingMiddleware, localMiddleware]) + .handler(async ({ context }) => { + const counts = getMiddlewareExecutionCounts() + return { + fn: 'serverFn3', + loggingMiddlewareExecuted: (context as any).loggingMiddlewareExecuted, + localMiddlewareExecuted: (context as any).localMiddlewareExecuted, + counts, + } + }) + +// Final server function to get execution counts after all others have run +const getExecutionCountsFn = createServerFn().handler(async () => { + const counts = getMiddlewareExecutionCounts() + return { counts } +}) + +export const Route = createFileRoute('/multiple-server-functions')({ + loader: async () => { + // Call multiple server functions in the same request + // Each one has global middleware, but it should only execute once per function call + // The BUG is that global middleware executes multiple times per function call + const [result1, result2, result3] = await Promise.all([ + serverFn1(), + serverFn2(), + serverFn3(), + ]) + + // Get the final execution counts + const executionData = await getExecutionCountsFn() + + return { + results: [result1, result2, result3], + executionCounts: executionData.counts, + } + }, + component: MultipleServerFunctionsComponent, +}) + +function MultipleServerFunctionsComponent() { + const data = Route.useLoaderData() + + // For SSR: all functions run in same request, counts accumulate + // For client-side: each function is separate request, counts are per-request + // + // What we're testing is deduplication WITHIN each server function call: + // - loggingMiddleware is in requestMiddleware AND serverFn3 - should run once per request + // - globalFunctionMiddleware is in functionMiddleware AND explicitly added - should run once per fn + // + // For SSR mode (accumulating counts): + // - loggingMiddleware: 1 (runs in request middleware, deduped in serverFn3) + // - globalFunctionMiddleware: 4 (once per server function call) + // - globalFunctionMiddleware2: 4 (once per server function call) + // - localMiddleware: 2 (serverFn2 and serverFn3) + // + // For client-side mode: we verify each function's individual counts show deduplication worked + + const loggingCount = data.executionCounts['loggingMiddleware'] || 0 + const globalCount = data.executionCounts['globalFunctionMiddleware'] || 0 + const global2Count = data.executionCounts['globalFunctionMiddleware2'] || 0 + const localCount = data.executionCounts['localMiddleware'] || 0 + + // Detect if we're in SSR mode by checking if counts are > 1 + // (In client-side, last request is getExecutionCountsFn which only has global middlewares) + const isSSRMode = globalCount > 1 + + // Expected counts for SSR mode + const expectedLoggingCountSSR = 1 + const expectedGlobalCountSSR = 4 + const expectedGlobal2CountSSR = 4 + const expectedLocalCountSSR = 2 + + // Expected counts for client-side mode (last request only has global middlewares running once) + const expectedLoggingCountClient = 1 + const expectedGlobalCountClient = 1 + const expectedGlobal2CountClient = 1 + const expectedLocalCountClient = 0 // not in getExecutionCountsFn + + const expectedLoggingCount = isSSRMode + ? expectedLoggingCountSSR + : expectedLoggingCountClient + const expectedGlobalCount = isSSRMode + ? expectedGlobalCountSSR + : expectedGlobalCountClient + const expectedGlobal2Count = isSSRMode + ? expectedGlobal2CountSSR + : expectedGlobal2CountClient + const expectedLocalCount = isSSRMode + ? expectedLocalCountSSR + : expectedLocalCountClient + + // For client-side, also verify that individual function results show correct deduplication + // Each function should have its middlewares run exactly once PER REQUEST + // Note: In SSR mode, all functions share the same counts (accumulated), so we skip these checks + const fn1Counts = (data.results[0] as any).counts || {} + const fn2Counts = (data.results[1] as any).counts || {} + const fn3Counts = (data.results[2] as any).counts || {} + + // Per-function deduplication check - only valid for client-side mode + // In SSR, all functions run in same request, so counts accumulate + // In client-side, each function is a separate request, so counts are per-request + let perFunctionDedupeOk = true + if (!isSSRMode) { + // serverFn1: globalFunctionMiddleware=1, globalFunctionMiddleware2=1, loggingMiddleware=1 + // serverFn2: adds localMiddleware=1 + // serverFn3: loggingMiddleware=1 (deduped with request middleware), localMiddleware=1 + const fn1GlobalOk = fn1Counts['globalFunctionMiddleware'] === 1 + const fn1Global2Ok = fn1Counts['globalFunctionMiddleware2'] === 1 + const fn2LocalOk = fn2Counts['localMiddleware'] === 1 + const fn3LoggingOk = fn3Counts['loggingMiddleware'] === 1 // deduped with request middleware + + perFunctionDedupeOk = + fn1GlobalOk && fn1Global2Ok && fn2LocalOk && fn3LoggingOk + } + + const isLoggingCorrect = loggingCount === expectedLoggingCount + const isGlobalDeduped = globalCount === expectedGlobalCount + const isGlobal2Deduped = global2Count === expectedGlobal2Count + const isLocalCorrect = localCount === expectedLocalCount + + const allPassed = isSSRMode + ? isLoggingCorrect && + isGlobalDeduped && + isGlobal2Deduped && + isLocalCorrect && + perFunctionDedupeOk + : perFunctionDedupeOk // For client-side, just verify per-function deduplication + + return ( +
+

+ Multiple Server Functions with Global Middleware (Issue #5239) +

+ +
+ + ← Back to Home + +
+ +
+ Mode: {isSSRMode ? 'SSR (direct navigation)' : 'Client-side navigation'} +
+ +
+

Overall Test Result:

+
+ {allPassed + ? 'PASS: All middlewares executed correct number of times' + : 'FAIL: Middleware execution counts are incorrect'} +
+
+ +
+

+ Middleware Execution Counts (Final Request): +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MiddlewareExpectedActualStatus
loggingMiddleware + {expectedLoggingCount} + + {loggingCount} + + {isLoggingCorrect ? '✓' : '✗'} +
+ globalFunctionMiddleware + + {expectedGlobalCount} + + {globalCount} + + {isGlobalDeduped ? '✓' : '✗'} +
+ globalFunctionMiddleware2 + + {expectedGlobal2Count} + + {global2Count} + + {isGlobal2Deduped ? '✓' : '✗'} +
localMiddleware + {expectedLocalCount} + + {localCount} + + {isLocalCorrect ? '✓' : '✗'} +
+
+ +
+

Per-Function Deduplication Check:

+
+ {isSSRMode ? ( +
+ (In SSR mode, all functions share the same request context, so + per-function checks are skipped) +
+ ) : ( + <> +
+ serverFn1 globalFunctionMiddleware=1:{' '} + {fn1Counts['globalFunctionMiddleware'] === 1 + ? '✓' + : '✗ got ' + fn1Counts['globalFunctionMiddleware']} +
+
+ serverFn1 globalFunctionMiddleware2=1:{' '} + {fn1Counts['globalFunctionMiddleware2'] === 1 + ? '✓' + : '✗ got ' + fn1Counts['globalFunctionMiddleware2']} +
+
+ serverFn2 localMiddleware=1:{' '} + {fn2Counts['localMiddleware'] === 1 + ? '✓' + : '✗ got ' + fn2Counts['localMiddleware']} +
+
+ serverFn3 loggingMiddleware=1 (deduped):{' '} + {fn3Counts['loggingMiddleware'] === 1 + ? '✓' + : '✗ got ' + fn3Counts['loggingMiddleware']} +
+ + )} +
+
+ +
+

Server Function Results:

+
+          {JSON.stringify(data.results, null, 2)}
+        
+
+ +
+

Raw Execution Counts (Final Request):

+
+          {JSON.stringify(data.executionCounts, null, 2)}
+        
+
+
+ ) +} diff --git a/e2e/react-start/server-functions-global-middleware/src/routes/simple.tsx b/e2e/react-start/server-functions-global-middleware/src/routes/simple.tsx new file mode 100644 index 00000000000..8f75ca16613 --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/src/routes/simple.tsx @@ -0,0 +1,125 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { loggingMiddleware, getMiddlewareExecutionCounts } from '~/start' + +// This test reproduces issue #5239: +// - loggingMiddleware is registered as global REQUEST middleware in start.ts +// - The same loggingMiddleware is also attached to this server function +// - The bug is that it executes multiple times instead of being deduped + +// Simple server function that uses loggingMiddleware +// loggingMiddleware is already in requestMiddleware in start.ts +// If we also attach it here, it should be deduped and run only once per context +const simpleServerFn = createServerFn() + .middleware([loggingMiddleware]) // Same middleware as in requestMiddleware - should be deduped + .handler(async ({ context }) => { + const counts = getMiddlewareExecutionCounts() + + return { + success: true, + loggingMiddlewareExecuted: (context as any).loggingMiddlewareExecuted, + globalMiddlewareExecuted: (context as any).globalMiddlewareExecuted, + globalMiddleware2Executed: (context as any).globalMiddleware2Executed, + middlewareExecutionCounts: counts, + } + }) + +export const Route = createFileRoute('/simple')({ + loader: async () => { + const result = await simpleServerFn() + return result + }, + component: SimpleComponent, +}) + +function SimpleComponent() { + const data = Route.useLoaderData() + + // Check loggingMiddleware count + // loggingMiddleware is in BOTH requestMiddleware AND server function middleware + // With proper deduplication, it should only run once total for this request + const loggingCount = data.middlewareExecutionCounts['loggingMiddleware'] || 0 + const globalCount = + data.middlewareExecutionCounts['globalFunctionMiddleware'] || 0 + const global2Count = + data.middlewareExecutionCounts['globalFunctionMiddleware2'] || 0 + + // Expected: loggingMiddleware runs once (deduped across request and function middleware) + // globalFunctionMiddleware and globalFunctionMiddleware2 each run once + const expectedLoggingCount = 1 // Should be deduped + const expectedGlobalCount = 1 + const expectedGlobal2Count = 1 + + const isDeduped = + loggingCount === expectedLoggingCount && + globalCount === expectedGlobalCount && + global2Count === expectedGlobal2Count + + return ( +
+

+ Simple Global Middleware Test (Issue #5239) +

+ +
+

Test Result:

+
+ {isDeduped + ? 'PASS: Middleware was deduped correctly' + : 'FAIL: Middleware executed multiple times'} +
+
+ +
+

Middleware Execution Counts:

+
+          {JSON.stringify(data.middlewareExecutionCounts, null, 2)}
+        
+
+ +
+

Context Values:

+
+ loggingMiddlewareExecuted: + + {String(data.loggingMiddlewareExecuted)} + +
+
+ globalMiddlewareExecuted: + + {String(data.globalMiddlewareExecuted)} + +
+
+ globalMiddleware2Executed: + + {String(data.globalMiddleware2Executed)} + +
+
+ +
+

+ Logging Middleware Count: {loggingCount} +

+

Global Middleware Count: {globalCount}

+

+ Global Middleware 2 Count: {global2Count} +

+
+ + {/* Hidden test values for e2e */} +
+ {expectedLoggingCount} + {expectedGlobalCount} + + {expectedGlobal2Count} + +
+
+ ) +} diff --git a/e2e/react-start/server-functions-global-middleware/src/start.ts b/e2e/react-start/server-functions-global-middleware/src/start.ts new file mode 100644 index 00000000000..474968298d6 --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/src/start.ts @@ -0,0 +1,83 @@ +import { + createStart, + createMiddleware, + createServerOnlyFn, +} from '@tanstack/react-start' +import { getRequest } from '@tanstack/react-start/server' + +// Use a WeakMap keyed by Request object for request-scoped tracking +// This is cleaner than global state and automatically garbage collects +const requestMiddlewareCounts = new WeakMap>() + +// Helper to track middleware execution - attaches to the Request object +// This is server-only since it uses getRequest() +export const trackMiddlewareExecution = createServerOnlyFn( + (middlewareName: string) => { + const request = getRequest() + let counts = requestMiddlewareCounts.get(request) + if (!counts) { + counts = {} + requestMiddlewareCounts.set(request, counts) + } + counts[middlewareName] = (counts[middlewareName] || 0) + 1 + console.log( + `[MIDDLEWARE] ${middlewareName} executed. Count: ${counts[middlewareName]}`, + ) + return counts[middlewareName] + }, +) + +// Helper to get execution counts for the current request +// Wrapped in createServerOnlyFn since it uses getRequest() +export const getMiddlewareExecutionCounts = createServerOnlyFn( + (): Record => { + const request = getRequest() + return requestMiddlewareCounts.get(request) || {} + }, +) + +// This is the middleware from issue #5239 - it's registered as BOTH: +// 1. Global request middleware (in startInstance) +// 2. Server function middleware (attached to individual server functions) +// The bug is that it executes multiple times instead of being deduped +export const loggingMiddleware = createMiddleware().server(async ({ next }) => { + trackMiddlewareExecution('loggingMiddleware') + return next({ + context: { + loggingMiddlewareExecuted: true, + }, + }) +}) + +// Global function middleware that should be deduped across server functions +export const globalFunctionMiddleware = createMiddleware({ + type: 'function', +}).server(async ({ next }) => { + trackMiddlewareExecution('globalFunctionMiddleware') + return next({ + context: { + globalMiddlewareExecuted: true, + }, + }) +}) + +// A second global middleware to test multiple global middlewares +export const globalFunctionMiddleware2 = createMiddleware({ + type: 'function', +}).server(async ({ next }) => { + trackMiddlewareExecution('globalFunctionMiddleware2') + return next({ + context: { + globalMiddleware2Executed: true, + }, + }) +}) + +// Create the start instance with global middleware +export const startInstance = createStart(() => ({ + // Global function middleware that applies to all server functions + functionMiddleware: [globalFunctionMiddleware, globalFunctionMiddleware2], + // Request middleware - includes loggingMiddleware (issue #5239 scenario) + // AND the same loggingMiddleware is also attached to server functions + requestMiddleware: [loggingMiddleware], +})) diff --git a/e2e/react-start/server-functions-global-middleware/src/styles/app.css b/e2e/react-start/server-functions-global-middleware/src/styles/app.css new file mode 100644 index 00000000000..d4b5078586e --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/src/styles/app.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/e2e/react-start/server-functions-global-middleware/tests/global-middleware.spec.ts b/e2e/react-start/server-functions-global-middleware/tests/global-middleware.spec.ts new file mode 100644 index 00000000000..dfc4ae315a6 --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/tests/global-middleware.spec.ts @@ -0,0 +1,131 @@ +import { expect, Page } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +type NavigationMethod = 'direct' | 'client-side' + +async function navigateToRoute( + page: Page, + route: string, + linkTestId: string, + method: NavigationMethod, +) { + if (method === 'direct') { + await page.goto(route) + } else { + // Client-side navigation: go to home first, then click link + await page.goto('/') + await page.waitForLoadState('networkidle') + await page.getByTestId(linkTestId).click() + } + await page.waitForLoadState('networkidle') +} + +async function testSimpleMiddlewareDeduplication( + page: Page, + method: NavigationMethod, +) { + await navigateToRoute(page, '/simple', 'link-simple', method) + + // Check that both global middlewares were executed + await expect(page.getByTestId('global-middleware-executed')).toContainText( + 'true', + ) + await expect(page.getByTestId('global-middleware-2-executed')).toContainText( + 'true', + ) + + // Check that deduplication worked - each global middleware should execute exactly once + const expectedGlobalCount = await page + .getByTestId('expected-global-count') + .textContent() + const actualGlobalCount = await page.getByTestId('global-count').textContent() + + // Extract the count number from the text + const actualCount = actualGlobalCount?.match(/\d+/)?.[0] || '0' + + expect(actualCount).toBe(expectedGlobalCount) + + // Check the deduplication status + await expect(page.getByTestId('deduplication-status')).toContainText('PASS') +} + +async function testMultipleServerFunctionsDeduplication( + page: Page, + method: NavigationMethod, +) { + await navigateToRoute( + page, + '/multiple-server-functions', + 'link-multiple', + method, + ) + + // For SSR (direct): all server functions run in same request, counts accumulate + // For client-side: each server function is a separate HTTP request + // The key thing we're testing is that middleware is deduped WITHIN each server fn call + + // Check overall status - the component handles both cases + await expect(page.getByTestId('overall-status')).toContainText('PASS') + + // For direct navigation, verify the accumulated counts + if (method === 'direct') { + const expectedGlobalCount = await page + .getByTestId('expected-global-count') + .textContent() + const actualGlobalCount = await page + .getByTestId('actual-global-count') + .textContent() + expect(actualGlobalCount).toBe(expectedGlobalCount) + + const expectedGlobal2Count = await page + .getByTestId('expected-global-2-count') + .textContent() + const actualGlobal2Count = await page + .getByTestId('actual-global-2-count') + .textContent() + expect(actualGlobal2Count).toBe(expectedGlobal2Count) + + const expectedLocalCount = await page + .getByTestId('expected-local-count') + .textContent() + const actualLocalCount = await page + .getByTestId('actual-local-count') + .textContent() + expect(actualLocalCount).toBe(expectedLocalCount) + + // Verify the status indicators + await expect(page.getByTestId('global-status')).toContainText('✓') + await expect(page.getByTestId('global-2-status')).toContainText('✓') + await expect(page.getByTestId('local-status')).toContainText('✓') + } +} + +test.describe('Global middleware deduplication (issue #5239)', () => { + test.describe('direct navigation (SSR)', () => { + test('simple test - global middleware attached to server function should be deduped', async ({ + page, + }) => { + await testSimpleMiddlewareDeduplication(page, 'direct') + }) + + test('multiple server functions - global middleware should be deduped per function call', async ({ + page, + }) => { + await testMultipleServerFunctionsDeduplication(page, 'direct') + }) + }) + + test.describe('client-side navigation', () => { + test('simple test - global middleware attached to server function should be deduped', async ({ + page, + }) => { + await testSimpleMiddlewareDeduplication(page, 'client-side') + }) + + test('multiple server functions - global middleware should be deduped per function call', async ({ + page, + }) => { + await testMultipleServerFunctionsDeduplication(page, 'client-side') + }) + }) +}) diff --git a/e2e/react-start/server-functions-global-middleware/tsconfig.json b/e2e/react-start/server-functions-global-middleware/tsconfig.json new file mode 100644 index 00000000000..a07911fe5f4 --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/tsconfig.json @@ -0,0 +1,23 @@ +{ + "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, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true, + "types": ["vite/client"] + } +} diff --git a/e2e/react-start/server-functions-global-middleware/vite.config.ts b/e2e/react-start/server-functions-global-middleware/vite.config.ts new file mode 100644 index 00000000000..3e946e6645b --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [ + tailwindcss(), + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + viteReact(), + ], +}) diff --git a/e2e/react-start/server-functions/src/routeTree.gen.ts b/e2e/react-start/server-functions/src/routeTree.gen.ts index f83dc9146be..89acfa559cd 100644 --- a/e2e/react-start/server-functions/src/routeTree.gen.ts +++ b/e2e/react-start/server-functions/src/routeTree.gen.ts @@ -34,6 +34,7 @@ import { Route as CookiesIndexRouteImport } from './routes/cookies/index' import { Route as AbortSignalIndexRouteImport } from './routes/abort-signal/index' import { Route as RedirectTestTargetRouteImport } from './routes/redirect-test/target' import { Route as RedirectTestSsrTargetRouteImport } from './routes/redirect-test-ssr/target' +import { Route as MiddlewareUnhandledExceptionRouteImport } from './routes/middleware/unhandled-exception' import { Route as MiddlewareServerImportMiddlewareRouteImport } from './routes/middleware/server-import-middleware' import { Route as MiddlewareSendServerFnRouteImport } from './routes/middleware/send-serverFn' import { Route as MiddlewareRequestMiddlewareRouteImport } from './routes/middleware/request-middleware' @@ -41,6 +42,8 @@ import { Route as MiddlewareMiddlewareFactoryRouteImport } from './routes/middle import { Route as MiddlewareClientMiddlewareRouterRouteImport } from './routes/middleware/client-middleware-router' import { Route as CookiesSetRouteImport } from './routes/cookies/set' import { Route as AbortSignalMethodRouteImport } from './routes/abort-signal/$method' +import { Route as MiddlewareRedirectWithMiddlewareIndexRouteImport } from './routes/middleware/redirect-with-middleware/index' +import { Route as MiddlewareRedirectWithMiddlewareTargetRouteImport } from './routes/middleware/redirect-with-middleware/target' import { Route as FormdataRedirectTargetNameRouteImport } from './routes/formdata-redirect/target.$name' const SubmitPostFormdataRoute = SubmitPostFormdataRouteImport.update({ @@ -168,6 +171,12 @@ const RedirectTestSsrTargetRoute = RedirectTestSsrTargetRouteImport.update({ path: '/redirect-test-ssr/target', getParentRoute: () => rootRouteImport, } as any) +const MiddlewareUnhandledExceptionRoute = + MiddlewareUnhandledExceptionRouteImport.update({ + id: '/middleware/unhandled-exception', + path: '/middleware/unhandled-exception', + getParentRoute: () => rootRouteImport, + } as any) const MiddlewareServerImportMiddlewareRoute = MiddlewareServerImportMiddlewareRouteImport.update({ id: '/middleware/server-import-middleware', @@ -207,6 +216,18 @@ const AbortSignalMethodRoute = AbortSignalMethodRouteImport.update({ path: '/abort-signal/$method', getParentRoute: () => rootRouteImport, } as any) +const MiddlewareRedirectWithMiddlewareIndexRoute = + MiddlewareRedirectWithMiddlewareIndexRouteImport.update({ + id: '/middleware/redirect-with-middleware/', + path: '/middleware/redirect-with-middleware/', + getParentRoute: () => rootRouteImport, + } as any) +const MiddlewareRedirectWithMiddlewareTargetRoute = + MiddlewareRedirectWithMiddlewareTargetRouteImport.update({ + id: '/middleware/redirect-with-middleware/target', + path: '/middleware/redirect-with-middleware/target', + getParentRoute: () => rootRouteImport, + } as any) const FormdataRedirectTargetNameRoute = FormdataRedirectTargetNameRouteImport.update({ id: '/formdata-redirect/target/$name', @@ -237,6 +258,7 @@ export interface FileRoutesByFullPath { '/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute '/middleware/server-import-middleware': typeof MiddlewareServerImportMiddlewareRoute + '/middleware/unhandled-exception': typeof MiddlewareUnhandledExceptionRoute '/redirect-test-ssr/target': typeof RedirectTestSsrTargetRoute '/redirect-test/target': typeof RedirectTestTargetRoute '/abort-signal': typeof AbortSignalIndexRoute @@ -248,6 +270,8 @@ export interface FileRoutesByFullPath { '/redirect-test-ssr': typeof RedirectTestSsrIndexRoute '/redirect-test': typeof RedirectTestIndexRoute '/formdata-redirect/target/$name': typeof FormdataRedirectTargetNameRoute + '/middleware/redirect-with-middleware/target': typeof MiddlewareRedirectWithMiddlewareTargetRoute + '/middleware/redirect-with-middleware': typeof MiddlewareRedirectWithMiddlewareIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -272,6 +296,7 @@ export interface FileRoutesByTo { '/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute '/middleware/server-import-middleware': typeof MiddlewareServerImportMiddlewareRoute + '/middleware/unhandled-exception': typeof MiddlewareUnhandledExceptionRoute '/redirect-test-ssr/target': typeof RedirectTestSsrTargetRoute '/redirect-test/target': typeof RedirectTestTargetRoute '/abort-signal': typeof AbortSignalIndexRoute @@ -283,6 +308,8 @@ export interface FileRoutesByTo { '/redirect-test-ssr': typeof RedirectTestSsrIndexRoute '/redirect-test': typeof RedirectTestIndexRoute '/formdata-redirect/target/$name': typeof FormdataRedirectTargetNameRoute + '/middleware/redirect-with-middleware/target': typeof MiddlewareRedirectWithMiddlewareTargetRoute + '/middleware/redirect-with-middleware': typeof MiddlewareRedirectWithMiddlewareIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -308,6 +335,7 @@ export interface FileRoutesById { '/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute '/middleware/server-import-middleware': typeof MiddlewareServerImportMiddlewareRoute + '/middleware/unhandled-exception': typeof MiddlewareUnhandledExceptionRoute '/redirect-test-ssr/target': typeof RedirectTestSsrTargetRoute '/redirect-test/target': typeof RedirectTestTargetRoute '/abort-signal/': typeof AbortSignalIndexRoute @@ -319,6 +347,8 @@ export interface FileRoutesById { '/redirect-test-ssr/': typeof RedirectTestSsrIndexRoute '/redirect-test/': typeof RedirectTestIndexRoute '/formdata-redirect/target/$name': typeof FormdataRedirectTargetNameRoute + '/middleware/redirect-with-middleware/target': typeof MiddlewareRedirectWithMiddlewareTargetRoute + '/middleware/redirect-with-middleware/': typeof MiddlewareRedirectWithMiddlewareIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -345,6 +375,7 @@ export interface FileRouteTypes { | '/middleware/request-middleware' | '/middleware/send-serverFn' | '/middleware/server-import-middleware' + | '/middleware/unhandled-exception' | '/redirect-test-ssr/target' | '/redirect-test/target' | '/abort-signal' @@ -356,6 +387,8 @@ export interface FileRouteTypes { | '/redirect-test-ssr' | '/redirect-test' | '/formdata-redirect/target/$name' + | '/middleware/redirect-with-middleware/target' + | '/middleware/redirect-with-middleware' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -380,6 +413,7 @@ export interface FileRouteTypes { | '/middleware/request-middleware' | '/middleware/send-serverFn' | '/middleware/server-import-middleware' + | '/middleware/unhandled-exception' | '/redirect-test-ssr/target' | '/redirect-test/target' | '/abort-signal' @@ -391,6 +425,8 @@ export interface FileRouteTypes { | '/redirect-test-ssr' | '/redirect-test' | '/formdata-redirect/target/$name' + | '/middleware/redirect-with-middleware/target' + | '/middleware/redirect-with-middleware' id: | '__root__' | '/' @@ -415,6 +451,7 @@ export interface FileRouteTypes { | '/middleware/request-middleware' | '/middleware/send-serverFn' | '/middleware/server-import-middleware' + | '/middleware/unhandled-exception' | '/redirect-test-ssr/target' | '/redirect-test/target' | '/abort-signal/' @@ -426,6 +463,8 @@ export interface FileRouteTypes { | '/redirect-test-ssr/' | '/redirect-test/' | '/formdata-redirect/target/$name' + | '/middleware/redirect-with-middleware/target' + | '/middleware/redirect-with-middleware/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -451,6 +490,7 @@ export interface RootRouteChildren { MiddlewareRequestMiddlewareRoute: typeof MiddlewareRequestMiddlewareRoute MiddlewareSendServerFnRoute: typeof MiddlewareSendServerFnRoute MiddlewareServerImportMiddlewareRoute: typeof MiddlewareServerImportMiddlewareRoute + MiddlewareUnhandledExceptionRoute: typeof MiddlewareUnhandledExceptionRoute RedirectTestSsrTargetRoute: typeof RedirectTestSsrTargetRoute RedirectTestTargetRoute: typeof RedirectTestTargetRoute AbortSignalIndexRoute: typeof AbortSignalIndexRoute @@ -462,6 +502,8 @@ export interface RootRouteChildren { RedirectTestSsrIndexRoute: typeof RedirectTestSsrIndexRoute RedirectTestIndexRoute: typeof RedirectTestIndexRoute FormdataRedirectTargetNameRoute: typeof FormdataRedirectTargetNameRoute + MiddlewareRedirectWithMiddlewareTargetRoute: typeof MiddlewareRedirectWithMiddlewareTargetRoute + MiddlewareRedirectWithMiddlewareIndexRoute: typeof MiddlewareRedirectWithMiddlewareIndexRoute } declare module '@tanstack/react-router' { @@ -641,6 +683,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RedirectTestSsrTargetRouteImport parentRoute: typeof rootRouteImport } + '/middleware/unhandled-exception': { + id: '/middleware/unhandled-exception' + path: '/middleware/unhandled-exception' + fullPath: '/middleware/unhandled-exception' + preLoaderRoute: typeof MiddlewareUnhandledExceptionRouteImport + parentRoute: typeof rootRouteImport + } '/middleware/server-import-middleware': { id: '/middleware/server-import-middleware' path: '/middleware/server-import-middleware' @@ -690,6 +739,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AbortSignalMethodRouteImport parentRoute: typeof rootRouteImport } + '/middleware/redirect-with-middleware/': { + id: '/middleware/redirect-with-middleware/' + path: '/middleware/redirect-with-middleware' + fullPath: '/middleware/redirect-with-middleware' + preLoaderRoute: typeof MiddlewareRedirectWithMiddlewareIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/middleware/redirect-with-middleware/target': { + id: '/middleware/redirect-with-middleware/target' + path: '/middleware/redirect-with-middleware/target' + fullPath: '/middleware/redirect-with-middleware/target' + preLoaderRoute: typeof MiddlewareRedirectWithMiddlewareTargetRouteImport + parentRoute: typeof rootRouteImport + } '/formdata-redirect/target/$name': { id: '/formdata-redirect/target/$name' path: '/formdata-redirect/target/$name' @@ -723,6 +786,7 @@ const rootRouteChildren: RootRouteChildren = { MiddlewareRequestMiddlewareRoute: MiddlewareRequestMiddlewareRoute, MiddlewareSendServerFnRoute: MiddlewareSendServerFnRoute, MiddlewareServerImportMiddlewareRoute: MiddlewareServerImportMiddlewareRoute, + MiddlewareUnhandledExceptionRoute: MiddlewareUnhandledExceptionRoute, RedirectTestSsrTargetRoute: RedirectTestSsrTargetRoute, RedirectTestTargetRoute: RedirectTestTargetRoute, AbortSignalIndexRoute: AbortSignalIndexRoute, @@ -734,6 +798,10 @@ const rootRouteChildren: RootRouteChildren = { RedirectTestSsrIndexRoute: RedirectTestSsrIndexRoute, RedirectTestIndexRoute: RedirectTestIndexRoute, FormdataRedirectTargetNameRoute: FormdataRedirectTargetNameRoute, + MiddlewareRedirectWithMiddlewareTargetRoute: + MiddlewareRedirectWithMiddlewareTargetRoute, + MiddlewareRedirectWithMiddlewareIndexRoute: + MiddlewareRedirectWithMiddlewareIndexRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/e2e/react-start/server-functions/src/routes/index.tsx b/e2e/react-start/server-functions/src/routes/index.tsx index ca57f1540b9..30a90ae6f09 100644 --- a/e2e/react-start/server-functions/src/routes/index.tsx +++ b/e2e/react-start/server-functions/src/routes/index.tsx @@ -94,6 +94,11 @@ function Home() { server build +
  • + + Server Functions Middleware Unhandled Exception E2E tests + +
  • ) diff --git a/e2e/react-start/server-functions/src/routes/middleware/index.tsx b/e2e/react-start/server-functions/src/routes/middleware/index.tsx index 8a12ac30537..1f95f655a6a 100644 --- a/e2e/react-start/server-functions/src/routes/middleware/index.tsx +++ b/e2e/react-start/server-functions/src/routes/middleware/index.tsx @@ -50,6 +50,14 @@ function RouteComponent() { build +
  • + + Redirect via server function with middleware + +
  • ) diff --git a/e2e/react-start/server-functions/src/routes/middleware/redirect-with-middleware/index.tsx b/e2e/react-start/server-functions/src/routes/middleware/redirect-with-middleware/index.tsx new file mode 100644 index 00000000000..676eefe273e --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/redirect-with-middleware/index.tsx @@ -0,0 +1,65 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' +import { + createMiddleware, + createServerFn, + useServerFn, +} from '@tanstack/react-start' +import { useState } from 'react' + +// Function middleware that adds context +// Issue #5372: Middleware causes serialization error when throwing redirects via server function +const testMiddleware = createMiddleware({ type: 'function' }).server( + async ({ next }) => { + return next({ + context: { + fromMiddleware: true, + }, + }) + }, +) + +// Server function with middleware that throws redirect +const $redirectWithMiddleware = createServerFn({ method: 'POST' }) + .middleware([testMiddleware]) + .handler(async () => { + throw redirect({ to: '/middleware/redirect-with-middleware/target' }) + }) + +export const Route = createFileRoute('/middleware/redirect-with-middleware/')({ + component: RouteComponent, +}) + +function RouteComponent() { + const redirectFn = useServerFn($redirectWithMiddleware) + const [error, setError] = useState(null) + + return ( +
    +

    Middleware Redirect Test

    +

    + This tests that redirects thrown via server functions work correctly + when function middleware is attached (Issue #5372) +

    + + {error && ( +
    + Error: {error} +
    + )} +
    + ) +} diff --git a/e2e/react-start/server-functions/src/routes/middleware/redirect-with-middleware/target.tsx b/e2e/react-start/server-functions/src/routes/middleware/redirect-with-middleware/target.tsx new file mode 100644 index 00000000000..d47cd7b70a0 --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/redirect-with-middleware/target.tsx @@ -0,0 +1,20 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute( + '/middleware/redirect-with-middleware/target', +)({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
    +

    + Redirect Target (Middleware) +

    +

    + Successfully redirected! Middleware did not cause serialization error. +

    +
    + ) +} diff --git a/e2e/react-start/server-functions/src/routes/middleware/unhandled-exception.tsx b/e2e/react-start/server-functions/src/routes/middleware/unhandled-exception.tsx new file mode 100644 index 00000000000..e1fb36c589d --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/unhandled-exception.tsx @@ -0,0 +1,39 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createMiddleware, createServerFn } from '@tanstack/react-start' + +// Middleware that throws an unhandled exception +// Issue #5266: Server crashes when unhandled exception in server function or middleware +const throwingMiddleware = createMiddleware({ type: 'function' }).server( + async () => { + throw new Error('Unhandled middleware exception') + }, +) + +const serverFnWithThrowingMiddleware = createServerFn({ method: 'GET' }) + .middleware([throwingMiddleware]) + .handler(() => { + return { success: true } + }) + +export const Route = createFileRoute('/middleware/unhandled-exception')({ + loader: async () => { + return { + result: await serverFnWithThrowingMiddleware(), + } + }, + errorComponent: ({ error }) => { + return ( +
    +

    Error Caught

    +

    + {error instanceof Error ? error.message : 'Unknown error'} +

    +
    + ) + }, + component: RouteComponent, +}) + +function RouteComponent() { + return
    Should not render
    +} diff --git a/e2e/react-start/server-functions/src/routes/submit-post-formdata.tsx b/e2e/react-start/server-functions/src/routes/submit-post-formdata.tsx index 6bff33f35f3..4f6303c28e3 100644 --- a/e2e/react-start/server-functions/src/routes/submit-post-formdata.tsx +++ b/e2e/react-start/server-functions/src/routes/submit-post-formdata.tsx @@ -33,7 +33,7 @@ function SubmitPostFormDataFn() {

    Submit POST FormData Fn Call

    - It should return navigate and return{' '} + It should navigate to a raw response of {''}
                 Hello, {testValues.name}!
    diff --git a/e2e/react-start/server-functions/tests/server-functions.spec.ts b/e2e/react-start/server-functions/tests/server-functions.spec.ts
    index a428f5640ea..52f8726f091 100644
    --- a/e2e/react-start/server-functions/tests/server-functions.spec.ts
    +++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts
    @@ -664,3 +664,48 @@ test('middleware factories with server-only imports are stripped from client bui
         'x-factory-two',
       )
     })
    +
    +test('redirect via server function with middleware does not cause serialization error (issue #5372)', async ({
    +  page,
    +}) => {
    +  // This test verifies that throwing a redirect from a server function
    +  // that has middleware attached does not cause a SerovalUnsupportedTypeError.
    +  // Issue #5372: Middleware causes serialization error when throwing redirects via server function
    +  await page.goto('/middleware/redirect-with-middleware')
    +
    +  await page.waitForLoadState('networkidle')
    +
    +  // Verify we're on the source page
    +  await expect(page.getByTestId('middleware-redirect-source')).toBeVisible()
    +
    +  // Click the button to trigger the redirect via server function with middleware
    +  await page.getByTestId('trigger-redirect-btn').click()
    +
    +  // Should redirect to target page without serialization error
    +  await expect(page.getByTestId('middleware-redirect-target')).toBeVisible()
    +  expect(page.url()).toContain('/middleware/redirect-with-middleware/target')
    +
    +  // Verify no error was shown (would indicate serialization failure)
    +  await expect(page.getByTestId('error-message')).not.toBeVisible()
    +})
    +
    +test.describe('unhandled exception in middleware (issue #5266)', () => {
    +  // Whitelist the expected 500 error since this test verifies error handling
    +  test.use({ whitelistErrors: [/500/] })
    +
    +  test('does not crash server and shows error component', async ({ page }) => {
    +    // This test verifies that when a middleware throws an unhandled exception,
    +    // the server does not crash and the error is properly caught and displayed.
    +    // Issue #5266: Server crashes when unhandled exception in server function or middleware
    +    await page.goto('/middleware/unhandled-exception')
    +
    +    // The error should be caught and displayed via errorComponent
    +    await expect(page.getByTestId('unhandled-exception-error')).toBeVisible()
    +
    +    // Verify the error message is shown
    +    await expect(page.getByTestId('error-message')).toBeVisible()
    +
    +    // The route component should NOT render since error was thrown
    +    await expect(page.getByTestId('route-success')).not.toBeVisible()
    +  })
    +})
    diff --git a/e2e/react-start/server-routes-global-middleware/.gitignore b/e2e/react-start/server-routes-global-middleware/.gitignore
    new file mode 100644
    index 00000000000..6f11533a728
    --- /dev/null
    +++ b/e2e/react-start/server-routes-global-middleware/.gitignore
    @@ -0,0 +1,5 @@
    +node_modules
    +dist
    +.tanstack
    +port-*.txt
    +test-results
    diff --git a/e2e/react-start/server-routes-global-middleware/package.json b/e2e/react-start/server-routes-global-middleware/package.json
    new file mode 100644
    index 00000000000..1fa0ee5fc81
    --- /dev/null
    +++ b/e2e/react-start/server-routes-global-middleware/package.json
    @@ -0,0 +1,34 @@
    +{
    +  "name": "tanstack-react-start-e2e-server-routes-global-middleware",
    +  "private": true,
    +  "sideEffects": false,
    +  "type": "module",
    +  "scripts": {
    +    "dev": "vite dev --port 3000",
    +    "dev:e2e": "vite dev",
    +    "build": "vite build && tsc --noEmit",
    +    "preview": "vite preview",
    +    "start": "pnpx srvx --prod -s ../client dist/server/server.js",
    +    "test:e2e": "rm -rf port*.txt; playwright test --project=chromium"
    +  },
    +  "dependencies": {
    +    "@tanstack/react-router": "workspace:^",
    +    "@tanstack/react-start": "workspace:^",
    +    "react": "^19.0.0",
    +    "react-dom": "^19.0.0",
    +    "vite": "^7.1.7"
    +  },
    +  "devDependencies": {
    +    "@playwright/test": "^1.50.1",
    +    "@tailwindcss/vite": "^4.1.18",
    +    "@tanstack/router-e2e-utils": "workspace:^",
    +    "@types/node": "^22.10.2",
    +    "@types/react": "^19.0.8",
    +    "@types/react-dom": "^19.0.3",
    +    "@vitejs/plugin-react": "^4.3.4",
    +    "srvx": "^0.9.8",
    +    "tailwindcss": "^4.1.18",
    +    "typescript": "^5.7.2",
    +    "vite-tsconfig-paths": "^5.1.4"
    +  }
    +}
    diff --git a/e2e/react-start/server-routes-global-middleware/playwright.config.ts b/e2e/react-start/server-routes-global-middleware/playwright.config.ts
    new file mode 100644
    index 00000000000..8330e023eea
    --- /dev/null
    +++ b/e2e/react-start/server-routes-global-middleware/playwright.config.ts
    @@ -0,0 +1,35 @@
    +import { defineConfig, devices } from '@playwright/test'
    +import { getTestServerPort } from '@tanstack/router-e2e-utils'
    +import packageJson from './package.json' with { type: 'json' }
    +
    +export const PORT = await getTestServerPort(packageJson.name)
    +const baseURL = `http://localhost:${PORT}`
    +
    +/**
    + * See https://playwright.dev/docs/test-configuration.
    + */
    +export default defineConfig({
    +  testDir: './tests',
    +  workers: 1,
    +
    +  reporter: [['line']],
    +
    +  use: {
    +    /* Base URL to use in actions like `await page.goto('/')`. */
    +    baseURL,
    +  },
    +
    +  webServer: {
    +    command: `VITE_SERVER_PORT=${PORT} pnpm build && PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`,
    +    url: baseURL,
    +    reuseExistingServer: !process.env.CI,
    +    stdout: 'pipe',
    +  },
    +
    +  projects: [
    +    {
    +      name: 'chromium',
    +      use: { ...devices['Desktop Chrome'] },
    +    },
    +  ],
    +})
    diff --git a/e2e/react-start/server-routes-global-middleware/src/routeTree.gen.ts b/e2e/react-start/server-routes-global-middleware/src/routeTree.gen.ts
    new file mode 100644
    index 00000000000..6ec0a951282
    --- /dev/null
    +++ b/e2e/react-start/server-routes-global-middleware/src/routeTree.gen.ts
    @@ -0,0 +1,88 @@
    +/* 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 ServerRouteWithMiddlewareRouteImport } from './routes/server-route-with-middleware'
    +import { Route as IndexRouteImport } from './routes/index'
    +
    +const ServerRouteWithMiddlewareRoute =
    +  ServerRouteWithMiddlewareRouteImport.update({
    +    id: '/server-route-with-middleware',
    +    path: '/server-route-with-middleware',
    +    getParentRoute: () => rootRouteImport,
    +  } as any)
    +const IndexRoute = IndexRouteImport.update({
    +  id: '/',
    +  path: '/',
    +  getParentRoute: () => rootRouteImport,
    +} as any)
    +
    +export interface FileRoutesByFullPath {
    +  '/': typeof IndexRoute
    +  '/server-route-with-middleware': typeof ServerRouteWithMiddlewareRoute
    +}
    +export interface FileRoutesByTo {
    +  '/': typeof IndexRoute
    +  '/server-route-with-middleware': typeof ServerRouteWithMiddlewareRoute
    +}
    +export interface FileRoutesById {
    +  __root__: typeof rootRouteImport
    +  '/': typeof IndexRoute
    +  '/server-route-with-middleware': typeof ServerRouteWithMiddlewareRoute
    +}
    +export interface FileRouteTypes {
    +  fileRoutesByFullPath: FileRoutesByFullPath
    +  fullPaths: '/' | '/server-route-with-middleware'
    +  fileRoutesByTo: FileRoutesByTo
    +  to: '/' | '/server-route-with-middleware'
    +  id: '__root__' | '/' | '/server-route-with-middleware'
    +  fileRoutesById: FileRoutesById
    +}
    +export interface RootRouteChildren {
    +  IndexRoute: typeof IndexRoute
    +  ServerRouteWithMiddlewareRoute: typeof ServerRouteWithMiddlewareRoute
    +}
    +
    +declare module '@tanstack/react-router' {
    +  interface FileRoutesByPath {
    +    '/server-route-with-middleware': {
    +      id: '/server-route-with-middleware'
    +      path: '/server-route-with-middleware'
    +      fullPath: '/server-route-with-middleware'
    +      preLoaderRoute: typeof ServerRouteWithMiddlewareRouteImport
    +      parentRoute: typeof rootRouteImport
    +    }
    +    '/': {
    +      id: '/'
    +      path: '/'
    +      fullPath: '/'
    +      preLoaderRoute: typeof IndexRouteImport
    +      parentRoute: typeof rootRouteImport
    +    }
    +  }
    +}
    +
    +const rootRouteChildren: RootRouteChildren = {
    +  IndexRoute: IndexRoute,
    +  ServerRouteWithMiddlewareRoute: ServerRouteWithMiddlewareRoute,
    +}
    +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/server-routes-global-middleware/src/router.tsx b/e2e/react-start/server-routes-global-middleware/src/router.tsx
    new file mode 100644
    index 00000000000..6ee5705ca9b
    --- /dev/null
    +++ b/e2e/react-start/server-routes-global-middleware/src/router.tsx
    @@ -0,0 +1,12 @@
    +import { createRouter } from '@tanstack/react-router'
    +import { routeTree } from './routeTree.gen'
    +
    +export function getRouter() {
    +  const router = createRouter({
    +    routeTree,
    +    defaultPreload: 'intent',
    +    scrollRestoration: true,
    +  })
    +
    +  return router
    +}
    diff --git a/e2e/react-start/server-routes-global-middleware/src/routes/__root.tsx b/e2e/react-start/server-routes-global-middleware/src/routes/__root.tsx
    new file mode 100644
    index 00000000000..97ee885af73
    --- /dev/null
    +++ b/e2e/react-start/server-routes-global-middleware/src/routes/__root.tsx
    @@ -0,0 +1,61 @@
    +/// 
    +import * as React from 'react'
    +import {
    +  HeadContent,
    +  Link,
    +  Outlet,
    +  Scripts,
    +  createRootRoute,
    +} from '@tanstack/react-router'
    +import appCss from '~/styles/app.css?url'
    +
    +export const Route = createRootRoute({
    +  head: () => ({
    +    meta: [
    +      {
    +        charSet: 'utf-8',
    +      },
    +      {
    +        name: 'viewport',
    +        content: 'width=device-width, initial-scale=1',
    +      },
    +    ],
    +    links: [{ rel: 'stylesheet', href: appCss }],
    +  }),
    +  component: RootComponent,
    +})
    +
    +function RootComponent() {
    +  return (
    +    
    +      
    +        
    +      
    +      
    +        
    + + Home + + + Server Route Test + +
    +
    + + + + + ) +} diff --git a/e2e/react-start/server-routes-global-middleware/src/routes/index.tsx b/e2e/react-start/server-routes-global-middleware/src/routes/index.tsx new file mode 100644 index 00000000000..362ef0947a6 --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/src/routes/index.tsx @@ -0,0 +1,30 @@ +import { Link, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
    +

    + Server Routes Global Middleware Deduplication E2E Tests +

    +

    + Tests for issue #5239: global request middleware is executed multiple + times for single request (server routes variant) +

    +
      +
    • + + Server route with middleware - tests deduplication when same + middleware is in global requestMiddleware and server route + +
    • +
    +
    + ) +} diff --git a/e2e/react-start/server-routes-global-middleware/src/routes/server-route-with-middleware.tsx b/e2e/react-start/server-routes-global-middleware/src/routes/server-route-with-middleware.tsx new file mode 100644 index 00000000000..c14655e3328 --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/src/routes/server-route-with-middleware.tsx @@ -0,0 +1,100 @@ +import { createFileRoute } from '@tanstack/react-router' +import { getMiddlewareExecutionCounts, loggingMiddleware } from '~/start' + +export const Route = createFileRoute('/server-route-with-middleware')({ + // Server route configuration + // loggingMiddleware is ALSO in global requestMiddleware (see start.ts) + // This tests that it only executes once, not twice + server: { + middleware: [loggingMiddleware], + handlers: { + GET: async ({ next, context }) => { + // Get the middleware execution counts at the time of the GET handler + const counts = await getMiddlewareExecutionCounts() + + // Add counts to context - this will be passed to the router as serverContext + return next({ + context: { + middlewareCountsAtHandler: counts, + }, + }) + }, + }, + }, + // beforeLoad runs on server during SSR, can access serverContext + beforeLoad: async ({ context, serverContext }) => { + // On server, serverContext contains data from GET handler + middleware context + // On client (navigation), serverContext won't be present + + // Get the final middleware execution counts + const finalCounts = await getMiddlewareExecutionCounts() + + return { + serverContext, + middlewareCounts: finalCounts, + } + }, + component: ServerRouteWithMiddleware, +}) + +function ServerRouteWithMiddleware() { + const { serverContext, middlewareCounts } = Route.useRouteContext() + + return ( +
    +

    + Server Route with Global Middleware Test +

    + +
    +

    Middleware Execution Counts:

    +
    +          {JSON.stringify(middlewareCounts, null, 2)}
    +        
    +
    + +
    +

    loggingMiddleware Count:

    + + {(middlewareCounts as any)?.loggingMiddleware ?? 'N/A'} + +
    + +
    +

    authMiddleware Count:

    + + {(middlewareCounts as any)?.authMiddleware ?? 'N/A'} + +
    + +
    +

    Server Context:

    +
    +          {JSON.stringify(serverContext, null, 2)}
    +        
    +
    + +
    +

    Deduplication Status:

    + + {(middlewareCounts as any)?.loggingMiddleware === 1 + ? 'SUCCESS: loggingMiddleware executed exactly once' + : `FAILURE: loggingMiddleware executed ${(middlewareCounts as any)?.loggingMiddleware ?? 0} times (expected 1)`} + +
    +
    + ) +} diff --git a/e2e/react-start/server-routes-global-middleware/src/start.ts b/e2e/react-start/server-routes-global-middleware/src/start.ts new file mode 100644 index 00000000000..5c22a7e00ce --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/src/start.ts @@ -0,0 +1,67 @@ +import { + createIsomorphicFn, + createMiddleware, + createStart, +} from '@tanstack/react-start' +import { getRequest } from '@tanstack/react-start/server' + +// Use a WeakMap keyed by Request object for request-scoped tracking +const requestMiddlewareCounts = new WeakMap>() + +// Helper to track middleware execution - only runs on server +export const trackMiddlewareExecution = createIsomorphicFn().server( + (middlewareName: string) => { + const request = getRequest() + let counts = requestMiddlewareCounts.get(request) + if (!counts) { + counts = {} + requestMiddlewareCounts.set(request, counts) + } + counts[middlewareName] = (counts[middlewareName] || 0) + 1 + console.log( + `[MIDDLEWARE] ${middlewareName} executed. Count: ${counts[middlewareName]}`, + ) + return counts[middlewareName] + }, +) + +// Helper to get execution counts for the current request +// Uses createIsomorphicFn so it can be called in beforeLoad without crashing on client +// Returns undefined on client (no .client() impl), returns counts on server +export const getMiddlewareExecutionCounts = createIsomorphicFn().server( + (): Record => { + const request = getRequest() + return requestMiddlewareCounts.get(request) || {} + }, +) + +// This middleware is registered as BOTH: +// 1. Global request middleware (in startInstance) +// 2. Server route middleware (attached to individual routes) +// The bug would be that it executes multiple times instead of being deduped +export const loggingMiddleware = createMiddleware().server(async ({ next }) => { + trackMiddlewareExecution('loggingMiddleware') + return next({ + context: { + loggingMiddlewareExecuted: true, + loggingMiddlewareTimestamp: Date.now(), + }, + }) +}) + +// Another global middleware for testing +export const authMiddleware = createMiddleware().server(async ({ next }) => { + trackMiddlewareExecution('authMiddleware') + return next({ + context: { + authMiddlewareExecuted: true, + userId: 'test-user-123', + }, + }) +}) + +// Create the start instance with global request middleware +export const startInstance = createStart(() => ({ + // Global request middleware - applies to all requests + requestMiddleware: [loggingMiddleware, authMiddleware], +})) diff --git a/e2e/react-start/server-routes-global-middleware/src/styles/app.css b/e2e/react-start/server-routes-global-middleware/src/styles/app.css new file mode 100644 index 00000000000..d4b5078586e --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/src/styles/app.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/e2e/react-start/server-routes-global-middleware/tests/server-routes-global-middleware.spec.ts b/e2e/react-start/server-routes-global-middleware/tests/server-routes-global-middleware.spec.ts new file mode 100644 index 00000000000..a97a2692469 --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/tests/server-routes-global-middleware.spec.ts @@ -0,0 +1,114 @@ +import { expect, test } from '@playwright/test' + +/** + * E2E tests for server routes global middleware deduplication + * Issue #5239: global request middleware is executed multiple times for single request + * + * These tests verify that when the same middleware is registered in BOTH: + * 1. Global requestMiddleware (in start.ts) + * 2. Server route middleware (in the route's server.middleware array) + * + * ...the middleware only executes ONCE per request, not twice. + * + * NOTE: Server route handlers (GET, POST, etc.) only execute on direct navigation (SSR). + * Client-side navigation does NOT trigger the server route handler. + */ + +test.describe('Server Routes Global Middleware Deduplication', () => { + test.describe('Direct Navigation (SSR)', () => { + test('loggingMiddleware should execute exactly once when in both global and route middleware', async ({ + page, + }) => { + // Navigate directly to the server route + // This triggers SSR with the full server route handler execution + await page.goto('/server-route-with-middleware') + + // Wait for the page to fully load + await expect( + page.locator('[data-testid="middleware-counts"]'), + ).toBeVisible() + + // Get the middleware execution count for loggingMiddleware + const loggingCount = await page + .locator('[data-testid="logging-middleware-count"]') + .textContent() + + // loggingMiddleware should execute exactly once + // It's in global requestMiddleware AND in the route's server.middleware + // With proper deduplication, it should only run once + expect(loggingCount).toBe('1') + + // Verify the deduplication status message + const dedupStatus = await page + .locator('[data-testid="dedup-status"]') + .textContent() + expect(dedupStatus).toContain('SUCCESS') + }) + + test('authMiddleware should execute exactly once (global only)', async ({ + page, + }) => { + // Navigate directly to the server route + await page.goto('/server-route-with-middleware') + + // Wait for the page to fully load + await expect( + page.locator('[data-testid="middleware-counts"]'), + ).toBeVisible() + + // Get the middleware execution count for authMiddleware + const authCount = await page + .locator('[data-testid="auth-middleware-count"]') + .textContent() + + // authMiddleware is only in global requestMiddleware, not in route middleware + // It should execute exactly once + expect(authCount).toBe('1') + }) + + test('server context should contain middleware data', async ({ page }) => { + // Navigate directly to the server route + await page.goto('/server-route-with-middleware') + + // Wait for the page to fully load + await expect(page.locator('[data-testid="server-context"]')).toBeVisible() + + // Get the server context + const serverContextText = await page + .locator('[data-testid="server-context"]') + .textContent() + + const serverContext = JSON.parse(serverContextText || '{}') + + // Verify middleware context data is present + expect(serverContext.loggingMiddlewareExecuted).toBe(true) + expect(serverContext.authMiddlewareExecuted).toBe(true) + expect(serverContext.userId).toBe('test-user-123') + }) + + test('middleware counts object should have correct structure', async ({ + page, + }) => { + // Navigate directly to the server route + await page.goto('/server-route-with-middleware') + + // Wait for the page to fully load + await expect( + page.locator('[data-testid="middleware-counts"]'), + ).toBeVisible() + + // Get the full middleware counts object + const countsText = await page + .locator('[data-testid="middleware-counts"]') + .textContent() + + const counts = JSON.parse(countsText || '{}') + + // Both middlewares should have executed exactly once + expect(counts).toEqual({ + loggingMiddleware: 1, + authMiddleware: 1, + }) + }) + }) +}) diff --git a/e2e/react-start/server-routes-global-middleware/tsconfig.json b/e2e/react-start/server-routes-global-middleware/tsconfig.json new file mode 100644 index 00000000000..a07911fe5f4 --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/tsconfig.json @@ -0,0 +1,23 @@ +{ + "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, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true, + "types": ["vite/client"] + } +} diff --git a/e2e/react-start/server-routes-global-middleware/vite.config.ts b/e2e/react-start/server-routes-global-middleware/vite.config.ts new file mode 100644 index 00000000000..3e946e6645b --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [ + tailwindcss(), + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + viteReact(), + ], +}) diff --git a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts index 4cf9ac381ce..6e54793d863 100644 --- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts +++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts @@ -25,6 +25,15 @@ function hasOwnProperties(obj: object): boolean { } return false } +// caller => +// serverFnFetcher => +// client => +// server => +// fn => +// seroval => +// client middleware => +// serverFnFetcher => +// caller export async function serverFnFetcher( url: string, @@ -43,7 +52,7 @@ export async function serverFnFetcher( // Arrange the headers const headers = first.headers ? new Headers(first.headers) : new Headers() - headers.set('x-tsr-redirect', 'manual') + headers.set('x-tsr-serverFn', 'true') if (type === 'payload') { headers.set('accept', 'application/x-ndjson, application/json') @@ -146,7 +155,7 @@ async function getFetchBody( async function getResponse(fn: () => Promise) { let response: Response try { - response = await fn() + response = await fn() // client => server => fn => server => client } catch (error) { if (error instanceof Response) { response = error @@ -159,22 +168,16 @@ async function getResponse(fn: () => Promise) { if (response.headers.get(X_TSS_RAW_RESPONSE) === 'true') { return response } + const contentType = response.headers.get('content-type') invariant(contentType, 'expected content-type header to be set') const serializedByStart = !!response.headers.get(X_TSS_SERIALIZED) - // If the response is not ok, throw an error - if (!response.ok) { - if (serializedByStart && contentType.includes('application/json')) { - const jsonPayload = await response.json() - const result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins! }) - throw result - } - - throw new Error(await response.text()) - } + // If the response is serialized by the start server, we need to process it + // differently than a normal response. if (serializedByStart) { let result + // If it's a stream from the start serializer, process it as such if (contentType.includes('application/x-ndjson')) { const refs = new Map() result = await processServerFnResponse({ @@ -187,17 +190,22 @@ async function getResponse(fn: () => Promise) { }, }) } + // If it's a JSON response, it can be simpler if (contentType.includes('application/json')) { const jsonPayload = await response.json() result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins! }) } + invariant(result, 'expected result to be resolved') if (result instanceof Error) { throw result } + return result } + // If it wasn't processed by the start serializer, check + // if it's JSON if (contentType.includes('application/json')) { const jsonPayload = await response.json() const redirect = parseRedirect(jsonPayload) @@ -210,6 +218,12 @@ async function getResponse(fn: () => Promise) { return jsonPayload } + // Otherwise, if it's not OK, throw the content + if (!response.ok) { + throw new Error(await response.text()) + } + + // Or return the response itself return response } diff --git a/packages/start-client-core/src/constants.ts b/packages/start-client-core/src/constants.ts index 1e541af3231..4e0777068d2 100644 --- a/packages/start-client-core/src/constants.ts +++ b/packages/start-client-core/src/constants.ts @@ -6,4 +6,5 @@ export const TSS_SERVER_FUNCTION_FACTORY = Symbol.for( export const X_TSS_SERIALIZED = 'x-tss-serialized' export const X_TSS_RAW_RESPONSE = 'x-tss-raw' +export const X_TSS_CONTEXT = 'x-tss-context' export {} diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index 3307d4b7cee..b9294328e67 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -1,10 +1,10 @@ -import { isNotFound, isRedirect } from '@tanstack/router-core' import { mergeHeaders } from '@tanstack/router-core/ssr/client' +import { isRedirect, parseRedirect } from '@tanstack/router-core' import { TSS_SERVER_FUNCTION_FACTORY } from './constants' import { getStartOptions } from './getStartOptions' import { getStartContextServerOnly } from './getStartContextServerOnly' -import type { TSS_SERVER_FUNCTION } from './constants' +import { createNullProtoObject, safeObjectMerge } from './safeObjectMerge' import type { AnyValidator, Constrain, @@ -16,6 +16,7 @@ import type { ValidateSerializableInput, Validator, } from '@tanstack/router-core' +import type { TSS_SERVER_FUNCTION } from './constants' import type { AnyFunctionMiddleware, AnyRequestMiddleware, @@ -112,17 +113,22 @@ export const createServerFn: CreateServerFn = (options, __opts) => { return Object.assign( async (opts?: CompiledFetcherFnOptions) => { // Start by executing the client-side middleware chain - return executeMiddleware(resolvedMiddleware, 'client', { + const result = await executeMiddleware(resolvedMiddleware, 'client', { ...extractedFn, ...newOptions, data: opts?.data as any, headers: opts?.headers, signal: opts?.signal, - context: {}, - }).then((d) => { - if (d.error) throw d.error - return d.result + context: createNullProtoObject(), }) + + const redirect = parseRedirect(result.error) + if (redirect) { + throw redirect + } + + if (result.error) throw result.error + return result.result }, { // This copies over the URL, function ID @@ -133,25 +139,30 @@ export const createServerFn: CreateServerFn = (options, __opts) => { const startContext = getStartContextServerOnly() const serverContextAfterGlobalMiddlewares = startContext.contextAfterGlobalMiddlewares + // Use safeObjectMerge for opts.context which comes from client const ctx = { ...extractedFn, ...opts, - context: { - ...serverContextAfterGlobalMiddlewares, - ...opts.context, - }, + context: safeObjectMerge( + serverContextAfterGlobalMiddlewares, + opts.context, + ), signal, request: startContext.request, } - return executeMiddleware(resolvedMiddleware, 'server', ctx).then( - (d) => ({ - // Only send the result and sendContext back to the client - result: d.result, - error: d.error, - context: d.sendContext, - }), - ) + const result = await executeMiddleware( + resolvedMiddleware, + 'server', + ctx, + ).then((d) => ({ + // Only send the result and sendContext back to the client + result: d.result, + error: d.error, + context: d.sendContext, + })) + + return result }, }, ) as any @@ -173,12 +184,23 @@ export async function executeMiddleware( opts: ServerFnMiddlewareOptions, ): Promise { const globalMiddlewares = getStartOptions()?.functionMiddleware || [] - const flattenedMiddlewares = flattenMiddlewares([ + let flattenedMiddlewares = flattenMiddlewares([ ...globalMiddlewares, ...middlewares, ]) - const next: NextFn = async (ctx) => { + // On server, filter out middlewares that already executed in the request phase + // to prevent duplicate execution (issue #5239) + if (env === 'server') { + const startContext = getStartContextServerOnly({ throwIfNotFound: false }) + if (startContext?.executedRequestMiddlewares) { + flattenedMiddlewares = flattenedMiddlewares.filter( + (m) => !startContext.executedRequestMiddlewares.has(m), + ) + } + } + + const callNextMiddleware: NextFn = async (ctx) => { // Get the next middleware const nextMiddleware = flattenedMiddlewares.shift() @@ -187,54 +209,110 @@ export async function executeMiddleware( return ctx } - if ( - 'inputValidator' in nextMiddleware.options && - nextMiddleware.options.inputValidator && - env === 'server' - ) { - // Execute the middleware's input function - ctx.data = await execValidator( - nextMiddleware.options.inputValidator, - ctx.data, - ) - } + // Execute the middleware + try { + if ( + 'inputValidator' in nextMiddleware.options && + nextMiddleware.options.inputValidator && + env === 'server' + ) { + // Execute the middleware's input function + ctx.data = await execValidator( + nextMiddleware.options.inputValidator, + ctx.data, + ) + } - let middlewareFn: MiddlewareFn | undefined = undefined - if (env === 'client') { - if ('client' in nextMiddleware.options) { - middlewareFn = nextMiddleware.options.client as MiddlewareFn | undefined + let middlewareFn: MiddlewareFn | undefined = undefined + if (env === 'client') { + if ('client' in nextMiddleware.options) { + middlewareFn = nextMiddleware.options.client as + | MiddlewareFn + | undefined + } + } + // env === 'server' + else if ('server' in nextMiddleware.options) { + middlewareFn = nextMiddleware.options.server as MiddlewareFn | undefined } - } - // env === 'server' - else if ('server' in nextMiddleware.options) { - middlewareFn = nextMiddleware.options.server as MiddlewareFn | undefined - } - if (middlewareFn) { - // Execute the middleware - return applyMiddleware(middlewareFn, ctx, async (newCtx) => { - return next(newCtx).catch((error: any) => { - if (isRedirect(error) || isNotFound(error)) { + if (middlewareFn) { + const userNext = async ( + userCtx: ServerFnMiddlewareResult | undefined = {} as any, + ) => { + // Return the next middleware + // Use safeObjectMerge for context objects to prevent prototype pollution + const nextCtx = { + ...ctx, + ...userCtx, + context: safeObjectMerge(ctx.context, userCtx.context), + sendContext: safeObjectMerge(ctx.sendContext, userCtx.sendContext), + headers: mergeHeaders(ctx.headers, userCtx.headers), + result: + userCtx.result !== undefined + ? userCtx.result + : userCtx instanceof Response + ? userCtx + : (ctx as any).result, + error: userCtx.error ?? (ctx as any).error, + } + + try { + return await callNextMiddleware(nextCtx) + } catch (error: any) { return { - ...newCtx, + ...nextCtx, error, } } + } - throw error - }) - }) - } + // Execute the middleware + const result = await middlewareFn({ + ...ctx, + next: userNext as any, + } as any) + + // If result is NOT a ctx object, we need to return it as + // the { result } + if (isRedirect(result)) { + return { + ...ctx, + error: result, + } + } + + if (result instanceof Response) { + return { + ...ctx, + result, + } + } - return next(ctx) + if (!(result as any)) { + throw new Error( + 'User middleware returned undefined. You must call next() or return a result in your middlewares.', + ) + } + + return result + } + + return callNextMiddleware(ctx) + } catch (error: any) { + return { + ...ctx, + error, + } + } } // Start the middleware chain - return next({ + return callNextMiddleware({ ...opts, headers: opts.headers || {}, sendContext: opts.sendContext || {}, - context: opts.context || {}, + context: opts.context || createNullProtoObject(), }) } @@ -571,18 +649,21 @@ export interface ServerFnTypes< allOutput: IntersectAllValidatorOutputs } -export function flattenMiddlewares( - middlewares: Array, -): Array { - const seen = new Set() - const flattened: Array = [] +export function flattenMiddlewares< + T extends AnyFunctionMiddleware | AnyRequestMiddleware, +>(middlewares: Array, maxDepth: number = 100): Array { + const seen = new Set() + const flattened: Array = [] - const recurse = ( - middleware: Array, - ) => { + const recurse = (middleware: Array, depth: number) => { + if (depth > maxDepth) { + throw new Error( + `Middleware nesting depth exceeded maximum of ${maxDepth}. Check for circular references.`, + ) + } middleware.forEach((m) => { if (m.options.middleware) { - recurse(m.options.middleware) + recurse(m.options.middleware as Array, depth + 1) } if (!seen.has(m)) { @@ -592,7 +673,7 @@ export function flattenMiddlewares( }) } - recurse(middlewares) + recurse(middlewares, 0) return flattened } @@ -622,41 +703,6 @@ export type MiddlewareFn = ( }, ) => Promise -export const applyMiddleware = async ( - middlewareFn: MiddlewareFn, - ctx: ServerFnMiddlewareOptions, - nextFn: NextFn, -) => { - return middlewareFn({ - ...ctx, - next: (async ( - userCtx: ServerFnMiddlewareResult | undefined = {} as any, - ) => { - // Return the next middleware - return nextFn({ - ...ctx, - ...userCtx, - context: { - ...ctx.context, - ...userCtx.context, - }, - sendContext: { - ...ctx.sendContext, - ...(userCtx.sendContext ?? {}), - }, - headers: mergeHeaders(ctx.headers, userCtx.headers), - result: - userCtx.result !== undefined - ? userCtx.result - : userCtx instanceof Response - ? userCtx - : (ctx as any).result, - error: userCtx.error ?? (ctx as any).error, - }) - }) as any, - } as any) -} - export function execValidator( validator: AnyValidator, input: unknown, diff --git a/packages/start-client-core/src/index.tsx b/packages/start-client-core/src/index.tsx index eb5f7eb8f0e..1dbdff8f19c 100644 --- a/packages/start-client-core/src/index.tsx +++ b/packages/start-client-core/src/index.tsx @@ -72,7 +72,6 @@ export type { RequiredFetcher, } from './createServerFn' export { - applyMiddleware, execValidator, flattenMiddlewares, executeMiddleware, @@ -83,6 +82,7 @@ export { TSS_SERVER_FUNCTION, X_TSS_SERIALIZED, X_TSS_RAW_RESPONSE, + X_TSS_CONTEXT, } from './constants' export type * from './serverRoute' @@ -100,3 +100,4 @@ export type { Register } from '@tanstack/router-core' export { getRouterInstance } from './getRouterInstance' export { getDefaultSerovalPlugins } from './getDefaultSerovalPlugins' export { getGlobalStartContext } from './getGlobalStartContext' +export { safeObjectMerge, createNullProtoObject } from './safeObjectMerge' diff --git a/packages/start-client-core/src/safeObjectMerge.ts b/packages/start-client-core/src/safeObjectMerge.ts new file mode 100644 index 00000000000..e5eb6966b2f --- /dev/null +++ b/packages/start-client-core/src/safeObjectMerge.ts @@ -0,0 +1,38 @@ +function isSafeKey(key: string): boolean { + return key !== '__proto__' && key !== 'constructor' && key !== 'prototype' +} + +/** + * Merge target and source into a new null-proto object, filtering dangerous keys. + */ +export function safeObjectMerge>( + target: T | undefined, + source: Record | null | undefined, +): T { + const result = Object.create(null) as T + if (target) { + for (const key of Object.keys(target)) { + if (isSafeKey(key)) result[key as keyof T] = target[key] as T[keyof T] + } + } + if (source && typeof source === 'object') { + for (const key of Object.keys(source)) { + if (isSafeKey(key)) result[key as keyof T] = source[key] as T[keyof T] + } + } + return result +} + +/** + * Create a null-prototype object, optionally copying from source. + */ +export function createNullProtoObject( + source?: T, +): { [K in keyof T]: T[K] } { + if (!source) return Object.create(null) + const obj = Object.create(null) + for (const key of Object.keys(source)) { + if (isSafeKey(key)) obj[key] = (source as Record)[key] + } + return obj +} diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index b0984b32751..1f2820c7f13 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -1,5 +1,10 @@ import { createMemoryHistory } from '@tanstack/history' -import { flattenMiddlewares, mergeHeaders } from '@tanstack/start-client-core' +import { + createNullProtoObject, + flattenMiddlewares, + mergeHeaders, + safeObjectMerge, +} from '@tanstack/start-client-core' import { executeRewriteInput, isRedirect, @@ -17,6 +22,8 @@ import { handleServerAction } from './server-functions-handler' import { HEADERS } from './constants' import { ServerFunctionSerializationAdapter } from './serializer/ServerFunctionSerializationAdapter' import type { + AnyFunctionMiddleware, + AnyRequestMiddleware, AnyStartInstanceOptions, RouteMethod, RouteMethodHandlerFn, @@ -27,7 +34,6 @@ import type { RequestHandler } from './request-handler' import type { AnyRoute, AnyRouter, - Awaitable, Manifest, Register, } from '@tanstack/router-core' @@ -35,6 +41,10 @@ import type { HandlerCallback } from '@tanstack/router-core/ssr/server' type TODO = any +type AnyMiddlewareServerFn = + | AnyRequestMiddleware['options']['server'] + | AnyFunctionMiddleware['options']['server'] + function getStartResponseHeaders(opts: { router: AnyRouter }) { const headers = mergeHeaders( { @@ -47,46 +57,165 @@ function getStartResponseHeaders(opts: { router: AnyRouter }) { return headers } -export function createStartHandler( - cb: HandlerCallback, -): RequestHandler { - const ROUTER_BASEPATH = process.env.TSS_ROUTER_BASEPATH || '/' - let startRoutesManifest: Manifest | null = null - let startEntry: StartEntry | null = null - let routerEntry: RouterEntry | null = null - const getEntries = async (): Promise<{ - startEntry: StartEntry - routerEntry: RouterEntry - }> => { - if (routerEntry === null) { - // @ts-ignore when building, we currently don't respect tsconfig.ts' `include` so we are not picking up the .d.ts from start-client-core - routerEntry = await import('#tanstack-router-entry') +// Cached entries - loaded once per process +let cachedStartEntry: StartEntry | null = null +let cachedRouterEntry: RouterEntry | null = null +let cachedManifest: Manifest | null = null + +async function getEntries(): Promise<{ + startEntry: StartEntry + routerEntry: RouterEntry +}> { + if (cachedRouterEntry === null) { + // @ts-ignore when building, we currently don't respect tsconfig.ts' `include` so we are not picking up the .d.ts from start-client-core + cachedRouterEntry = await import('#tanstack-router-entry') + } + if (cachedStartEntry === null) { + // @ts-ignore when building, we currently don't respect tsconfig.ts' `include` so we are not picking up the .d.ts from start-client-core + cachedStartEntry = await import('#tanstack-start-entry') + } + return { + startEntry: cachedStartEntry as unknown as StartEntry, + routerEntry: cachedRouterEntry as unknown as RouterEntry, + } +} + +async function getManifest(): Promise { + if (cachedManifest === null) { + cachedManifest = await getStartManifest() + } + return cachedManifest +} + +// Pre-computed constants +const ROUTER_BASEPATH = process.env.TSS_ROUTER_BASEPATH || '/' +const SERVER_FN_BASE = process.env.TSS_SERVER_FN_BASE +const IS_PRERENDERING = process.env.TSS_PRERENDERING === 'true' +const IS_SHELL_ENV = process.env.TSS_SHELL === 'true' +const IS_DEV = process.env.NODE_ENV === 'development' + +// Reusable error messages +const ERR_NO_RESPONSE = IS_DEV + ? `It looks like you forgot to return a response from your server route handler. If you want to defer to the app router, make sure to have a component set in this route.` + : 'Internal Server Error' + +const ERR_NO_DEFER = IS_DEV + ? `You cannot defer to the app router if there is no component defined on this route.` + : 'Internal Server Error' + +function throwRouteHandlerError(): never { + throw new Error(ERR_NO_RESPONSE) +} + +function throwIfMayNotDefer(): never { + throw new Error(ERR_NO_DEFER) +} + +/** + * Check if a value is a special response (Response or Redirect) + */ +function isSpecialResponse(value: unknown): value is Response { + return value instanceof Response || isRedirect(value) +} + +/** + * Normalize middleware result to context shape + */ +function handleCtxResult(result: TODO) { + if (isSpecialResponse(result)) { + return { response: result } + } + return result +} + +/** + * Execute a middleware chain + */ +function executeMiddleware(middlewares: Array, ctx: TODO): Promise { + let index = -1 + + const next = async (nextCtx?: TODO): Promise => { + // Merge context if provided using safeObjectMerge for prototype pollution prevention + if (nextCtx) { + if (nextCtx.context) { + ctx.context = safeObjectMerge(ctx.context, nextCtx.context) + } + // Copy own properties except context (Object.keys returns only own enumerable properties) + for (const key of Object.keys(nextCtx)) { + if (key !== 'context') { + ctx[key] = nextCtx[key] + } + } } - if (startEntry === null) { - // @ts-ignore when building, we currently don't respect tsconfig.ts' `include` so we are not picking up the .d.ts from start-client-core - startEntry = await import('#tanstack-start-entry') + + index++ + const middleware = middlewares[index] + if (!middleware) return ctx + + let result: TODO + try { + result = await middleware({ ...ctx, next }) + } catch (err) { + if (isSpecialResponse(err)) { + ctx.response = err + return ctx + } + throw err } - return { - startEntry: startEntry as unknown as StartEntry, - routerEntry: routerEntry as unknown as RouterEntry, + + const normalized = handleCtxResult(result) + if (normalized) { + if (normalized.response !== undefined) { + ctx.response = normalized.response + } + if (normalized.context) { + ctx.context = safeObjectMerge(ctx.context, normalized.context) + } } + + return ctx } + return next() +} + +/** + * Wrap a route handler as middleware + */ +function handlerToMiddleware( + handler: RouteMethodHandlerFn, + mayDefer: boolean = false, +): TODO { + if (mayDefer) { + return handler + } + return async (ctx: TODO) => { + const response = await handler({ ...ctx, next: throwIfMayNotDefer }) + if (!response) { + throwRouteHandlerError() + } + return response + } +} + +export function createStartHandler( + cb: HandlerCallback, +): RequestHandler { const startRequestResolver: RequestHandler = async ( request, requestOpts, ) => { let router: AnyRouter | null = null as AnyRouter | null - // Track whether the callback will handle cleanup let cbWillCleanup = false as boolean - try { - const origin = getOrigin(request) + try { const url = new URL(request.url) const href = url.href.replace(url.origin, '') + const origin = getOrigin(request) + const entries = await getEntries() const startOptions: AnyStartInstanceOptions = - (await (await getEntries()).startEntry.startInstance?.getOptions()) || + (await entries.startEntry.startInstance?.getOptions()) || ({} as AnyStartInstanceOptions) const serializationAdapters = [ @@ -99,22 +228,27 @@ export function createStartHandler( serializationAdapters, } - const getRouter = async () => { + // Flatten request middlewares once + const flattenedRequestMiddlewares = startOptions.requestMiddleware + ? flattenMiddlewares(startOptions.requestMiddleware) + : [] + + // Create set for deduplication + const executedRequestMiddlewares = new Set( + flattenedRequestMiddlewares, + ) + + // Memoized router getter + const getRouter = async (): Promise => { if (router) return router - router = await (await getEntries()).routerEntry.getRouter() - - // Update the client-side router with the history - const isPrerendering = process.env.TSS_PRERENDERING === 'true' - // env var is set during dev is SPA mode is enabled - let isShell = process.env.TSS_SHELL === 'true' - if (isPrerendering && !isShell) { - // only read the shell header if we are prerendering - // to avoid runtime behavior changes by injecting this header - // the header is set by the prerender plugin + + router = await entries.routerEntry.getRouter() + + let isShell = IS_SHELL_ENV + if (IS_PRERENDERING && !isShell) { isShell = request.headers.get(HEADERS.TSS_SHELL) === 'true' } - // Create a history for the client-side router const history = createMemoryHistory({ initialEntries: [href], }) @@ -122,7 +256,7 @@ export function createStartHandler( router.update({ history, isShell, - isPrerendering, + isPrerendering: IS_PRERENDERING, origin: router.options.origin ?? origin, ...{ defaultSsr: requestStartOptions.defaultSsr, @@ -133,184 +267,134 @@ export function createStartHandler( }, basepath: ROUTER_BASEPATH, }) + return router } - const requestHandlerMiddleware = handlerToMiddleware( - async ({ context }) => { - const response = await runWithStartContext( + // Check for server function requests first (early exit) + if (SERVER_FN_BASE && url.pathname.startsWith(SERVER_FN_BASE)) { + const serverFnId = url.pathname + .slice(SERVER_FN_BASE.length) + .split('/')[0] + + if (!serverFnId) { + throw new Error('Invalid server action param for serverFnId') + } + + const serverFnHandler = async ({ context }: TODO) => { + return runWithStartContext( { getRouter, startOptions: requestStartOptions, contextAfterGlobalMiddlewares: context, request, + executedRequestMiddlewares, }, - async () => { - try { - // First, let's attempt to handle server functions - if (href.startsWith(process.env.TSS_SERVER_FN_BASE)) { - return await handleServerAction({ - request, - context: requestOpts?.context, - }) - } - - const executeRouter = async ({ - serverContext, - }: { - serverContext: any - }) => { - const requestAcceptHeader = - request.headers.get('Accept') || '*/*' - const splitRequestAcceptHeader = - requestAcceptHeader.split(',') - - const supportedMimeTypes = ['*/*', 'text/html'] - const isRouterAcceptSupported = supportedMimeTypes.some( - (mimeType) => - splitRequestAcceptHeader.some((acceptedMimeType) => - acceptedMimeType.trim().startsWith(mimeType), - ), - ) - - if (!isRouterAcceptSupported) { - return Response.json( - { - error: 'Only HTML requests are supported here', - }, - { - status: 500, - }, - ) - } - - // if the startRoutesManifest is not loaded yet, load it once - if (startRoutesManifest === null) { - startRoutesManifest = await getStartManifest() - } - const router = await getRouter() - attachRouterServerSsrUtils({ - router, - manifest: startRoutesManifest, - }) - - router.update({ additionalContext: { serverContext } }) - await router.load() - - // If there was a redirect, skip rendering the page at all - if (router.state.redirect) { - return router.state.redirect - } - - await router.serverSsr!.dehydrate() - - const responseHeaders = getStartResponseHeaders({ router }) - // Mark that the callback will handle cleanup - cbWillCleanup = true - const response = await cb({ - request, - router, - responseHeaders, - }) - - return response - } - - const response = await handleServerRoutes({ - getRouter, - request, - executeRouter, - context, - }) - - return response - } catch (err) { - if (err instanceof Response) { - return err - } - - throw err - } - }, + () => + handleServerAction({ + request, + context: requestOpts?.context, + serverFnId, + }), ) - return response - }, - ) + } - const flattenedMiddlewares = startOptions.requestMiddleware - ? flattenMiddlewares(startOptions.requestMiddleware) - : [] - const middlewares = flattenedMiddlewares.map((d) => d.options.server) - const ctx = await executeMiddleware( - [...middlewares, requestHandlerMiddleware], - { + const middlewares = flattenedRequestMiddlewares.map( + (d) => d.options.server, + ) + const ctx = await executeMiddleware([...middlewares, serverFnHandler], { request, + context: createNullProtoObject(requestOpts?.context), + }) - context: requestOpts?.context || {}, - }, - ) + return handleRedirectResponse(ctx.response, request, getRouter) + } - const response: Response = ctx.response - - if (isRedirect(response)) { - if (isResolvedRedirect(response)) { - if (request.headers.get('x-tsr-redirect') === 'manual') { - return Response.json( - { - ...response.options, - isSerializedRedirect: true, - }, - { - headers: response.headers, - }, - ) - } - return response - } - if ( - response.options.to && - typeof response.options.to === 'string' && - !response.options.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(response.options)}`, - ) - } + // Router execution function + const executeRouter = async (serverContext: TODO): Promise => { + const acceptHeader = request.headers.get('Accept') || '*/*' + const acceptParts = acceptHeader.split(',') + const supportedMimeTypes = ['*/*', 'text/html'] - if ( - ['params', 'search', 'hash'].some( - (d) => typeof (response.options as any)[d] === 'function', - ) - ) { - 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( - response.options, - ) - .filter((d) => typeof (response.options as any)[d] === 'function') - .map((d) => `"${d}"`) - .join(', ')}`, + const isSupported = supportedMimeTypes.some((mimeType) => + acceptParts.some((part) => part.trim().startsWith(mimeType)), + ) + + if (!isSupported) { + return Response.json( + { error: 'Only HTML requests are supported here' }, + { status: 500 }, ) } - const router = await getRouter() - const redirect = router.resolveRedirect(response) + const manifest = await getManifest() + const routerInstance = await getRouter() - if (request.headers.get('x-tsr-redirect') === 'manual') { - return Response.json( - { - ...response.options, - isSerializedRedirect: true, - }, - { - headers: response.headers, - }, - ) + attachRouterServerSsrUtils({ + router: routerInstance, + manifest, + }) + + routerInstance.update({ additionalContext: { serverContext } }) + await routerInstance.load() + + if (routerInstance.state.redirect) { + return routerInstance.state.redirect } - return redirect + await routerInstance.serverSsr!.dehydrate() + + const responseHeaders = getStartResponseHeaders({ + router: routerInstance, + }) + cbWillCleanup = true + + return cb({ + request, + router: routerInstance, + responseHeaders, + }) } - return response + // Main request handler + const requestHandlerMiddleware = async ({ context }: TODO) => { + return runWithStartContext( + { + getRouter, + startOptions: requestStartOptions, + contextAfterGlobalMiddlewares: context, + request, + executedRequestMiddlewares, + }, + 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 ctx = await executeMiddleware( + [...middlewares, requestHandlerMiddleware], + { request, context: createNullProtoObject(requestOpts?.context) }, + ) + + return handleRedirectResponse(ctx.response, request, getRouter) } finally { if (router && !cbWillCleanup) { // Clean up router SSR state if it was set up but won't be cleaned up by the callback @@ -326,25 +410,78 @@ export function createStartHandler( return requestHandler(startRequestResolver) } +async function handleRedirectResponse( + response: Response, + request: Request, + getRouter: () => Promise, +): Promise { + if (!isRedirect(response)) { + return response + } + + if (isResolvedRedirect(response)) { + if (request.headers.get('x-tsr-serverFn') === 'true') { + return Response.json( + { ...response.options, isSerializedRedirect: true }, + { headers: response.headers }, + ) + } + return response + } + + const opts = response.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', + ) + ) { + 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') + .map((d) => `"${d}"`) + .join(', ')}`, + ) + } + + const router = await getRouter() + const redirect = router.resolveRedirect(response) + + if (request.headers.get('x-tsr-serverFn') === 'true') { + return Response.json( + { ...response.options, isSerializedRedirect: true }, + { headers: response.headers }, + ) + } + + return redirect +} + async function handleServerRoutes({ getRouter, request, + url, executeRouter, context, + executedRequestMiddlewares, }: { - getRouter: () => Awaitable + getRouter: () => Promise request: Request - executeRouter: ({ - serverContext, - }: { - serverContext: any - }) => Promise + url: URL + executeRouter: (serverContext: any) => Promise context: any -}) { + executedRequestMiddlewares: Set +}): Promise { const router = await getRouter() - let url = new URL(request.url) - url = executeRewriteInput(router.rewrite, url) - const pathname = url.pathname + const rewrittenUrl = executeRewriteInput(router.rewrite, url) + const pathname = rewrittenUrl.pathname // this will perform a fuzzy match, however for server routes we need an exact match // if the route is not an exact match, executeRouter will handle rendering the app router // the match will be cached internally, so no extra work is done during the app router render @@ -353,158 +490,64 @@ async function handleServerRoutes({ const isExactMatch = foundRoute && routeParams['**'] === undefined - // TODO: Error handling? What happens when its `throw redirect()` vs `throw new Error()`? - - const middlewares = flattenMiddlewares( - matchedRoutes.flatMap((r) => r.options.server?.middleware).filter(Boolean), - ).map((d) => d.options.server) - - const server = foundRoute?.options.server - if (server && isExactMatch) { - if (server.handlers) { - const handlers = - typeof server.handlers === 'function' - ? server.handlers({ - createHandlers: (d: any) => d, - }) - : server.handlers - - const requestMethod = request.method.toUpperCase() as RouteMethod - - // Attempt to find the method in the handlers - const handler = handlers[requestMethod] ?? handlers['ANY'] - - // If a method is found, execute the handler - if (handler) { - const mayDefer = !!foundRoute.options.component - if (typeof handler === 'function') { - middlewares.push(handlerToMiddleware(handler, mayDefer)) - } else { - const { middleware } = handler - if (middleware && middleware.length) { - middlewares.push( - ...flattenMiddlewares(middleware).map((d) => d.options.server), - ) - } - if (handler.handler) { - middlewares.push(handlerToMiddleware(handler.handler, mayDefer)) - } + // Collect and dedupe route middlewares + const routeMiddlewares: Array = [] + + // Collect middleware from matched routes, filtering out those already executed + // in the request phase + for (const route of matchedRoutes) { + const serverMiddleware = route.options.server?.middleware as + | Array + | undefined + if (serverMiddleware) { + const flattened = flattenMiddlewares(serverMiddleware) + for (const m of flattened) { + if (!executedRequestMiddlewares.has(m)) { + routeMiddlewares.push(m.options.server) } } } } - // eventually, execute the router - middlewares.push( - handlerToMiddleware((ctx) => executeRouter({ serverContext: ctx.context })), - ) - - const ctx = await executeMiddleware(middlewares, { - request, - context, - params: routeParams, - pathname, - }) - - const response: Response = ctx.response - - return response -} - -function throwRouteHandlerError() { - if (process.env.NODE_ENV === 'development') { - throw new Error( - `It looks like you forgot to return a response from your server route handler. If you want to defer to the app router, make sure to have a component set in this route.`, - ) - } - throw new Error('Internal Server Error') -} - -function throwIfMayNotDefer() { - if (process.env.NODE_ENV === 'development') { - throw new Error( - `You cannot defer to the app router if there is no component defined on this route.`, - ) - } - throw new Error('Internal Server Error') -} -function handlerToMiddleware( - handler: RouteMethodHandlerFn, - mayDefer: boolean = false, -) { - if (mayDefer) { - return handler as TODO - } - return async ({ next: _next, ...rest }: TODO) => { - const response = await handler({ ...rest, next: throwIfMayNotDefer }) - if (!response) { - throwRouteHandlerError() - } - return response - } -} + // Add handler middleware if exact match + const server = foundRoute?.options.server + if (server?.handlers && isExactMatch) { + const handlers = + typeof server.handlers === 'function' + ? server.handlers({ createHandlers: (d: any) => d }) + : server.handlers -function executeMiddleware(middlewares: TODO, ctx: TODO) { - let index = -1 + const requestMethod = request.method.toUpperCase() as RouteMethod + const handler = handlers[requestMethod] ?? handlers['ANY'] - const next = async (ctx: TODO) => { - index++ - const middleware = middlewares[index] - if (!middleware) return ctx + if (handler) { + const mayDefer = !!foundRoute.options.component - let result - try { - result = await middleware({ - ...ctx, - // Allow the middleware to call the next middleware in the chain - next: async (nextCtx: TODO) => { - // Allow the caller to extend the context for the next middleware - const nextResult = await next({ - ...ctx, - ...nextCtx, - context: { - ...ctx.context, - ...(nextCtx?.context || {}), - }, - }) - - // Merge the result into the context\ - return Object.assign(ctx, handleCtxResult(nextResult)) - }, - // Allow the middleware result to extend the return context - }) - } catch (err: TODO) { - if (isSpecialResponse(err)) { - result = { - response: err, - } + if (typeof handler === 'function') { + routeMiddlewares.push(handlerToMiddleware(handler, mayDefer)) } else { - throw err + if (handler.middleware?.length) { + const handlerMiddlewares = flattenMiddlewares(handler.middleware) + for (const m of handlerMiddlewares) { + routeMiddlewares.push(m.options.server) + } + } + if (handler.handler) { + routeMiddlewares.push(handlerToMiddleware(handler.handler, mayDefer)) + } } } - - // Merge the middleware result into the context, just in case it - // returns a partial context - return Object.assign(ctx, handleCtxResult(result)) - } - - return handleCtxResult(next(ctx)) -} - -function handleCtxResult(result: TODO) { - if (isSpecialResponse(result)) { - return { - response: result, - } } - return result -} + // Final middleware: execute router + routeMiddlewares.push((ctx: TODO) => executeRouter(ctx.context)) -function isSpecialResponse(err: TODO) { - return isResponse(err) || isRedirect(err) -} + const ctx = await executeMiddleware(routeMiddlewares, { + request, + context, + params: routeParams, + pathname, + }) -function isResponse(response: Response): response is Response { - return response instanceof Response + return ctx.response } diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts index 129f80afe78..0c3b5cb99a9 100644 --- a/packages/start-server-core/src/server-functions-handler.ts +++ b/packages/start-server-core/src/server-functions-handler.ts @@ -1,18 +1,17 @@ -import { isNotFound } from '@tanstack/router-core' +import { isNotFound, isRedirect } from '@tanstack/router-core' import invariant from 'tiny-invariant' import { TSS_FORMDATA_CONTEXT, X_TSS_RAW_RESPONSE, X_TSS_SERIALIZED, getDefaultSerovalPlugins, + safeObjectMerge, } from '@tanstack/start-client-core' import { fromJSON, toCrossJSONAsync, toCrossJSONStream } from 'seroval' import { getResponse } from './request-response' import { getServerFnById } from './getServerFnById' import type { Plugin as SerovalPlugin } from 'seroval' -let regex: RegExp | undefined = undefined - // Cache serovalPlugins at module level to avoid repeated calls let serovalPlugins: Array> | undefined = undefined @@ -22,38 +21,31 @@ const FORM_DATA_CONTENT_TYPES = [ 'application/x-www-form-urlencoded', ] +// Maximum payload size for GET requests (1MB) +const MAX_PAYLOAD_SIZE = 1_000_000 + export const handleServerAction = async ({ request, context, + serverFnId, }: { request: Request context: any + serverFnId: string }) => { const controller = new AbortController() const signal = controller.signal const abort = () => controller.abort() request.signal.addEventListener('abort', abort) - if (regex === undefined) { - regex = new RegExp(`${process.env.TSS_SERVER_FN_BASE}([^/?#]+)`) - } - const method = request.method const methodLower = method.toLowerCase() - const url = new URL(request.url, 'http://localhost:3000') - // extract the serverFnId from the url as host/_serverFn/:serverFnId - // Define a regex to match the path and extract the :thing part - - // Execute the regex - const match = url.pathname.match(regex) - const serverFnId = match ? match[1] : null - - if (typeof serverFnId !== 'string') { - throw new Error('Invalid server action param for serverFnId: ' + serverFnId) - } + const url = new URL(request.url) const action = await getServerFnById(serverFnId, { fromClient: true }) + const isServerFn = request.headers.get('x-tsr-serverFn') === 'true' + // Initialize serovalPlugins lazily (cached at module level) if (!serovalPlugins) { serovalPlugins = getDefaultSerovalPlugins() @@ -68,7 +60,7 @@ export const handleServerAction = async ({ const response = await (async () => { try { - const result = await (async () => { + let res = await (async () => { // FormData if ( FORM_DATA_CONTENT_TYPES.some( @@ -98,9 +90,17 @@ export const handleServerAction = async ({ typeof deserializedContext === 'object' && deserializedContext ) { - params.context = { ...context, ...deserializedContext } + params.context = safeObjectMerge( + context, + deserializedContext as Record, + ) } - } catch {} + } 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, signal) @@ -110,11 +110,15 @@ export const handleServerAction = async ({ if (methodLower === '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 = { ...context, ...payload.context } + payload.context = safeObjectMerge(context, payload.context) // Send it through! return await action(payload, signal) } @@ -129,103 +133,114 @@ export const handleServerAction = async ({ } const payload = jsonPayload ? parsePayload(jsonPayload) : {} - payload.context = { ...payload.context, ...context } + payload.context = safeObjectMerge(payload.context, context) return await action(payload, signal) })() - // Any time we get a Response back, we should just - // return it immediately. - if (result.result instanceof Response) { - result.result.headers.set(X_TSS_RAW_RESPONSE, 'true') - return result.result + const unwrapped = res.result || res.error + + if (isNotFound(res)) { + res = isNotFoundResponse(res) } - if (isNotFound(result)) { - return isNotFoundResponse(result) + if (!isServerFn) { + return unwrapped } - const response = getResponse() - let nonStreamingBody: any = undefined - - if (result !== undefined) { - // 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 - }, + if (unwrapped instanceof Response) { + if (isRedirect(unwrapped)) { + return unwrapped } - toCrossJSONStream(result, { - refs: new Map(), - plugins: serovalPlugins, - onParse(value) { - callbacks.onParse(value) - }, - onDone() { - callbacks.onDone() - }, - onError: (error) => { - callbacks.onError(error) - }, - }) - if (done) { - return new Response( - nonStreamingBody ? JSON.stringify(nonStreamingBody) : undefined, - { - status: response.status, - statusText: response.statusText, - headers: { - 'Content-Type': 'application/json', - [X_TSS_SERIALIZED]: 'true', + unwrapped.headers.set(X_TSS_RAW_RESPONSE, 'true') + return unwrapped + } + + return serializeResult(res) + + function serializeResult(res: unknown): Response { + let nonStreamingBody: any = undefined + + const alsResponse = getResponse() + if (res !== undefined) { + // 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 + }, + } + toCrossJSONStream(res, { + refs: new Map(), + plugins: serovalPlugins, + onParse(value) { + callbacks.onParse(value) + }, + onDone() { + callbacks.onDone() + }, + onError: (error) => { + callbacks.onError(error) + }, + }) + if (done) { + return new Response( + nonStreamingBody ? JSON.stringify(nonStreamingBody) : undefined, + { + status: alsResponse.status, + statusText: alsResponse.statusText, + headers: { + 'Content-Type': 'application/json', + [X_TSS_SERIALIZED]: 'true', + }, }, + ) + } + + // not done yet, we need to stream + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + callbacks.onParse = (value) => + controller.enqueue(encoder.encode(JSON.stringify(value) + '\n')) + callbacks.onDone = () => { + try { + controller.close() + } catch (error) { + controller.error(error) + } + } + callbacks.onError = (error) => controller.error(error) + // stream the initial body + if (nonStreamingBody !== undefined) { + callbacks.onParse(nonStreamingBody) + } }, - ) + }) + return new Response(stream, { + status: alsResponse.status, + statusText: alsResponse.statusText, + headers: { + 'Content-Type': 'application/x-ndjson', + [X_TSS_SERIALIZED]: 'true', + }, + }) } - // not done yet, we need to stream - const encoder = new TextEncoder() - const stream = new ReadableStream({ - start(controller) { - callbacks.onParse = (value) => - controller.enqueue(encoder.encode(JSON.stringify(value) + '\n')) - callbacks.onDone = () => { - try { - controller.close() - } catch (error) { - controller.error(error) - } - } - callbacks.onError = (error) => controller.error(error) - // stream the initial body - if (nonStreamingBody !== undefined) { - callbacks.onParse(nonStreamingBody) - } - }, - }) - return new Response(stream, { - status: response.status, - statusText: response.statusText, - headers: { - 'Content-Type': 'application/x-ndjson', - [X_TSS_SERIALIZED]: 'true', - }, + return new Response(undefined, { + status: alsResponse.status, + statusText: alsResponse.statusText, }) } - - return new Response(undefined, { - status: response.status, - statusText: response.statusText, - }) } catch (error: any) { if (error instanceof Response) { return error diff --git a/packages/start-storage-context/src/async-local-storage.ts b/packages/start-storage-context/src/async-local-storage.ts index d952ca6bdda..526c918266e 100644 --- a/packages/start-storage-context/src/async-local-storage.ts +++ b/packages/start-storage-context/src/async-local-storage.ts @@ -8,6 +8,9 @@ export interface StartStorageContext { startOptions: /* AnyStartInstanceOptions*/ any contextAfterGlobalMiddlewares: any + // Track middlewares that have already executed in the request phase + // to prevent duplicate execution + executedRequestMiddlewares: Set } // Use a global symbol to ensure the same AsyncLocalStorage instance is shared diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b62d3b08ec3..a4afc2465af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1966,6 +1966,58 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.8.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/react-start/server-functions-global-middleware: + 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.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + vite: + specifier: ^7.1.7 + version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + devDependencies: + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tailwindcss/vite': + specifier: ^4.1.18 + version: 4.1.18(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + '@types/react': + specifier: ^19.2.2 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.2(@types/react@19.2.2) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + srvx: + specifier: ^0.9.8 + version: 0.9.8 + tailwindcss: + specifier: ^4.1.18 + version: 4.1.18 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/react-start/server-routes: dependencies: '@tanstack/react-query': @@ -2045,6 +2097,58 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.8.3)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/react-start/server-routes-global-middleware: + 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.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + vite: + specifier: ^7.1.7 + version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + devDependencies: + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tailwindcss/vite': + specifier: ^4.1.18 + version: 4.1.18(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + '@types/react': + specifier: ^19.2.2 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.2(@types/react@19.2.2) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + srvx: + specifier: ^0.9.8 + version: 0.9.8 + tailwindcss: + specifier: ^4.1.18 + version: 4.1.18 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/react-start/spa-mode: dependencies: '@tanstack/react-router': @@ -10269,7 +10373,7 @@ importers: devDependencies: '@netlify/vite-plugin-tanstack-start': specifier: ^1.1.4 - version: 1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + version: 1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) @@ -26405,13 +26509,13 @@ snapshots: uuid: 11.1.0 write-file-atomic: 5.0.1 - '@netlify/dev@4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)': + '@netlify/dev@4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)': dependencies: '@netlify/blobs': 10.1.0 '@netlify/config': 23.2.0 '@netlify/dev-utils': 4.3.0 '@netlify/edge-functions-dev': 1.0.0 - '@netlify/functions-dev': 1.0.0(rollup@4.52.5) + '@netlify/functions-dev': 1.0.0(encoding@0.1.13)(rollup@4.52.5) '@netlify/headers': 2.1.0 '@netlify/images': 1.3.0(@netlify/blobs@10.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0) '@netlify/redirects': 3.1.0 @@ -26479,12 +26583,12 @@ snapshots: dependencies: '@netlify/types': 2.1.0 - '@netlify/functions-dev@1.0.0(rollup@4.52.5)': + '@netlify/functions-dev@1.0.0(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@netlify/blobs': 10.1.0 '@netlify/dev-utils': 4.3.0 '@netlify/functions': 5.0.0 - '@netlify/zip-it-and-ship-it': 14.1.11(rollup@4.52.5) + '@netlify/zip-it-and-ship-it': 14.1.11(encoding@0.1.13)(rollup@4.52.5) cron-parser: 4.9.0 decache: 4.6.2 extract-zip: 2.0.1 @@ -26574,9 +26678,9 @@ snapshots: '@netlify/types@2.1.0': {} - '@netlify/vite-plugin-tanstack-start@1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': + '@netlify/vite-plugin-tanstack-start@1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': dependencies: - '@netlify/vite-plugin': 2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + '@netlify/vite-plugin': 2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) vite: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) optionalDependencies: '@tanstack/solid-start': link:packages/solid-start @@ -26604,9 +26708,9 @@ snapshots: - supports-color - uploadthing - '@netlify/vite-plugin@2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': + '@netlify/vite-plugin@2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': dependencies: - '@netlify/dev': 4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5) + '@netlify/dev': 4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5) '@netlify/dev-utils': 4.3.0 dedent: 1.7.0(babel-plugin-macros@3.1.0) vite: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) @@ -26634,13 +26738,13 @@ snapshots: - supports-color - uploadthing - '@netlify/zip-it-and-ship-it@14.1.11(rollup@4.52.5)': + '@netlify/zip-it-and-ship-it@14.1.11(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@babel/parser': 7.28.5 '@babel/types': 7.28.4 '@netlify/binary-info': 1.0.0 '@netlify/serverless-functions-api': 2.7.1 - '@vercel/nft': 0.29.4(rollup@4.52.5) + '@vercel/nft': 0.29.4(encoding@0.1.13)(rollup@4.52.5) archiver: 7.0.1 common-path-prefix: 3.0.0 copy-file: 11.1.0 @@ -29741,7 +29845,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/nft@0.29.4(rollup@4.52.5)': + '@vercel/nft@0.29.4(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@mapbox/node-pre-gyp': 2.0.0(encoding@0.1.13) '@rollup/pluginutils': 5.1.4(rollup@4.52.5)