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/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 5d43c20f..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"; @@ -1384,6 +1392,35 @@ export default async function handler(request, ctx) { ` : "" } + 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; + 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 @@ -1399,18 +1436,23 @@ 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: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const _mwCtx = { + 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. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // 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) || "/";` : ""} @@ -1440,7 +1482,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 +1526,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. @@ -1515,6 +1557,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { ${ 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 +1639,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { applyMiddlewareRequestHeaders(_mwCtx.headers); processMiddlewareHeaders(_mwCtx.headers); } + } ` : "" } @@ -1604,12 +1648,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 +1917,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 +1932,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)) { @@ -2708,8 +2752,41 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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/index.ts b/packages/vinext/src/index.ts index 215100e0..ec0ad787 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 { @@ -8,7 +9,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"; @@ -50,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"; @@ -60,8 +61,13 @@ 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"; +import { + setAppRouterPreparedRequestState, + stripAppRouterPreparedRequestHeaders, +} from "./server/app-router-prepared-state.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -1655,6 +1661,582 @@ 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) || "/"; + + pathname = pathname.replaceAll("\\", "/"); + if (pathname.startsWith("//")) return null; + + try { + pathname = normalizePath(decodeURIComponent(pathname)); + } catch { + return null; + } + + const bp = nextConfig?.basePath ?? ""; + if (bp) pathname = stripBasePath(pathname, bp); + + 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 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 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("?"); + 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); + 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; + 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) { + const matchedRoute = appMatch.route as unknown as AppRoute; + if (!matchedRoute.pagePath) return null; + 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; + } + + if (!options.allowFullAppFallback) return null; + + const files = new Set(); + for (const route of appRoutes) { + for (const filePath of collectAppRouteModuleFiles(route)) { + files.add(filePath); + } + } + for (const filePath of collectAllDynamicMetadataRouteFiles()) { + 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); + } + + 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"}`; + 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), { + 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) { + middlewareResponseHeaders = middlewareResult.responseHeaders; + requestHeaders = + buildRequestHeadersFromMiddlewareResponse( + requestHeaders, + middlewareResult.responseHeaders, + ) ?? requestHeaders; + } + + if (middlewareResult.rewriteUrl) { + url = fromRoutingUrl(toRoutingUrl(middlewareResult.rewriteUrl)); + pathname = normalizeAppRequestPath(url) ?? pathname; + rewriteStatus = middlewareResult.rewriteStatus ?? null; + } + } + + 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.headers, { + rewriteStatus, + requestUrl: url, + sourceUrl, + middlewareHeaders: middlewareResponseHeaders, + }); + + 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); @@ -1680,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, @@ -1727,15 +2310,36 @@ 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) { + if (await prepareDirectAppRouterRequest(req, _res, url, { staticExportToken })) { + return; + } + } + next(); + } catch (err) { + next(err); + } + }); + server.middlewares.use((req, res, next) => { const url = req.url ?? "/"; // Skip Vite internals, HMR, and static assets. @@ -1947,11 +2551,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 @@ -2019,13 +2623,28 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } } - const applyRequestHeadersToNodeRequest = (nextRequestHeaders: Headers) => { - for (const key of Object.keys(req.headers)) { - delete req.headers[key]; + const handOffToAppRouter = async ( + appUrl: string, + options?: { allowFullAppFallback?: boolean }, + ) => { + if (middlewareRequestHeaders) { + applyRequestHeadersToNodeRequest(req, middlewareRequestHeaders); } - for (const [key, value] of nextRequestHeaders) { - req.headers[key] = value; + 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; @@ -2110,14 +2729,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) @@ -2217,14 +2832,17 @@ 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; // 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 handOffToAppRouter(resolvedUrl); + return; + } res.statusCode = 404; res.end("404 - API route not found"); @@ -2265,7 +2883,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; @@ -2286,10 +2904,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } const fallbackMatch = matchRoute(fallbackRewrite.split("?")[0], routes); if (!fallbackMatch && hasAppDir) { - return next(); + await handOffToAppRouter(fallbackRewrite, { + allowFullAppFallback: true, + }); + return; } if (middlewareRequestHeaders) { - applyRequestHeadersToNodeRequest(middlewareRequestHeaders); + applyRequestHeadersToNodeRequest(req, middlewareRequestHeaders); } await handler(req, res, fallbackRewrite, mwStatus); return; @@ -2298,7 +2919,10 @@ 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 handOffToAppRouter(resolvedUrl); + return; + } await handler(req, res, resolvedUrl, mwStatus); } catch (e) { 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/packages/vinext/src/shims/headers.ts b/packages/vinext/src/shims/headers.ts index 055cc2b0..9f5c03c3 100644 --- a/packages/vinext/src/shims/headers.ts +++ b/packages/vinext/src/shims/headers.ts @@ -443,12 +443,13 @@ 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; 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/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index cb23dad7..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"; @@ -1630,6 +1632,35 @@ async function __readFormDataWithLimit(request, maxBytes) { export default async function handler(request, ctx) { + 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; + 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 @@ -1645,18 +1676,23 @@ 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: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const _mwCtx = { + 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. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // 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; } @@ -1686,7 +1722,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 +1759,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 +1793,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 +2062,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 +2077,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)) { @@ -2849,8 +2885,41 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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) { @@ -3066,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"; @@ -4342,6 +4413,35 @@ async function __readFormDataWithLimit(request, maxBytes) { export default async function handler(request, ctx) { + 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; + 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 @@ -4357,18 +4457,23 @@ 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: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const _mwCtx = { + 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. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // 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) || "/"; @@ -4398,7 +4503,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 +4543,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 +4577,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 +4846,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 +4861,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)) { @@ -5564,8 +5669,41 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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) { @@ -5781,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"; @@ -7087,6 +7227,35 @@ async function __readFormDataWithLimit(request, maxBytes) { export default async function handler(request, ctx) { + 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; + 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 @@ -7102,18 +7271,23 @@ 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: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const _mwCtx = { + 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. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // 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; } @@ -7143,7 +7317,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 +7354,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 +7388,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 +7657,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 +7672,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)) { @@ -8314,8 +8488,41 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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) { @@ -8531,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"; @@ -9839,6 +10048,35 @@ export default async function handler(request, ctx) { // This is a no-op after the first call (guarded by __instrumentationInitialized). await __ensureInstrumentation(); + 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; + 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 @@ -9854,18 +10092,23 @@ 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: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const _mwCtx = { + 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. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // 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; } @@ -9895,7 +10138,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 +10175,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 +10209,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 +10478,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 +10493,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)) { @@ -11058,8 +11301,41 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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) { @@ -11275,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"; @@ -12558,6 +12836,35 @@ async function __readFormDataWithLimit(request, maxBytes) { export default async function handler(request, ctx) { + 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; + 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 @@ -12573,18 +12880,23 @@ 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: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const _mwCtx = { + 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. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // 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; } @@ -12614,7 +12926,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 +12963,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 +12997,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 +13266,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 +13281,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)) { @@ -13777,8 +14089,41 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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) { @@ -13994,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"; @@ -15466,6 +15813,35 @@ async function __readFormDataWithLimit(request, maxBytes) { export default async function handler(request, ctx) { + 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; + 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 @@ -15481,18 +15857,23 @@ 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: null }; - const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx); + const _mwCtx = { + 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. // Skip redirects (3xx) because Response.redirect() creates immutable headers, // 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; } @@ -15522,7 +15903,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 +15940,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 +15969,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 +16051,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 +16327,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 +16342,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)) { @@ -16767,8 +17150,41 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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 d7180d60..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); @@ -2039,6 +2057,62 @@ 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); + 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("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); @@ -2088,6 +2162,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); @@ -2120,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 () => { @@ -2144,6 +2246,133 @@ 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: [], + }; + }, + 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( + 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); + }); + + 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)", () => { // Use a minimal route list for testing — we only care about the generated config handling code const minimalRoutes = [ @@ -2488,9 +2717,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/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/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/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/next.config.ts b/tests/fixtures/app-basic/next.config.ts index e779e321..10e16946 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", + }, ], }; }, @@ -137,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/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..bab3b2d7 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,143 @@ 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); + }); + + 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); + 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) ────── 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