From 6b2230714d369390cf1c593ce0ef2c22558574d8 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Tue, 10 Mar 2026 23:36:21 -0500 Subject: [PATCH 01/10] Ensure static export token header --- packages/vinext/src/build/static-export.ts | 22 +- packages/vinext/src/index.ts | 272 +++++++++++++++++- tests/app-router.test.ts | 18 ++ .../nextjs-compat/isr-imported-module/data.ts | 1 + .../isr-imported-module/page.tsx | 5 + .../nextjs-compat/isr-shared-module/page.tsx | 5 + tests/fixtures/app-basic/middleware.ts | 5 + .../pages/mw-pages-to-app-rewrite.tsx | 3 + tests/fixtures/shared/rsc-shared-now.ts | 1 + tests/nextjs-compat/app-rendering.test.ts | 46 +-- 10 files changed, 356 insertions(+), 22 deletions(-) create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/isr-imported-module/data.ts create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/isr-imported-module/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/isr-shared-module/page.tsx create mode 100644 tests/fixtures/app-basic/pages/mw-pages-to-app-rewrite.tsx create mode 100644 tests/fixtures/shared/rsc-shared-now.ts diff --git a/packages/vinext/src/build/static-export.ts b/packages/vinext/src/build/static-export.ts index 1163898d..52c80fb9 100644 --- a/packages/vinext/src/build/static-export.ts +++ b/packages/vinext/src/build/static-export.ts @@ -625,6 +625,10 @@ export async function staticExportApp( options: AppStaticExportOptions, ): Promise { const { baseUrl, routes, server, outDir, config } = options; + const staticExportToken = + typeof (server as any).__vinextStaticExportToken === "string" + ? ((server as any).__vinextStaticExportToken as string) + : null; const result: StaticExportResult = { pageCount: 0, files: [], @@ -709,7 +713,14 @@ export async function staticExportApp( // Fetch each URL from the dev server and write HTML for (const urlPath of urlsToRender) { try { - const res = await fetch(`${baseUrl}${urlPath}`); + const res = await fetch( + `${baseUrl}${urlPath}`, + staticExportToken + ? { + headers: { "x-vinext-static-export": staticExportToken }, + } + : undefined, + ); if (!res.ok) { result.errors.push({ route: urlPath, @@ -736,7 +747,14 @@ export async function staticExportApp( // Render 404 page try { - const res = await fetch(`${baseUrl}/__nonexistent_page_for_404__`); + const res = await fetch( + `${baseUrl}/__nonexistent_page_for_404__`, + staticExportToken + ? { + headers: { "x-vinext-static-export": staticExportToken }, + } + : undefined, + ); if (res.status === 404) { const html = await res.text(); if (html.length > 0) { diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 215100e0..a804dbd1 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -8,7 +8,7 @@ import { } from "./routing/pages-router.js"; import { generateServerEntry as _generateServerEntry } from "./entries/pages-server-entry.js"; import { generateClientEntry as _generateClientEntry } from "./entries/pages-client-entry.js"; -import { appRouter, invalidateAppRouteCache } from "./routing/app-router.js"; +import { appRouter, invalidateAppRouteCache, type AppRoute } from "./routing/app-router.js"; import { createValidFileMatcher } from "./routing/file-matcher.js"; import { createSSRHandler } from "./server/dev-server.js"; import { handleApiRoute } from "./server/api-handler.js"; @@ -60,6 +60,7 @@ import MagicString from "magic-string"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { createRequire } from "node:module"; +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import commonjs from "vite-plugin-commonjs"; @@ -1655,6 +1656,213 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } } + function normalizeAppRequestPath(url: string): string | null { + const [rawPathname] = url.split("?"); + if ( + url.startsWith("/@") || + url.startsWith("/__vite") || + url.startsWith("/node_modules") + ) { + return null; + } + + let normalizedUrl = url; + if (rawPathname.endsWith("/index.html")) { + normalizedUrl = normalizedUrl.replace("/index.html", "/"); + } else if (rawPathname.endsWith(".html")) { + normalizedUrl = normalizedUrl.replace(/\.html(?=\?|$)/, ""); + } + + let pathname = normalizedUrl.split("?")[0]; + if (pathname.endsWith(".rsc")) pathname = pathname.slice(0, -4) || "/"; + if (pathname.includes(".")) return null; + + pathname = pathname.replaceAll("\\", "/"); + if (pathname.startsWith("//")) return null; + + try { + pathname = normalizePath(decodeURIComponent(pathname)); + } catch { + return null; + } + + const bp = nextConfig?.basePath ?? ""; + if (bp && pathname.startsWith(bp)) { + pathname = pathname.slice(bp.length) || "/"; + } + + return pathname || "/"; + } + + function cleanViteModuleId(moduleId: string): string { + const hashIndex = moduleId.indexOf("#"); + const queryIndex = moduleId.indexOf("?"); + const cutIndex = + hashIndex === -1 + ? queryIndex + : queryIndex === -1 + ? hashIndex + : Math.min(hashIndex, queryIndex); + return cutIndex === -1 ? moduleId : moduleId.slice(0, cutIndex); + } + + function shouldTraverseAppRscDependency(moduleId: string): boolean { + if (!moduleId || moduleId.startsWith("\0")) return false; + const cleanId = cleanViteModuleId(moduleId); + if (!path.isAbsolute(cleanId)) return false; + if (cleanId.includes("/node_modules/")) return false; + if (cleanId.startsWith(__dirname)) return false; + return true; + } + + function collectAppRouteModuleFiles(route: AppRoute): string[] { + const files = new Set(); + const add = (filePath: string | null | undefined) => { + if (filePath) files.add(filePath); + }; + + add(route.pagePath); + add(route.routePath); + for (const layout of route.layouts) add(layout); + for (const tmpl of route.templates) add(tmpl); + add(route.loadingPath); + add(route.errorPath); + for (const layoutErrorPath of route.layoutErrorPaths) add(layoutErrorPath); + add(route.notFoundPath); + for (const notFoundPath of route.notFoundPaths) add(notFoundPath); + add(route.forbiddenPath); + add(route.unauthorizedPath); + for (const slot of route.parallelSlots) { + add(slot.pagePath); + add(slot.defaultPath); + add(slot.layoutPath); + add(slot.loadingPath); + add(slot.errorPath); + for (const interceptingRoute of slot.interceptingRoutes) { + add(interceptingRoute.pagePath); + } + } + + return [...files]; + } + + function getRscModulesByFile(rscEnv: any, filePath: string): any[] { + const modulesByFile = rscEnv.moduleGraph.getModulesByFile?.(filePath); + if (modulesByFile && modulesByFile.size > 0) { + return [...modulesByFile]; + } + + const modules: any[] = []; + for (const mod of rscEnv.moduleGraph.idToModuleMap.values()) { + if (mod.id && cleanViteModuleId(mod.id) === filePath) { + modules.push(mod); + } + } + return modules; + } + + function collectAppRscModules(rscEnv: any, routeModuleFiles: Iterable): Set { + const queue: any[] = []; + const visited = new Set(); + const collected = new Set(); + + for (const filePath of routeModuleFiles) { + for (const mod of getRscModulesByFile(rscEnv, filePath)) { + queue.push(mod); + } + } + + while (queue.length > 0) { + const mod = queue.pop(); + if (!mod?.id) continue; + if (visited.has(mod.id)) continue; + visited.add(mod.id); + + if (!shouldTraverseAppRscDependency(mod.id)) continue; + + collected.add(mod); + for (const imported of mod.importedModules ?? []) { + if (imported?.id && shouldTraverseAppRscDependency(imported.id)) { + queue.push(imported); + } + } + } + + return collected; + } + + async function resolveAppRscRouteModuleFilesForRequest( + url: string, + options: { + allowFullAppFallback: boolean; + skipIfPagesRouteMatches: boolean; + }, + ): Promise { + const pathname = normalizeAppRequestPath(url); + if (!pathname) return null; + + const appRoutes = await appRouter(appDir, nextConfig?.pageExtensions, fileMatcher); + const appMatch = matchRoute(pathname, appRoutes as any); + if (appMatch) { + return collectAppRouteModuleFiles(appMatch.route as unknown as AppRoute); + } + + if (options.skipIfPagesRouteMatches && hasPagesDir) { + const pageRoutes = await pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher); + if (matchRoute(pathname, pageRoutes)) return null; + } + + if (!options.allowFullAppFallback) return null; + + const files = new Set(); + for (const route of appRoutes) { + for (const filePath of collectAppRouteModuleFiles(route)) { + files.add(filePath); + } + } + return [...files]; + } + + async function invalidateAppRscModulesForRequest( + url: string, + options?: { + allowFullAppFallback?: boolean; + skipIfPagesRouteMatches?: boolean; + staticExportToken?: string; + requestStaticExportToken?: string; + }, + ) { + if (hasCloudflarePlugin) return; + + const rscEnv = server.environments["rsc"]; + if (!rscEnv) return; + + // Static export crawls the dev server to materialize HTML files. + // Those requests are build-time, not interactive dev requests, so + // skip per-request invalidation to avoid recompiling the full App + // Router tree for every exported page. + if ( + options?.staticExportToken && + options.requestStaticExportToken && + options.requestStaticExportToken === options.staticExportToken + ) { + return; + } + + const routeModuleFiles = await resolveAppRscRouteModuleFilesForRequest(url, { + allowFullAppFallback: options?.allowFullAppFallback ?? !hasPagesDir, + skipIfPagesRouteMatches: options?.skipIfPagesRouteMatches ?? hasPagesDir, + }); + if (!routeModuleFiles || routeModuleFiles.length === 0) return; + + for (const mod of collectAppRscModules(rscEnv, routeModuleFiles)) { + rscEnv.moduleGraph.invalidateModule(mod); + } + + const entryModule = rscEnv.moduleGraph.getModuleById(RESOLVED_RSC_ENTRY); + if (entryModule) rscEnv.moduleGraph.invalidateModule(entryModule); + } + server.watcher.on("add", (filePath: string) => { if (hasPagesDir && filePath.startsWith(pagesDir) && pageExtensions.test(filePath)) { invalidateRouteCache(pagesDir); @@ -1727,15 +1935,42 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { console.error("[vinext] Instrumentation error:", err); }); } + const staticExportToken = hasAppDir ? randomUUID() : undefined; + if (staticExportToken) { + (server as any).__vinextStaticExportToken = staticExportToken; + } // App Router request logging in dev server // // For App Router, the RSC plugin handles requests internally. // We install a timing middleware here that: + // 0. Invalidates the generated RSC entry and its route modules so + // server components are re-executed on every dev request. // 1. Intercepts writeHead() to pluck the X-Vinext-Timing header // (compileMs,renderMs) that the RSC entry attaches before // it is flushed to the client. // 2. Logs the full request after res finishes, using those timings. if (hasAppDir) { + server.middlewares.use(async (req, _res, next) => { + try { + const url = req.url ?? "/"; + const isRscRequest = url.split("?")[0].endsWith(".rsc"); + if (!hasPagesDir || isRscRequest) { + await invalidateAppRscModulesForRequest(url, { + allowFullAppFallback: true, + skipIfPagesRouteMatches: false, + staticExportToken, + requestStaticExportToken: + typeof req.headers["x-vinext-static-export"] === "string" + ? req.headers["x-vinext-static-export"] + : undefined, + }); + } + next(); + } catch (err) { + next(err); + } + }); + server.middlewares.use((req, res, next) => { const url = req.url ?? "/"; // Skip Vite internals, HMR, and static assets. @@ -2224,7 +2459,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // No API route matched — if app dir exists, let the RSC plugin handle it // (app/api/* route handlers live there). Otherwise hard-404. - if (hasAppDir) return next(); + if (hasAppDir) { + await invalidateAppRscModulesForRequest(resolvedUrl, { + allowFullAppFallback: true, + skipIfPagesRouteMatches: false, + staticExportToken, + requestStaticExportToken: + typeof req.headers["x-vinext-static-export"] === "string" + ? req.headers["x-vinext-static-export"] + : undefined, + }); + return next(); + } res.statusCode = 404; res.end("404 - API route not found"); @@ -2286,6 +2532,15 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } const fallbackMatch = matchRoute(fallbackRewrite.split("?")[0], routes); if (!fallbackMatch && hasAppDir) { + await invalidateAppRscModulesForRequest(fallbackRewrite, { + allowFullAppFallback: true, + skipIfPagesRouteMatches: false, + staticExportToken, + requestStaticExportToken: + typeof req.headers["x-vinext-static-export"] === "string" + ? req.headers["x-vinext-static-export"] + : undefined, + }); return next(); } if (middlewareRequestHeaders) { @@ -2298,7 +2553,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // No fallback matched - if app dir exists, let the RSC plugin handle it, // otherwise render via the pages SSR handler (will 404 for unknown routes). - if (hasAppDir) return next(); + if (hasAppDir) { + await invalidateAppRscModulesForRequest(resolvedUrl, { + allowFullAppFallback: true, + skipIfPagesRouteMatches: false, + staticExportToken, + requestStaticExportToken: + typeof req.headers["x-vinext-static-export"] === "string" + ? req.headers["x-vinext-static-export"] + : undefined, + }); + return next(); + } await handler(req, res, resolvedUrl, mwStatus); } catch (e) { diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index d7180d60..e0cefde8 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2039,6 +2039,24 @@ describe("App Router next.config.js features (dev server integration)", () => { expect(html).toContain("About"); }); + it("re-executes App Router modules when middleware rewrites a Pages path into app/", async () => { + const res1 = await fetch(`${baseUrl}/mw-pages-to-app-rewrite`); + expect(res1.status).toBe(200); + const html1 = await res1.text(); + const importedNow1 = html1.match(/id="shared-imported-now"[^>]*>(\d+)/)?.[1]; + + await new Promise((r) => setTimeout(r, 50)); + + const res2 = await fetch(`${baseUrl}/mw-pages-to-app-rewrite`); + expect(res2.status).toBe(200); + const html2 = await res2.text(); + const importedNow2 = html2.match(/id="shared-imported-now"[^>]*>(\d+)/)?.[1]; + + expect(importedNow1).toBeTruthy(); + expect(importedNow2).toBeTruthy(); + expect(importedNow1).not.toBe(importedNow2); + }); + it("applies rewrites with repeated dynamic params in the destination", async () => { const res = await fetch(`${baseUrl}/repeat-rewrite/hello`); expect(res.status).toBe(200); diff --git a/tests/fixtures/app-basic/app/nextjs-compat/isr-imported-module/data.ts b/tests/fixtures/app-basic/app/nextjs-compat/isr-imported-module/data.ts new file mode 100644 index 00000000..fb5dcda7 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/isr-imported-module/data.ts @@ -0,0 +1 @@ +export const importedNow = Date.now(); diff --git a/tests/fixtures/app-basic/app/nextjs-compat/isr-imported-module/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/isr-imported-module/page.tsx new file mode 100644 index 00000000..b3ccf0f0 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/isr-imported-module/page.tsx @@ -0,0 +1,5 @@ +import { importedNow } from "./data"; + +export default function IsrImportedModulePage() { + return

{importedNow}

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/isr-shared-module/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/isr-shared-module/page.tsx new file mode 100644 index 00000000..d0c2606a --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/isr-shared-module/page.tsx @@ -0,0 +1,5 @@ +import { sharedImportedNow } from "../../../../shared/rsc-shared-now"; + +export default function IsrSharedModulePage() { + return

{sharedImportedNow}

; +} diff --git a/tests/fixtures/app-basic/middleware.ts b/tests/fixtures/app-basic/middleware.ts index 0325a2fd..8baf7d1c 100644 --- a/tests/fixtures/app-basic/middleware.ts +++ b/tests/fixtures/app-basic/middleware.ts @@ -49,6 +49,10 @@ export function middleware(request: NextRequest) { return NextResponse.rewrite(new URL("/", request.url)); } + if (pathname === "/mw-pages-to-app-rewrite") { + return NextResponse.rewrite(new URL("/nextjs-compat/isr-shared-module", request.url)); + } + // Rewrite with custom status code // Ref: opennextjs-cloudflare middleware.ts — NextResponse.rewrite with status if (pathname === "/middleware-rewrite-status") { @@ -149,6 +153,7 @@ export const config = { "/about", "/middleware-redirect", "/middleware-rewrite", + "/mw-pages-to-app-rewrite", "/middleware-rewrite-status", "/middleware-blocked", "/middleware-throw", diff --git a/tests/fixtures/app-basic/pages/mw-pages-to-app-rewrite.tsx b/tests/fixtures/app-basic/pages/mw-pages-to-app-rewrite.tsx new file mode 100644 index 00000000..eb80fd7d --- /dev/null +++ b/tests/fixtures/app-basic/pages/mw-pages-to-app-rewrite.tsx @@ -0,0 +1,3 @@ +export default function PagesToAppRewritePage() { + return

Pages rewrite placeholder

; +} diff --git a/tests/fixtures/shared/rsc-shared-now.ts b/tests/fixtures/shared/rsc-shared-now.ts new file mode 100644 index 00000000..e1ff27f5 --- /dev/null +++ b/tests/fixtures/shared/rsc-shared-now.ts @@ -0,0 +1 @@ +export const sharedImportedNow = Date.now(); diff --git a/tests/nextjs-compat/app-rendering.test.ts b/tests/nextjs-compat/app-rendering.test.ts index abfd892a..b06a982e 100644 --- a/tests/nextjs-compat/app-rendering.test.ts +++ b/tests/nextjs-compat/app-rendering.test.ts @@ -104,23 +104,7 @@ describe("Next.js compat: app-rendering", () => { // This tests that subsequent requests get fresh timestamps (revalidation works). // In dev mode, vinext always re-renders (no ISR caching), so timestamps should differ. - // SKIP: The use(getData()) pattern with Date.now() in the ISR layout produces identical - // timestamps across requests. The async function getData() returns a cached promise at - // module scope in the RSC environment, so Date.now() is evaluated once. - // - // ROOT CAUSE: vinext's RSC module instances persist across requests in dev mode. - // Next.js re-executes server components fresh per request by invalidating the module cache. - // Note: The ISR cache has been removed from dev mode (issue #228), but this test still - // fails because the underlying module caching issue is separate from ISR. - // - // TO FIX: The RSC environment needs to invalidate/re-import server component modules on - // each request so that top-level expressions like Date.now() get re-evaluated. This may - // involve calling server.moduleGraph.invalidateModule() for RSC modules before each render, - // or using Vite's ssrLoadModule with a cache-bust query param. - // - // VERIFY: Once fixed, also confirm that the "Invalid hook call" warnings from use() go away - // (they may be related to the same module caching causing duplicate React instances). - it.skip("should produce different timestamps on subsequent requests", async () => { + it("should produce different timestamps on subsequent requests", async () => { const { html: html1 } = await fetchHtml(baseUrl, "/nextjs-compat/isr-multiple/nested"); const layoutNow1 = html1.match(/id="layout-now"[^>]*>(\d+)/)?.[1]; const pageNow1 = html1.match(/id="page-now"[^>]*>(\d+)/)?.[1]; @@ -141,6 +125,34 @@ describe("Next.js compat: app-rendering", () => { expect(layoutNow1).not.toBe(layoutNow2); expect(pageNow1).not.toBe(pageNow2); }); + + it("should re-execute imported server modules on subsequent requests", async () => { + const { html: html1 } = await fetchHtml(baseUrl, "/nextjs-compat/isr-imported-module"); + const importedNow1 = html1.match(/id="imported-now"[^>]*>(\d+)/)?.[1]; + + await new Promise((r) => setTimeout(r, 50)); + + const { html: html2 } = await fetchHtml(baseUrl, "/nextjs-compat/isr-imported-module"); + const importedNow2 = html2.match(/id="imported-now"[^>]*>(\d+)/)?.[1]; + + expect(importedNow1).toBeTruthy(); + expect(importedNow2).toBeTruthy(); + expect(importedNow1).not.toBe(importedNow2); + }); + + it("should re-execute imported modules outside the app root on subsequent requests", async () => { + const { html: html1 } = await fetchHtml(baseUrl, "/nextjs-compat/isr-shared-module"); + const importedNow1 = html1.match(/id="shared-imported-now"[^>]*>(\d+)/)?.[1]; + + await new Promise((r) => setTimeout(r, 50)); + + const { html: html2 } = await fetchHtml(baseUrl, "/nextjs-compat/isr-shared-module"); + const importedNow2 = html2.match(/id="shared-imported-now"[^>]*>(\d+)/)?.[1]; + + expect(importedNow1).toBeTruthy(); + expect(importedNow2).toBeTruthy(); + expect(importedNow1).not.toBe(importedNow2); + }); }); // ── Mixed static and dynamic (skipped in Next.js too) ────── From 3da870e4c6d8fffe31aa915ce9a3a88f56247759 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Tue, 10 Mar 2026 23:56:55 -0500 Subject: [PATCH 02/10] Fix dotted App Router invalidation --- packages/vinext/src/index.ts | 9 ++-- .../nextjs-compat/isr-dotted/[slug]/page.tsx | 12 +++++ tests/nextjs-compat/app-rendering.test.ts | 44 +++++++++++++++++++ 3 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/isr-dotted/[slug]/page.tsx diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index a804dbd1..d157c814 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1675,7 +1675,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let pathname = normalizedUrl.split("?")[0]; if (pathname.endsWith(".rsc")) pathname = pathname.slice(0, -4) || "/"; - if (pathname.includes(".")) return null; pathname = pathname.replaceAll("\\", "/"); if (pathname.startsWith("//")) return null; @@ -2182,11 +2181,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { url = url.replace(/\.html(?=\?|$)/, ""); } - // Skip requests for files with extensions (static assets) + // Do not blanket-skip dotted paths here. Next.js allows dots in + // route segments (including dynamic params), so let the normal + // routing and rewrite pipeline decide whether this is a page or + // a true asset miss after Vite's built-in static handling. let pathname = url.split("?")[0]; - if (pathname.includes(".") && !pathname.endsWith(".html")) { - return next(); - } // Guard against protocol-relative URL open redirects. // Normalize backslashes first: browsers treat /\ as // in URL diff --git a/tests/fixtures/app-basic/app/nextjs-compat/isr-dotted/[slug]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/isr-dotted/[slug]/page.tsx new file mode 100644 index 00000000..ecb32a17 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/isr-dotted/[slug]/page.tsx @@ -0,0 +1,12 @@ +import { sharedImportedNow } from "../../../../../shared/rsc-shared-now"; + +export default async function IsrDottedPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + + return ( + <> +

{slug}

+

{sharedImportedNow}

+ + ); +} diff --git a/tests/nextjs-compat/app-rendering.test.ts b/tests/nextjs-compat/app-rendering.test.ts index b06a982e..c4398b06 100644 --- a/tests/nextjs-compat/app-rendering.test.ts +++ b/tests/nextjs-compat/app-rendering.test.ts @@ -153,6 +153,50 @@ describe("Next.js compat: app-rendering", () => { expect(importedNow2).toBeTruthy(); expect(importedNow1).not.toBe(importedNow2); }); + + it("should re-execute dotted App Router paths on subsequent requests", async () => { + const routePath = "/nextjs-compat/isr-dotted/jane.doe"; + const { html: html1 } = await fetchHtml(baseUrl, routePath); + const importedNow1 = html1.match(/id="dotted-imported-now"[^>]*>(\d+)/)?.[1]; + + await new Promise((r) => setTimeout(r, 50)); + + const { html: html2 } = await fetchHtml(baseUrl, routePath); + const importedNow2 = html2.match(/id="dotted-imported-now"[^>]*>(\d+)/)?.[1]; + + expect(html1).toContain("jane.doe"); + expect(html2).toContain("jane.doe"); + expect(importedNow1).toBeTruthy(); + expect(importedNow2).toBeTruthy(); + expect(importedNow1).not.toBe(importedNow2); + }); + + it("should re-execute dotted App Router .rsc requests on subsequent requests", async () => { + const routePath = "/nextjs-compat/isr-dotted/jane.doe.rsc"; + const res1 = await fetch(`${baseUrl}${routePath}`, { + headers: { Accept: "text/x-component" }, + }); + const rsc1 = await res1.text(); + const importedNow1 = rsc1.match(/dotted-imported-now.*?(\d{10,})/)?.[1]; + + await new Promise((r) => setTimeout(r, 50)); + + const res2 = await fetch(`${baseUrl}${routePath}`, { + headers: { Accept: "text/x-component" }, + }); + const rsc2 = await res2.text(); + const importedNow2 = rsc2.match(/dotted-imported-now.*?(\d{10,})/)?.[1]; + + expect(res1.status).toBe(200); + expect(res2.status).toBe(200); + expect(res1.headers.get("content-type")).toContain("text/x-component"); + expect(res2.headers.get("content-type")).toContain("text/x-component"); + expect(rsc1).toContain("jane.doe"); + expect(rsc2).toContain("jane.doe"); + expect(importedNow1).toBeTruthy(); + expect(importedNow2).toBeTruthy(); + expect(importedNow1).not.toBe(importedNow2); + }); }); // ── Mixed static and dynamic (skipped in Next.js too) ────── From 06efb463b86edb793838fb52814fb17af0fefce2 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 00:47:08 -0500 Subject: [PATCH 03/10] Fix App Router invalidation for dots --- packages/vinext/src/index.ts | 102 ++++++++++++++++++++++------------- tests/app-router.test.ts | 30 +++++++++++ 2 files changed, 96 insertions(+), 36 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index d157c814..fbbd9a1f 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1,3 +1,4 @@ +import type { IncomingMessage } from "node:http"; import type { Plugin, PluginOption, UserConfig, ViteDevServer } from "vite"; import { loadEnv, parseAst } from "vite"; import { @@ -1693,6 +1694,27 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { return pathname || "/"; } + function shouldInvalidateAppRscRequest(req: IncomingMessage, url: string): boolean { + const method = (req.method ?? "GET").toUpperCase(); + if (method !== "GET" && method !== "HEAD") return false; + + // Server action POSTs re-use action identifiers from the already + // rendered client tree. Invalidating the live RSC module graph before + // handling them can swap out that graph mid-session and break action + // execution. + if (typeof req.headers["x-rsc-action"] === "string") return false; + + const pathname = normalizeAppRequestPath(url); + if (!pathname) return false; + + // App Route Handlers (app/api/*) are request handlers, not RSC page + // renders. Re-executing them per request breaks legitimate module- + // scoped state such as instrumentation and next/after test fixtures. + if (pathname === "/api" || pathname.startsWith("/api/")) return false; + + return true; + } + function cleanViteModuleId(moduleId: string): string { const hashIndex = moduleId.indexOf("#"); const queryIndex = moduleId.indexOf("?"); @@ -1721,7 +1743,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }; add(route.pagePath); - add(route.routePath); for (const layout of route.layouts) add(layout); for (const tmpl of route.templates) add(tmpl); add(route.loadingPath); @@ -1799,11 +1820,14 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ): Promise { const pathname = normalizeAppRequestPath(url); if (!pathname) return null; + if (pathname === "/api" || pathname.startsWith("/api/")) return null; const appRoutes = await appRouter(appDir, nextConfig?.pageExtensions, fileMatcher); const appMatch = matchRoute(pathname, appRoutes as any); if (appMatch) { - return collectAppRouteModuleFiles(appMatch.route as unknown as AppRoute); + const matchedRoute = appMatch.route as unknown as AppRoute; + if (!matchedRoute.pagePath) return null; + return collectAppRouteModuleFiles(matchedRoute); } if (options.skipIfPagesRouteMatches && hasPagesDir) { @@ -1953,9 +1977,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { try { const url = req.url ?? "/"; const isRscRequest = url.split("?")[0].endsWith(".rsc"); - if (!hasPagesDir || isRscRequest) { + if ((!hasPagesDir || isRscRequest) && shouldInvalidateAppRscRequest(req, url)) { await invalidateAppRscModulesForRequest(url, { - allowFullAppFallback: true, + allowFullAppFallback: false, skipIfPagesRouteMatches: false, staticExportToken, requestStaticExportToken: @@ -2262,6 +2286,30 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } }; + const handOffToAppRouter = async ( + appUrl: string, + options?: { allowFullAppFallback?: boolean }, + ) => { + if (middlewareRequestHeaders) { + applyRequestHeadersToNodeRequest(middlewareRequestHeaders); + } + req.url = appUrl; + + if (shouldInvalidateAppRscRequest(req, appUrl)) { + await invalidateAppRscModulesForRequest(appUrl, { + allowFullAppFallback: options?.allowFullAppFallback ?? false, + skipIfPagesRouteMatches: false, + staticExportToken, + requestStaticExportToken: + typeof req.headers["x-vinext-static-export"] === "string" + ? req.headers["x-vinext-static-export"] + : undefined, + }); + } + + next(); + }; + let middlewareRequestHeaders: Headers | null = null; // Run middleware.ts if present @@ -2459,16 +2507,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // No API route matched — if app dir exists, let the RSC plugin handle it // (app/api/* route handlers live there). Otherwise hard-404. if (hasAppDir) { - await invalidateAppRscModulesForRequest(resolvedUrl, { - allowFullAppFallback: true, - skipIfPagesRouteMatches: false, - staticExportToken, - requestStaticExportToken: - typeof req.headers["x-vinext-static-export"] === "string" - ? req.headers["x-vinext-static-export"] - : undefined, - }); - return next(); + await handOffToAppRouter(resolvedUrl); + return; } res.statusCode = 404; @@ -2530,18 +2570,16 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { return; } const fallbackMatch = matchRoute(fallbackRewrite.split("?")[0], routes); - if (!fallbackMatch && hasAppDir) { - await invalidateAppRscModulesForRequest(fallbackRewrite, { - allowFullAppFallback: true, - skipIfPagesRouteMatches: false, - staticExportToken, - requestStaticExportToken: - typeof req.headers["x-vinext-static-export"] === "string" - ? req.headers["x-vinext-static-export"] - : undefined, - }); - return next(); - } + if ( + !fallbackMatch && + hasAppDir && + shouldInvalidateAppRscRequest(req, fallbackRewrite) + ) { + await handOffToAppRouter(fallbackRewrite, { + allowFullAppFallback: true, + }); + return; + } if (middlewareRequestHeaders) { applyRequestHeadersToNodeRequest(middlewareRequestHeaders); } @@ -2553,16 +2591,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // No fallback matched - if app dir exists, let the RSC plugin handle it, // otherwise render via the pages SSR handler (will 404 for unknown routes). if (hasAppDir) { - await invalidateAppRscModulesForRequest(resolvedUrl, { - allowFullAppFallback: true, - skipIfPagesRouteMatches: false, - staticExportToken, - requestStaticExportToken: - typeof req.headers["x-vinext-static-export"] === "string" - ? req.headers["x-vinext-static-export"] - : undefined, - }); - return next(); + await handOffToAppRouter(resolvedUrl); + return; } await handler(req, res, resolvedUrl, mwStatus); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index e0cefde8..d052563e 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2057,6 +2057,36 @@ describe("App Router next.config.js features (dev server integration)", () => { expect(importedNow1).not.toBe(importedNow2); }); + it("does not invalidate the App Router RSC graph for /_vinext/image requests", async () => { + const rscEnv = server.environments["rsc"] as any; + const invalidateSpy = vi.spyOn(rscEnv.moduleGraph, "invalidateModule"); + try { + const res = await fetch( + `${baseUrl}/_vinext/image?url=${encodeURIComponent("/missing-dev-image.png")}&w=64&q=75`, + { redirect: "manual" }, + ); + + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/missing-dev-image.png"); + expect(invalidateSpy).not.toHaveBeenCalled(); + } finally { + invalidateSpy.mockRestore(); + } + }); + + it("does not invalidate the App Router RSC graph for missing asset requests", async () => { + const rscEnv = server.environments["rsc"] as any; + const invalidateSpy = vi.spyOn(rscEnv.moduleGraph, "invalidateModule"); + try { + const res = await fetch(`${baseUrl}/missing-dev-asset.js`, { redirect: "manual" }); + + expect(res.status).toBe(404); + expect(invalidateSpy).not.toHaveBeenCalled(); + } finally { + invalidateSpy.mockRestore(); + } + }); + it("applies rewrites with repeated dynamic params in the destination", async () => { const res = await fetch(`${baseUrl}/repeat-rewrite/hello`); expect(res.status).toBe(200); From 61aca7b5d643f31af0abd557e18286f9384169ce Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 00:47:27 -0500 Subject: [PATCH 04/10] Fix App Router dot routing invalid --- packages/vinext/src/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index fbbd9a1f..bf38a383 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2575,11 +2575,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { hasAppDir && shouldInvalidateAppRscRequest(req, fallbackRewrite) ) { - await handOffToAppRouter(fallbackRewrite, { - allowFullAppFallback: true, - }); - return; - } + await handOffToAppRouter(fallbackRewrite, { + allowFullAppFallback: true, + }); + return; + } if (middlewareRequestHeaders) { applyRequestHeadersToNodeRequest(middlewareRequestHeaders); } From df7d17e945d04c547f17910300c9cb557a45422f Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 01:22:05 -0500 Subject: [PATCH 05/10] Fix App Router invalidation bugs --- packages/vinext/src/entries/app-rsc-entry.ts | 45 +- packages/vinext/src/index.ts | 397 ++++++++++++++++-- tests/app-router.test.ts | 98 +++++ .../nextjs-compat/fresh-metadata/sitemap.ts | 9 + tests/fixtures/app-basic/next.config.ts | 10 + tests/nextjs-compat/app-rendering.test.ts | 65 +++ 6 files changed, 580 insertions(+), 44 deletions(-) create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/fresh-metadata/sitemap.ts diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 5d43c20f..07a7c784 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -1271,6 +1271,9 @@ const __configRedirects = ${JSON.stringify(redirects)}; const __configRewrites = ${JSON.stringify(rewrites)}; const __configHeaders = ${JSON.stringify(headers)}; const __allowedOrigins = ${JSON.stringify(allowedOrigins)}; +const __hostPreparedHeader = "x-vinext-app-router-prepared"; +const __hostRewriteStatusHeader = "x-vinext-app-router-rewrite-status"; +const __hostTargetHeader = "x-vinext-app-router-target"; ${generateDevOriginCheckCode(config?.allowedDevOrigins)} @@ -1384,6 +1387,25 @@ export default async function handler(request, ctx) { ` : "" } + const __hostPrepared = request.headers.get(__hostPreparedHeader) === "1"; + const __hostRewriteStatus = request.headers.get(__hostRewriteStatusHeader); + const __hostPreparedTarget = request.headers.get(__hostTargetHeader); + if (__hostPrepared || __hostRewriteStatus || __hostPreparedTarget) { + const __sanitizedHeaders = new Headers(request.headers); + __sanitizedHeaders.delete(__hostPreparedHeader); + __sanitizedHeaders.delete(__hostRewriteStatusHeader); + __sanitizedHeaders.delete(__hostTargetHeader); + const __requestUrl = __hostPreparedTarget + ? new URL(__hostPreparedTarget, request.url).href + : request.url; + request = new Request(__requestUrl, { + method: request.method, + headers: __sanitizedHeaders, + body: request.body, + // @ts-expect-error -- duplex is required when reusing a streaming body + duplex: request.body ? "half" : undefined, + }); + } // Wrap the entire request in nested AsyncLocalStorage.run() scopes to ensure // per-request isolation for all state modules. Each runWith*() creates an // ALS scope that propagates through all async continuations (including RSC @@ -1403,8 +1425,11 @@ export default async function handler(request, ctx) { // Per-request container for middleware state. Passed into // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. - const _mwCtx = { headers: null, status: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const _mwCtx = { + headers: null, + status: __hostRewriteStatus ? Number(__hostRewriteStatus) || null : null, + }; + const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared); // Apply custom headers from next.config.js to non-redirect responses. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // and Next.js doesn't apply custom headers to redirects anyway. @@ -1440,7 +1465,7 @@ export default async function handler(request, ctx) { return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } -async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { +async function _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared) { const __reqStart = process.env.NODE_ENV !== "production" ? performance.now() : 0; let __compileEnd; let __renderEnd; @@ -1484,7 +1509,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { if (__tsRedirect) return __tsRedirect; // ── Apply redirects from next.config.js ─────────────────────────────── - if (__configRedirects.length) { + if (!__hostPrepared && __configRedirects.length) { // Strip .rsc suffix before matching redirect rules - RSC (client-side nav) requests // arrive as /some/path.rsc but redirect patterns are defined without it (e.g. // /some/path). Without this, soft-nav fetches bypass all config redirects. @@ -1512,9 +1537,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // _mwCtx (per-request container) so handler() can merge them into // every response path without module-level state that races on Workers. - ${ + ${ middlewarePath ? ` + if (!__hostPrepared) { // Run proxy/middleware if present and path matches. // Validate exports match the file type (proxy.ts vs middleware.ts), matching Next.js behavior. // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/proxy-missing-export/proxy-missing-export.test.ts @@ -1596,6 +1622,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { applyMiddlewareRequestHeaders(_mwCtx.headers); processMiddlewareHeaders(_mwCtx.headers); } + } ` : "" } @@ -1604,12 +1631,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // These run after middleware in the App Router execution order and should // evaluate has/missing conditions against middleware-modified headers. // When no middleware is present, this falls back to requestContextFromRequest. - const __postMwReqCtx = __buildPostMwRequestContext(request); + const __postMwReqCtx = __hostPrepared ? requestContextFromRequest(request) : __buildPostMwRequestContext(request); // ── Apply beforeFiles rewrites from next.config.js ──────────────────── // In App Router execution order, beforeFiles runs after middleware so that // has/missing conditions can evaluate against middleware-modified headers. - if (__configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { + if (!__hostPrepared && __configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx); if (__rewritten) { if (isExternalUrl(__rewritten)) { @@ -1873,7 +1900,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } // ── Apply afterFiles rewrites from next.config.js ────────────────────── - if (__configRewrites.afterFiles && __configRewrites.afterFiles.length) { + if (!__hostPrepared && __configRewrites.afterFiles && __configRewrites.afterFiles.length) { const __afterRewritten = matchRewrite(cleanPathname, __configRewrites.afterFiles, __postMwReqCtx); if (__afterRewritten) { if (isExternalUrl(__afterRewritten)) { @@ -1888,7 +1915,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { let match = matchRoute(cleanPathname, routes); // ── Fallback rewrites from next.config.js (if no route matched) ─────── - if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { + if (!__hostPrepared && !match && __configRewrites.fallback && __configRewrites.fallback.length) { const __fallbackRewritten = matchRewrite(cleanPathname, __configRewrites.fallback, __postMwReqCtx); if (__fallbackRewritten) { if (isExternalUrl(__fallbackRewritten)) { diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index bf38a383..f5787e2b 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -51,7 +51,7 @@ import { manifestFilesWithBase, normalizeManifestFile, } from "./utils/manifest-paths.js"; -import { hasBasePath } from "./utils/base-path.js"; +import { hasBasePath, stripBasePath } from "./utils/base-path.js"; import { asyncHooksStubPlugin } from "./plugins/async-hooks-stub.js"; import { clientReferenceDedupPlugin } from "./plugins/client-reference-dedup.js"; import { hasWranglerConfig, formatMissingCloudflarePluginError } from "./deploy.js"; @@ -66,6 +66,9 @@ import fs from "node:fs"; import commonjs from "vite-plugin-commonjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const APP_ROUTER_PREPARED_HEADER = "x-vinext-app-router-prepared"; +const APP_ROUTER_REWRITE_STATUS_HEADER = "x-vinext-app-router-rewrite-status"; +const APP_ROUTER_TARGET_HEADER = "x-vinext-app-router-target"; /** * Fetch Google Fonts CSS, download .woff2 files, cache locally, and return @@ -1687,9 +1690,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } const bp = nextConfig?.basePath ?? ""; - if (bp && pathname.startsWith(bp)) { - pathname = pathname.slice(bp.length) || "/"; - } + if (bp) pathname = stripBasePath(pathname, bp); return pathname || "/"; } @@ -1715,6 +1716,81 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { return true; } + function applyRequestHeadersToNodeRequest( + req: IncomingMessage, + nextRequestHeaders: Headers, + ): void { + for (const key of Object.keys(req.headers)) { + delete req.headers[key]; + } + for (const [key, value] of nextRequestHeaders) { + req.headers[key] = value; + } + } + + function setAppRouterPreparedRequestState( + req: IncomingMessage, + options?: { rewriteStatus?: number | null; requestUrl?: string | null }, + ): void { + req.headers[APP_ROUTER_PREPARED_HEADER] = "1"; + const rewriteStatus = options?.rewriteStatus; + if (typeof rewriteStatus === "number") { + req.headers[APP_ROUTER_REWRITE_STATUS_HEADER] = String(rewriteStatus); + } else { + delete req.headers[APP_ROUTER_REWRITE_STATUS_HEADER]; + } + if (options?.requestUrl) { + req.headers[APP_ROUTER_TARGET_HEADER] = options.requestUrl; + } else { + delete req.headers[APP_ROUTER_TARGET_HEADER]; + } + } + + function appendNodeResponseHeaders( + res: any, + headers: Headers | null | undefined, + ): void { + if (!headers) return; + for (const [key, value] of headers) { + if (!key.startsWith("x-middleware-")) { + res.appendHeader(key, value); + } + } + } + + function buildNodeRequestHeaders(req: IncomingMessage): Headers { + return new Headers( + Object.fromEntries( + Object.entries(req.headers) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key, Array.isArray(value) ? value.join(", ") : String(value)]), + ), + ); + } + + function resolveDynamicMetadataRouteModuleFiles(pathname: string): string[] | null { + const metadataRoutes = scanMetadataFiles(appDir); + for (const metadataRoute of metadataRoutes) { + if (!metadataRoute.isDynamic) continue; + if (pathname === metadataRoute.servedUrl) return [metadataRoute.filePath]; + if ( + metadataRoute.type === "sitemap" && + metadataRoute.servedUrl.endsWith(".xml") && + pathname.startsWith(metadataRoute.servedUrl.slice(0, -4) + "/") && + pathname.endsWith(".xml") + ) { + return [metadataRoute.filePath]; + } + } + return null; + } + + function collectAllDynamicMetadataRouteFiles(): string[] { + return scanMetadataFiles(appDir) + .filter((metadataRoute) => metadataRoute.isDynamic) + .map((metadataRoute) => metadataRoute.filePath); + } + function cleanViteModuleId(moduleId: string): string { const hashIndex = moduleId.indexOf("#"); const queryIndex = moduleId.indexOf("?"); @@ -1830,6 +1906,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { return collectAppRouteModuleFiles(matchedRoute); } + const metadataRouteFiles = resolveDynamicMetadataRouteModuleFiles(pathname); + if (metadataRouteFiles) return metadataRouteFiles; + if (options.skipIfPagesRouteMatches && hasPagesDir) { const pageRoutes = await pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher); if (matchRoute(pathname, pageRoutes)) return null; @@ -1843,6 +1922,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { files.add(filePath); } } + for (const filePath of collectAllDynamicMetadataRouteFiles()) { + files.add(filePath); + } return [...files]; } @@ -1886,6 +1968,271 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (entryModule) rscEnv.moduleGraph.invalidateModule(entryModule); } + async function prepareDirectAppRouterRequest( + req: IncomingMessage, + res: any, + initialUrl: string, + options?: { staticExportToken?: string }, + ): Promise { + if (hasCloudflarePlugin) return false; + let url = initialUrl; + if ( + url.startsWith("/@") || + url.startsWith("/__vite") || + url.startsWith("/node_modules") + ) { + return false; + } + + const rawPathname = url.split("?")[0]; + const requestHasRscSuffix = rawPathname.endsWith(".rsc"); + const toRoutingUrl = (requestUrl: string): string => + requestHasRscSuffix ? requestUrl.replace(/\.rsc(?=\?|$)/, "") : requestUrl; + const fromRoutingUrl = (routingUrl: string): string => { + if (!requestHasRscSuffix) return routingUrl; + const [routingPathname, search = ""] = routingUrl.split("?"); + return `${routingPathname}.rsc${search ? `?${search}` : ""}`; + }; + if (rawPathname.endsWith("/index.html")) { + url = url.replace("/index.html", "/"); + } else if (rawPathname.endsWith(".html")) { + url = url.replace(/\.html(?=\?|$)/, ""); + } + + if (url.split("?")[0] === "/_vinext/image") { + const imgParams = new URLSearchParams(url.split("?")[1] ?? ""); + const rawImgUrl = imgParams.get("url"); + const imgUrl = rawImgUrl?.replaceAll("\\", "/") ?? null; + if ( + !imgUrl || + !imgUrl.startsWith("/") || + imgUrl.startsWith("//") || + imgUrl.startsWith("/@") || + imgUrl.startsWith("/__vite") || + imgUrl.startsWith("/node_modules") + ) { + res.writeHead(400); + res.end(!rawImgUrl ? "Missing url parameter" : "Only relative URLs allowed"); + return true; + } + const resolvedImg = new URL(imgUrl, `http://${req.headers.host || "localhost"}`); + if (resolvedImg.origin !== `http://${req.headers.host || "localhost"}`) { + res.writeHead(400); + res.end("Only relative URLs allowed"); + return true; + } + res.writeHead(302, { Location: imgUrl }); + res.end(); + return true; + } + + let pathname = toRoutingUrl(url).split("?")[0].replaceAll("\\", "/"); + if (pathname.startsWith("//")) { + res.writeHead(404); + res.end("404 Not Found"); + return true; + } + + try { + pathname = normalizePath(decodeURIComponent(pathname)); + } catch { + res.writeHead(400); + res.end("Bad Request"); + return true; + } + + const bp = nextConfig?.basePath ?? ""; + if (bp) { + const stripped = stripBasePath(pathname, bp); + if (stripped !== pathname) { + const qs = url.includes("?") ? url.slice(url.indexOf("?")) : ""; + url = stripped + qs; + pathname = stripped; + } + } + + if ( + nextConfig && + pathname !== "/" && + pathname !== "/api" && + !pathname.startsWith("/api/") && + !requestHasRscSuffix + ) { + const hasTrailing = pathname.endsWith("/"); + if (nextConfig.trailingSlash && !hasTrailing) { + const qs = url.includes("?") ? url.slice(url.indexOf("?")) : ""; + const dest = bp + pathname + "/" + qs; + res.writeHead(308, { Location: dest }); + res.end(); + return true; + } + if (!nextConfig.trailingSlash && hasTrailing) { + const qs = url.includes("?") ? url.slice(url.indexOf("?")) : ""; + const dest = bp + pathname.replace(/\/+$/, "") + qs; + res.writeHead(308, { Location: dest }); + res.end(); + return true; + } + } + + const devTrustProxy = + process.env.VINEXT_TRUST_PROXY === "1" || + (process.env.VINEXT_TRUSTED_HOSTS ?? "").split(",").some((h) => h.trim()); + const rawProto = devTrustProxy + ? String(req.headers["x-forwarded-proto"] || "") + .split(",")[0] + .trim() + : ""; + const originProto = rawProto === "https" || rawProto === "http" ? rawProto : "http"; + const origin = `${originProto}://${req.headers.host || "localhost"}`; + + let requestHeaders = buildNodeRequestHeaders(req); + let rewriteStatus: number | null = null; + const buildRequestForUrl = (requestUrl: string, headers: Headers): Request => + new Request(new URL(toRoutingUrl(requestUrl), origin), { + method: req.method, + headers, + }); + + if (nextConfig?.redirects.length) { + const redirectMatch = matchRedirect( + pathname, + nextConfig.redirects, + requestContextFromRequest(buildRequestForUrl(url, requestHeaders)), + ); + if (redirectMatch) { + const destination = sanitizeDestination( + bp && + !isExternalUrl(redirectMatch.destination) && + !hasBasePath(redirectMatch.destination, bp) + ? bp + redirectMatch.destination + : redirectMatch.destination, + ); + res.writeHead(redirectMatch.permanent ? 308 : 307, { Location: destination }); + res.end(); + return true; + } + } + + if (middlewarePath) { + const middlewareResult = await runMiddleware( + getPagesRunner(), + middlewarePath, + buildRequestForUrl(url, requestHeaders), + nextConfig?.i18n, + ); + + if (!middlewareResult.continue) { + if (middlewareResult.redirectUrl) { + const redirectHeaders: Record = { + Location: middlewareResult.redirectUrl, + }; + if (middlewareResult.responseHeaders) { + for (const [key, value] of middlewareResult.responseHeaders) { + const existing = redirectHeaders[key]; + if (existing === undefined) { + redirectHeaders[key] = value; + } else if (Array.isArray(existing)) { + existing.push(value); + } else { + redirectHeaders[key] = [existing, value]; + } + } + } + res.writeHead(middlewareResult.redirectStatus ?? 307, redirectHeaders); + res.end(); + return true; + } + if (middlewareResult.response) { + res.statusCode = middlewareResult.response.status; + for (const [key, value] of middlewareResult.response.headers) { + res.appendHeader(key, value); + } + res.end(await middlewareResult.response.text()); + return true; + } + } + + if (middlewareResult.responseHeaders) { + requestHeaders = + buildRequestHeadersFromMiddlewareResponse(requestHeaders, middlewareResult.responseHeaders) ?? + requestHeaders; + appendNodeResponseHeaders(res, middlewareResult.responseHeaders); + } + + if (middlewareResult.rewriteUrl) { + url = fromRoutingUrl(toRoutingUrl(middlewareResult.rewriteUrl)); + pathname = normalizeAppRequestPath(url) ?? pathname; + rewriteStatus = middlewareResult.rewriteStatus ?? null; + } + } + + applyRequestHeadersToNodeRequest(req, requestHeaders); + + const buildRequestContext = (requestUrl: string): RequestContext => + requestContextFromRequest(buildRequestForUrl(requestUrl, requestHeaders)); + const postMwReqCtx = buildRequestContext(url); + + if (nextConfig?.rewrites.beforeFiles.length) { + const rewritten = applyRewrites(pathname, nextConfig.rewrites.beforeFiles, postMwReqCtx); + if (rewritten) { + if (isExternalUrl(rewritten)) { + await proxyExternalRewriteNode(req, res, rewritten); + return true; + } + url = fromRoutingUrl(toRoutingUrl(rewritten)); + pathname = normalizeAppRequestPath(url) ?? pathname; + } + } + + const metadataRouteFiles = pathname ? resolveDynamicMetadataRouteModuleFiles(pathname) : null; + if (!metadataRouteFiles) { + if (nextConfig?.rewrites.afterFiles.length) { + const afterRewrite = applyRewrites(pathname, nextConfig.rewrites.afterFiles, postMwReqCtx); + if (afterRewrite) { + if (isExternalUrl(afterRewrite)) { + await proxyExternalRewriteNode(req, res, afterRewrite); + return true; + } + url = fromRoutingUrl(toRoutingUrl(afterRewrite)); + pathname = normalizeAppRequestPath(url) ?? pathname; + } + } + + const appRoutes = await appRouter(appDir, nextConfig?.pageExtensions, fileMatcher); + let appMatch = pathname ? matchRoute(pathname, appRoutes as any) : null; + if (!appMatch && nextConfig?.rewrites.fallback.length) { + const fallbackRewrite = applyRewrites(pathname, nextConfig.rewrites.fallback, postMwReqCtx); + if (fallbackRewrite) { + if (isExternalUrl(fallbackRewrite)) { + await proxyExternalRewriteNode(req, res, fallbackRewrite); + return true; + } + url = fromRoutingUrl(toRoutingUrl(fallbackRewrite)); + pathname = normalizeAppRequestPath(url) ?? pathname; + appMatch = pathname ? matchRoute(pathname, appRoutes as any) : null; + } + } + } + + req.url = url; + setAppRouterPreparedRequestState(req, { rewriteStatus, requestUrl: url }); + + if (shouldInvalidateAppRscRequest(req, url)) { + await invalidateAppRscModulesForRequest(url, { + allowFullAppFallback: false, + skipIfPagesRouteMatches: false, + staticExportToken: options?.staticExportToken, + requestStaticExportToken: + typeof req.headers["x-vinext-static-export"] === "string" + ? req.headers["x-vinext-static-export"] + : undefined, + }); + } + + return false; + } + server.watcher.on("add", (filePath: string) => { if (hasPagesDir && filePath.startsWith(pagesDir) && pageExtensions.test(filePath)) { invalidateRouteCache(pagesDir); @@ -1977,16 +2324,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { try { const url = req.url ?? "/"; const isRscRequest = url.split("?")[0].endsWith(".rsc"); - if ((!hasPagesDir || isRscRequest) && shouldInvalidateAppRscRequest(req, url)) { - await invalidateAppRscModulesForRequest(url, { - allowFullAppFallback: false, - skipIfPagesRouteMatches: false, - staticExportToken, - requestStaticExportToken: - typeof req.headers["x-vinext-static-export"] === "string" - ? req.headers["x-vinext-static-export"] - : undefined, - }); + if (!hasPagesDir || isRscRequest) { + if (await prepareDirectAppRouterRequest(req, _res, url, { staticExportToken })) { + return; + } } next(); } catch (err) { @@ -2277,21 +2618,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } } - const applyRequestHeadersToNodeRequest = (nextRequestHeaders: Headers) => { - for (const key of Object.keys(req.headers)) { - delete req.headers[key]; - } - for (const [key, value] of nextRequestHeaders) { - req.headers[key] = value; - } - }; - const handOffToAppRouter = async ( appUrl: string, options?: { allowFullAppFallback?: boolean }, ) => { if (middlewareRequestHeaders) { - applyRequestHeadersToNodeRequest(middlewareRequestHeaders); + applyRequestHeadersToNodeRequest(req, middlewareRequestHeaders); } req.url = appUrl; @@ -2392,14 +2724,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ); if (middlewareRequestHeaders && !hasAppDir) { - applyRequestHeadersToNodeRequest(middlewareRequestHeaders); + applyRequestHeadersToNodeRequest(req, middlewareRequestHeaders); } - for (const [key, value] of result.responseHeaders) { - if (!key.startsWith("x-middleware-")) { - res.appendHeader(key, value); - } - } + appendNodeResponseHeaders(res, result.responseHeaders); } // Apply middleware rewrite (URL and optional status code) @@ -2499,7 +2827,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ); const apiMatch = matchRoute(resolvedUrl, apiRoutes); if (apiMatch && middlewareRequestHeaders) { - applyRequestHeadersToNodeRequest(middlewareRequestHeaders); + applyRequestHeadersToNodeRequest(req, middlewareRequestHeaders); } const handled = await handleApiRoute(server, req, res, resolvedUrl, apiRoutes); if (handled) return; @@ -2550,7 +2878,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const match = matchRoute(resolvedUrl.split("?")[0], routes); if (match) { if (middlewareRequestHeaders) { - applyRequestHeadersToNodeRequest(middlewareRequestHeaders); + applyRequestHeadersToNodeRequest(req, middlewareRequestHeaders); } await handler(req, res, resolvedUrl, mwStatus); return; @@ -2572,8 +2900,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const fallbackMatch = matchRoute(fallbackRewrite.split("?")[0], routes); if ( !fallbackMatch && - hasAppDir && - shouldInvalidateAppRscRequest(req, fallbackRewrite) + hasAppDir ) { await handOffToAppRouter(fallbackRewrite, { allowFullAppFallback: true, @@ -2581,7 +2908,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { return; } if (middlewareRequestHeaders) { - applyRequestHeadersToNodeRequest(middlewareRequestHeaders); + applyRequestHeadersToNodeRequest(req, middlewareRequestHeaders); } await handler(req, res, fallbackRewrite, mwStatus); return; diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index d052563e..a9f5e873 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2136,6 +2136,17 @@ describe("App Router next.config.js features (dev server integration)", () => { expect(html).toContain("About"); }); + it("fallback rewrites still hand off POST requests to app/api targets", async () => { + const res = await fetch(`${baseUrl}/fallback-app-api`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ via: "fallback-rewrite" }), + }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ echo: { via: "fallback-rewrite" } }); + }); + it("fallback rewrites targeting Pages routes still work in mixed app/pages projects", async () => { const noAuthRes = await fetch(`${baseUrl}/mw-gated-fallback-pages`); expect(noAuthRes.status).toBe(404); @@ -2192,6 +2203,93 @@ describe("App Router next.config.js features (dev server integration)", () => { }); }); +describe("App Router rewrite freshness in app-only projects", () => { + let server: ViteDevServer; + let baseUrl: string; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync( + path.join(path.resolve(import.meta.dirname, "./fixtures"), "app-only-rewrite-"), + ); + fs.mkdirSync(path.join(tmpDir, "app", "target"), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, "package.json"), + JSON.stringify({ name: "app-only-rewrite-test", private: true, type: "module" }, null, 2), + ); + fs.writeFileSync( + path.join(tmpDir, "next.config.ts"), + ` +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + async rewrites() { + return { + beforeFiles: [{ source: "/rewrite-target", destination: "/target" }], + afterFiles: [], + fallback: [], + }; + }, +}; + +export default nextConfig; +`, + ); + fs.writeFileSync( + path.join(tmpDir, "shared-now.ts"), + `export const sharedImportedNow = Date.now();\n`, + ); + fs.writeFileSync( + path.join(tmpDir, "app", "layout.tsx"), + ` +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +`, + ); + fs.writeFileSync( + path.join(tmpDir, "app", "target", "page.tsx"), + ` +import { sharedImportedNow } from "../../shared-now"; + +export default function TargetPage() { + return
{sharedImportedNow}
; +} +`, + ); + ({ server, baseUrl } = await startFixtureServer(tmpDir, { appRouter: true })); + }, 30000); + + afterAll(async () => { + await server?.close(); + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("re-executes App Router modules when rewrites happen within an app-only project", async () => { + const res1 = await fetch(`${baseUrl}/rewrite-target`); + const html1 = await res1.text(); + expect(res1.status).toBe(200); + const importedNow1 = html1.match(/id="shared-imported-now"[^>]*>(\d+)/)?.[1]; + + await new Promise((r) => setTimeout(r, 50)); + + const res2 = await fetch(`${baseUrl}/rewrite-target`); + expect(res2.status).toBe(200); + const html2 = await res2.text(); + const importedNow2 = html2.match(/id="shared-imported-now"[^>]*>(\d+)/)?.[1]; + + expect(importedNow1).toBeTruthy(); + expect(importedNow2).toBeTruthy(); + expect(importedNow1).not.toBe(importedNow2); + }); +}); + describe("App Router next.config.js features (generateRscEntry)", () => { // Use a minimal route list for testing — we only care about the generated config handling code const minimalRoutes = [ diff --git a/tests/fixtures/app-basic/app/nextjs-compat/fresh-metadata/sitemap.ts b/tests/fixtures/app-basic/app/nextjs-compat/fresh-metadata/sitemap.ts new file mode 100644 index 00000000..e97f0ff7 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/fresh-metadata/sitemap.ts @@ -0,0 +1,9 @@ +import { sharedImportedNow } from "../../../../shared/rsc-shared-now"; + +export default function sitemap() { + return [ + { + url: `https://example.com/fresh/${sharedImportedNow}`, + }, + ]; +} diff --git a/tests/fixtures/app-basic/next.config.ts b/tests/fixtures/app-basic/next.config.ts index e779e321..9b6d0db7 100644 --- a/tests/fixtures/app-basic/next.config.ts +++ b/tests/fixtures/app-basic/next.config.ts @@ -74,6 +74,9 @@ const nextConfig: NextConfig = { beforeFiles: [ // Used by Vitest: app-router.test.ts { source: "/rewrite-about", destination: "/about" }, + // Used by Vitest: app-rendering.test.ts — direct App Router rewrite target + // should still get fresh RSC module execution on .rsc requests. + { source: "/rewrite-shared", destination: "/nextjs-compat/isr-shared-module" }, // Used by Vitest: app-router.test.ts — repeated param substitution { source: "/repeat-rewrite/:slug", @@ -121,6 +124,13 @@ const nextConfig: NextConfig = { has: [{ type: "cookie", key: "mw-pages-fallback-user" }], destination: "/pages-header-override-delete", }, + // Used by Vitest: app-router.test.ts — fallback rewrites to app/api/* + // must still hand off on POST requests even though those requests do + // not participate in per-request RSC invalidation. + { + source: "/fallback-app-api", + destination: "/api/hello", + }, ], }; }, diff --git a/tests/nextjs-compat/app-rendering.test.ts b/tests/nextjs-compat/app-rendering.test.ts index c4398b06..bab3b2d7 100644 --- a/tests/nextjs-compat/app-rendering.test.ts +++ b/tests/nextjs-compat/app-rendering.test.ts @@ -154,6 +154,71 @@ describe("Next.js compat: app-rendering", () => { expect(importedNow1).not.toBe(importedNow2); }); + it("should re-execute modules for direct App Router config rewrite .rsc requests", async () => { + const routePath = "/rewrite-shared.rsc"; + const res1 = await fetch(`${baseUrl}${routePath}`, { + headers: { Accept: "text/x-component" }, + }); + const rsc1 = await res1.text(); + const importedNow1 = rsc1.match(/shared-imported-now.*?(\d{10,})/)?.[1]; + + await new Promise((r) => setTimeout(r, 50)); + + const res2 = await fetch(`${baseUrl}${routePath}`, { + headers: { Accept: "text/x-component" }, + }); + const rsc2 = await res2.text(); + const importedNow2 = rsc2.match(/shared-imported-now.*?(\d{10,})/)?.[1]; + + expect(res1.status).toBe(200); + expect(res2.status).toBe(200); + expect(importedNow1).toBeTruthy(); + expect(importedNow2).toBeTruthy(); + expect(importedNow1).not.toBe(importedNow2); + }); + + it("should re-execute modules for direct App Router middleware rewrite .rsc requests", async () => { + const routePath = "/mw-pages-to-app-rewrite.rsc"; + const res1 = await fetch(`${baseUrl}${routePath}`, { + headers: { Accept: "text/x-component" }, + }); + const rsc1 = await res1.text(); + const importedNow1 = rsc1.match(/shared-imported-now.*?(\d{10,})/)?.[1]; + + await new Promise((r) => setTimeout(r, 50)); + + const res2 = await fetch(`${baseUrl}${routePath}`, { + headers: { Accept: "text/x-component" }, + }); + const rsc2 = await res2.text(); + const importedNow2 = rsc2.match(/shared-imported-now.*?(\d{10,})/)?.[1]; + + expect(res1.status).toBe(200); + expect(res2.status).toBe(200); + expect(importedNow1).toBeTruthy(); + expect(importedNow2).toBeTruthy(); + expect(importedNow1).not.toBe(importedNow2); + }); + + it("should re-execute dynamic metadata routes on subsequent requests", async () => { + const routePath = "/nextjs-compat/fresh-metadata/sitemap.xml"; + const res1 = await fetch(`${baseUrl}${routePath}`); + const xml1 = await res1.text(); + const importedNow1 = xml1.match(/fresh\/(\d{10,})/)?.[1]; + + await new Promise((r) => setTimeout(r, 50)); + + const res2 = await fetch(`${baseUrl}${routePath}`); + const xml2 = await res2.text(); + const importedNow2 = xml2.match(/fresh\/(\d{10,})/)?.[1]; + + expect(res1.status).toBe(200); + expect(res2.status).toBe(200); + expect(importedNow1).toBeTruthy(); + expect(importedNow2).toBeTruthy(); + expect(importedNow1).not.toBe(importedNow2); + }); + it("should re-execute dotted App Router paths on subsequent requests", async () => { const routePath = "/nextjs-compat/isr-dotted/jane.doe"; const { html: html1 } = await fetchHtml(baseUrl, routePath); From 607120dae3f6a288ca85093856d87908b7bd4199 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 01:22:19 -0500 Subject: [PATCH 06/10] Fix App Router invalidation regresss --- packages/vinext/src/entries/app-rsc-entry.ts | 2 +- packages/vinext/src/index.ts | 43 +++++++++++++------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 07a7c784..a33e295e 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -1537,7 +1537,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared) { // _mwCtx (per-request container) so handler() can merge them into // every response path without module-level state that races on Workers. - ${ + ${ middlewarePath ? ` if (!__hostPrepared) { diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index f5787e2b..500273f3 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1746,10 +1746,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } } - function appendNodeResponseHeaders( - res: any, - headers: Headers | null | undefined, - ): void { + function appendNodeResponseHeaders(res: any, headers: Headers | null | undefined): void { if (!headers) return; for (const [key, value] of headers) { if (!key.startsWith("x-middleware-")) { @@ -1763,7 +1760,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { Object.fromEntries( Object.entries(req.headers) .filter(([, value]) => value !== undefined) - .map(([key, value]) => [key, Array.isArray(value) ? value.join(", ") : String(value)]), + .map(([key, value]) => [ + key, + Array.isArray(value) ? value.join(", ") : String(value), + ]), ), ); } @@ -2155,8 +2155,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (middlewareResult.responseHeaders) { requestHeaders = - buildRequestHeadersFromMiddlewareResponse(requestHeaders, middlewareResult.responseHeaders) ?? - requestHeaders; + buildRequestHeadersFromMiddlewareResponse( + requestHeaders, + middlewareResult.responseHeaders, + ) ?? requestHeaders; appendNodeResponseHeaders(res, middlewareResult.responseHeaders); } @@ -2174,7 +2176,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const postMwReqCtx = buildRequestContext(url); if (nextConfig?.rewrites.beforeFiles.length) { - const rewritten = applyRewrites(pathname, nextConfig.rewrites.beforeFiles, postMwReqCtx); + const rewritten = applyRewrites( + pathname, + nextConfig.rewrites.beforeFiles, + postMwReqCtx, + ); if (rewritten) { if (isExternalUrl(rewritten)) { await proxyExternalRewriteNode(req, res, rewritten); @@ -2185,10 +2191,16 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } } - const metadataRouteFiles = pathname ? resolveDynamicMetadataRouteModuleFiles(pathname) : null; + const metadataRouteFiles = pathname + ? resolveDynamicMetadataRouteModuleFiles(pathname) + : null; if (!metadataRouteFiles) { if (nextConfig?.rewrites.afterFiles.length) { - const afterRewrite = applyRewrites(pathname, nextConfig.rewrites.afterFiles, postMwReqCtx); + const afterRewrite = applyRewrites( + pathname, + nextConfig.rewrites.afterFiles, + postMwReqCtx, + ); if (afterRewrite) { if (isExternalUrl(afterRewrite)) { await proxyExternalRewriteNode(req, res, afterRewrite); @@ -2202,7 +2214,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const appRoutes = await appRouter(appDir, nextConfig?.pageExtensions, fileMatcher); let appMatch = pathname ? matchRoute(pathname, appRoutes as any) : null; if (!appMatch && nextConfig?.rewrites.fallback.length) { - const fallbackRewrite = applyRewrites(pathname, nextConfig.rewrites.fallback, postMwReqCtx); + const fallbackRewrite = applyRewrites( + pathname, + nextConfig.rewrites.fallback, + postMwReqCtx, + ); if (fallbackRewrite) { if (isExternalUrl(fallbackRewrite)) { await proxyExternalRewriteNode(req, res, fallbackRewrite); @@ -2898,10 +2914,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { return; } const fallbackMatch = matchRoute(fallbackRewrite.split("?")[0], routes); - if ( - !fallbackMatch && - hasAppDir - ) { + if (!fallbackMatch && hasAppDir) { await handOffToAppRouter(fallbackRewrite, { allowFullAppFallback: true, }); From dd12f30231b8160c84379cea8d7e347555a4b725 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 01:33:07 -0500 Subject: [PATCH 07/10] Fix App Router rewrite invalidation --- .../entry-templates.test.ts.snap | 248 ++++++++++++++---- 1 file changed, 200 insertions(+), 48 deletions(-) diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index cb23dad7..124d8d9e 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -1387,6 +1387,9 @@ const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; const __allowedOrigins = []; +const __hostPreparedHeader = "x-vinext-app-router-prepared"; +const __hostRewriteStatusHeader = "x-vinext-app-router-rewrite-status"; +const __hostTargetHeader = "x-vinext-app-router-target"; // ── Dev server origin verification ────────────────────────────────────── @@ -1630,6 +1633,25 @@ async function __readFormDataWithLimit(request, maxBytes) { export default async function handler(request, ctx) { + const __hostPrepared = request.headers.get(__hostPreparedHeader) === "1"; + const __hostRewriteStatus = request.headers.get(__hostRewriteStatusHeader); + const __hostPreparedTarget = request.headers.get(__hostTargetHeader); + if (__hostPrepared || __hostRewriteStatus || __hostPreparedTarget) { + const __sanitizedHeaders = new Headers(request.headers); + __sanitizedHeaders.delete(__hostPreparedHeader); + __sanitizedHeaders.delete(__hostRewriteStatusHeader); + __sanitizedHeaders.delete(__hostTargetHeader); + const __requestUrl = __hostPreparedTarget + ? new URL(__hostPreparedTarget, request.url).href + : request.url; + request = new Request(__requestUrl, { + method: request.method, + headers: __sanitizedHeaders, + body: request.body, + // @ts-expect-error -- duplex is required when reusing a streaming body + duplex: request.body ? "half" : undefined, + }); + } // Wrap the entire request in nested AsyncLocalStorage.run() scopes to ensure // per-request isolation for all state modules. Each runWith*() creates an // ALS scope that propagates through all async continuations (including RSC @@ -1649,8 +1671,11 @@ export default async function handler(request, ctx) { // Per-request container for middleware state. Passed into // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. - const _mwCtx = { headers: null, status: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const _mwCtx = { + headers: null, + status: __hostRewriteStatus ? Number(__hostRewriteStatus) || null : null, + }; + const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared); // Apply custom headers from next.config.js to non-redirect responses. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // and Next.js doesn't apply custom headers to redirects anyway. @@ -1686,7 +1711,7 @@ export default async function handler(request, ctx) { return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } -async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { +async function _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared) { const __reqStart = process.env.NODE_ENV !== "production" ? performance.now() : 0; let __compileEnd; let __renderEnd; @@ -1723,7 +1748,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { if (__tsRedirect) return __tsRedirect; // ── Apply redirects from next.config.js ─────────────────────────────── - if (__configRedirects.length) { + if (!__hostPrepared && __configRedirects.length) { // Strip .rsc suffix before matching redirect rules - RSC (client-side nav) requests // arrive as /some/path.rsc but redirect patterns are defined without it (e.g. // /some/path). Without this, soft-nav fetches bypass all config redirects. @@ -1757,12 +1782,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // These run after middleware in the App Router execution order and should // evaluate has/missing conditions against middleware-modified headers. // When no middleware is present, this falls back to requestContextFromRequest. - const __postMwReqCtx = __buildPostMwRequestContext(request); + const __postMwReqCtx = __hostPrepared ? requestContextFromRequest(request) : __buildPostMwRequestContext(request); // ── Apply beforeFiles rewrites from next.config.js ──────────────────── // In App Router execution order, beforeFiles runs after middleware so that // has/missing conditions can evaluate against middleware-modified headers. - if (__configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { + if (!__hostPrepared && __configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx); if (__rewritten) { if (isExternalUrl(__rewritten)) { @@ -2026,7 +2051,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } // ── Apply afterFiles rewrites from next.config.js ────────────────────── - if (__configRewrites.afterFiles && __configRewrites.afterFiles.length) { + if (!__hostPrepared && __configRewrites.afterFiles && __configRewrites.afterFiles.length) { const __afterRewritten = matchRewrite(cleanPathname, __configRewrites.afterFiles, __postMwReqCtx); if (__afterRewritten) { if (isExternalUrl(__afterRewritten)) { @@ -2041,7 +2066,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { let match = matchRoute(cleanPathname, routes); // ── Fallback rewrites from next.config.js (if no route matched) ─────── - if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { + if (!__hostPrepared && !match && __configRewrites.fallback && __configRewrites.fallback.length) { const __fallbackRewritten = matchRewrite(cleanPathname, __configRewrites.fallback, __postMwReqCtx); if (__fallbackRewritten) { if (isExternalUrl(__fallbackRewritten)) { @@ -4099,6 +4124,9 @@ const __configRedirects = [{"source":"/old","destination":"/new","permanent":tru const __configRewrites = {"beforeFiles":[{"source":"/api/:path*","destination":"/backend/:path*"}],"afterFiles":[],"fallback":[]}; const __configHeaders = [{"source":"/api/:path*","headers":[{"key":"X-Custom","value":"test"}]}]; const __allowedOrigins = ["https://example.com"]; +const __hostPreparedHeader = "x-vinext-app-router-prepared"; +const __hostRewriteStatusHeader = "x-vinext-app-router-rewrite-status"; +const __hostTargetHeader = "x-vinext-app-router-target"; // ── Dev server origin verification ────────────────────────────────────── @@ -4342,6 +4370,25 @@ async function __readFormDataWithLimit(request, maxBytes) { export default async function handler(request, ctx) { + const __hostPrepared = request.headers.get(__hostPreparedHeader) === "1"; + const __hostRewriteStatus = request.headers.get(__hostRewriteStatusHeader); + const __hostPreparedTarget = request.headers.get(__hostTargetHeader); + if (__hostPrepared || __hostRewriteStatus || __hostPreparedTarget) { + const __sanitizedHeaders = new Headers(request.headers); + __sanitizedHeaders.delete(__hostPreparedHeader); + __sanitizedHeaders.delete(__hostRewriteStatusHeader); + __sanitizedHeaders.delete(__hostTargetHeader); + const __requestUrl = __hostPreparedTarget + ? new URL(__hostPreparedTarget, request.url).href + : request.url; + request = new Request(__requestUrl, { + method: request.method, + headers: __sanitizedHeaders, + body: request.body, + // @ts-expect-error -- duplex is required when reusing a streaming body + duplex: request.body ? "half" : undefined, + }); + } // Wrap the entire request in nested AsyncLocalStorage.run() scopes to ensure // per-request isolation for all state modules. Each runWith*() creates an // ALS scope that propagates through all async continuations (including RSC @@ -4361,8 +4408,11 @@ export default async function handler(request, ctx) { // Per-request container for middleware state. Passed into // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. - const _mwCtx = { headers: null, status: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const _mwCtx = { + headers: null, + status: __hostRewriteStatus ? Number(__hostRewriteStatus) || null : null, + }; + const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared); // Apply custom headers from next.config.js to non-redirect responses. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // and Next.js doesn't apply custom headers to redirects anyway. @@ -4398,7 +4448,7 @@ export default async function handler(request, ctx) { return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } -async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { +async function _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared) { const __reqStart = process.env.NODE_ENV !== "production" ? performance.now() : 0; let __compileEnd; let __renderEnd; @@ -4438,7 +4488,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { if (__tsRedirect) return __tsRedirect; // ── Apply redirects from next.config.js ─────────────────────────────── - if (__configRedirects.length) { + if (!__hostPrepared && __configRedirects.length) { // Strip .rsc suffix before matching redirect rules - RSC (client-side nav) requests // arrive as /some/path.rsc but redirect patterns are defined without it (e.g. // /some/path). Without this, soft-nav fetches bypass all config redirects. @@ -4472,12 +4522,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // These run after middleware in the App Router execution order and should // evaluate has/missing conditions against middleware-modified headers. // When no middleware is present, this falls back to requestContextFromRequest. - const __postMwReqCtx = __buildPostMwRequestContext(request); + const __postMwReqCtx = __hostPrepared ? requestContextFromRequest(request) : __buildPostMwRequestContext(request); // ── Apply beforeFiles rewrites from next.config.js ──────────────────── // In App Router execution order, beforeFiles runs after middleware so that // has/missing conditions can evaluate against middleware-modified headers. - if (__configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { + if (!__hostPrepared && __configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx); if (__rewritten) { if (isExternalUrl(__rewritten)) { @@ -4741,7 +4791,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } // ── Apply afterFiles rewrites from next.config.js ────────────────────── - if (__configRewrites.afterFiles && __configRewrites.afterFiles.length) { + if (!__hostPrepared && __configRewrites.afterFiles && __configRewrites.afterFiles.length) { const __afterRewritten = matchRewrite(cleanPathname, __configRewrites.afterFiles, __postMwReqCtx); if (__afterRewritten) { if (isExternalUrl(__afterRewritten)) { @@ -4756,7 +4806,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { let match = matchRoute(cleanPathname, routes); // ── Fallback rewrites from next.config.js (if no route matched) ─────── - if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { + if (!__hostPrepared && !match && __configRewrites.fallback && __configRewrites.fallback.length) { const __fallbackRewritten = matchRewrite(cleanPathname, __configRewrites.fallback, __postMwReqCtx); if (__fallbackRewritten) { if (isExternalUrl(__fallbackRewritten)) { @@ -6844,6 +6894,9 @@ const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; const __allowedOrigins = []; +const __hostPreparedHeader = "x-vinext-app-router-prepared"; +const __hostRewriteStatusHeader = "x-vinext-app-router-rewrite-status"; +const __hostTargetHeader = "x-vinext-app-router-target"; // ── Dev server origin verification ────────────────────────────────────── @@ -7087,6 +7140,25 @@ async function __readFormDataWithLimit(request, maxBytes) { export default async function handler(request, ctx) { + const __hostPrepared = request.headers.get(__hostPreparedHeader) === "1"; + const __hostRewriteStatus = request.headers.get(__hostRewriteStatusHeader); + const __hostPreparedTarget = request.headers.get(__hostTargetHeader); + if (__hostPrepared || __hostRewriteStatus || __hostPreparedTarget) { + const __sanitizedHeaders = new Headers(request.headers); + __sanitizedHeaders.delete(__hostPreparedHeader); + __sanitizedHeaders.delete(__hostRewriteStatusHeader); + __sanitizedHeaders.delete(__hostTargetHeader); + const __requestUrl = __hostPreparedTarget + ? new URL(__hostPreparedTarget, request.url).href + : request.url; + request = new Request(__requestUrl, { + method: request.method, + headers: __sanitizedHeaders, + body: request.body, + // @ts-expect-error -- duplex is required when reusing a streaming body + duplex: request.body ? "half" : undefined, + }); + } // Wrap the entire request in nested AsyncLocalStorage.run() scopes to ensure // per-request isolation for all state modules. Each runWith*() creates an // ALS scope that propagates through all async continuations (including RSC @@ -7106,8 +7178,11 @@ export default async function handler(request, ctx) { // Per-request container for middleware state. Passed into // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. - const _mwCtx = { headers: null, status: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const _mwCtx = { + headers: null, + status: __hostRewriteStatus ? Number(__hostRewriteStatus) || null : null, + }; + const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared); // Apply custom headers from next.config.js to non-redirect responses. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // and Next.js doesn't apply custom headers to redirects anyway. @@ -7143,7 +7218,7 @@ export default async function handler(request, ctx) { return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } -async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { +async function _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared) { const __reqStart = process.env.NODE_ENV !== "production" ? performance.now() : 0; let __compileEnd; let __renderEnd; @@ -7180,7 +7255,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { if (__tsRedirect) return __tsRedirect; // ── Apply redirects from next.config.js ─────────────────────────────── - if (__configRedirects.length) { + if (!__hostPrepared && __configRedirects.length) { // Strip .rsc suffix before matching redirect rules - RSC (client-side nav) requests // arrive as /some/path.rsc but redirect patterns are defined without it (e.g. // /some/path). Without this, soft-nav fetches bypass all config redirects. @@ -7214,12 +7289,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // These run after middleware in the App Router execution order and should // evaluate has/missing conditions against middleware-modified headers. // When no middleware is present, this falls back to requestContextFromRequest. - const __postMwReqCtx = __buildPostMwRequestContext(request); + const __postMwReqCtx = __hostPrepared ? requestContextFromRequest(request) : __buildPostMwRequestContext(request); // ── Apply beforeFiles rewrites from next.config.js ──────────────────── // In App Router execution order, beforeFiles runs after middleware so that // has/missing conditions can evaluate against middleware-modified headers. - if (__configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { + if (!__hostPrepared && __configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx); if (__rewritten) { if (isExternalUrl(__rewritten)) { @@ -7483,7 +7558,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } // ── Apply afterFiles rewrites from next.config.js ────────────────────── - if (__configRewrites.afterFiles && __configRewrites.afterFiles.length) { + if (!__hostPrepared && __configRewrites.afterFiles && __configRewrites.afterFiles.length) { const __afterRewritten = matchRewrite(cleanPathname, __configRewrites.afterFiles, __postMwReqCtx); if (__afterRewritten) { if (isExternalUrl(__afterRewritten)) { @@ -7498,7 +7573,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { let match = matchRoute(cleanPathname, routes); // ── Fallback rewrites from next.config.js (if no route matched) ─────── - if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { + if (!__hostPrepared && !match && __configRewrites.fallback && __configRewrites.fallback.length) { const __fallbackRewritten = matchRewrite(cleanPathname, __configRewrites.fallback, __postMwReqCtx); if (__fallbackRewritten) { if (isExternalUrl(__fallbackRewritten)) { @@ -9593,6 +9668,9 @@ const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; const __allowedOrigins = []; +const __hostPreparedHeader = "x-vinext-app-router-prepared"; +const __hostRewriteStatusHeader = "x-vinext-app-router-rewrite-status"; +const __hostTargetHeader = "x-vinext-app-router-target"; // ── Dev server origin verification ────────────────────────────────────── @@ -9839,6 +9917,25 @@ export default async function handler(request, ctx) { // This is a no-op after the first call (guarded by __instrumentationInitialized). await __ensureInstrumentation(); + const __hostPrepared = request.headers.get(__hostPreparedHeader) === "1"; + const __hostRewriteStatus = request.headers.get(__hostRewriteStatusHeader); + const __hostPreparedTarget = request.headers.get(__hostTargetHeader); + if (__hostPrepared || __hostRewriteStatus || __hostPreparedTarget) { + const __sanitizedHeaders = new Headers(request.headers); + __sanitizedHeaders.delete(__hostPreparedHeader); + __sanitizedHeaders.delete(__hostRewriteStatusHeader); + __sanitizedHeaders.delete(__hostTargetHeader); + const __requestUrl = __hostPreparedTarget + ? new URL(__hostPreparedTarget, request.url).href + : request.url; + request = new Request(__requestUrl, { + method: request.method, + headers: __sanitizedHeaders, + body: request.body, + // @ts-expect-error -- duplex is required when reusing a streaming body + duplex: request.body ? "half" : undefined, + }); + } // Wrap the entire request in nested AsyncLocalStorage.run() scopes to ensure // per-request isolation for all state modules. Each runWith*() creates an // ALS scope that propagates through all async continuations (including RSC @@ -9858,8 +9955,11 @@ export default async function handler(request, ctx) { // Per-request container for middleware state. Passed into // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. - const _mwCtx = { headers: null, status: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const _mwCtx = { + headers: null, + status: __hostRewriteStatus ? Number(__hostRewriteStatus) || null : null, + }; + const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared); // Apply custom headers from next.config.js to non-redirect responses. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // and Next.js doesn't apply custom headers to redirects anyway. @@ -9895,7 +9995,7 @@ export default async function handler(request, ctx) { return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } -async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { +async function _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared) { const __reqStart = process.env.NODE_ENV !== "production" ? performance.now() : 0; let __compileEnd; let __renderEnd; @@ -9932,7 +10032,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { if (__tsRedirect) return __tsRedirect; // ── Apply redirects from next.config.js ─────────────────────────────── - if (__configRedirects.length) { + if (!__hostPrepared && __configRedirects.length) { // Strip .rsc suffix before matching redirect rules - RSC (client-side nav) requests // arrive as /some/path.rsc but redirect patterns are defined without it (e.g. // /some/path). Without this, soft-nav fetches bypass all config redirects. @@ -9966,12 +10066,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // These run after middleware in the App Router execution order and should // evaluate has/missing conditions against middleware-modified headers. // When no middleware is present, this falls back to requestContextFromRequest. - const __postMwReqCtx = __buildPostMwRequestContext(request); + const __postMwReqCtx = __hostPrepared ? requestContextFromRequest(request) : __buildPostMwRequestContext(request); // ── Apply beforeFiles rewrites from next.config.js ──────────────────── // In App Router execution order, beforeFiles runs after middleware so that // has/missing conditions can evaluate against middleware-modified headers. - if (__configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { + if (!__hostPrepared && __configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx); if (__rewritten) { if (isExternalUrl(__rewritten)) { @@ -10235,7 +10335,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } // ── Apply afterFiles rewrites from next.config.js ────────────────────── - if (__configRewrites.afterFiles && __configRewrites.afterFiles.length) { + if (!__hostPrepared && __configRewrites.afterFiles && __configRewrites.afterFiles.length) { const __afterRewritten = matchRewrite(cleanPathname, __configRewrites.afterFiles, __postMwReqCtx); if (__afterRewritten) { if (isExternalUrl(__afterRewritten)) { @@ -10250,7 +10350,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { let match = matchRoute(cleanPathname, routes); // ── Fallback rewrites from next.config.js (if no route matched) ─────── - if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { + if (!__hostPrepared && !match && __configRewrites.fallback && __configRewrites.fallback.length) { const __fallbackRewritten = matchRewrite(cleanPathname, __configRewrites.fallback, __postMwReqCtx); if (__fallbackRewritten) { if (isExternalUrl(__fallbackRewritten)) { @@ -12315,6 +12415,9 @@ const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; const __allowedOrigins = []; +const __hostPreparedHeader = "x-vinext-app-router-prepared"; +const __hostRewriteStatusHeader = "x-vinext-app-router-rewrite-status"; +const __hostTargetHeader = "x-vinext-app-router-target"; // ── Dev server origin verification ────────────────────────────────────── @@ -12558,6 +12661,25 @@ async function __readFormDataWithLimit(request, maxBytes) { export default async function handler(request, ctx) { + const __hostPrepared = request.headers.get(__hostPreparedHeader) === "1"; + const __hostRewriteStatus = request.headers.get(__hostRewriteStatusHeader); + const __hostPreparedTarget = request.headers.get(__hostTargetHeader); + if (__hostPrepared || __hostRewriteStatus || __hostPreparedTarget) { + const __sanitizedHeaders = new Headers(request.headers); + __sanitizedHeaders.delete(__hostPreparedHeader); + __sanitizedHeaders.delete(__hostRewriteStatusHeader); + __sanitizedHeaders.delete(__hostTargetHeader); + const __requestUrl = __hostPreparedTarget + ? new URL(__hostPreparedTarget, request.url).href + : request.url; + request = new Request(__requestUrl, { + method: request.method, + headers: __sanitizedHeaders, + body: request.body, + // @ts-expect-error -- duplex is required when reusing a streaming body + duplex: request.body ? "half" : undefined, + }); + } // Wrap the entire request in nested AsyncLocalStorage.run() scopes to ensure // per-request isolation for all state modules. Each runWith*() creates an // ALS scope that propagates through all async continuations (including RSC @@ -12577,8 +12699,11 @@ export default async function handler(request, ctx) { // Per-request container for middleware state. Passed into // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. - const _mwCtx = { headers: null, status: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const _mwCtx = { + headers: null, + status: __hostRewriteStatus ? Number(__hostRewriteStatus) || null : null, + }; + const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared); // Apply custom headers from next.config.js to non-redirect responses. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // and Next.js doesn't apply custom headers to redirects anyway. @@ -12614,7 +12739,7 @@ export default async function handler(request, ctx) { return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } -async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { +async function _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared) { const __reqStart = process.env.NODE_ENV !== "production" ? performance.now() : 0; let __compileEnd; let __renderEnd; @@ -12651,7 +12776,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { if (__tsRedirect) return __tsRedirect; // ── Apply redirects from next.config.js ─────────────────────────────── - if (__configRedirects.length) { + if (!__hostPrepared && __configRedirects.length) { // Strip .rsc suffix before matching redirect rules - RSC (client-side nav) requests // arrive as /some/path.rsc but redirect patterns are defined without it (e.g. // /some/path). Without this, soft-nav fetches bypass all config redirects. @@ -12685,12 +12810,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // These run after middleware in the App Router execution order and should // evaluate has/missing conditions against middleware-modified headers. // When no middleware is present, this falls back to requestContextFromRequest. - const __postMwReqCtx = __buildPostMwRequestContext(request); + const __postMwReqCtx = __hostPrepared ? requestContextFromRequest(request) : __buildPostMwRequestContext(request); // ── Apply beforeFiles rewrites from next.config.js ──────────────────── // In App Router execution order, beforeFiles runs after middleware so that // has/missing conditions can evaluate against middleware-modified headers. - if (__configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { + if (!__hostPrepared && __configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx); if (__rewritten) { if (isExternalUrl(__rewritten)) { @@ -12954,7 +13079,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } // ── Apply afterFiles rewrites from next.config.js ────────────────────── - if (__configRewrites.afterFiles && __configRewrites.afterFiles.length) { + if (!__hostPrepared && __configRewrites.afterFiles && __configRewrites.afterFiles.length) { const __afterRewritten = matchRewrite(cleanPathname, __configRewrites.afterFiles, __postMwReqCtx); if (__afterRewritten) { if (isExternalUrl(__afterRewritten)) { @@ -12969,7 +13094,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { let match = matchRoute(cleanPathname, routes); // ── Fallback rewrites from next.config.js (if no route matched) ─────── - if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { + if (!__hostPrepared && !match && __configRewrites.fallback && __configRewrites.fallback.length) { const __fallbackRewritten = matchRewrite(cleanPathname, __configRewrites.fallback, __postMwReqCtx); if (__fallbackRewritten) { if (isExternalUrl(__fallbackRewritten)) { @@ -15223,6 +15348,9 @@ const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; const __allowedOrigins = []; +const __hostPreparedHeader = "x-vinext-app-router-prepared"; +const __hostRewriteStatusHeader = "x-vinext-app-router-rewrite-status"; +const __hostTargetHeader = "x-vinext-app-router-target"; // ── Dev server origin verification ────────────────────────────────────── @@ -15466,6 +15594,25 @@ async function __readFormDataWithLimit(request, maxBytes) { export default async function handler(request, ctx) { + const __hostPrepared = request.headers.get(__hostPreparedHeader) === "1"; + const __hostRewriteStatus = request.headers.get(__hostRewriteStatusHeader); + const __hostPreparedTarget = request.headers.get(__hostTargetHeader); + if (__hostPrepared || __hostRewriteStatus || __hostPreparedTarget) { + const __sanitizedHeaders = new Headers(request.headers); + __sanitizedHeaders.delete(__hostPreparedHeader); + __sanitizedHeaders.delete(__hostRewriteStatusHeader); + __sanitizedHeaders.delete(__hostTargetHeader); + const __requestUrl = __hostPreparedTarget + ? new URL(__hostPreparedTarget, request.url).href + : request.url; + request = new Request(__requestUrl, { + method: request.method, + headers: __sanitizedHeaders, + body: request.body, + // @ts-expect-error -- duplex is required when reusing a streaming body + duplex: request.body ? "half" : undefined, + }); + } // Wrap the entire request in nested AsyncLocalStorage.run() scopes to ensure // per-request isolation for all state modules. Each runWith*() creates an // ALS scope that propagates through all async continuations (including RSC @@ -15485,8 +15632,11 @@ export default async function handler(request, ctx) { // Per-request container for middleware state. Passed into // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. - const _mwCtx = { headers: null, status: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const _mwCtx = { + headers: null, + status: __hostRewriteStatus ? Number(__hostRewriteStatus) || null : null, + }; + const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared); // Apply custom headers from next.config.js to non-redirect responses. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // and Next.js doesn't apply custom headers to redirects anyway. @@ -15522,7 +15672,7 @@ export default async function handler(request, ctx) { return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } -async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { +async function _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared) { const __reqStart = process.env.NODE_ENV !== "production" ? performance.now() : 0; let __compileEnd; let __renderEnd; @@ -15559,7 +15709,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { if (__tsRedirect) return __tsRedirect; // ── Apply redirects from next.config.js ─────────────────────────────── - if (__configRedirects.length) { + if (!__hostPrepared && __configRedirects.length) { // Strip .rsc suffix before matching redirect rules - RSC (client-side nav) requests // arrive as /some/path.rsc but redirect patterns are defined without it (e.g. // /some/path). Without this, soft-nav fetches bypass all config redirects. @@ -15588,6 +15738,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // every response path without module-level state that races on Workers. + if (!__hostPrepared) { // Run proxy/middleware if present and path matches. // Validate exports match the file type (proxy.ts vs middleware.ts), matching Next.js behavior. // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/proxy-missing-export/proxy-missing-export.test.ts @@ -15669,18 +15820,19 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { applyMiddlewareRequestHeaders(_mwCtx.headers); processMiddlewareHeaders(_mwCtx.headers); } + } // Build post-middleware request context for afterFiles/fallback rewrites. // These run after middleware in the App Router execution order and should // evaluate has/missing conditions against middleware-modified headers. // When no middleware is present, this falls back to requestContextFromRequest. - const __postMwReqCtx = __buildPostMwRequestContext(request); + const __postMwReqCtx = __hostPrepared ? requestContextFromRequest(request) : __buildPostMwRequestContext(request); // ── Apply beforeFiles rewrites from next.config.js ──────────────────── // In App Router execution order, beforeFiles runs after middleware so that // has/missing conditions can evaluate against middleware-modified headers. - if (__configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { + if (!__hostPrepared && __configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx); if (__rewritten) { if (isExternalUrl(__rewritten)) { @@ -15944,7 +16096,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } // ── Apply afterFiles rewrites from next.config.js ────────────────────── - if (__configRewrites.afterFiles && __configRewrites.afterFiles.length) { + if (!__hostPrepared && __configRewrites.afterFiles && __configRewrites.afterFiles.length) { const __afterRewritten = matchRewrite(cleanPathname, __configRewrites.afterFiles, __postMwReqCtx); if (__afterRewritten) { if (isExternalUrl(__afterRewritten)) { @@ -15959,7 +16111,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { let match = matchRoute(cleanPathname, routes); // ── Fallback rewrites from next.config.js (if no route matched) ─────── - if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { + if (!__hostPrepared && !match && __configRewrites.fallback && __configRewrites.fallback.length) { const __fallbackRewritten = matchRewrite(cleanPathname, __configRewrites.fallback, __postMwReqCtx); if (__fallbackRewritten) { if (isExternalUrl(__fallbackRewritten)) { From bf97e72797194c43bd2016d2a823aea9022a71b0 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 02:01:11 -0500 Subject: [PATCH 08/10] Add App Router and headers regressions --- packages/vinext/src/entries/app-rsc-entry.ts | 37 ++++++++++++++++++-- packages/vinext/src/shims/headers.ts | 6 ++-- tests/app-router.test.ts | 12 +++++-- tests/fixtures/app-basic/next.config.ts | 7 ++++ tests/shims.test.ts | 16 +++++++++ 5 files changed, 71 insertions(+), 7 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index a33e295e..73f784c0 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -2735,8 +2735,41 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared) { // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) const draftCookie = getDraftModeCookieHeader(); - setHeadersContext(null); - setNavigationContext(null); + // Keep request-scoped headers/navigation state alive until the HTML stream is + // fully consumed. Libraries like better-auth call cookies() from async hooks + // that run after handleSsr() returns, while the response is still streaming. + let __requestStateCleaned = false; + function __cleanupRequestState() { + if (__requestStateCleaned) return; + __requestStateCleaned = true; + setHeadersContext(null); + setNavigationContext(null); + } + + const __htmlReader = htmlStream.getReader(); + htmlStream = new ReadableStream({ + async pull(controller) { + try { + const chunk = await __htmlReader.read(); + if (chunk.done) { + controller.close(); + __cleanupRequestState(); + return; + } + controller.enqueue(chunk.value); + } catch (error) { + __cleanupRequestState(); + controller.error(error); + } + }, + async cancel(reason) { + try { + await __htmlReader.cancel(reason); + } finally { + __cleanupRequestState(); + } + }, + }); // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response function attachMiddlewareContext(response) { diff --git a/packages/vinext/src/shims/headers.ts b/packages/vinext/src/shims/headers.ts index 055cc2b0..f34ddb64 100644 --- a/packages/vinext/src/shims/headers.ts +++ b/packages/vinext/src/shims/headers.ts @@ -448,7 +448,8 @@ export function headersContextFromRequest(request: Request): HeadersContext { const src = _mutable ?? target; if (typeof prop !== "string") { - return Reflect.get(src, prop, receiver); + const value = Reflect.get(src, prop, src); + return typeof value === "function" ? value.bind(src) : value; } // Intercept mutating methods: materialise on first write. @@ -545,7 +546,8 @@ export function cookies(): Promise & RequestCookies { if (!state.headersContext) { return _decorateRejectedRequestApiPromise( new Error( - "cookies() can only be called from a Server Component, Route Handler, or Server Action.", + "`cookies` was called outside a request scope. " + + "cookies() can only be called from a Server Component, Route Handler, or Server Action.", ), ); } diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index a9f5e873..4ee4fef0 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2039,6 +2039,14 @@ describe("App Router next.config.js features (dev server integration)", () => { expect(html).toContain("About"); }); + it("matches next.config.js headers against the source path for rewritten requests", async () => { + const res = await fetch(`${baseUrl}/rewrite-about`); + + expect(res.status).toBe(200); + expect(res.headers.get("x-rewrite-source-header")).toBe("rewrite-about"); + expect(res.headers.get("x-page-header")).toBeNull(); + }); + it("re-executes App Router modules when middleware rewrites a Pages path into app/", async () => { const res1 = await fetch(`${baseUrl}/mw-pages-to-app-rewrite`); expect(res1.status).toBe(200); @@ -2634,9 +2642,7 @@ describe("App Router next.config.js features (generateRscEntry)", () => { expect(code).toContain("__safeDevHosts"); // Should call dev origin validation inside _handleRequest const callSite = code.indexOf("const __originBlock = __validateDevRequestOrigin(request)"); - const handleRequestIdx = code.indexOf( - "async function _handleRequest(request, __reqCtx, _mwCtx, ctx)", - ); + const handleRequestIdx = code.indexOf("async function _handleRequest("); expect(callSite).toBeGreaterThan(-1); expect(handleRequestIdx).toBeGreaterThan(-1); // The call should be inside the function body (after the function declaration) diff --git a/tests/fixtures/app-basic/next.config.ts b/tests/fixtures/app-basic/next.config.ts index 9b6d0db7..10e16946 100644 --- a/tests/fixtures/app-basic/next.config.ts +++ b/tests/fixtures/app-basic/next.config.ts @@ -147,6 +147,13 @@ const nextConfig: NextConfig = { source: "/about", headers: [{ key: "X-Page-Header", value: "about-page" }], }, + // Used by Vitest: app-router.test.ts — config headers should keep + // matching the incoming source path even when a beforeFiles rewrite + // routes the request to /about. + { + source: "/rewrite-about", + headers: [{ key: "X-Rewrite-Source-Header", value: "rewrite-about" }], + }, // Used by E2E: config-redirect.spec.ts — has/missing on headers rules { source: "/about", diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 0af73aa2..b5d5d7af 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -244,6 +244,22 @@ describe("next/headers shim", () => { setHeadersContext(null); }); + it("awaited headers() can be cloned with the standard Headers constructor", async () => { + const { setHeadersContext, headers } = await import("../packages/vinext/src/shims/headers.js"); + setHeadersContext({ + headers: new Headers({ + cookie: "session=abc123", + "x-clone-test": "clone-value", + }), + cookies: new Map(), + }); + + const cloned = new Headers(await headers()); + expect(cloned.get("x-clone-test")).toBe("clone-value"); + expect(cloned.get("cookie")).toBe("session=abc123"); + setHeadersContext(null); + }); + it("headers() is read-only for both sync and awaited access", async () => { // Ported from Next.js: // packages/next/src/server/web/spec-extension/adapters/headers.test.ts From f3340fb3551ad577e8dd5c9e889fc1fbe45fbd95 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 02:09:46 -0500 Subject: [PATCH 09/10] Fix headers shim lint warning --- packages/vinext/src/shims/headers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vinext/src/shims/headers.ts b/packages/vinext/src/shims/headers.ts index f34ddb64..9f5c03c3 100644 --- a/packages/vinext/src/shims/headers.ts +++ b/packages/vinext/src/shims/headers.ts @@ -443,7 +443,7 @@ export function headersContextFromRequest(request: Request): HeadersContext { let _mutable: Headers | null = null; const headersProxy = new Proxy(request.headers, { - get(target, prop: string | symbol, receiver) { + get(target, prop: string | symbol) { // Route to the materialised copy if it exists. const src = _mutable ?? target; From 2c8c5492a27ebe7048b5902b0acf86b79920eda1 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 02:37:08 -0500 Subject: [PATCH 10/10] Harden App Router prepared request handoff --- packages/vinext/src/entries/app-rsc-entry.ts | 53 +- packages/vinext/src/index.ts | 39 +- .../vinext/src/server/app-router-entry.ts | 10 + .../src/server/app-router-prepared-state.ts | 120 +++++ packages/vinext/src/server/prod-server.ts | 2 + .../entry-templates.test.ts.snap | 504 +++++++++++++----- tests/app-router.test.ts | 75 +++ 7 files changed, 640 insertions(+), 163 deletions(-) create mode 100644 packages/vinext/src/server/app-router-prepared-state.ts diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 73f784c0..a5b02e58 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -37,6 +37,12 @@ const requestPipelinePath = fileURLToPath( const requestContextShimPath = fileURLToPath( new URL("../shims/request-context.js", import.meta.url), ).replace(/\\/g, "/"); +const middlewareRequestHeadersPath = fileURLToPath( + new URL("../server/middleware-request-headers.js", import.meta.url), +).replace(/\\/g, "/"); +const preparedStatePath = fileURLToPath( + new URL("../server/app-router-prepared-state.js", import.meta.url), +).replace(/\\/g, "/"); /** * Resolved config options relevant to App Router request handling. @@ -257,6 +263,8 @@ ${instrumentationPath ? `import * as _instrumentation from ${JSON.stringify(inst ${effectiveMetaRoutes.length > 0 ? `import { sitemapToXml, robotsToText, manifestToJson } from ${JSON.stringify(fileURLToPath(new URL("../server/metadata-routes.js", import.meta.url)).replace(/\\/g, "/"))};` : ""} import { requestContextFromRequest, normalizeHost, matchRedirect, matchRewrite, matchHeaders, isExternalUrl, proxyExternalRequest, sanitizeDestination } from ${JSON.stringify(configMatchersPath)}; import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBasePath, stripBasePath, normalizeTrailingSlash, processMiddlewareHeaders } from ${JSON.stringify(requestPipelinePath)}; +import { buildRequestHeadersFromMiddlewareResponse } from ${JSON.stringify(middlewareRequestHeadersPath)}; +import { readAppRouterPreparedRequestState, sanitizeAppRouterPreparedRequestHeaders } from ${JSON.stringify(preparedStatePath)}; import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from ${JSON.stringify(requestContextShimPath)}; import { runWithFetchCache } from "vinext/fetch-cache"; @@ -1271,9 +1279,6 @@ const __configRedirects = ${JSON.stringify(redirects)}; const __configRewrites = ${JSON.stringify(rewrites)}; const __configHeaders = ${JSON.stringify(headers)}; const __allowedOrigins = ${JSON.stringify(allowedOrigins)}; -const __hostPreparedHeader = "x-vinext-app-router-prepared"; -const __hostRewriteStatusHeader = "x-vinext-app-router-rewrite-status"; -const __hostTargetHeader = "x-vinext-app-router-target"; ${generateDevOriginCheckCode(config?.allowedDevOrigins)} @@ -1387,25 +1392,35 @@ export default async function handler(request, ctx) { ` : "" } - const __hostPrepared = request.headers.get(__hostPreparedHeader) === "1"; - const __hostRewriteStatus = request.headers.get(__hostRewriteStatusHeader); - const __hostPreparedTarget = request.headers.get(__hostTargetHeader); - if (__hostPrepared || __hostRewriteStatus || __hostPreparedTarget) { - const __sanitizedHeaders = new Headers(request.headers); - __sanitizedHeaders.delete(__hostPreparedHeader); - __sanitizedHeaders.delete(__hostRewriteStatusHeader); - __sanitizedHeaders.delete(__hostTargetHeader); - const __requestUrl = __hostPreparedTarget - ? new URL(__hostPreparedTarget, request.url).href + const __hostPreparedState = readAppRouterPreparedRequestState(request.headers); + let __configHeadersRequest = request; + if (__hostPreparedState.hasStateHeaders) { + const __sanitizedHeaders = sanitizeAppRouterPreparedRequestHeaders(request.headers); + const __sourceUrl = __hostPreparedState.sourceUrl + ? new URL(__hostPreparedState.sourceUrl, request.url).href + : request.url; + const __targetUrl = __hostPreparedState.targetUrl + ? new URL(__hostPreparedState.targetUrl, request.url).href : request.url; - request = new Request(__requestUrl, { + const __preparedRequestHeaders = __hostPreparedState.middlewareHeaders + ? buildRequestHeadersFromMiddlewareResponse( + __sanitizedHeaders, + __hostPreparedState.middlewareHeaders, + ) ?? __sanitizedHeaders + : __sanitizedHeaders; + __configHeadersRequest = new Request(__sourceUrl, { method: request.method, headers: __sanitizedHeaders, + }); + request = new Request(__targetUrl, { + method: request.method, + headers: __preparedRequestHeaders, body: request.body, // @ts-expect-error -- duplex is required when reusing a streaming body duplex: request.body ? "half" : undefined, }); } + const __hostPrepared = __hostPreparedState.prepared; // Wrap the entire request in nested AsyncLocalStorage.run() scopes to ensure // per-request isolation for all state modules. Each runWith*() creates an // ALS scope that propagates through all async continuations (including RSC @@ -1421,13 +1436,15 @@ export default async function handler(request, ctx) { _runWithCacheState(() => _runWithPrivateCache(() => runWithFetchCache(async () => { - const __reqCtx = requestContextFromRequest(request); + const __reqCtx = requestContextFromRequest(__configHeadersRequest); // Per-request container for middleware state. Passed into // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. const _mwCtx = { - headers: null, - status: __hostRewriteStatus ? Number(__hostRewriteStatus) || null : null, + headers: __hostPreparedState.middlewareHeaders + ? new Headers(__hostPreparedState.middlewareHeaders) + : null, + status: __hostPreparedState.rewriteStatus, }; const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared); // Apply custom headers from next.config.js to non-redirect responses. @@ -1435,7 +1452,7 @@ export default async function handler(request, ctx) { // and Next.js doesn't apply custom headers to redirects anyway. if (response && response.headers && !(response.status >= 300 && response.status < 400)) { if (__configHeaders.length) { - const url = new URL(request.url); + const url = new URL(__configHeadersRequest.url); let pathname; try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; } ${bp ? `if (pathname.startsWith(${JSON.stringify(bp)})) pathname = pathname.slice(${JSON.stringify(bp)}.length) || "/";` : ""} diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 500273f3..ec0ad787 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -64,11 +64,12 @@ import { createRequire } from "node:module"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; import commonjs from "vite-plugin-commonjs"; +import { + setAppRouterPreparedRequestState, + stripAppRouterPreparedRequestHeaders, +} from "./server/app-router-prepared-state.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const APP_ROUTER_PREPARED_HEADER = "x-vinext-app-router-prepared"; -const APP_ROUTER_REWRITE_STATUS_HEADER = "x-vinext-app-router-rewrite-status"; -const APP_ROUTER_TARGET_HEADER = "x-vinext-app-router-target"; /** * Fetch Google Fonts CSS, download .woff2 files, cache locally, and return @@ -1728,24 +1729,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } } - function setAppRouterPreparedRequestState( - req: IncomingMessage, - options?: { rewriteStatus?: number | null; requestUrl?: string | null }, - ): void { - req.headers[APP_ROUTER_PREPARED_HEADER] = "1"; - const rewriteStatus = options?.rewriteStatus; - if (typeof rewriteStatus === "number") { - req.headers[APP_ROUTER_REWRITE_STATUS_HEADER] = String(rewriteStatus); - } else { - delete req.headers[APP_ROUTER_REWRITE_STATUS_HEADER]; - } - if (options?.requestUrl) { - req.headers[APP_ROUTER_TARGET_HEADER] = options.requestUrl; - } else { - delete req.headers[APP_ROUTER_TARGET_HEADER]; - } - } - function appendNodeResponseHeaders(res: any, headers: Headers | null | undefined): void { if (!headers) return; for (const [key, value] of headers) { @@ -2085,8 +2068,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { : ""; const originProto = rawProto === "https" || rawProto === "http" ? rawProto : "http"; const origin = `${originProto}://${req.headers.host || "localhost"}`; + const sourceUrl = url; let requestHeaders = buildNodeRequestHeaders(req); + let middlewareResponseHeaders: Headers | null = null; let rewriteStatus: number | null = null; const buildRequestForUrl = (requestUrl: string, headers: Headers): Request => new Request(new URL(toRoutingUrl(requestUrl), origin), { @@ -2154,12 +2139,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } if (middlewareResult.responseHeaders) { + middlewareResponseHeaders = middlewareResult.responseHeaders; requestHeaders = buildRequestHeadersFromMiddlewareResponse( requestHeaders, middlewareResult.responseHeaders, ) ?? requestHeaders; - appendNodeResponseHeaders(res, middlewareResult.responseHeaders); } if (middlewareResult.rewriteUrl) { @@ -2169,8 +2154,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } } - applyRequestHeadersToNodeRequest(req, requestHeaders); - const buildRequestContext = (requestUrl: string): RequestContext => requestContextFromRequest(buildRequestForUrl(requestUrl, requestHeaders)); const postMwReqCtx = buildRequestContext(url); @@ -2232,7 +2215,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } req.url = url; - setAppRouterPreparedRequestState(req, { rewriteStatus, requestUrl: url }); + setAppRouterPreparedRequestState(req.headers, { + rewriteStatus, + requestUrl: url, + sourceUrl, + middlewareHeaders: middlewareResponseHeaders, + }); if (shouldInvalidateAppRscRequest(req, url)) { await invalidateAppRscModulesForRequest(url, { @@ -2274,6 +2262,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // (including /@*, /__vite*, /node_modules* paths) are validated // before Vite serves any content. server.middlewares.use((req: any, res: any, next: any) => { + stripAppRouterPreparedRequestHeaders(req.headers); const blockReason = validateDevRequest( { origin: req.headers.origin as string | undefined, diff --git a/packages/vinext/src/server/app-router-entry.ts b/packages/vinext/src/server/app-router-entry.ts index 45595803..f1304195 100644 --- a/packages/vinext/src/server/app-router-entry.ts +++ b/packages/vinext/src/server/app-router-entry.ts @@ -15,9 +15,19 @@ // @ts-expect-error — virtual module resolved by vinext import rscHandler from "virtual:vinext-rsc-entry"; import { runWithExecutionContext, type ExecutionContextLike } from "../shims/request-context.js"; +import { + hasAppRouterPreparedRequestHeaders, + sanitizeAppRouterPreparedRequestHeaders, +} from "./app-router-prepared-state.js"; export default { async fetch(request: Request, _env?: unknown, ctx?: ExecutionContextLike): Promise { + if (hasAppRouterPreparedRequestHeaders(request.headers)) { + request = new Request(request, { + headers: sanitizeAppRouterPreparedRequestHeaders(request.headers), + }); + } + const url = new URL(request.url); // Normalize backslashes (browsers treat /\ as //) before any other checks. diff --git a/packages/vinext/src/server/app-router-prepared-state.ts b/packages/vinext/src/server/app-router-prepared-state.ts new file mode 100644 index 00000000..9f18e9f7 --- /dev/null +++ b/packages/vinext/src/server/app-router-prepared-state.ts @@ -0,0 +1,120 @@ +export const APP_ROUTER_PREPARED_HEADER = "x-vinext-app-router-prepared"; +export const APP_ROUTER_REWRITE_STATUS_HEADER = "x-vinext-app-router-rewrite-status"; +export const APP_ROUTER_TARGET_HEADER = "x-vinext-app-router-target"; +export const APP_ROUTER_SOURCE_HEADER = "x-vinext-app-router-source"; +export const APP_ROUTER_MIDDLEWARE_HEADERS_HEADER = "x-vinext-app-router-middleware-headers"; + +const APP_ROUTER_INTERNAL_HEADERS = [ + APP_ROUTER_PREPARED_HEADER, + APP_ROUTER_REWRITE_STATUS_HEADER, + APP_ROUTER_TARGET_HEADER, + APP_ROUTER_SOURCE_HEADER, + APP_ROUTER_MIDDLEWARE_HEADERS_HEADER, +] as const; + +type MutableHeaderRecord = Record; + +function parsePreparedMiddlewareHeaders(rawValue: string | null): Headers | null { + if (!rawValue) return null; + + try { + const parsed = JSON.parse(decodeURIComponent(rawValue)); + if (!Array.isArray(parsed)) return null; + + const headers = new Headers(); + for (const entry of parsed) { + if ( + Array.isArray(entry) && + entry.length === 2 && + typeof entry[0] === "string" && + typeof entry[1] === "string" + ) { + headers.append(entry[0], entry[1]); + } + } + return headers; + } catch { + return null; + } +} + +export interface AppRouterPreparedRequestState { + hasStateHeaders: boolean; + prepared: boolean; + rewriteStatus: number | null; + targetUrl: string | null; + sourceUrl: string | null; + middlewareHeaders: Headers | null; +} + +export function hasAppRouterPreparedRequestHeaders(headers: Headers): boolean { + return APP_ROUTER_INTERNAL_HEADERS.some((header) => headers.has(header)); +} + +export function sanitizeAppRouterPreparedRequestHeaders(headers: Headers): Headers { + const sanitized = new Headers(headers); + for (const header of APP_ROUTER_INTERNAL_HEADERS) { + sanitized.delete(header); + } + return sanitized; +} + +export function stripAppRouterPreparedRequestHeaders(headers: MutableHeaderRecord): void { + for (const header of APP_ROUTER_INTERNAL_HEADERS) { + delete headers[header]; + } +} + +export function readAppRouterPreparedRequestState(headers: Headers): AppRouterPreparedRequestState { + const rewriteStatusHeader = headers.get(APP_ROUTER_REWRITE_STATUS_HEADER); + const rewriteStatus = rewriteStatusHeader ? Number(rewriteStatusHeader) : null; + + return { + hasStateHeaders: hasAppRouterPreparedRequestHeaders(headers), + prepared: headers.get(APP_ROUTER_PREPARED_HEADER) === "1", + rewriteStatus: Number.isFinite(rewriteStatus) ? rewriteStatus : null, + targetUrl: headers.get(APP_ROUTER_TARGET_HEADER), + sourceUrl: headers.get(APP_ROUTER_SOURCE_HEADER), + middlewareHeaders: parsePreparedMiddlewareHeaders( + headers.get(APP_ROUTER_MIDDLEWARE_HEADERS_HEADER), + ), + }; +} + +export function setAppRouterPreparedRequestState( + headers: MutableHeaderRecord, + options?: { + rewriteStatus?: number | null; + requestUrl?: string | null; + sourceUrl?: string | null; + middlewareHeaders?: Headers | null; + }, +): void { + headers[APP_ROUTER_PREPARED_HEADER] = "1"; + + if (typeof options?.rewriteStatus === "number") { + headers[APP_ROUTER_REWRITE_STATUS_HEADER] = String(options.rewriteStatus); + } else { + delete headers[APP_ROUTER_REWRITE_STATUS_HEADER]; + } + + if (options?.requestUrl) { + headers[APP_ROUTER_TARGET_HEADER] = options.requestUrl; + } else { + delete headers[APP_ROUTER_TARGET_HEADER]; + } + + if (options?.sourceUrl) { + headers[APP_ROUTER_SOURCE_HEADER] = options.sourceUrl; + } else { + delete headers[APP_ROUTER_SOURCE_HEADER]; + } + + if (options?.middlewareHeaders) { + headers[APP_ROUTER_MIDDLEWARE_HEADERS_HEADER] = encodeURIComponent( + JSON.stringify([...options.middlewareHeaders]), + ); + } else { + delete headers[APP_ROUTER_MIDDLEWARE_HEADERS_HEADER]; + } +} diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index b13dee14..b8abee0b 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -47,6 +47,7 @@ import { normalizePath } from "./normalize-path.js"; import { hasBasePath, stripBasePath } from "../utils/base-path.js"; import { computeLazyChunks } from "../index.js"; import { manifestFileWithBase } from "../utils/manifest-paths.js"; +import { stripAppRouterPreparedRequestHeaders } from "./app-router-prepared-state.js"; /** Convert a Node.js IncomingMessage into a ReadableStream for Web Request body. */ function readNodeStream(req: IncomingMessage): ReadableStream { @@ -819,6 +820,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { : undefined; const protocol = rawProtocol === "https" || rawProtocol === "http" ? rawProtocol : "http"; const hostHeader = resolveHost(req, `${host}:${port}`); + stripAppRouterPreparedRequestHeaders(req.headers); const reqHeaders = Object.entries(req.headers).reduce((h, [k, v]) => { if (v) h.set(k, Array.isArray(v) ? v.join(", ") : v); return h; diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 124d8d9e..f7a14d3a 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -354,6 +354,8 @@ import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, merge import { requestContextFromRequest, normalizeHost, matchRedirect, matchRewrite, matchHeaders, isExternalUrl, proxyExternalRequest, sanitizeDestination } from "/packages/vinext/src/config/config-matchers.js"; import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBasePath, stripBasePath, normalizeTrailingSlash, processMiddlewareHeaders } from "/packages/vinext/src/server/request-pipeline.js"; +import { buildRequestHeadersFromMiddlewareResponse } from "/packages/vinext/src/server/middleware-request-headers.js"; +import { readAppRouterPreparedRequestState, sanitizeAppRouterPreparedRequestHeaders } from "/packages/vinext/src/server/app-router-prepared-state.js"; import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; import { runWithFetchCache } from "vinext/fetch-cache"; @@ -1387,9 +1389,6 @@ const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; const __allowedOrigins = []; -const __hostPreparedHeader = "x-vinext-app-router-prepared"; -const __hostRewriteStatusHeader = "x-vinext-app-router-rewrite-status"; -const __hostTargetHeader = "x-vinext-app-router-target"; // ── Dev server origin verification ────────────────────────────────────── @@ -1633,25 +1632,35 @@ async function __readFormDataWithLimit(request, maxBytes) { export default async function handler(request, ctx) { - const __hostPrepared = request.headers.get(__hostPreparedHeader) === "1"; - const __hostRewriteStatus = request.headers.get(__hostRewriteStatusHeader); - const __hostPreparedTarget = request.headers.get(__hostTargetHeader); - if (__hostPrepared || __hostRewriteStatus || __hostPreparedTarget) { - const __sanitizedHeaders = new Headers(request.headers); - __sanitizedHeaders.delete(__hostPreparedHeader); - __sanitizedHeaders.delete(__hostRewriteStatusHeader); - __sanitizedHeaders.delete(__hostTargetHeader); - const __requestUrl = __hostPreparedTarget - ? new URL(__hostPreparedTarget, request.url).href + const __hostPreparedState = readAppRouterPreparedRequestState(request.headers); + let __configHeadersRequest = request; + if (__hostPreparedState.hasStateHeaders) { + const __sanitizedHeaders = sanitizeAppRouterPreparedRequestHeaders(request.headers); + const __sourceUrl = __hostPreparedState.sourceUrl + ? new URL(__hostPreparedState.sourceUrl, request.url).href : request.url; - request = new Request(__requestUrl, { + const __targetUrl = __hostPreparedState.targetUrl + ? new URL(__hostPreparedState.targetUrl, request.url).href + : request.url; + const __preparedRequestHeaders = __hostPreparedState.middlewareHeaders + ? buildRequestHeadersFromMiddlewareResponse( + __sanitizedHeaders, + __hostPreparedState.middlewareHeaders, + ) ?? __sanitizedHeaders + : __sanitizedHeaders; + __configHeadersRequest = new Request(__sourceUrl, { method: request.method, headers: __sanitizedHeaders, + }); + request = new Request(__targetUrl, { + method: request.method, + headers: __preparedRequestHeaders, body: request.body, // @ts-expect-error -- duplex is required when reusing a streaming body duplex: request.body ? "half" : undefined, }); } + const __hostPrepared = __hostPreparedState.prepared; // Wrap the entire request in nested AsyncLocalStorage.run() scopes to ensure // per-request isolation for all state modules. Each runWith*() creates an // ALS scope that propagates through all async continuations (including RSC @@ -1667,13 +1676,15 @@ export default async function handler(request, ctx) { _runWithCacheState(() => _runWithPrivateCache(() => runWithFetchCache(async () => { - const __reqCtx = requestContextFromRequest(request); + const __reqCtx = requestContextFromRequest(__configHeadersRequest); // Per-request container for middleware state. Passed into // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. const _mwCtx = { - headers: null, - status: __hostRewriteStatus ? Number(__hostRewriteStatus) || null : null, + headers: __hostPreparedState.middlewareHeaders + ? new Headers(__hostPreparedState.middlewareHeaders) + : null, + status: __hostPreparedState.rewriteStatus, }; const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared); // Apply custom headers from next.config.js to non-redirect responses. @@ -1681,7 +1692,7 @@ export default async function handler(request, ctx) { // and Next.js doesn't apply custom headers to redirects anyway. if (response && response.headers && !(response.status >= 300 && response.status < 400)) { if (__configHeaders.length) { - const url = new URL(request.url); + const url = new URL(__configHeadersRequest.url); let pathname; try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; } @@ -2874,8 +2885,41 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared) { // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) const draftCookie = getDraftModeCookieHeader(); - setHeadersContext(null); - setNavigationContext(null); + // Keep request-scoped headers/navigation state alive until the HTML stream is + // fully consumed. Libraries like better-auth call cookies() from async hooks + // that run after handleSsr() returns, while the response is still streaming. + let __requestStateCleaned = false; + function __cleanupRequestState() { + if (__requestStateCleaned) return; + __requestStateCleaned = true; + setHeadersContext(null); + setNavigationContext(null); + } + + const __htmlReader = htmlStream.getReader(); + htmlStream = new ReadableStream({ + async pull(controller) { + try { + const chunk = await __htmlReader.read(); + if (chunk.done) { + controller.close(); + __cleanupRequestState(); + return; + } + controller.enqueue(chunk.value); + } catch (error) { + __cleanupRequestState(); + controller.error(error); + } + }, + async cancel(reason) { + try { + await __htmlReader.cancel(reason); + } finally { + __cleanupRequestState(); + } + }, + }); // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response function attachMiddlewareContext(response) { @@ -3091,6 +3135,8 @@ import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, merge import { requestContextFromRequest, normalizeHost, matchRedirect, matchRewrite, matchHeaders, isExternalUrl, proxyExternalRequest, sanitizeDestination } from "/packages/vinext/src/config/config-matchers.js"; import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBasePath, stripBasePath, normalizeTrailingSlash, processMiddlewareHeaders } from "/packages/vinext/src/server/request-pipeline.js"; +import { buildRequestHeadersFromMiddlewareResponse } from "/packages/vinext/src/server/middleware-request-headers.js"; +import { readAppRouterPreparedRequestState, sanitizeAppRouterPreparedRequestHeaders } from "/packages/vinext/src/server/app-router-prepared-state.js"; import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; import { runWithFetchCache } from "vinext/fetch-cache"; @@ -4124,9 +4170,6 @@ const __configRedirects = [{"source":"/old","destination":"/new","permanent":tru const __configRewrites = {"beforeFiles":[{"source":"/api/:path*","destination":"/backend/:path*"}],"afterFiles":[],"fallback":[]}; const __configHeaders = [{"source":"/api/:path*","headers":[{"key":"X-Custom","value":"test"}]}]; const __allowedOrigins = ["https://example.com"]; -const __hostPreparedHeader = "x-vinext-app-router-prepared"; -const __hostRewriteStatusHeader = "x-vinext-app-router-rewrite-status"; -const __hostTargetHeader = "x-vinext-app-router-target"; // ── Dev server origin verification ────────────────────────────────────── @@ -4370,25 +4413,35 @@ async function __readFormDataWithLimit(request, maxBytes) { export default async function handler(request, ctx) { - const __hostPrepared = request.headers.get(__hostPreparedHeader) === "1"; - const __hostRewriteStatus = request.headers.get(__hostRewriteStatusHeader); - const __hostPreparedTarget = request.headers.get(__hostTargetHeader); - if (__hostPrepared || __hostRewriteStatus || __hostPreparedTarget) { - const __sanitizedHeaders = new Headers(request.headers); - __sanitizedHeaders.delete(__hostPreparedHeader); - __sanitizedHeaders.delete(__hostRewriteStatusHeader); - __sanitizedHeaders.delete(__hostTargetHeader); - const __requestUrl = __hostPreparedTarget - ? new URL(__hostPreparedTarget, request.url).href + const __hostPreparedState = readAppRouterPreparedRequestState(request.headers); + let __configHeadersRequest = request; + if (__hostPreparedState.hasStateHeaders) { + const __sanitizedHeaders = sanitizeAppRouterPreparedRequestHeaders(request.headers); + const __sourceUrl = __hostPreparedState.sourceUrl + ? new URL(__hostPreparedState.sourceUrl, request.url).href : request.url; - request = new Request(__requestUrl, { + const __targetUrl = __hostPreparedState.targetUrl + ? new URL(__hostPreparedState.targetUrl, request.url).href + : request.url; + const __preparedRequestHeaders = __hostPreparedState.middlewareHeaders + ? buildRequestHeadersFromMiddlewareResponse( + __sanitizedHeaders, + __hostPreparedState.middlewareHeaders, + ) ?? __sanitizedHeaders + : __sanitizedHeaders; + __configHeadersRequest = new Request(__sourceUrl, { method: request.method, headers: __sanitizedHeaders, + }); + request = new Request(__targetUrl, { + method: request.method, + headers: __preparedRequestHeaders, body: request.body, // @ts-expect-error -- duplex is required when reusing a streaming body duplex: request.body ? "half" : undefined, }); } + const __hostPrepared = __hostPreparedState.prepared; // Wrap the entire request in nested AsyncLocalStorage.run() scopes to ensure // per-request isolation for all state modules. Each runWith*() creates an // ALS scope that propagates through all async continuations (including RSC @@ -4404,13 +4457,15 @@ export default async function handler(request, ctx) { _runWithCacheState(() => _runWithPrivateCache(() => runWithFetchCache(async () => { - const __reqCtx = requestContextFromRequest(request); + const __reqCtx = requestContextFromRequest(__configHeadersRequest); // Per-request container for middleware state. Passed into // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. const _mwCtx = { - headers: null, - status: __hostRewriteStatus ? Number(__hostRewriteStatus) || null : null, + headers: __hostPreparedState.middlewareHeaders + ? new Headers(__hostPreparedState.middlewareHeaders) + : null, + status: __hostPreparedState.rewriteStatus, }; const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared); // Apply custom headers from next.config.js to non-redirect responses. @@ -4418,7 +4473,7 @@ export default async function handler(request, ctx) { // and Next.js doesn't apply custom headers to redirects anyway. if (response && response.headers && !(response.status >= 300 && response.status < 400)) { if (__configHeaders.length) { - const url = new URL(request.url); + const url = new URL(__configHeadersRequest.url); let pathname; try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; } if (pathname.startsWith("/base")) pathname = pathname.slice("/base".length) || "/"; @@ -5614,8 +5669,41 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared) { // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) const draftCookie = getDraftModeCookieHeader(); - setHeadersContext(null); - setNavigationContext(null); + // Keep request-scoped headers/navigation state alive until the HTML stream is + // fully consumed. Libraries like better-auth call cookies() from async hooks + // that run after handleSsr() returns, while the response is still streaming. + let __requestStateCleaned = false; + function __cleanupRequestState() { + if (__requestStateCleaned) return; + __requestStateCleaned = true; + setHeadersContext(null); + setNavigationContext(null); + } + + const __htmlReader = htmlStream.getReader(); + htmlStream = new ReadableStream({ + async pull(controller) { + try { + const chunk = await __htmlReader.read(); + if (chunk.done) { + controller.close(); + __cleanupRequestState(); + return; + } + controller.enqueue(chunk.value); + } catch (error) { + __cleanupRequestState(); + controller.error(error); + } + }, + async cancel(reason) { + try { + await __htmlReader.cancel(reason); + } finally { + __cleanupRequestState(); + } + }, + }); // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response function attachMiddlewareContext(response) { @@ -5831,6 +5919,8 @@ import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, merge import { requestContextFromRequest, normalizeHost, matchRedirect, matchRewrite, matchHeaders, isExternalUrl, proxyExternalRequest, sanitizeDestination } from "/packages/vinext/src/config/config-matchers.js"; import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBasePath, stripBasePath, normalizeTrailingSlash, processMiddlewareHeaders } from "/packages/vinext/src/server/request-pipeline.js"; +import { buildRequestHeadersFromMiddlewareResponse } from "/packages/vinext/src/server/middleware-request-headers.js"; +import { readAppRouterPreparedRequestState, sanitizeAppRouterPreparedRequestHeaders } from "/packages/vinext/src/server/app-router-prepared-state.js"; import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; import { runWithFetchCache } from "vinext/fetch-cache"; @@ -6894,9 +6984,6 @@ const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; const __allowedOrigins = []; -const __hostPreparedHeader = "x-vinext-app-router-prepared"; -const __hostRewriteStatusHeader = "x-vinext-app-router-rewrite-status"; -const __hostTargetHeader = "x-vinext-app-router-target"; // ── Dev server origin verification ────────────────────────────────────── @@ -7140,25 +7227,35 @@ async function __readFormDataWithLimit(request, maxBytes) { export default async function handler(request, ctx) { - const __hostPrepared = request.headers.get(__hostPreparedHeader) === "1"; - const __hostRewriteStatus = request.headers.get(__hostRewriteStatusHeader); - const __hostPreparedTarget = request.headers.get(__hostTargetHeader); - if (__hostPrepared || __hostRewriteStatus || __hostPreparedTarget) { - const __sanitizedHeaders = new Headers(request.headers); - __sanitizedHeaders.delete(__hostPreparedHeader); - __sanitizedHeaders.delete(__hostRewriteStatusHeader); - __sanitizedHeaders.delete(__hostTargetHeader); - const __requestUrl = __hostPreparedTarget - ? new URL(__hostPreparedTarget, request.url).href + const __hostPreparedState = readAppRouterPreparedRequestState(request.headers); + let __configHeadersRequest = request; + if (__hostPreparedState.hasStateHeaders) { + const __sanitizedHeaders = sanitizeAppRouterPreparedRequestHeaders(request.headers); + const __sourceUrl = __hostPreparedState.sourceUrl + ? new URL(__hostPreparedState.sourceUrl, request.url).href : request.url; - request = new Request(__requestUrl, { + const __targetUrl = __hostPreparedState.targetUrl + ? new URL(__hostPreparedState.targetUrl, request.url).href + : request.url; + const __preparedRequestHeaders = __hostPreparedState.middlewareHeaders + ? buildRequestHeadersFromMiddlewareResponse( + __sanitizedHeaders, + __hostPreparedState.middlewareHeaders, + ) ?? __sanitizedHeaders + : __sanitizedHeaders; + __configHeadersRequest = new Request(__sourceUrl, { method: request.method, headers: __sanitizedHeaders, + }); + request = new Request(__targetUrl, { + method: request.method, + headers: __preparedRequestHeaders, body: request.body, // @ts-expect-error -- duplex is required when reusing a streaming body duplex: request.body ? "half" : undefined, }); } + const __hostPrepared = __hostPreparedState.prepared; // Wrap the entire request in nested AsyncLocalStorage.run() scopes to ensure // per-request isolation for all state modules. Each runWith*() creates an // ALS scope that propagates through all async continuations (including RSC @@ -7174,13 +7271,15 @@ export default async function handler(request, ctx) { _runWithCacheState(() => _runWithPrivateCache(() => runWithFetchCache(async () => { - const __reqCtx = requestContextFromRequest(request); + const __reqCtx = requestContextFromRequest(__configHeadersRequest); // Per-request container for middleware state. Passed into // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. const _mwCtx = { - headers: null, - status: __hostRewriteStatus ? Number(__hostRewriteStatus) || null : null, + headers: __hostPreparedState.middlewareHeaders + ? new Headers(__hostPreparedState.middlewareHeaders) + : null, + status: __hostPreparedState.rewriteStatus, }; const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared); // Apply custom headers from next.config.js to non-redirect responses. @@ -7188,7 +7287,7 @@ export default async function handler(request, ctx) { // and Next.js doesn't apply custom headers to redirects anyway. if (response && response.headers && !(response.status >= 300 && response.status < 400)) { if (__configHeaders.length) { - const url = new URL(request.url); + const url = new URL(__configHeadersRequest.url); let pathname; try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; } @@ -8389,8 +8488,41 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared) { // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) const draftCookie = getDraftModeCookieHeader(); - setHeadersContext(null); - setNavigationContext(null); + // Keep request-scoped headers/navigation state alive until the HTML stream is + // fully consumed. Libraries like better-auth call cookies() from async hooks + // that run after handleSsr() returns, while the response is still streaming. + let __requestStateCleaned = false; + function __cleanupRequestState() { + if (__requestStateCleaned) return; + __requestStateCleaned = true; + setHeadersContext(null); + setNavigationContext(null); + } + + const __htmlReader = htmlStream.getReader(); + htmlStream = new ReadableStream({ + async pull(controller) { + try { + const chunk = await __htmlReader.read(); + if (chunk.done) { + controller.close(); + __cleanupRequestState(); + return; + } + controller.enqueue(chunk.value); + } catch (error) { + __cleanupRequestState(); + controller.error(error); + } + }, + async cancel(reason) { + try { + await __htmlReader.cancel(reason); + } finally { + __cleanupRequestState(); + } + }, + }); // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response function attachMiddlewareContext(response) { @@ -8606,6 +8738,8 @@ import * as _instrumentation from "/tmp/test/instrumentation.ts"; import { requestContextFromRequest, normalizeHost, matchRedirect, matchRewrite, matchHeaders, isExternalUrl, proxyExternalRequest, sanitizeDestination } from "/packages/vinext/src/config/config-matchers.js"; import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBasePath, stripBasePath, normalizeTrailingSlash, processMiddlewareHeaders } from "/packages/vinext/src/server/request-pipeline.js"; +import { buildRequestHeadersFromMiddlewareResponse } from "/packages/vinext/src/server/middleware-request-headers.js"; +import { readAppRouterPreparedRequestState, sanitizeAppRouterPreparedRequestHeaders } from "/packages/vinext/src/server/app-router-prepared-state.js"; import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; import { runWithFetchCache } from "vinext/fetch-cache"; @@ -9668,9 +9802,6 @@ const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; const __allowedOrigins = []; -const __hostPreparedHeader = "x-vinext-app-router-prepared"; -const __hostRewriteStatusHeader = "x-vinext-app-router-rewrite-status"; -const __hostTargetHeader = "x-vinext-app-router-target"; // ── Dev server origin verification ────────────────────────────────────── @@ -9917,25 +10048,35 @@ export default async function handler(request, ctx) { // This is a no-op after the first call (guarded by __instrumentationInitialized). await __ensureInstrumentation(); - const __hostPrepared = request.headers.get(__hostPreparedHeader) === "1"; - const __hostRewriteStatus = request.headers.get(__hostRewriteStatusHeader); - const __hostPreparedTarget = request.headers.get(__hostTargetHeader); - if (__hostPrepared || __hostRewriteStatus || __hostPreparedTarget) { - const __sanitizedHeaders = new Headers(request.headers); - __sanitizedHeaders.delete(__hostPreparedHeader); - __sanitizedHeaders.delete(__hostRewriteStatusHeader); - __sanitizedHeaders.delete(__hostTargetHeader); - const __requestUrl = __hostPreparedTarget - ? new URL(__hostPreparedTarget, request.url).href + const __hostPreparedState = readAppRouterPreparedRequestState(request.headers); + let __configHeadersRequest = request; + if (__hostPreparedState.hasStateHeaders) { + const __sanitizedHeaders = sanitizeAppRouterPreparedRequestHeaders(request.headers); + const __sourceUrl = __hostPreparedState.sourceUrl + ? new URL(__hostPreparedState.sourceUrl, request.url).href : request.url; - request = new Request(__requestUrl, { + const __targetUrl = __hostPreparedState.targetUrl + ? new URL(__hostPreparedState.targetUrl, request.url).href + : request.url; + const __preparedRequestHeaders = __hostPreparedState.middlewareHeaders + ? buildRequestHeadersFromMiddlewareResponse( + __sanitizedHeaders, + __hostPreparedState.middlewareHeaders, + ) ?? __sanitizedHeaders + : __sanitizedHeaders; + __configHeadersRequest = new Request(__sourceUrl, { method: request.method, headers: __sanitizedHeaders, + }); + request = new Request(__targetUrl, { + method: request.method, + headers: __preparedRequestHeaders, body: request.body, // @ts-expect-error -- duplex is required when reusing a streaming body duplex: request.body ? "half" : undefined, }); } + const __hostPrepared = __hostPreparedState.prepared; // Wrap the entire request in nested AsyncLocalStorage.run() scopes to ensure // per-request isolation for all state modules. Each runWith*() creates an // ALS scope that propagates through all async continuations (including RSC @@ -9951,13 +10092,15 @@ export default async function handler(request, ctx) { _runWithCacheState(() => _runWithPrivateCache(() => runWithFetchCache(async () => { - const __reqCtx = requestContextFromRequest(request); + const __reqCtx = requestContextFromRequest(__configHeadersRequest); // Per-request container for middleware state. Passed into // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. const _mwCtx = { - headers: null, - status: __hostRewriteStatus ? Number(__hostRewriteStatus) || null : null, + headers: __hostPreparedState.middlewareHeaders + ? new Headers(__hostPreparedState.middlewareHeaders) + : null, + status: __hostPreparedState.rewriteStatus, }; const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared); // Apply custom headers from next.config.js to non-redirect responses. @@ -9965,7 +10108,7 @@ export default async function handler(request, ctx) { // and Next.js doesn't apply custom headers to redirects anyway. if (response && response.headers && !(response.status >= 300 && response.status < 400)) { if (__configHeaders.length) { - const url = new URL(request.url); + const url = new URL(__configHeadersRequest.url); let pathname; try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; } @@ -11158,8 +11301,41 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared) { // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) const draftCookie = getDraftModeCookieHeader(); - setHeadersContext(null); - setNavigationContext(null); + // Keep request-scoped headers/navigation state alive until the HTML stream is + // fully consumed. Libraries like better-auth call cookies() from async hooks + // that run after handleSsr() returns, while the response is still streaming. + let __requestStateCleaned = false; + function __cleanupRequestState() { + if (__requestStateCleaned) return; + __requestStateCleaned = true; + setHeadersContext(null); + setNavigationContext(null); + } + + const __htmlReader = htmlStream.getReader(); + htmlStream = new ReadableStream({ + async pull(controller) { + try { + const chunk = await __htmlReader.read(); + if (chunk.done) { + controller.close(); + __cleanupRequestState(); + return; + } + controller.enqueue(chunk.value); + } catch (error) { + __cleanupRequestState(); + controller.error(error); + } + }, + async cancel(reason) { + try { + await __htmlReader.cancel(reason); + } finally { + __cleanupRequestState(); + } + }, + }); // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response function attachMiddlewareContext(response) { @@ -11375,6 +11551,8 @@ import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, merge import { sitemapToXml, robotsToText, manifestToJson } from "/packages/vinext/src/server/metadata-routes.js"; import { requestContextFromRequest, normalizeHost, matchRedirect, matchRewrite, matchHeaders, isExternalUrl, proxyExternalRequest, sanitizeDestination } from "/packages/vinext/src/config/config-matchers.js"; import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBasePath, stripBasePath, normalizeTrailingSlash, processMiddlewareHeaders } from "/packages/vinext/src/server/request-pipeline.js"; +import { buildRequestHeadersFromMiddlewareResponse } from "/packages/vinext/src/server/middleware-request-headers.js"; +import { readAppRouterPreparedRequestState, sanitizeAppRouterPreparedRequestHeaders } from "/packages/vinext/src/server/app-router-prepared-state.js"; import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; import { runWithFetchCache } from "vinext/fetch-cache"; @@ -12415,9 +12593,6 @@ const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; const __allowedOrigins = []; -const __hostPreparedHeader = "x-vinext-app-router-prepared"; -const __hostRewriteStatusHeader = "x-vinext-app-router-rewrite-status"; -const __hostTargetHeader = "x-vinext-app-router-target"; // ── Dev server origin verification ────────────────────────────────────── @@ -12661,25 +12836,35 @@ async function __readFormDataWithLimit(request, maxBytes) { export default async function handler(request, ctx) { - const __hostPrepared = request.headers.get(__hostPreparedHeader) === "1"; - const __hostRewriteStatus = request.headers.get(__hostRewriteStatusHeader); - const __hostPreparedTarget = request.headers.get(__hostTargetHeader); - if (__hostPrepared || __hostRewriteStatus || __hostPreparedTarget) { - const __sanitizedHeaders = new Headers(request.headers); - __sanitizedHeaders.delete(__hostPreparedHeader); - __sanitizedHeaders.delete(__hostRewriteStatusHeader); - __sanitizedHeaders.delete(__hostTargetHeader); - const __requestUrl = __hostPreparedTarget - ? new URL(__hostPreparedTarget, request.url).href + const __hostPreparedState = readAppRouterPreparedRequestState(request.headers); + let __configHeadersRequest = request; + if (__hostPreparedState.hasStateHeaders) { + const __sanitizedHeaders = sanitizeAppRouterPreparedRequestHeaders(request.headers); + const __sourceUrl = __hostPreparedState.sourceUrl + ? new URL(__hostPreparedState.sourceUrl, request.url).href : request.url; - request = new Request(__requestUrl, { + const __targetUrl = __hostPreparedState.targetUrl + ? new URL(__hostPreparedState.targetUrl, request.url).href + : request.url; + const __preparedRequestHeaders = __hostPreparedState.middlewareHeaders + ? buildRequestHeadersFromMiddlewareResponse( + __sanitizedHeaders, + __hostPreparedState.middlewareHeaders, + ) ?? __sanitizedHeaders + : __sanitizedHeaders; + __configHeadersRequest = new Request(__sourceUrl, { method: request.method, headers: __sanitizedHeaders, + }); + request = new Request(__targetUrl, { + method: request.method, + headers: __preparedRequestHeaders, body: request.body, // @ts-expect-error -- duplex is required when reusing a streaming body duplex: request.body ? "half" : undefined, }); } + const __hostPrepared = __hostPreparedState.prepared; // Wrap the entire request in nested AsyncLocalStorage.run() scopes to ensure // per-request isolation for all state modules. Each runWith*() creates an // ALS scope that propagates through all async continuations (including RSC @@ -12695,13 +12880,15 @@ export default async function handler(request, ctx) { _runWithCacheState(() => _runWithPrivateCache(() => runWithFetchCache(async () => { - const __reqCtx = requestContextFromRequest(request); + const __reqCtx = requestContextFromRequest(__configHeadersRequest); // Per-request container for middleware state. Passed into // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. const _mwCtx = { - headers: null, - status: __hostRewriteStatus ? Number(__hostRewriteStatus) || null : null, + headers: __hostPreparedState.middlewareHeaders + ? new Headers(__hostPreparedState.middlewareHeaders) + : null, + status: __hostPreparedState.rewriteStatus, }; const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared); // Apply custom headers from next.config.js to non-redirect responses. @@ -12709,7 +12896,7 @@ export default async function handler(request, ctx) { // and Next.js doesn't apply custom headers to redirects anyway. if (response && response.headers && !(response.status >= 300 && response.status < 400)) { if (__configHeaders.length) { - const url = new URL(request.url); + const url = new URL(__configHeadersRequest.url); let pathname; try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; } @@ -13902,8 +14089,41 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared) { // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) const draftCookie = getDraftModeCookieHeader(); - setHeadersContext(null); - setNavigationContext(null); + // Keep request-scoped headers/navigation state alive until the HTML stream is + // fully consumed. Libraries like better-auth call cookies() from async hooks + // that run after handleSsr() returns, while the response is still streaming. + let __requestStateCleaned = false; + function __cleanupRequestState() { + if (__requestStateCleaned) return; + __requestStateCleaned = true; + setHeadersContext(null); + setNavigationContext(null); + } + + const __htmlReader = htmlStream.getReader(); + htmlStream = new ReadableStream({ + async pull(controller) { + try { + const chunk = await __htmlReader.read(); + if (chunk.done) { + controller.close(); + __cleanupRequestState(); + return; + } + controller.enqueue(chunk.value); + } catch (error) { + __cleanupRequestState(); + controller.error(error); + } + }, + async cancel(reason) { + try { + await __htmlReader.cancel(reason); + } finally { + __cleanupRequestState(); + } + }, + }); // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response function attachMiddlewareContext(response) { @@ -14119,6 +14339,8 @@ import * as middlewareModule from "/tmp/test/middleware.ts"; import { requestContextFromRequest, normalizeHost, matchRedirect, matchRewrite, matchHeaders, isExternalUrl, proxyExternalRequest, sanitizeDestination } from "/packages/vinext/src/config/config-matchers.js"; import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBasePath, stripBasePath, normalizeTrailingSlash, processMiddlewareHeaders } from "/packages/vinext/src/server/request-pipeline.js"; +import { buildRequestHeadersFromMiddlewareResponse } from "/packages/vinext/src/server/middleware-request-headers.js"; +import { readAppRouterPreparedRequestState, sanitizeAppRouterPreparedRequestHeaders } from "/packages/vinext/src/server/app-router-prepared-state.js"; import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; import { runWithFetchCache } from "vinext/fetch-cache"; @@ -15348,9 +15570,6 @@ const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; const __allowedOrigins = []; -const __hostPreparedHeader = "x-vinext-app-router-prepared"; -const __hostRewriteStatusHeader = "x-vinext-app-router-rewrite-status"; -const __hostTargetHeader = "x-vinext-app-router-target"; // ── Dev server origin verification ────────────────────────────────────── @@ -15594,25 +15813,35 @@ async function __readFormDataWithLimit(request, maxBytes) { export default async function handler(request, ctx) { - const __hostPrepared = request.headers.get(__hostPreparedHeader) === "1"; - const __hostRewriteStatus = request.headers.get(__hostRewriteStatusHeader); - const __hostPreparedTarget = request.headers.get(__hostTargetHeader); - if (__hostPrepared || __hostRewriteStatus || __hostPreparedTarget) { - const __sanitizedHeaders = new Headers(request.headers); - __sanitizedHeaders.delete(__hostPreparedHeader); - __sanitizedHeaders.delete(__hostRewriteStatusHeader); - __sanitizedHeaders.delete(__hostTargetHeader); - const __requestUrl = __hostPreparedTarget - ? new URL(__hostPreparedTarget, request.url).href + const __hostPreparedState = readAppRouterPreparedRequestState(request.headers); + let __configHeadersRequest = request; + if (__hostPreparedState.hasStateHeaders) { + const __sanitizedHeaders = sanitizeAppRouterPreparedRequestHeaders(request.headers); + const __sourceUrl = __hostPreparedState.sourceUrl + ? new URL(__hostPreparedState.sourceUrl, request.url).href : request.url; - request = new Request(__requestUrl, { + const __targetUrl = __hostPreparedState.targetUrl + ? new URL(__hostPreparedState.targetUrl, request.url).href + : request.url; + const __preparedRequestHeaders = __hostPreparedState.middlewareHeaders + ? buildRequestHeadersFromMiddlewareResponse( + __sanitizedHeaders, + __hostPreparedState.middlewareHeaders, + ) ?? __sanitizedHeaders + : __sanitizedHeaders; + __configHeadersRequest = new Request(__sourceUrl, { method: request.method, headers: __sanitizedHeaders, + }); + request = new Request(__targetUrl, { + method: request.method, + headers: __preparedRequestHeaders, body: request.body, // @ts-expect-error -- duplex is required when reusing a streaming body duplex: request.body ? "half" : undefined, }); } + const __hostPrepared = __hostPreparedState.prepared; // Wrap the entire request in nested AsyncLocalStorage.run() scopes to ensure // per-request isolation for all state modules. Each runWith*() creates an // ALS scope that propagates through all async continuations (including RSC @@ -15628,13 +15857,15 @@ export default async function handler(request, ctx) { _runWithCacheState(() => _runWithPrivateCache(() => runWithFetchCache(async () => { - const __reqCtx = requestContextFromRequest(request); + const __reqCtx = requestContextFromRequest(__configHeadersRequest); // Per-request container for middleware state. Passed into // _handleRequest which fills in .headers and .status; // avoids module-level variables that race on Workers. const _mwCtx = { - headers: null, - status: __hostRewriteStatus ? Number(__hostRewriteStatus) || null : null, + headers: __hostPreparedState.middlewareHeaders + ? new Headers(__hostPreparedState.middlewareHeaders) + : null, + status: __hostPreparedState.rewriteStatus, }; const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared); // Apply custom headers from next.config.js to non-redirect responses. @@ -15642,7 +15873,7 @@ export default async function handler(request, ctx) { // and Next.js doesn't apply custom headers to redirects anyway. if (response && response.headers && !(response.status >= 300 && response.status < 400)) { if (__configHeaders.length) { - const url = new URL(request.url); + const url = new URL(__configHeadersRequest.url); let pathname; try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; } @@ -16919,8 +17150,41 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared) { // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) const draftCookie = getDraftModeCookieHeader(); - setHeadersContext(null); - setNavigationContext(null); + // Keep request-scoped headers/navigation state alive until the HTML stream is + // fully consumed. Libraries like better-auth call cookies() from async hooks + // that run after handleSsr() returns, while the response is still streaming. + let __requestStateCleaned = false; + function __cleanupRequestState() { + if (__requestStateCleaned) return; + __requestStateCleaned = true; + setHeadersContext(null); + setNavigationContext(null); + } + + const __htmlReader = htmlStream.getReader(); + htmlStream = new ReadableStream({ + async pull(controller) { + try { + const chunk = await __htmlReader.read(); + if (chunk.done) { + controller.close(); + __cleanupRequestState(); + return; + } + controller.enqueue(chunk.value); + } catch (error) { + __cleanupRequestState(); + controller.error(error); + } + }, + async cancel(reason) { + try { + await __htmlReader.cancel(reason); + } finally { + __cleanupRequestState(); + } + }, + }); // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response function attachMiddlewareContext(response) { diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 4ee4fef0..334ebb39 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2032,6 +2032,24 @@ describe("App Router next.config.js features (dev server integration)", () => { expect(res.headers.get("location")).toContain("/blog/hello/hello"); }); + it("ignores spoofed internal prepared-request headers when matching redirects", async () => { + const res = await fetch(`${baseUrl}/old-about`, { + redirect: "manual", + headers: { + "x-vinext-app-router-prepared": "1", + "x-vinext-app-router-target": "/about", + "x-vinext-app-router-source": "/about", + "x-vinext-app-router-rewrite-status": "200", + "x-vinext-app-router-middleware-headers": encodeURIComponent( + JSON.stringify([["e2e-headers", "spoofed"]]), + ), + }, + }); + + expect(res.status).toBe(308); + expect(res.headers.get("location")).toContain("/about"); + }); + it("applies beforeFiles rewrites from next.config.js", async () => { const res = await fetch(`${baseUrl}/rewrite-about`); expect(res.status).toBe(200); @@ -2187,6 +2205,23 @@ describe("App Router next.config.js features (dev server integration)", () => { expect(res.redirected).toBe(false); }); + it("ignores spoofed internal prepared-request headers when running middleware", async () => { + const res = await fetch(`${baseUrl}/middleware-blocked`, { + headers: { + "x-vinext-app-router-prepared": "1", + "x-vinext-app-router-target": "/about", + "x-vinext-app-router-source": "/about", + "x-vinext-app-router-rewrite-status": "200", + "x-vinext-app-router-middleware-headers": encodeURIComponent( + JSON.stringify([["e2e-headers", "spoofed"]]), + ), + }, + }); + + expect(res.status).toBe(403); + expect(await res.text()).toBe("Blocked by middleware"); + }); + // ── Percent-encoded paths should be decoded before config matching ── it("percent-encoded redirect path is decoded before config matching", async () => { @@ -2238,9 +2273,40 @@ const nextConfig: NextConfig = { fallback: [], }; }, + async headers() { + return [ + { + source: "/rewrite-target", + headers: [{ key: "X-Rewrite-Source-Header", value: "rewrite-target" }], + }, + { + source: "/target", + headers: [{ key: "X-Target-Header", value: "target" }], + }, + { + source: "/(.*)", + headers: [{ key: "e2e-headers", value: "next.config.js" }], + }, + ]; + }, }; export default nextConfig; +`, + ); + fs.writeFileSync( + path.join(tmpDir, "middleware.ts"), + ` +import { NextResponse } from "next/server"; + +export function middleware(request: Request) { + if (new URL(request.url).pathname === "/rewrite-target") { + const res = NextResponse.next(); + res.headers.set("e2e-headers", "middleware"); + return res; + } + return NextResponse.next(); +} `, ); fs.writeFileSync( @@ -2296,6 +2362,15 @@ export default function TargetPage() { expect(importedNow2).toBeTruthy(); expect(importedNow1).not.toBe(importedNow2); }); + + it("matches headers against the source path and preserves middleware header precedence", async () => { + const res = await fetch(`${baseUrl}/rewrite-target`); + + expect(res.status).toBe(200); + expect(res.headers.get("x-rewrite-source-header")).toBe("rewrite-target"); + expect(res.headers.get("x-target-header")).toBeNull(); + expect(res.headers.get("e2e-headers")).toBe("middleware"); + }); }); describe("App Router next.config.js features (generateRscEntry)", () => {