From 86aedca02d571406b900d7cd1572a4ae18ba8acd Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 23:47:23 -0500 Subject: [PATCH 01/11] Add tests for render headers --- packages/vinext/src/entries/app-rsc-entry.ts | 1133 ++- packages/vinext/src/server/isr-cache.ts | 3 +- packages/vinext/src/shims/headers.ts | 278 +- .../entry-templates.test.ts.snap | 6238 +++++++---------- tests/app-router.test.ts | 233 +- .../app/lib/render-response-header.ts | 1 + .../cached-render-headers-rsc-first/page.tsx | 1 + .../cached-render-headers/page.tsx | 30 + .../[slug]/layout.tsx | 12 + .../[slug]/page.tsx | 3 + .../not-found.tsx | 3 + .../render-headers-metadata-redirect/page.tsx | 13 + tests/fixtures/app-basic/middleware.ts | 42 + tests/isr-cache.test.ts | 15 +- tests/shims.test.ts | 116 + 15 files changed, 3729 insertions(+), 4392 deletions(-) create mode 100644 tests/fixtures/app-basic/app/lib/render-response-header.ts create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-rsc-first/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/[slug]/layout.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/[slug]/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/not-found.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/render-headers-metadata-redirect/page.tsx diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 071514f13..751c63d64 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -9,19 +9,19 @@ */ import fs from "node:fs"; import { fileURLToPath } from "node:url"; +import type { AppRoute } from "../routing/app-router.js"; +import type { MetadataFileRoute } from "../server/metadata-routes.js"; import type { - NextHeader, - NextI18nConfig, NextRedirect, NextRewrite, + NextHeader, + NextI18nConfig, } from "../config/next-config.js"; -import type { AppRoute } from "../routing/app-router.js"; import { generateDevOriginCheckCode } from "../server/dev-origin-check.js"; -import type { MetadataFileRoute } from "../server/metadata-routes.js"; import { + generateSafeRegExpCode, generateMiddlewareMatcherCode, generateNormalizePathCode, - generateSafeRegExpCode, generateRouteMatchNormalizationCode, } from "../server/middleware-codegen.js"; import { isProxyFile } from "../server/middleware.js"; @@ -43,30 +43,6 @@ const routeTriePath = fileURLToPath(new URL("../routing/route-trie.js", import.m "/", ); -// Canonical order of HTTP method handlers supported by route.ts modules. -const ROUTE_HANDLER_HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; - -// Runtime helpers injected into the generated RSC entry so OPTIONS/Allow handling -// logic stays alongside the route handler pipeline. -const routeHandlerHelperCode = String.raw` -// Duplicated from the build-time constant above via JSON.stringify. -const ROUTE_HANDLER_HTTP_METHODS = ${JSON.stringify(ROUTE_HANDLER_HTTP_METHODS)}; - -function collectRouteHandlerMethods(handler) { - const methods = ROUTE_HANDLER_HTTP_METHODS.filter((method) => typeof handler[method] === "function"); - if (methods.includes("GET") && !methods.includes("HEAD")) { - methods.push("HEAD"); - } - return methods; -} - -function buildRouteHandlerAllowHeader(exportedMethods) { - const allow = new Set(exportedMethods); - allow.add("OPTIONS"); - return Array.from(allow).sort().join(", "); -} -`; - /** * Resolved config options relevant to App Router request handling. * Passed from the Vite plugin where the full next.config.js is loaded. @@ -87,15 +63,6 @@ export interface AppRouterConfig { bodySizeLimit?: number; /** Internationalization routing config for middleware matcher locale handling. */ i18n?: NextI18nConfig | null; - /** - * When true, the project has a `pages/` directory alongside the App Router. - * The generated RSC entry exposes `/__vinext/prerender/pages-static-paths` - * so `prerenderPages` can call `getStaticPaths` via `wrangler unstable_startWorker` - * in CF Workers builds. `pageRoutes` is loaded from the SSR environment via - * `import("./ssr/index.js")`, which re-exports it from - * `virtual:vinext-server-entry` when this flag is set. - */ - hasPagesDir?: boolean; } /** @@ -124,7 +91,6 @@ export function generateRscEntry( const allowedOrigins = config?.allowedOrigins ?? []; const bodySizeLimit = config?.bodySizeLimit ?? 1 * 1024 * 1024; const i18nConfig = config?.i18n ?? null; - const hasPagesDir = config?.hasPagesDir ?? false; // Build import map for all page and layout files const imports: string[] = []; const importMap: Map = new Map(); @@ -249,40 +215,14 @@ ${slotEntries.join(",\n")} // For static metadata files, read the file content at code-generation time // and embed it as base64. This ensures static metadata files work on runtimes // without filesystem access (e.g., Cloudflare Workers). - // - // For metadata routes in dynamic segments (e.g., /blog/[slug]/opengraph-image), - // generate patternParts so the runtime can use matchPattern() instead of strict - // equality — the same matching used for intercept routes. const metaRouteEntries = effectiveMetaRoutes.map((mr) => { - // Convert dynamic segments in servedUrl to matchPattern format. - // Keep in sync with routing/app-router.ts patternParts generation. - // [param] → :param - // [...param] → :param+ - // [[...param]] → :param* - const patternParts = - mr.isDynamic && mr.servedUrl.includes("[") - ? JSON.stringify( - mr.servedUrl - .split("/") - .filter(Boolean) - .map((seg) => { - if (seg.startsWith("[[...") && seg.endsWith("]]")) - return ":" + seg.slice(5, -2) + "*"; - if (seg.startsWith("[...") && seg.endsWith("]")) - return ":" + seg.slice(4, -1) + "+"; - if (seg.startsWith("[") && seg.endsWith("]")) return ":" + seg.slice(1, -1); - return seg; - }), - ) - : null; - if (mr.isDynamic) { return ` { type: ${JSON.stringify(mr.type)}, isDynamic: true, servedUrl: ${JSON.stringify(mr.servedUrl)}, contentType: ${JSON.stringify(mr.contentType)}, - module: ${getImportVar(mr.filePath)},${patternParts ? `\n patternParts: ${patternParts},` : ""} + module: ${getImportVar(mr.filePath)}, }`; } // Static: read file and embed as base64 @@ -342,7 +282,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; +import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; @@ -352,20 +292,18 @@ ${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 { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; -import { getRequestExecutionContext as _getRequestExecutionContext } from ${JSON.stringify(requestContextShimPath)}; -import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; +import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; +import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from ${JSON.stringify(requestContextShimPath)}; +import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from ${JSON.stringify(routeTriePath)}; +import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. -import "vinext/navigation-state"; -import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; +import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; function _getSSRFontStyles() { return [..._getSSRFontStylesGoogle(), ..._getSSRFontStylesLocal()]; } function _getSSRFontPreloads() { return [..._getSSRFontPreloadsGoogle(), ..._getSSRFontPreloadsLocal()]; } -${hasPagesDir ? `// Note: pageRoutes loaded lazily via SSR env in /__vinext/prerender/pages-static-paths handler` : ""} -${routeHandlerHelperCode} // ALS used to suppress the expected "Invalid hook call" dev warning when // layout/page components are probed outside React's render cycle. Patching @@ -430,16 +368,71 @@ function __pageCacheTags(pathname, extraTags) { } return tags; } -// Note: cache entries are written with \`headers: undefined\`. Next.js stores -// response headers (e.g. set-cookie from cookies().set() during render) in the -// cache entry so they can be replayed on HIT. We don't do this because: -// 1. Pages that call cookies().set() during render trigger dynamicUsedDuringRender, -// which opts them out of ISR caching before we reach the write path. -// 2. Custom response headers set via next/headers are not yet captured separately -// from the live Response object in vinext's server pipeline. -// In practice this means ISR-cached responses won't replay render-time set-cookie -// headers — but that case is already prevented by the dynamic-usage opt-out. -// TODO: capture render-time response headers for full Next.js parity. +function __isAppendOnlyResponseHeader(lowerKey) { + return lowerKey === "set-cookie" || lowerKey === "vary" || lowerKey === "www-authenticate" || lowerKey === "proxy-authenticate"; +} +function __mergeResponseHeaderValues(targetHeaders, key, value, mode) { + const lowerKey = key.toLowerCase(); + const values = Array.isArray(value) ? value : [value]; + if (__isAppendOnlyResponseHeader(lowerKey)) { + for (const item of values) targetHeaders.append(key, item); + return; + } + if (mode === "fallback" && targetHeaders.has(key)) return; + targetHeaders.delete(key); + if (values.length === 1) { + targetHeaders.set(key, values[0]); + return; + } + for (const item of values) targetHeaders.append(key, item); +} +function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) { + if (!sourceHeaders) return; + if (sourceHeaders instanceof Headers) { + const __setCookies = typeof sourceHeaders.getSetCookie === "function" + ? sourceHeaders.getSetCookie() + : []; + for (const [key, value] of sourceHeaders) { + if (key.toLowerCase() === "set-cookie") { + // entries() flattens Set-Cookie into a single comma-joined value. + // If getSetCookie() is unavailable, drop cookies rather than corrupt them. + continue; + } + __mergeResponseHeaderValues(targetHeaders, key, value, mode); + } + for (const cookie of __setCookies) { + targetHeaders.append("Set-Cookie", cookie); + } + return; + } + for (const [key, value] of Object.entries(sourceHeaders)) { + __mergeResponseHeaderValues(targetHeaders, key, value, mode); + } +} +function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) { + const headers = new Headers(); + __mergeResponseHeaders(headers, renderHeaders, "fallback"); + __mergeResponseHeaders(headers, baseHeaders, "override"); + return headers; +} +function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) { + __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override"); +} +function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) { + const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status; + if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response; + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderHeaders); + __applyMiddlewareResponseHeaders(responseHeaders, middlewareCtx?.headers); + const status = rewriteStatus ?? response.status; + const responseInit = { + status, + headers: responseHeaders, + }; + if (status === response.status && response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); +} const __pendingRegenerations = new Map(); function __triggerBackgroundRegeneration(key, renderFn) { if (__pendingRegenerations.has(key)) return; @@ -487,7 +480,6 @@ function __isrCacheKey(pathname, suffix) { } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } -function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators // have a single knob for all cache tracing. @@ -640,7 +632,9 @@ function rscOnError(error, requestInfo, errorContext) { error instanceof Error ? error : new Error(String(error)), requestInfo, errorContext, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report render error:", reportErr); + }); } // In production, generate a digest hash for non-navigation errors @@ -683,7 +677,6 @@ ${ let __instrumentationInitialized = false; let __instrumentationInitPromise = null; async function __ensureInstrumentation() { - if (process.env.VINEXT_PRERENDER === "1") return; if (__instrumentationInitialized) return; if (__instrumentationInitPromise) return __instrumentationInitPromise; __instrumentationInitPromise = (async () => { @@ -826,7 +819,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. + // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. return new Response(rscStream, { status: statusCode, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -980,7 +973,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. + // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. return new Response(rscStream, { status: 200, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -1493,24 +1486,6 @@ async function __readFormDataWithLimit(request, maxBytes) { return new Response(combined, { headers: { "Content-Type": contentType } }).formData(); } -// Map from route pattern to generateStaticParams function. -// Used by the prerender phase to enumerate dynamic route URLs without -// loading route modules via the dev server. -export const generateStaticParamsMap = { -// TODO: layout-level generateStaticParams — this map only includes routes that -// have a pagePath (leaf pages). Layout segments can also export generateStaticParams -// to provide parent params for nested dynamic routes, but they don't have a pagePath -// so they are excluded here. Supporting layout-level generateStaticParams requires -// scanning layout.tsx files separately and including them in this map. -${routes - .filter((r) => r.isDynamic && r.pagePath) - .map( - (r) => - ` ${JSON.stringify(r.pattern)}: ${getImportVar(r.pagePath!)}?.generateStaticParams ?? null,`, - ) - .join("\n")} -}; - export default async function handler(request, ctx) { ${ instrumentationPath @@ -1520,50 +1495,60 @@ export default async function handler(request, ctx) { ` : "" } - // Wrap the entire request in a single unified ALS scope for per-request - // isolation. All state modules (headers, navigation, cache, fetch-cache, - // execution-context) read from this store via isInsideUnifiedScope(). + // 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 + // streaming), preventing state leakage between concurrent requests on + // Cloudflare Workers and other concurrent runtimes. + // + // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so + // that KVCacheHandler._putInBackground can register background KV puts with + // ctx.waitUntil() without needing ctx passed at construction time. const headersCtx = headersContextFromRequest(request); - const __uCtx = _createUnifiedCtx({ - headersContext: headersCtx, - executionContext: ctx ?? _getRequestExecutionContext() ?? null, - }); - return _runWithUnifiedCtx(__uCtx, async () => { - _ensureFetchPatch(); - const __reqCtx = requestContextFromRequest(request); - // 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); - // 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); - let pathname; - try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } - ${bp ? `if (pathname.startsWith(${JSON.stringify(bp)})) pathname = pathname.slice(${JSON.stringify(bp)}.length) || "/";` : ""} - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); - for (const h of extraHeaders) { - // Use append() for headers where multiple values must coexist - // (Vary, Set-Cookie). Using set() on these would destroy - // existing values like "Vary: RSC, Accept" which are critical - // for correct CDN caching behavior. - const lk = h.key.toLowerCase(); - if (lk === "vary" || lk === "set-cookie") { - response.headers.append(h.key, h.value); - } else if (!response.headers.has(lk)) { - // Middleware headers take precedence: skip config keys already - // set by middleware so middleware headers always win. - response.headers.set(h.key, h.value); - } - } - } - } - return response; - }); + const _run = () => runWithHeadersContext(headersCtx, () => + _runWithNavigationContext(() => + _runWithCacheState(() => + _runWithPrivateCache(() => + runWithFetchCache(async () => { + const __reqCtx = requestContextFromRequest(request); + // 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); + // 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); + let pathname; + try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } + ${bp ? `if (pathname.startsWith(${JSON.stringify(bp)})) pathname = pathname.slice(${JSON.stringify(bp)}.length) || "/";` : ""} + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + for (const h of extraHeaders) { + // Use append() for headers where multiple values must coexist + // (Vary, Set-Cookie). Using set() on these would destroy + // existing values like "Vary: RSC, Accept" which are critical + // for correct CDN caching behavior. + const lk = h.key.toLowerCase(); + if (lk === "vary" || lk === "set-cookie") { + response.headers.append(h.key, h.value); + } else if (!response.headers.has(lk)) { + // Middleware headers take precedence: skip config keys already + // set by middleware so middleware headers always win. + response.headers.set(h.key, h.value); + } + } + } + } + return response; + }) + ) + ) + ) + ); + return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } async function _handleRequest(request, __reqCtx, _mwCtx) { @@ -1605,79 +1590,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { : "" } - // ── Prerender: static-params endpoint ──────────────────────────────── - // Internal endpoint used by prerenderApp() during build to fetch - // generateStaticParams results via wrangler unstable_startWorker. - // Gated on VINEXT_PRERENDER=1 to prevent exposure in normal deployments. - // For Node builds, process.env.VINEXT_PRERENDER is set directly by the - // prerender orchestrator. For CF Workers builds, wrangler unstable_startWorker - // injects VINEXT_PRERENDER as a binding which Miniflare exposes via process.env - // in bundled workers. The /__vinext/ prefix ensures no user route ever conflicts. - if (pathname === "/__vinext/prerender/static-params") { - if (process.env.VINEXT_PRERENDER !== "1") { - return new Response("Not Found", { status: 404 }); - } - const pattern = url.searchParams.get("pattern"); - if (!pattern) return new Response("missing pattern", { status: 400 }); - const fn = generateStaticParamsMap[pattern]; - if (typeof fn !== "function") return new Response("null", { status: 200, headers: { "content-type": "application/json" } }); - try { - const parentParams = url.searchParams.get("parentParams"); - const raw = parentParams ? JSON.parse(parentParams) : {}; - // Ensure params is a plain object — reject primitives, arrays, and null - // so user-authored generateStaticParams always receives { params: {} } - // rather than { params: 5 } or similar if input is malformed. - const params = (typeof raw === "object" && raw !== null && !Array.isArray(raw)) ? raw : {}; - const result = await fn({ params }); - return new Response(JSON.stringify(result), { status: 200, headers: { "content-type": "application/json" } }); - } catch (e) { - return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers: { "content-type": "application/json" } }); - } - } - - ${ - hasPagesDir - ? ` - // ── Prerender: pages-static-paths endpoint ─────────────────────────── - // Internal endpoint used by prerenderPages() during a CF Workers hybrid - // build to call getStaticPaths() for dynamic Pages Router routes via - // wrangler unstable_startWorker. Returns JSON-serialised getStaticPaths result. - // Gated on VINEXT_PRERENDER=1 to prevent exposure in normal deployments. - // See static-params endpoint above for process.env vs CF vars notes. - // - // pageRoutes lives in the SSR environment (virtual:vinext-server-entry). - // We load it lazily via import.meta.viteRsc.loadModule — the same pattern - // used by handleSsr() elsewhere in this template. At build time, Vite's RSC - // plugin transforms this call into a bundled cross-environment import, so it - // works correctly in the CF Workers production bundle running in Miniflare. - if (pathname === "/__vinext/prerender/pages-static-paths") { - if (process.env.VINEXT_PRERENDER !== "1") { - return new Response("Not Found", { status: 404 }); - } - const __gspPattern = url.searchParams.get("pattern"); - if (!__gspPattern) return new Response("missing pattern", { status: 400 }); - try { - const __gspSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - const __pagesRoutes = __gspSsrEntry.pageRoutes; - const __gspRoute = Array.isArray(__pagesRoutes) - ? __pagesRoutes.find((r) => r.pattern === __gspPattern) - : undefined; - if (!__gspRoute || typeof __gspRoute.module?.getStaticPaths !== "function") { - return new Response("null", { status: 200, headers: { "content-type": "application/json" } }); - } - const __localesParam = url.searchParams.get("locales"); - const __locales = __localesParam ? JSON.parse(__localesParam) : []; - const __defaultLocale = url.searchParams.get("defaultLocale") ?? ""; - const __gspResult = await __gspRoute.module.getStaticPaths({ locales: __locales, defaultLocale: __defaultLocale }); - return new Response(JSON.stringify(__gspResult), { status: 200, headers: { "content-type": "application/json" } }); - } catch (e) { - return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers: { "content-type": "application/json" } }); - } - } - ` - : "" - } - // Trailing slash normalization (redirect to canonical form) const __tsRedirect = normalizeTrailingSlash(pathname, __basePath, __trailingSlash, url.search); if (__tsRedirect) return __tsRedirect; @@ -1714,47 +1626,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { ${ middlewarePath ? ` - // In hybrid app+pages dev mode the connect handler already ran middleware - // and forwarded the results via x-vinext-mw-ctx. Reconstruct _mwCtx from - // the forwarded data instead of re-running the middleware function. - // Guarded by NODE_ENV because this header only exists in dev (the connect - // handler sets it). In production there is no connect handler, so an - // attacker-supplied header must not be trusted. - let __mwCtxApplied = false; - if (process.env.NODE_ENV !== "production") { - const __mwCtxHeader = request.headers.get("x-vinext-mw-ctx"); - if (__mwCtxHeader) { - try { - const __mwCtxData = JSON.parse(__mwCtxHeader); - if (__mwCtxData.h && __mwCtxData.h.length > 0) { - // Note: h may include x-middleware-request-* internal headers so - // applyMiddlewareRequestHeaders() can unpack them below. - // processMiddlewareHeaders() strips them before any response. - _mwCtx.headers = new Headers(); - for (const [key, value] of __mwCtxData.h) { - _mwCtx.headers.append(key, value); - } - } - if (__mwCtxData.s != null) { - _mwCtx.status = __mwCtxData.s; - } - // Apply forwarded middleware rewrite so routing uses the rewritten path. - // The RSC plugin constructs its Request from the original HTTP request, - // not from req.url, so the connect handler's req.url rewrite is invisible. - if (__mwCtxData.r) { - const __rewriteParsed = new URL(__mwCtxData.r, request.url); - cleanPathname = __rewriteParsed.pathname; - url.search = __rewriteParsed.search; - } - // Flag set after full context application — if any step fails (e.g. malformed - // rewrite URL), we fall back to re-running middleware as a safety net. - __mwCtxApplied = true; - } catch (e) { - console.error("[vinext] Failed to parse forwarded middleware context:", e); - } - } - } - if (!__mwCtxApplied) { // 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 @@ -1779,9 +1650,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest); const mwFetchEvent = new NextFetchEvent({ page: cleanPathname }); const mwResponse = await middlewareFn(nextRequest, mwFetchEvent); - const _mwWaitUntil = mwFetchEvent.drainWaitUntil(); - const _mwExecCtx = _getRequestExecutionContext(); - if (_mwExecCtx && typeof _mwExecCtx.waitUntil === "function") { _mwExecCtx.waitUntil(_mwWaitUntil); } + mwFetchEvent.drainWaitUntil(); if (mwResponse) { // Check for x-middleware-next (continue) if (mwResponse.headers.get("x-middleware-next") === "1") { @@ -1806,10 +1675,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (rewriteUrl) { const rewriteParsed = new URL(rewriteUrl, request.url); cleanPathname = rewriteParsed.pathname; - // Carry over query params from the rewrite URL so that - // searchParams props, useSearchParams(), and navigation context - // reflect the rewrite destination, not the original request. - url.search = rewriteParsed.search; // Capture custom status code from rewrite (e.g. NextResponse.rewrite(url, { status: 403 })) if (mwResponse.status !== 200) { _mwCtx.status = mwResponse.status; @@ -1832,7 +1697,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return new Response("Internal Server Error", { status: 500 }); } } - } // end of if (!__mwCtxApplied) // Unpack x-middleware-request-* headers into the request context so that // headers() returns the middleware-modified headers instead of the original @@ -1906,53 +1770,45 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Skip — the base servedUrl is not served when generateSitemaps exists continue; } - // Match metadata route — use pattern matching for dynamic segments, - // strict equality for static paths. - var _metaParams = null; - if (metaRoute.patternParts) { - var _metaUrlParts = cleanPathname.split("/").filter(Boolean); - _metaParams = matchPattern(_metaUrlParts, metaRoute.patternParts); - if (!_metaParams) continue; - } else if (cleanPathname !== metaRoute.servedUrl) { - continue; - } - if (metaRoute.isDynamic) { - // Dynamic metadata route — call the default export and serialize - const metaFn = metaRoute.module.default; - if (typeof metaFn === "function") { - const result = await metaFn({ params: makeThenableParams(_metaParams || {}) }); - let body; - // If it's already a Response (e.g., ImageResponse), return directly - if (result instanceof Response) return result; - // Serialize based on type - if (metaRoute.type === "sitemap") body = sitemapToXml(result); - else if (metaRoute.type === "robots") body = robotsToText(result); - else if (metaRoute.type === "manifest") body = manifestToJson(result); - else body = JSON.stringify(result); - return new Response(body, { - headers: { "Content-Type": metaRoute.contentType }, - }); - } - } else { - // Static metadata file — decode from embedded base64 data - try { - const binary = atob(metaRoute.fileDataBase64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new Response(bytes, { - headers: { - "Content-Type": metaRoute.contentType, - "Cache-Control": "public, max-age=0, must-revalidate", - }, - }); - } catch { - return new Response("Not Found", { status: 404 }); + if (cleanPathname === metaRoute.servedUrl) { + if (metaRoute.isDynamic) { + // Dynamic metadata route — call the default export and serialize + const metaFn = metaRoute.module.default; + if (typeof metaFn === "function") { + const result = await metaFn(); + let body; + // If it's already a Response (e.g., ImageResponse), return directly + if (result instanceof Response) return result; + // Serialize based on type + if (metaRoute.type === "sitemap") body = sitemapToXml(result); + else if (metaRoute.type === "robots") body = robotsToText(result); + else if (metaRoute.type === "manifest") body = manifestToJson(result); + else body = JSON.stringify(result); + return new Response(body, { + headers: { "Content-Type": metaRoute.contentType }, + }); + } + } else { + // Static metadata file — decode from embedded base64 data + try { + const binary = atob(metaRoute.fileDataBase64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new Response(bytes, { + headers: { + "Content-Type": metaRoute.contentType, + "Cache-Control": "public, max-age=0, must-revalidate", + }, + }); + } catch { + return new Response("Not Found", { status: 404 }); + } } } } // Set navigation context for Server Components. - // Note: Headers context is already set by runWithRequestContext in the handler wrapper. + // Note: Headers context is already set by runWithHeadersContext in the handler wrapper. setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, @@ -2044,21 +1900,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // and receive a page HTML instead of RSC stream). Instead, we return a 200 // with x-action-redirect header that the client entry detects and handles. if (actionRedirect) { - const actionPendingCookies = getAndClearPendingCookies(); - const actionDraftCookie = getDraftModeCookieHeader(); + const actionRenderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - const redirectHeaders = new Headers({ + const redirectHeaders = __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", "x-action-redirect": actionRedirect.url, "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), - }); - for (const cookie of actionPendingCookies) { - redirectHeaders.append("Set-Cookie", cookie); - } - if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); + }, actionRenderHeaders); // Send an empty RSC-like body (client will navigate instead of parsing) return new Response("", { status: 200, headers: redirectHeaders }); } @@ -2092,28 +1943,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Collect cookies set during the action synchronously (before stream is consumed). // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithRequestContext + // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext // handles cleanup naturally when all async continuations complete. - const actionPendingCookies = getAndClearPendingCookies(); - const actionDraftCookie = getDraftModeCookieHeader(); - - const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; - const actionResponse = new Response(rscStream, { headers: actionHeaders }); - if (actionPendingCookies.length > 0 || actionDraftCookie) { - for (const cookie of actionPendingCookies) { - actionResponse.headers.append("Set-Cookie", cookie); - } - if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie); - } - return actionResponse; + const actionRenderHeaders = consumeRenderResponseHeaders(); + + const actionHeaders = __headersWithRenderResponseHeaders({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }, actionRenderHeaders); + return new Response(rscStream, { headers: actionHeaders }); } catch (err) { - getAndClearPendingCookies(); // Clear pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error console.error("[vinext] Server action error:", err); _reportRequestError( err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: cleanPathname, routeType: "action" }, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report server action error:", reportErr); + }); setHeadersContext(null); setNavigationContext(null); return new Response( @@ -2155,32 +2003,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (!match) { - ${ - hasPagesDir - ? ` - // ── Pages Router fallback ──────────────────────────────────────────── - // When a request doesn't match any App Router route, delegate to the - // Pages Router handler (available in the SSR environment). This covers - // both production request serving and prerender fetches from wrangler. - // RSC requests (.rsc suffix or Accept: text/x-component) cannot be - // handled by the Pages Router, so skip the delegation for those. - if (!isRscRequest) { - const __pagesEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - if (typeof __pagesEntry.renderPage === "function") { - const __pagesRes = await __pagesEntry.renderPage(request, decodeURIComponent(url.pathname) + (url.search || ""), {}); - // Only return the Pages Router response if it matched a route - // (non-404). A 404 means the path isn't a Pages route either, - // so fall through to the App Router not-found page below. - if (__pagesRes.status !== 404) { - setHeadersContext(null); - setNavigationContext(null); - return __pagesRes; - } - } - } - ` - : "" - } // Render custom not-found page if available, otherwise plain 404 const notFoundResponse = await renderNotFoundPage(null, isRscRequest, request); if (notFoundResponse) return notFoundResponse; @@ -2202,16 +2024,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (route.routeHandler) { const handler = route.routeHandler; const method = request.method.toUpperCase(); - const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 && handler.revalidate !== Infinity ? handler.revalidate : null; - if (typeof handler["default"] === "function" && process.env.NODE_ENV === "development") { - console.error( - "[vinext] Detected default export in route handler " + route.pattern + ". Export a named export for each HTTP method instead.", - ); - } + const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 ? handler.revalidate : null; // Collect exported HTTP methods for OPTIONS auto-response and Allow header - const exportedMethods = collectRouteHandlerMethods(handler); - const allowHeaderForOptions = buildRouteHandlerAllowHeader(exportedMethods); + const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; + const exportedMethods = HTTP_METHODS.filter((m) => typeof handler[m] === "function"); + // If GET is exported, HEAD is implicitly supported + if (exportedMethods.includes("GET") && !exportedMethods.includes("HEAD")) { + exportedMethods.push("HEAD"); + } + const hasDefault = typeof handler["default"] === "function"; // Route handlers need the same middleware header/status merge behavior as // page responses. This keeps middleware response headers visible on API @@ -2223,12 +2045,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // return is skipped, but the copy loop below is a no-op, so no incorrect // headers are added. The allocation cost in that case is acceptable. if (!_mwCtx.headers && _mwCtx.status == null) return response; - const responseHeaders = new Headers(response.headers); - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - responseHeaders.append(key, value); - } - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers); + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); return new Response(response.body, { status: _mwCtx.status ?? response.status, statusText: response.statusText, @@ -2238,107 +2056,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // OPTIONS auto-implementation: respond with Allow header and 204 if (method === "OPTIONS" && typeof handler["OPTIONS"] !== "function") { + const allowMethods = hasDefault ? HTTP_METHODS : exportedMethods; + if (!allowMethods.includes("OPTIONS")) allowMethods.push("OPTIONS"); setHeadersContext(null); setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 204, - headers: { "Allow": allowHeaderForOptions }, + headers: { "Allow": allowMethods.join(", ") }, })); } // HEAD auto-implementation: run GET handler and strip body - let handlerFn = handler[method]; + let handlerFn = handler[method] || handler["default"]; let isAutoHead = false; if (method === "HEAD" && typeof handler["HEAD"] !== "function" && typeof handler["GET"] === "function") { handlerFn = handler["GET"]; isAutoHead = true; } - // ISR cache read for route handlers (production only). - // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. - // This runs before handler execution so a cache HIT skips the handler entirely. - if ( - process.env.NODE_ENV === "production" && - revalidateSeconds !== null && - handler.dynamic !== "force-dynamic" && - (method === "GET" || isAutoHead) && - typeof handlerFn === "function" - ) { - const __routeKey = __isrRouteKey(cleanPathname); - try { - const __cached = await __isrGet(__routeKey); - if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { - // HIT — return cached response immediately - const __cv = __cached.value.value; - __isrDebug?.("HIT (route)", cleanPathname); - setHeadersContext(null); - setNavigationContext(null); - const __hitHeaders = Object.assign({}, __cv.headers || {}); - __hitHeaders["X-Vinext-Cache"] = "HIT"; - __hitHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; - if (isAutoHead) { - return attachRouteHandlerMiddlewareContext(new Response(null, { status: __cv.status, headers: __hitHeaders })); - } - return attachRouteHandlerMiddlewareContext(new Response(__cv.body, { status: __cv.status, headers: __hitHeaders })); - } - if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { - // STALE — serve stale response, trigger background regeneration - const __sv = __cached.value.value; - const __revalSecs = revalidateSeconds; - const __revalHandlerFn = handlerFn; - const __revalParams = params; - const __revalUrl = request.url; - const __revalSearchParams = new URLSearchParams(url.searchParams); - __triggerBackgroundRegeneration(__routeKey, async function() { - const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalUCtx = _createUnifiedCtx({ - headersContext: __revalHeadCtx, - executionContext: _getRequestExecutionContext(), - }); - await _runWithUnifiedCtx(__revalUCtx, async () => { - _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); - const __syntheticReq = new Request(__revalUrl, { method: "GET" }); - const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); - const __regenDynamic = consumeDynamicUsage(); - setNavigationContext(null); - if (__regenDynamic) { - __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); - return; - } - const __freshBody = await __revalResponse.arrayBuffer(); - const __freshHeaders = {}; - __revalResponse.headers.forEach(function(v, k) { - if (k !== "x-vinext-cache" && k !== "cache-control") __freshHeaders[k] = v; - }); - const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __freshBody, status: __revalResponse.status, headers: __freshHeaders }, __revalSecs, __routeTags); - __isrDebug?.("route regen complete", __routeKey); - }); - }); - __isrDebug?.("STALE (route)", cleanPathname); - setHeadersContext(null); - setNavigationContext(null); - const __staleHeaders = Object.assign({}, __sv.headers || {}); - __staleHeaders["X-Vinext-Cache"] = "STALE"; - __staleHeaders["Cache-Control"] = "s-maxage=0, stale-while-revalidate"; - if (isAutoHead) { - return attachRouteHandlerMiddlewareContext(new Response(null, { status: __sv.status, headers: __staleHeaders })); - } - return attachRouteHandlerMiddlewareContext(new Response(__sv.body, { status: __sv.status, headers: __staleHeaders })); - } - } catch (__routeCacheErr) { - // Cache read failure — fall through to normal handler execution - console.error("[vinext] ISR route cache read error:", __routeCacheErr); - } - } - if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); - const handlerSetCacheControl = response.headers.has("cache-control"); // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, @@ -2347,56 +2087,20 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { revalidateSeconds !== null && !dynamicUsedInHandler && (method === "GET" || isAutoHead) && - !handlerSetCacheControl + !response.headers.has("cache-control") ) { response.headers.set("cache-control", "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"); } - // ISR cache write for route handlers (production, MISS). - // Store the raw handler response before cookie/middleware transforms - // (those are request-specific and shouldn't be cached). - if ( - process.env.NODE_ENV === "production" && - revalidateSeconds !== null && - handler.dynamic !== "force-dynamic" && - !dynamicUsedInHandler && - (method === "GET" || isAutoHead) && - !handlerSetCacheControl - ) { - response.headers.set("X-Vinext-Cache", "MISS"); - const __routeClone = response.clone(); - const __routeKey = __isrRouteKey(cleanPathname); - const __revalSecs = revalidateSeconds; - const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - const __routeWritePromise = (async () => { - try { - const __buf = await __routeClone.arrayBuffer(); - const __hdrs = {}; - __routeClone.headers.forEach(function(v, k) { - if (k !== "x-vinext-cache" && k !== "cache-control") __hdrs[k] = v; - }); - await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __buf, status: __routeClone.status, headers: __hdrs }, __revalSecs, __routeTags); - __isrDebug?.("route cache written", __routeKey); - } catch (__cacheErr) { - console.error("[vinext] ISR route cache write error:", __cacheErr); - } - })(); - _getRequestExecutionContext()?.waitUntil(__routeWritePromise); - } - - // Collect any Set-Cookie headers from cookies().set()/delete() calls - const pendingCookies = getAndClearPendingCookies(); - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // If we have pending cookies, create a new response with them attached - if (pendingCookies.length > 0 || draftCookie) { - const newHeaders = new Headers(response.headers); - for (const cookie of pendingCookies) { - newHeaders.append("Set-Cookie", cookie); - } - if (draftCookie) newHeaders.append("Set-Cookie", draftCookie); + if (renderResponseHeaders) { + const newHeaders = __headersWithRenderResponseHeaders( + response.headers, + renderResponseHeaders, + ); if (isAutoHead) { return attachRouteHandlerMiddlewareContext(new Response(null, { @@ -2422,7 +2126,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return attachRouteHandlerMiddlewareContext(response); } catch (err) { - getAndClearPendingCookies(); // Clear any pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error // Catch redirect() / notFound() thrown from route handlers if (err && typeof err === "object" && "digest" in err) { const digest = String(err.digest); @@ -2451,7 +2155,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: route.pattern, routeType: "route" }, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report route handler error:", reportErr); + }); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 500 })); } finally { setHeadersAccessPhase(previousHeadersPhase); @@ -2461,6 +2167,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 405, + headers: { Allow: exportedMethods.join(", ") }, })); } @@ -2542,29 +2249,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HIT (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.rscData, { + return __responseWithMiddlewareContext(new Response(__cachedValue.rscData, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } if (!isRscRequest && __hasHtml) { __isrDebug?.("HIT (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.html, { + return __responseWithMiddlewareContext(new Response(__cachedValue.html, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } __isrDebug?.("MISS (empty cached entry)", cleanPathname); } @@ -2580,58 +2287,62 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // user request — to prevent user-specific cookies/auth headers from leaking // into content that is cached and served to all subsequent users. const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalUCtx = _createUnifiedCtx({ - headersContext: __revalHeadCtx, - executionContext: _getRequestExecutionContext(), - }); - const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { - _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); - const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); - const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); - // Tee RSC stream: one for SSR, one to capture rscData - const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); - // Capture rscData bytes in parallel with SSR - const __rscDataPromise = (async () => { - const __rscReader = __revalRscForCapture.getReader(); - const __rscChunks = []; - let __rscTotal = 0; - for (;;) { - const { done, value } = await __rscReader.read(); - if (done) break; - __rscChunks.push(value); - __rscTotal += value.byteLength; - } - const __rscBuf = new Uint8Array(__rscTotal); - let __rscOff = 0; - for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } - return __rscBuf.buffer; - })(); - const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; - const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); - setHeadersContext(null); - setNavigationContext(null); - // Collect the full HTML string from the stream - const __revalReader = __revalHtmlStream.getReader(); - const __revalDecoder = new TextDecoder(); - const __revalChunks = []; - for (;;) { - const { done, value } = await __revalReader.read(); - if (done) break; - __revalChunks.push(__revalDecoder.decode(value, { stream: true })); - } - __revalChunks.push(__revalDecoder.decode()); - const __freshHtml = __revalChunks.join(""); - const __freshRscData = await __rscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; - }); + const __revalResult = await runWithHeadersContext(__revalHeadCtx, () => + _runWithNavigationContext(() => + _runWithCacheState(() => + _runWithPrivateCache(() => + runWithFetchCache(async () => { + setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); + const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); + const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); + const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); + // Tee RSC stream: one for SSR, one to capture rscData + const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); + // Capture rscData bytes in parallel with SSR + const __rscDataPromise = (async () => { + const __rscReader = __revalRscForCapture.getReader(); + const __rscChunks = []; + let __rscTotal = 0; + for (;;) { + const { done, value } = await __rscReader.read(); + if (done) break; + __rscChunks.push(value); + __rscTotal += value.byteLength; + } + const __rscBuf = new Uint8Array(__rscTotal); + let __rscOff = 0; + for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } + return __rscBuf.buffer; + })(); + const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; + const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); + const __freshRscData = await __rscDataPromise; + const __renderHeaders = consumeRenderResponseHeaders(); + setHeadersContext(null); + setNavigationContext(null); + // Collect the full HTML string from the stream + const __revalReader = __revalHtmlStream.getReader(); + const __revalDecoder = new TextDecoder(); + const __revalChunks = []; + for (;;) { + const { done, value } = await __revalReader.read(); + if (done) break; + __revalChunks.push(__revalDecoder.decode(value, { stream: true })); + } + __revalChunks.push(__revalDecoder.decode()); + const __freshHtml = __revalChunks.join(""); + const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; + }) + ) + ) + ) + ); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); __isrDebug?.("regen complete", cleanPathname); }); @@ -2639,29 +2350,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("STALE (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.rscData, { + return __responseWithMiddlewareContext(new Response(__staleValue.rscData, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { __isrDebug?.("STALE (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.html, { + return __responseWithMiddlewareContext(new Response(__staleValue.html, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } // Stale entry exists but is empty for this request type — fall through to render __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); @@ -2736,7 +2447,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError }); // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithRequestContext + // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext // handles cleanup naturally when all async continuations complete. return new Response(interceptStream, { headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -2751,65 +2462,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let element; - try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); - } catch (buildErr) { - // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components - if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { - const digest = String(buildErr.digest); + // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim + async function handleRenderError(err, fallbackOpts) { + if (err && typeof err === "object" && "digest" in err) { + const digest = String(err.digest); if (digest.startsWith("NEXT_REDIRECT;")) { const parts = digest.split(";"); const redirectUrl = decodeURIComponent(parts[2]); const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); + return __responseWithMiddlewareContext(new Response(null, { + status: statusCode, + headers: { Location: new URL(redirectUrl, request.url).toString() }, + }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false }); } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + ...fallbackOpts, + }); + const renderResponseHeaders = consumeRenderResponseHeaders(); + if (fallbackResp) { + return __responseWithMiddlewareContext( + fallbackResp, + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); + } setHeadersContext(null); setNavigationContext(null); const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); + return __responseWithMiddlewareContext( + new Response(statusText, { status: statusCode }), + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); } } + return null; + } + + let element; + try { + element = await buildPageElement(route, params, interceptOpts, url.searchParams); + } catch (buildErr) { + const specialResponse = await handleRenderError(buildErr); + if (specialResponse) return specialResponse; // Non-special error (e.g. generateMetadata() threw) — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw buildErr; } + const __buildRenderResponseHeaders = peekRenderResponseHeaders(); + const __buildDynamicUsage = peekDynamicUsage(); // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - if (err && typeof err === "object" && "digest" in err) { - const digest = String(err.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); - } - } - return null; - } - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws // notFound(), the parent layout's boundary catches it and renders the parent's @@ -2835,44 +2556,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const lr = LayoutComp({ params: asyncParams, children: null }); if (lr && typeof lr === "object" && typeof lr.then === "function") await lr; } catch (layoutErr) { - if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) { - const digest = String(layoutErr.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; } - if (!parentNotFound) parentNotFound = ${rootNotFoundVar ? `${rootNotFoundVar}?.default` : "null"}; - // Wrap in only the layouts above the throwing one - const parentLayouts = route.layouts.slice(0, li); - const fallbackResp = await renderHTTPAccessFallbackPage( - route, statusCode, isRscRequest, request, - { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params } - ); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); } } + if (!parentNotFound) parentNotFound = ${rootNotFoundVar ? `${rootNotFoundVar}?.default` : "null"}; + // Wrap in only the layouts above the throwing one + const parentLayouts = route.layouts.slice(0, li); + const specialResponse = await handleRenderError(layoutErr, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, + }); + if (specialResponse) return specialResponse; // Not a special error — let it propagate through normal RSC rendering } } @@ -2921,6 +2624,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); if (_pageProbeResult instanceof Response) return _pageProbeResult; + // The sync pre-render probes above are only for catching redirect/notFound + // before streaming begins. Discard any render-time response headers they + // may have produced while preserving headers generated during buildPageElement + // (e.g. generateMetadata), since those are part of the real render output. + restoreRenderResponseHeaders(__buildRenderResponseHeaders); + restoreDynamicUsage(__buildDynamicUsage); + // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); @@ -2971,7 +2681,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // NOTE: Do NOT clear headers/navigation context here! // The RSC stream is consumed lazily - components render when chunks are read. // If we clear context now, headers()/cookies() will fail during rendering. - // Context will be cleared when the next request starts (via runWithRequestContext). + // Context will be cleared when the next request starts (via runWithHeadersContext). const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; // Include matched route params so the client can hydrate useParams() if (params && Object.keys(params).length > 0) { @@ -2988,37 +2698,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } else if (revalidateSeconds) { responseHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; } - // Merge middleware response headers into the RSC response. - // set-cookie and vary are accumulated to preserve existing values - // (e.g. "Vary: RSC, Accept" set above); all other keys use plain - // assignment so middleware headers win over config headers, which - // the outer handler applies afterward and skips keys already present. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - const lk = key.toLowerCase(); - if (lk === "set-cookie") { - const existing = responseHeaders[lk]; - if (Array.isArray(existing)) { - existing.push(value); - } else if (existing) { - responseHeaders[lk] = [existing, value]; - } else { - responseHeaders[lk] = [value]; - } - } else if (lk === "vary") { - // Accumulate Vary values to preserve the existing "RSC, Accept" entry. - const existing = responseHeaders["Vary"] ?? responseHeaders["vary"]; - if (existing) { - responseHeaders["Vary"] = existing + ", " + value; - if (responseHeaders["vary"] !== undefined) delete responseHeaders["vary"]; - } else { - responseHeaders[key] = value; - } - } else { - responseHeaders[key] = value; - } - } - } // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -3038,22 +2717,45 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { - responseHeaders["X-Vinext-Cache"] = "MISS"; const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; + const __responseRenderHeaders = peekRenderResponseHeaders(); + if (peekDynamicUsage()) { + responseHeaders["Cache-Control"] = "no-store, must-revalidate"; + } else { + responseHeaders["X-Vinext-Cache"] = "MISS"; + } const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? __responseRenderHeaders; + // consume picks up headers added during late async RSC streaming work. + // Falls back to the snapshot taken before the live MISS response was returned. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip RSC cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); + await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); + } finally { + setHeadersContext(null); + setNavigationContext(null); } })(); _getRequestExecutionContext()?.waitUntil(__rscWritePromise); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } - return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: responseHeaders, + }), _mwCtx); } // Collect font data from RSC environment before passing to SSR @@ -3091,7 +2793,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw ssrErr; } @@ -3106,38 +2815,36 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const _hasLocalBoundary = !!(route?.error?.default) || !!(route?.errors && route.errors.some(function(e) { return e?.default; })); if (!_hasLocalBoundary) { const cleanResp = await renderErrorBoundaryPage(route, _rscErrorForRerender, false, request, params); - if (cleanResp) return cleanResp; + if (cleanResp) { + return __responseWithMiddlewareContext( + cleanResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } } } ` : "" } - // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response + // Helper to attach render-time response headers, middleware headers, font + // Link header, and rewrite status to a response. function attachMiddlewareContext(response) { - if (draftCookie) { - response.headers.append("Set-Cookie", draftCookie); - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderResponseHeaders); // Set HTTP Link header for font preloading if (fontLinkHeader) { - response.headers.set("Link", fontLinkHeader); - } - // Merge middleware response headers into the final response. - // The response is freshly constructed above (new Response(htmlStream, {...})), - // so set() and append() are equivalent — there are no same-key conflicts yet. - // Precedence over config headers is handled by the outer handler, which - // skips config keys that middleware already placed on the response. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - response.headers.append(key, value); - } + responseHeaders.set("Link", fontLinkHeader); } + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -3152,21 +2859,30 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const renderMs = __renderEnd !== undefined && __compileEnd !== undefined ? Math.round(__renderEnd - __compileEnd) : -1; - response.headers.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); + responseHeaders.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); } // Apply custom status code from middleware rewrite if (_mwCtx.status) { return new Response(response.body, { status: _mwCtx.status, - headers: response.headers, + headers: responseHeaders, }); } - return response; + const responseInit = { + status: response.status, + headers: responseHeaders, + }; + if (response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); } // Check if any component called connection(), cookies(), headers(), or noStore() // during rendering. If so, treat as dynamic (skip ISR, set no-store). - const dynamicUsedDuringRender = consumeDynamicUsage(); + const dynamicUsedDuringRender = __isrRscDataPromise + ? peekDynamicUsage() + : consumeDynamicUsage(); // Check if cacheLife() was called during rendering (e.g., page with file-level "use cache"). // If so, use its revalidation period for the Cache-Control header. @@ -3250,6 +2966,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? renderResponseHeaders; + // consume picks up any headers added during stream consumption by late + // async render work (for example, suspended branches). Falls back to + // the snapshot taken before streaming began when nothing new was added. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip HTML cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) @@ -3257,12 +2982,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ensuring the first client-side navigation after a direct visit is a // cache hit rather than a miss. const __writes = [ - __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags), + __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags), ]; if (__capturedRscDataPromise) { __writes.push( __capturedRscDataPromise.then((__rscBuf) => - __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags) + __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags) ) ); } @@ -3270,6 +2995,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HTML cache written", __isrKey); } catch (__cacheErr) { console.error("[vinext] ISR cache write error:", __cacheErr); + } finally { + consumeRenderResponseHeaders(); } })(); // Register with ExecutionContext (from ALS) so the Workers runtime keeps diff --git a/packages/vinext/src/server/isr-cache.ts b/packages/vinext/src/server/isr-cache.ts index 860aa4681..b455fdb75 100644 --- a/packages/vinext/src/server/isr-cache.ts +++ b/packages/vinext/src/server/isr-cache.ts @@ -131,13 +131,14 @@ export function buildPagesCacheValue( export function buildAppPageCacheValue( html: string, rscData?: ArrayBuffer, + headers?: Record, status?: number, ): CachedAppPageValue { return { kind: "APP_PAGE", html, rscData, - headers: undefined, + headers, postponed: undefined, status, }; diff --git a/packages/vinext/src/shims/headers.ts b/packages/vinext/src/shims/headers.ts index 170fcaf6c..5d5b6edb8 100644 --- a/packages/vinext/src/shims/headers.ts +++ b/packages/vinext/src/shims/headers.ts @@ -11,17 +11,12 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { buildRequestHeadersFromMiddlewareResponse } from "../server/middleware-request-headers.js"; import { parseCookieHeader } from "./internal/parse-cookie-header.js"; -import { - isInsideUnifiedScope, - getRequestContext, - runWithUnifiedStateMutation, -} from "./unified-request-context.js"; // --------------------------------------------------------------------------- // Request context // --------------------------------------------------------------------------- -export interface HeadersContext { +interface HeadersContext { headers: Headers; cookies: Map; accessError?: Error; @@ -32,14 +27,87 @@ export interface HeadersContext { export type HeadersAccessPhase = "render" | "action" | "route-handler"; -export type VinextHeadersShimState = { +type RenderSetCookieSource = "cookie" | "draft" | "header"; + +interface RenderSetCookieEntry { + source: RenderSetCookieSource; + value: string; +} + +interface RenderResponseHeaderEntry { + name: string; + values: string[]; +} + +interface RenderResponseHeaders { + headers: Map; + setCookies: RenderSetCookieEntry[]; +} + +type VinextHeadersShimState = { headersContext: HeadersContext | null; dynamicUsageDetected: boolean; - pendingSetCookies: string[]; - draftModeCookieHeader: string | null; + renderResponseHeaders: RenderResponseHeaders; phase: HeadersAccessPhase; }; +function createRenderResponseHeaders(): RenderResponseHeaders { + return { + headers: new Map(), + setCookies: [], + }; +} + +function serializeRenderResponseHeaders( + renderResponseHeaders: RenderResponseHeaders, +): Record | undefined { + const serialized: Record = {}; + + for (const entry of renderResponseHeaders.headers.values()) { + if (entry.values.length === 1) { + serialized[entry.name] = entry.values[0]!; + continue; + } + if (entry.values.length > 1) { + serialized[entry.name] = [...entry.values]; + } + } + + if (renderResponseHeaders.setCookies.length > 0) { + serialized["set-cookie"] = renderResponseHeaders.setCookies.map((entry) => entry.value); + } + + return Object.keys(serialized).length > 0 ? serialized : undefined; +} + +function deserializeRenderResponseHeaders( + serialized?: Record, +): RenderResponseHeaders { + const renderResponseHeaders = createRenderResponseHeaders(); + + if (!serialized) { + return renderResponseHeaders; + } + + for (const [key, value] of Object.entries(serialized)) { + if (key.toLowerCase() === "set-cookie") { + const values = Array.isArray(value) ? value : [value]; + renderResponseHeaders.setCookies = values.map((item) => ({ + source: "header", + value: item, + })); + continue; + } + + renderResponseHeaders.headers.set(key.toLowerCase(), { + name: key, + values: Array.isArray(value) ? [...value] : [value], + }); + } + + return renderResponseHeaders; +} + // NOTE: // - This shim can be loaded under multiple module specifiers in Vite's // multi-environment setup (RSC/SSR). Store the AsyncLocalStorage on @@ -56,17 +124,35 @@ const _als = (_g[_ALS_KEY] ??= const _fallbackState = (_g[_FALLBACK_KEY] ??= { headersContext: null, dynamicUsageDetected: false, - pendingSetCookies: [], - draftModeCookieHeader: null, + renderResponseHeaders: createRenderResponseHeaders(), phase: "render", } satisfies VinextHeadersShimState) as VinextHeadersShimState; -const EXPIRED_COOKIE_DATE = new Date(0).toUTCString(); function _getState(): VinextHeadersShimState { - if (isInsideUnifiedScope()) { - return getRequestContext(); - } - return _als.getStore() ?? _fallbackState; + const state = _als.getStore(); + return state ?? _fallbackState; +} + +function _appendRenderResponseHeaderWithSource( + name: string, + value: string, + source: RenderSetCookieSource, +): void { + const state = _getState(); + if (name.toLowerCase() === "set-cookie") { + state.renderResponseHeaders.setCookies.push({ source, value }); + return; + } + const lowerName = name.toLowerCase(); + const existing = state.renderResponseHeaders.headers.get(lowerName); + if (existing) { + existing.values.push(value); + return; + } + state.renderResponseHeaders.headers.set(lowerName, { + name, + values: [value], + }); } /** @@ -142,6 +228,14 @@ export function consumeDynamicUsage(): boolean { return used; } +export function peekDynamicUsage(): boolean { + return _getState().dynamicUsageDetected; +} + +export function restoreDynamicUsage(used: boolean): void { + _getState().dynamicUsageDetected = used; +} + function _setStatePhase( state: VinextHeadersShimState, phase: HeadersAccessPhase, @@ -179,16 +273,34 @@ export function getHeadersContext(): HeadersContext | null { } export function setHeadersContext(ctx: HeadersContext | null): void { - const state = _getState(); if (ctx !== null) { - state.headersContext = ctx; - state.dynamicUsageDetected = false; - state.pendingSetCookies = []; - state.draftModeCookieHeader = null; - state.phase = "render"; - } else { + // For backward compatibility, set context on the current ALS store + // if one exists, otherwise update the fallback. Callers should + // migrate to runWithHeadersContext() for new-request setup. + const existing = _als.getStore(); + if (existing) { + existing.headersContext = ctx; + existing.dynamicUsageDetected = false; + existing.renderResponseHeaders = createRenderResponseHeaders(); + existing.phase = "render"; + } else { + _fallbackState.headersContext = ctx; + _fallbackState.dynamicUsageDetected = false; + _fallbackState.renderResponseHeaders = createRenderResponseHeaders(); + _fallbackState.phase = "render"; + } + return; + } + + // End of request cleanup: keep the store (so consumeDynamicUsage and + // cookie flushing can still run), but clear the request headers/cookies. + const state = _als.getStore(); + if (state) { state.headersContext = null; state.phase = "render"; + } else { + _fallbackState.headersContext = null; + _fallbackState.phase = "render"; } } @@ -206,21 +318,10 @@ export function runWithHeadersContext( ctx: HeadersContext, fn: () => T | Promise, ): T | Promise { - if (isInsideUnifiedScope()) { - return runWithUnifiedStateMutation((uCtx) => { - uCtx.headersContext = ctx; - uCtx.dynamicUsageDetected = false; - uCtx.pendingSetCookies = []; - uCtx.draftModeCookieHeader = null; - uCtx.phase = "render"; - }, fn); - } - const state: VinextHeadersShimState = { headersContext: ctx, dynamicUsageDetected: false, - pendingSetCookies: [], - draftModeCookieHeader: null, + renderResponseHeaders: createRenderResponseHeaders(), phase: "render", }; @@ -441,12 +542,16 @@ export function headersContextFromRequest(request: Request): HeadersContext { let _mutable: Headers | null = null; const headersProxy = new Proxy(request.headers, { - get(target, prop: string | symbol) { + get(target, prop: string | symbol, receiver) { // Route to the materialised copy if it exists. const src = _mutable ?? target; + if (typeof prop !== "string") { + return Reflect.get(src, prop, receiver); + } + // Intercept mutating methods: materialise on first write. - if (typeof prop === "string" && _HEADERS_MUTATING_METHODS.has(prop)) { + if (_HEADERS_MUTATING_METHODS.has(prop)) { return (...args: unknown[]) => { if (!_mutable) { _mutable = new Headers(target); @@ -569,8 +674,13 @@ export function cookies(): Promise & RequestCookies { */ export function getAndClearPendingCookies(): string[] { const state = _getState(); - const cookies = state.pendingSetCookies; - state.pendingSetCookies = []; + const cookies = state.renderResponseHeaders.setCookies + .filter((entry) => entry.source === "cookie") + .map((entry) => entry.value); + if (cookies.length === 0) return []; + state.renderResponseHeaders.setCookies = state.renderResponseHeaders.setCookies.filter( + (entry) => entry.source !== "cookie", + ); return cookies; } @@ -600,9 +710,54 @@ function getDraftSecret(): string { */ export function getDraftModeCookieHeader(): string | null { const state = _getState(); - const header = state.draftModeCookieHeader; - state.draftModeCookieHeader = null; - return header; + const draftEntries = state.renderResponseHeaders.setCookies.filter( + (entry) => entry.source === "draft", + ); + if (draftEntries.length === 0) return null; + state.renderResponseHeaders.setCookies = state.renderResponseHeaders.setCookies.filter( + (entry) => entry.source !== "draft", + ); + return draftEntries[draftEntries.length - 1]?.value ?? null; +} + +export function appendRenderResponseHeader(name: string, value: string): void { + _appendRenderResponseHeaderWithSource(name, value, "header"); +} + +export function setRenderResponseHeader(name: string, value: string): void { + const state = _getState(); + if (name.toLowerCase() === "set-cookie") { + state.renderResponseHeaders.setCookies = [{ source: "header", value }]; + return; + } + state.renderResponseHeaders.headers.set(name.toLowerCase(), { + name, + values: [value], + }); +} + +export function deleteRenderResponseHeader(name: string): void { + const state = _getState(); + if (name.toLowerCase() === "set-cookie") { + state.renderResponseHeaders.setCookies = []; + return; + } + state.renderResponseHeaders.headers.delete(name.toLowerCase()); +} + +export function peekRenderResponseHeaders(): Record | undefined { + return serializeRenderResponseHeaders(_getState().renderResponseHeaders); +} + +export function restoreRenderResponseHeaders(serialized?: Record): void { + _getState().renderResponseHeaders = deserializeRenderResponseHeaders(serialized); +} + +export function consumeRenderResponseHeaders(): Record | undefined { + const state = _getState(); + const serialized = serializeRenderResponseHeaders(state.renderResponseHeaders); + state.renderResponseHeaders = createRenderResponseHeaders(); + return serialized; } interface DraftModeResult { @@ -642,7 +797,11 @@ export async function draftMode(): Promise { } const secure = typeof process !== "undefined" && process.env?.NODE_ENV === "production" ? "; Secure" : ""; - state.draftModeCookieHeader = `${DRAFT_MODE_COOKIE}=${secret}; Path=/; HttpOnly; SameSite=Lax${secure}`; + _appendRenderResponseHeaderWithSource( + "Set-Cookie", + `${DRAFT_MODE_COOKIE}=${secret}; Path=/; HttpOnly; SameSite=Lax${secure}`, + "draft", + ); }, disable(): void { if (state.headersContext?.accessError) { @@ -653,7 +812,11 @@ export async function draftMode(): Promise { } const secure = typeof process !== "undefined" && process.env?.NODE_ENV === "production" ? "; Secure" : ""; - state.draftModeCookieHeader = `${DRAFT_MODE_COOKIE}=; Path=/; HttpOnly; SameSite=Lax${secure}; Max-Age=0`; + _appendRenderResponseHeaderWithSource( + "Set-Cookie", + `${DRAFT_MODE_COOKIE}=; Path=/; HttpOnly; SameSite=Lax${secure}; Max-Age=0`, + "draft", + ); }, }; } @@ -770,9 +933,10 @@ class RequestCookies { // Build Set-Cookie header string const parts = [`${cookieName}=${encodeURIComponent(cookieValue)}`]; - const path = opts?.path ?? "/"; - validateCookieAttributeValue(path, "Path"); - parts.push(`Path=${path}`); + if (opts?.path) { + validateCookieAttributeValue(opts.path, "Path"); + parts.push(`Path=${opts.path}`); + } if (opts?.domain) { validateCookieAttributeValue(opts.domain, "Domain"); parts.push(`Domain=${opts.domain}`); @@ -783,29 +947,17 @@ class RequestCookies { if (opts?.secure) parts.push("Secure"); if (opts?.sameSite) parts.push(`SameSite=${opts.sameSite}`); - _getState().pendingSetCookies.push(parts.join("; ")); + _appendRenderResponseHeaderWithSource("Set-Cookie", parts.join("; "), "cookie"); return this; } /** - * Delete a cookie by emitting an expired Set-Cookie header. + * Delete a cookie by setting it with Max-Age=0. */ - delete(nameOrOptions: string | { name: string; path?: string; domain?: string }): this { - const name = typeof nameOrOptions === "string" ? nameOrOptions : nameOrOptions.name; - const path = typeof nameOrOptions === "string" ? "/" : (nameOrOptions.path ?? "/"); - const domain = typeof nameOrOptions === "string" ? undefined : nameOrOptions.domain; - + delete(name: string): this { validateCookieName(name); - validateCookieAttributeValue(path, "Path"); - if (domain) { - validateCookieAttributeValue(domain, "Domain"); - } - this._cookies.delete(name); - const parts = [`${name}=`, `Path=${path}`]; - if (domain) parts.push(`Domain=${domain}`); - parts.push(`Expires=${EXPIRED_COOKIE_DATE}`); - _getState().pendingSetCookies.push(parts.join("; ")); + _appendRenderResponseHeaderWithSource("Set-Cookie", `${name}=; Path=/; Max-Age=0`, "cookie"); return this; } diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 813388650..c4706ae36 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -374,7 +374,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; +import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; @@ -384,38 +384,19 @@ 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 { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; -import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; +import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; +import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; +import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; +import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. -import "vinext/navigation-state"; -import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; +import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; function _getSSRFontStyles() { return [..._getSSRFontStylesGoogle(), ..._getSSRFontStylesLocal()]; } function _getSSRFontPreloads() { return [..._getSSRFontPreloadsGoogle(), ..._getSSRFontPreloadsLocal()]; } - -// Duplicated from the build-time constant above via JSON.stringify. -const ROUTE_HANDLER_HTTP_METHODS = ["GET","HEAD","POST","PUT","DELETE","PATCH","OPTIONS"]; - -function collectRouteHandlerMethods(handler) { - const methods = ROUTE_HANDLER_HTTP_METHODS.filter((method) => typeof handler[method] === "function"); - if (methods.includes("GET") && !methods.includes("HEAD")) { - methods.push("HEAD"); - } - return methods; -} - -function buildRouteHandlerAllowHeader(exportedMethods) { - const allow = new Set(exportedMethods); - allow.add("OPTIONS"); - return Array.from(allow).sort().join(", "); -} - - // ALS used to suppress the expected "Invalid hook call" dev warning when // layout/page components are probed outside React's render cycle. Patching // console.error once at module load (instead of per-request) avoids the @@ -479,16 +460,71 @@ function __pageCacheTags(pathname, extraTags) { } return tags; } -// Note: cache entries are written with \`headers: undefined\`. Next.js stores -// response headers (e.g. set-cookie from cookies().set() during render) in the -// cache entry so they can be replayed on HIT. We don't do this because: -// 1. Pages that call cookies().set() during render trigger dynamicUsedDuringRender, -// which opts them out of ISR caching before we reach the write path. -// 2. Custom response headers set via next/headers are not yet captured separately -// from the live Response object in vinext's server pipeline. -// In practice this means ISR-cached responses won't replay render-time set-cookie -// headers — but that case is already prevented by the dynamic-usage opt-out. -// TODO: capture render-time response headers for full Next.js parity. +function __isAppendOnlyResponseHeader(lowerKey) { + return lowerKey === "set-cookie" || lowerKey === "vary" || lowerKey === "www-authenticate" || lowerKey === "proxy-authenticate"; +} +function __mergeResponseHeaderValues(targetHeaders, key, value, mode) { + const lowerKey = key.toLowerCase(); + const values = Array.isArray(value) ? value : [value]; + if (__isAppendOnlyResponseHeader(lowerKey)) { + for (const item of values) targetHeaders.append(key, item); + return; + } + if (mode === "fallback" && targetHeaders.has(key)) return; + targetHeaders.delete(key); + if (values.length === 1) { + targetHeaders.set(key, values[0]); + return; + } + for (const item of values) targetHeaders.append(key, item); +} +function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) { + if (!sourceHeaders) return; + if (sourceHeaders instanceof Headers) { + const __setCookies = typeof sourceHeaders.getSetCookie === "function" + ? sourceHeaders.getSetCookie() + : []; + for (const [key, value] of sourceHeaders) { + if (key.toLowerCase() === "set-cookie") { + // entries() flattens Set-Cookie into a single comma-joined value. + // If getSetCookie() is unavailable, drop cookies rather than corrupt them. + continue; + } + __mergeResponseHeaderValues(targetHeaders, key, value, mode); + } + for (const cookie of __setCookies) { + targetHeaders.append("Set-Cookie", cookie); + } + return; + } + for (const [key, value] of Object.entries(sourceHeaders)) { + __mergeResponseHeaderValues(targetHeaders, key, value, mode); + } +} +function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) { + const headers = new Headers(); + __mergeResponseHeaders(headers, renderHeaders, "fallback"); + __mergeResponseHeaders(headers, baseHeaders, "override"); + return headers; +} +function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) { + __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override"); +} +function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) { + const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status; + if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response; + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderHeaders); + __applyMiddlewareResponseHeaders(responseHeaders, middlewareCtx?.headers); + const status = rewriteStatus ?? response.status; + const responseInit = { + status, + headers: responseHeaders, + }; + if (status === response.status && response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); +} const __pendingRegenerations = new Map(); function __triggerBackgroundRegeneration(key, renderFn) { if (__pendingRegenerations.has(key)) return; @@ -536,7 +572,6 @@ function __isrCacheKey(pathname, suffix) { } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } -function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators // have a single knob for all cache tracing. @@ -689,7 +724,9 @@ function rscOnError(error, requestInfo, errorContext) { error instanceof Error ? error : new Error(String(error)), requestInfo, errorContext, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report render error:", reportErr); + }); } // In production, generate a digest hash for non-navigation errors @@ -926,7 +963,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. + // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. return new Response(rscStream, { status: statusCode, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -1059,7 +1096,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. + // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. return new Response(rscStream, { status: 200, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -1471,16 +1508,7 @@ function __validateDevRequestOrigin(request) { } const origin = request.headers.get("origin"); - if (!origin) return null; - - // Origin "null" is sent by opaque/sandboxed contexts. Block unless explicitly allowed. - if (origin === "null") { - if (!__allowedDevOrigins.includes("null")) { - console.warn("[vinext] Blocked request with Origin: null. Add \\"null\\" to allowedDevOrigins to allow sandboxed contexts."); - return new Response("Forbidden", { status: 403, headers: { "Content-Type": "text/plain" } }); - } - return null; - } + if (!origin || origin === "null") return null; let originHostname; try { @@ -1731,64 +1759,62 @@ async function __readFormDataWithLimit(request, maxBytes) { return new Response(combined, { headers: { "Content-Type": contentType } }).formData(); } -// Map from route pattern to generateStaticParams function. -// Used by the prerender phase to enumerate dynamic route URLs without -// loading route modules via the dev server. -export const generateStaticParamsMap = { -// TODO: layout-level generateStaticParams — this map only includes routes that -// have a pagePath (leaf pages). Layout segments can also export generateStaticParams -// to provide parent params for nested dynamic routes, but they don't have a pagePath -// so they are excluded here. Supporting layout-level generateStaticParams requires -// scanning layout.tsx files separately and including them in this map. - "/blog/:slug": mod_3?.generateStaticParams ?? null, -}; - export default async function handler(request, ctx) { - // Wrap the entire request in a single unified ALS scope for per-request - // isolation. All state modules (headers, navigation, cache, fetch-cache, - // execution-context) read from this store via isInsideUnifiedScope(). + // 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 + // streaming), preventing state leakage between concurrent requests on + // Cloudflare Workers and other concurrent runtimes. + // + // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so + // that KVCacheHandler._putInBackground can register background KV puts with + // ctx.waitUntil() without needing ctx passed at construction time. const headersCtx = headersContextFromRequest(request); - const __uCtx = _createUnifiedCtx({ - headersContext: headersCtx, - executionContext: ctx ?? _getRequestExecutionContext() ?? null, - }); - return _runWithUnifiedCtx(__uCtx, async () => { - _ensureFetchPatch(); - const __reqCtx = requestContextFromRequest(request); - // 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); - // 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); - let pathname; - try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } - - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); - for (const h of extraHeaders) { - // Use append() for headers where multiple values must coexist - // (Vary, Set-Cookie). Using set() on these would destroy - // existing values like "Vary: RSC, Accept" which are critical - // for correct CDN caching behavior. - const lk = h.key.toLowerCase(); - if (lk === "vary" || lk === "set-cookie") { - response.headers.append(h.key, h.value); - } else if (!response.headers.has(lk)) { - // Middleware headers take precedence: skip config keys already - // set by middleware so middleware headers always win. - response.headers.set(h.key, h.value); - } - } - } - } - return response; - }); + const _run = () => runWithHeadersContext(headersCtx, () => + _runWithNavigationContext(() => + _runWithCacheState(() => + _runWithPrivateCache(() => + runWithFetchCache(async () => { + const __reqCtx = requestContextFromRequest(request); + // 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); + // 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); + let pathname; + try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } + + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + for (const h of extraHeaders) { + // Use append() for headers where multiple values must coexist + // (Vary, Set-Cookie). Using set() on these would destroy + // existing values like "Vary: RSC, Accept" which are critical + // for correct CDN caching behavior. + const lk = h.key.toLowerCase(); + if (lk === "vary" || lk === "set-cookie") { + response.headers.append(h.key, h.value); + } else if (!response.headers.has(lk)) { + // Middleware headers take precedence: skip config keys already + // set by middleware so middleware headers always win. + response.headers.set(h.key, h.value); + } + } + } + } + return response; + }) + ) + ) + ) + ); + return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } async function _handleRequest(request, __reqCtx, _mwCtx) { @@ -1823,38 +1849,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { - // ── Prerender: static-params endpoint ──────────────────────────────── - // Internal endpoint used by prerenderApp() during build to fetch - // generateStaticParams results via wrangler unstable_startWorker. - // Gated on VINEXT_PRERENDER=1 to prevent exposure in normal deployments. - // For Node builds, process.env.VINEXT_PRERENDER is set directly by the - // prerender orchestrator. For CF Workers builds, wrangler unstable_startWorker - // injects VINEXT_PRERENDER as a binding which Miniflare exposes via process.env - // in bundled workers. The /__vinext/ prefix ensures no user route ever conflicts. - if (pathname === "/__vinext/prerender/static-params") { - if (process.env.VINEXT_PRERENDER !== "1") { - return new Response("Not Found", { status: 404 }); - } - const pattern = url.searchParams.get("pattern"); - if (!pattern) return new Response("missing pattern", { status: 400 }); - const fn = generateStaticParamsMap[pattern]; - if (typeof fn !== "function") return new Response("null", { status: 200, headers: { "content-type": "application/json" } }); - try { - const parentParams = url.searchParams.get("parentParams"); - const raw = parentParams ? JSON.parse(parentParams) : {}; - // Ensure params is a plain object — reject primitives, arrays, and null - // so user-authored generateStaticParams always receives { params: {} } - // rather than { params: 5 } or similar if input is malformed. - const params = (typeof raw === "object" && raw !== null && !Array.isArray(raw)) ? raw : {}; - const result = await fn({ params }); - return new Response(JSON.stringify(result), { status: 200, headers: { "content-type": "application/json" } }); - } catch (e) { - return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers: { "content-type": "application/json" } }); - } - } - - - // Trailing slash normalization (redirect to canonical form) const __tsRedirect = normalizeTrailingSlash(pathname, __basePath, __trailingSlash, url.search); if (__tsRedirect) return __tsRedirect; @@ -1949,53 +1943,45 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Skip — the base servedUrl is not served when generateSitemaps exists continue; } - // Match metadata route — use pattern matching for dynamic segments, - // strict equality for static paths. - var _metaParams = null; - if (metaRoute.patternParts) { - var _metaUrlParts = cleanPathname.split("/").filter(Boolean); - _metaParams = matchPattern(_metaUrlParts, metaRoute.patternParts); - if (!_metaParams) continue; - } else if (cleanPathname !== metaRoute.servedUrl) { - continue; - } - if (metaRoute.isDynamic) { - // Dynamic metadata route — call the default export and serialize - const metaFn = metaRoute.module.default; - if (typeof metaFn === "function") { - const result = await metaFn({ params: makeThenableParams(_metaParams || {}) }); - let body; - // If it's already a Response (e.g., ImageResponse), return directly - if (result instanceof Response) return result; - // Serialize based on type - if (metaRoute.type === "sitemap") body = sitemapToXml(result); - else if (metaRoute.type === "robots") body = robotsToText(result); - else if (metaRoute.type === "manifest") body = manifestToJson(result); - else body = JSON.stringify(result); - return new Response(body, { - headers: { "Content-Type": metaRoute.contentType }, - }); - } - } else { - // Static metadata file — decode from embedded base64 data - try { - const binary = atob(metaRoute.fileDataBase64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new Response(bytes, { - headers: { - "Content-Type": metaRoute.contentType, - "Cache-Control": "public, max-age=0, must-revalidate", - }, - }); - } catch { - return new Response("Not Found", { status: 404 }); + if (cleanPathname === metaRoute.servedUrl) { + if (metaRoute.isDynamic) { + // Dynamic metadata route — call the default export and serialize + const metaFn = metaRoute.module.default; + if (typeof metaFn === "function") { + const result = await metaFn(); + let body; + // If it's already a Response (e.g., ImageResponse), return directly + if (result instanceof Response) return result; + // Serialize based on type + if (metaRoute.type === "sitemap") body = sitemapToXml(result); + else if (metaRoute.type === "robots") body = robotsToText(result); + else if (metaRoute.type === "manifest") body = manifestToJson(result); + else body = JSON.stringify(result); + return new Response(body, { + headers: { "Content-Type": metaRoute.contentType }, + }); + } + } else { + // Static metadata file — decode from embedded base64 data + try { + const binary = atob(metaRoute.fileDataBase64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new Response(bytes, { + headers: { + "Content-Type": metaRoute.contentType, + "Cache-Control": "public, max-age=0, must-revalidate", + }, + }); + } catch { + return new Response("Not Found", { status: 404 }); + } } } } // Set navigation context for Server Components. - // Note: Headers context is already set by runWithRequestContext in the handler wrapper. + // Note: Headers context is already set by runWithHeadersContext in the handler wrapper. setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, @@ -2087,21 +2073,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // and receive a page HTML instead of RSC stream). Instead, we return a 200 // with x-action-redirect header that the client entry detects and handles. if (actionRedirect) { - const actionPendingCookies = getAndClearPendingCookies(); - const actionDraftCookie = getDraftModeCookieHeader(); + const actionRenderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - const redirectHeaders = new Headers({ + const redirectHeaders = __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", "x-action-redirect": actionRedirect.url, "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), - }); - for (const cookie of actionPendingCookies) { - redirectHeaders.append("Set-Cookie", cookie); - } - if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); + }, actionRenderHeaders); // Send an empty RSC-like body (client will navigate instead of parsing) return new Response("", { status: 200, headers: redirectHeaders }); } @@ -2135,28 +2116,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Collect cookies set during the action synchronously (before stream is consumed). // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithRequestContext + // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext // handles cleanup naturally when all async continuations complete. - const actionPendingCookies = getAndClearPendingCookies(); - const actionDraftCookie = getDraftModeCookieHeader(); - - const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; - const actionResponse = new Response(rscStream, { headers: actionHeaders }); - if (actionPendingCookies.length > 0 || actionDraftCookie) { - for (const cookie of actionPendingCookies) { - actionResponse.headers.append("Set-Cookie", cookie); - } - if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie); - } - return actionResponse; + const actionRenderHeaders = consumeRenderResponseHeaders(); + + const actionHeaders = __headersWithRenderResponseHeaders({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }, actionRenderHeaders); + return new Response(rscStream, { headers: actionHeaders }); } catch (err) { - getAndClearPendingCookies(); // Clear pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error console.error("[vinext] Server action error:", err); _reportRequestError( err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: cleanPathname, routeType: "action" }, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report server action error:", reportErr); + }); setHeadersContext(null); setNavigationContext(null); return new Response( @@ -2198,7 +2176,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (!match) { - // Render custom not-found page if available, otherwise plain 404 const notFoundResponse = await renderNotFoundPage(null, isRscRequest, request); if (notFoundResponse) return notFoundResponse; @@ -2220,16 +2197,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (route.routeHandler) { const handler = route.routeHandler; const method = request.method.toUpperCase(); - const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 && handler.revalidate !== Infinity ? handler.revalidate : null; - if (typeof handler["default"] === "function" && process.env.NODE_ENV === "development") { - console.error( - "[vinext] Detected default export in route handler " + route.pattern + ". Export a named export for each HTTP method instead.", - ); - } + const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 ? handler.revalidate : null; // Collect exported HTTP methods for OPTIONS auto-response and Allow header - const exportedMethods = collectRouteHandlerMethods(handler); - const allowHeaderForOptions = buildRouteHandlerAllowHeader(exportedMethods); + const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; + const exportedMethods = HTTP_METHODS.filter((m) => typeof handler[m] === "function"); + // If GET is exported, HEAD is implicitly supported + if (exportedMethods.includes("GET") && !exportedMethods.includes("HEAD")) { + exportedMethods.push("HEAD"); + } + const hasDefault = typeof handler["default"] === "function"; // Route handlers need the same middleware header/status merge behavior as // page responses. This keeps middleware response headers visible on API @@ -2241,12 +2218,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // return is skipped, but the copy loop below is a no-op, so no incorrect // headers are added. The allocation cost in that case is acceptable. if (!_mwCtx.headers && _mwCtx.status == null) return response; - const responseHeaders = new Headers(response.headers); - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - responseHeaders.append(key, value); - } - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers); + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); return new Response(response.body, { status: _mwCtx.status ?? response.status, statusText: response.statusText, @@ -2256,107 +2229,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // OPTIONS auto-implementation: respond with Allow header and 204 if (method === "OPTIONS" && typeof handler["OPTIONS"] !== "function") { + const allowMethods = hasDefault ? HTTP_METHODS : exportedMethods; + if (!allowMethods.includes("OPTIONS")) allowMethods.push("OPTIONS"); setHeadersContext(null); setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 204, - headers: { "Allow": allowHeaderForOptions }, + headers: { "Allow": allowMethods.join(", ") }, })); } // HEAD auto-implementation: run GET handler and strip body - let handlerFn = handler[method]; + let handlerFn = handler[method] || handler["default"]; let isAutoHead = false; if (method === "HEAD" && typeof handler["HEAD"] !== "function" && typeof handler["GET"] === "function") { handlerFn = handler["GET"]; isAutoHead = true; } - // ISR cache read for route handlers (production only). - // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. - // This runs before handler execution so a cache HIT skips the handler entirely. - if ( - process.env.NODE_ENV === "production" && - revalidateSeconds !== null && - handler.dynamic !== "force-dynamic" && - (method === "GET" || isAutoHead) && - typeof handlerFn === "function" - ) { - const __routeKey = __isrRouteKey(cleanPathname); - try { - const __cached = await __isrGet(__routeKey); - if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { - // HIT — return cached response immediately - const __cv = __cached.value.value; - __isrDebug?.("HIT (route)", cleanPathname); - setHeadersContext(null); - setNavigationContext(null); - const __hitHeaders = Object.assign({}, __cv.headers || {}); - __hitHeaders["X-Vinext-Cache"] = "HIT"; - __hitHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; - if (isAutoHead) { - return attachRouteHandlerMiddlewareContext(new Response(null, { status: __cv.status, headers: __hitHeaders })); - } - return attachRouteHandlerMiddlewareContext(new Response(__cv.body, { status: __cv.status, headers: __hitHeaders })); - } - if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { - // STALE — serve stale response, trigger background regeneration - const __sv = __cached.value.value; - const __revalSecs = revalidateSeconds; - const __revalHandlerFn = handlerFn; - const __revalParams = params; - const __revalUrl = request.url; - const __revalSearchParams = new URLSearchParams(url.searchParams); - __triggerBackgroundRegeneration(__routeKey, async function() { - const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalUCtx = _createUnifiedCtx({ - headersContext: __revalHeadCtx, - executionContext: _getRequestExecutionContext(), - }); - await _runWithUnifiedCtx(__revalUCtx, async () => { - _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); - const __syntheticReq = new Request(__revalUrl, { method: "GET" }); - const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); - const __regenDynamic = consumeDynamicUsage(); - setNavigationContext(null); - if (__regenDynamic) { - __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); - return; - } - const __freshBody = await __revalResponse.arrayBuffer(); - const __freshHeaders = {}; - __revalResponse.headers.forEach(function(v, k) { - if (k !== "x-vinext-cache" && k !== "cache-control") __freshHeaders[k] = v; - }); - const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __freshBody, status: __revalResponse.status, headers: __freshHeaders }, __revalSecs, __routeTags); - __isrDebug?.("route regen complete", __routeKey); - }); - }); - __isrDebug?.("STALE (route)", cleanPathname); - setHeadersContext(null); - setNavigationContext(null); - const __staleHeaders = Object.assign({}, __sv.headers || {}); - __staleHeaders["X-Vinext-Cache"] = "STALE"; - __staleHeaders["Cache-Control"] = "s-maxage=0, stale-while-revalidate"; - if (isAutoHead) { - return attachRouteHandlerMiddlewareContext(new Response(null, { status: __sv.status, headers: __staleHeaders })); - } - return attachRouteHandlerMiddlewareContext(new Response(__sv.body, { status: __sv.status, headers: __staleHeaders })); - } - } catch (__routeCacheErr) { - // Cache read failure — fall through to normal handler execution - console.error("[vinext] ISR route cache read error:", __routeCacheErr); - } - } - if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); - const handlerSetCacheControl = response.headers.has("cache-control"); // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, @@ -2365,56 +2260,20 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { revalidateSeconds !== null && !dynamicUsedInHandler && (method === "GET" || isAutoHead) && - !handlerSetCacheControl + !response.headers.has("cache-control") ) { response.headers.set("cache-control", "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"); } - // ISR cache write for route handlers (production, MISS). - // Store the raw handler response before cookie/middleware transforms - // (those are request-specific and shouldn't be cached). - if ( - process.env.NODE_ENV === "production" && - revalidateSeconds !== null && - handler.dynamic !== "force-dynamic" && - !dynamicUsedInHandler && - (method === "GET" || isAutoHead) && - !handlerSetCacheControl - ) { - response.headers.set("X-Vinext-Cache", "MISS"); - const __routeClone = response.clone(); - const __routeKey = __isrRouteKey(cleanPathname); - const __revalSecs = revalidateSeconds; - const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - const __routeWritePromise = (async () => { - try { - const __buf = await __routeClone.arrayBuffer(); - const __hdrs = {}; - __routeClone.headers.forEach(function(v, k) { - if (k !== "x-vinext-cache" && k !== "cache-control") __hdrs[k] = v; - }); - await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __buf, status: __routeClone.status, headers: __hdrs }, __revalSecs, __routeTags); - __isrDebug?.("route cache written", __routeKey); - } catch (__cacheErr) { - console.error("[vinext] ISR route cache write error:", __cacheErr); - } - })(); - _getRequestExecutionContext()?.waitUntil(__routeWritePromise); - } - - // Collect any Set-Cookie headers from cookies().set()/delete() calls - const pendingCookies = getAndClearPendingCookies(); - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // If we have pending cookies, create a new response with them attached - if (pendingCookies.length > 0 || draftCookie) { - const newHeaders = new Headers(response.headers); - for (const cookie of pendingCookies) { - newHeaders.append("Set-Cookie", cookie); - } - if (draftCookie) newHeaders.append("Set-Cookie", draftCookie); + if (renderResponseHeaders) { + const newHeaders = __headersWithRenderResponseHeaders( + response.headers, + renderResponseHeaders, + ); if (isAutoHead) { return attachRouteHandlerMiddlewareContext(new Response(null, { @@ -2440,7 +2299,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return attachRouteHandlerMiddlewareContext(response); } catch (err) { - getAndClearPendingCookies(); // Clear any pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error // Catch redirect() / notFound() thrown from route handlers if (err && typeof err === "object" && "digest" in err) { const digest = String(err.digest); @@ -2469,7 +2328,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: route.pattern, routeType: "route" }, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report route handler error:", reportErr); + }); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 500 })); } finally { setHeadersAccessPhase(previousHeadersPhase); @@ -2479,6 +2340,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 405, + headers: { Allow: exportedMethods.join(", ") }, })); } @@ -2560,29 +2422,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HIT (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.rscData, { + return __responseWithMiddlewareContext(new Response(__cachedValue.rscData, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } if (!isRscRequest && __hasHtml) { __isrDebug?.("HIT (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.html, { + return __responseWithMiddlewareContext(new Response(__cachedValue.html, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } __isrDebug?.("MISS (empty cached entry)", cleanPathname); } @@ -2598,58 +2460,62 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // user request — to prevent user-specific cookies/auth headers from leaking // into content that is cached and served to all subsequent users. const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalUCtx = _createUnifiedCtx({ - headersContext: __revalHeadCtx, - executionContext: _getRequestExecutionContext(), - }); - const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { - _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); - const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); - const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); - // Tee RSC stream: one for SSR, one to capture rscData - const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); - // Capture rscData bytes in parallel with SSR - const __rscDataPromise = (async () => { - const __rscReader = __revalRscForCapture.getReader(); - const __rscChunks = []; - let __rscTotal = 0; - for (;;) { - const { done, value } = await __rscReader.read(); - if (done) break; - __rscChunks.push(value); - __rscTotal += value.byteLength; - } - const __rscBuf = new Uint8Array(__rscTotal); - let __rscOff = 0; - for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } - return __rscBuf.buffer; - })(); - const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; - const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); - setHeadersContext(null); - setNavigationContext(null); - // Collect the full HTML string from the stream - const __revalReader = __revalHtmlStream.getReader(); - const __revalDecoder = new TextDecoder(); - const __revalChunks = []; - for (;;) { - const { done, value } = await __revalReader.read(); - if (done) break; - __revalChunks.push(__revalDecoder.decode(value, { stream: true })); - } - __revalChunks.push(__revalDecoder.decode()); - const __freshHtml = __revalChunks.join(""); - const __freshRscData = await __rscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; - }); + const __revalResult = await runWithHeadersContext(__revalHeadCtx, () => + _runWithNavigationContext(() => + _runWithCacheState(() => + _runWithPrivateCache(() => + runWithFetchCache(async () => { + setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); + const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); + const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); + const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); + // Tee RSC stream: one for SSR, one to capture rscData + const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); + // Capture rscData bytes in parallel with SSR + const __rscDataPromise = (async () => { + const __rscReader = __revalRscForCapture.getReader(); + const __rscChunks = []; + let __rscTotal = 0; + for (;;) { + const { done, value } = await __rscReader.read(); + if (done) break; + __rscChunks.push(value); + __rscTotal += value.byteLength; + } + const __rscBuf = new Uint8Array(__rscTotal); + let __rscOff = 0; + for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } + return __rscBuf.buffer; + })(); + const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; + const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); + const __freshRscData = await __rscDataPromise; + const __renderHeaders = consumeRenderResponseHeaders(); + setHeadersContext(null); + setNavigationContext(null); + // Collect the full HTML string from the stream + const __revalReader = __revalHtmlStream.getReader(); + const __revalDecoder = new TextDecoder(); + const __revalChunks = []; + for (;;) { + const { done, value } = await __revalReader.read(); + if (done) break; + __revalChunks.push(__revalDecoder.decode(value, { stream: true })); + } + __revalChunks.push(__revalDecoder.decode()); + const __freshHtml = __revalChunks.join(""); + const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; + }) + ) + ) + ) + ); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); __isrDebug?.("regen complete", cleanPathname); }); @@ -2657,29 +2523,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("STALE (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.rscData, { + return __responseWithMiddlewareContext(new Response(__staleValue.rscData, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { __isrDebug?.("STALE (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.html, { + return __responseWithMiddlewareContext(new Response(__staleValue.html, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } // Stale entry exists but is empty for this request type — fall through to render __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); @@ -2754,7 +2620,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError }); // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithRequestContext + // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext // handles cleanup naturally when all async continuations complete. return new Response(interceptStream, { headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -2769,65 +2635,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let element; - try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); - } catch (buildErr) { - // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components - if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { - const digest = String(buildErr.digest); + // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim + async function handleRenderError(err, fallbackOpts) { + if (err && typeof err === "object" && "digest" in err) { + const digest = String(err.digest); if (digest.startsWith("NEXT_REDIRECT;")) { const parts = digest.split(";"); const redirectUrl = decodeURIComponent(parts[2]); const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); + return __responseWithMiddlewareContext(new Response(null, { + status: statusCode, + headers: { Location: new URL(redirectUrl, request.url).toString() }, + }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false }); } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + ...fallbackOpts, + }); + const renderResponseHeaders = consumeRenderResponseHeaders(); + if (fallbackResp) { + return __responseWithMiddlewareContext( + fallbackResp, + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); + } setHeadersContext(null); setNavigationContext(null); const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); + return __responseWithMiddlewareContext( + new Response(statusText, { status: statusCode }), + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); } } + return null; + } + + let element; + try { + element = await buildPageElement(route, params, interceptOpts, url.searchParams); + } catch (buildErr) { + const specialResponse = await handleRenderError(buildErr); + if (specialResponse) return specialResponse; // Non-special error (e.g. generateMetadata() threw) — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw buildErr; } + const __buildRenderResponseHeaders = peekRenderResponseHeaders(); + const __buildDynamicUsage = peekDynamicUsage(); // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - if (err && typeof err === "object" && "digest" in err) { - const digest = String(err.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); - } - } - return null; - } - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws // notFound(), the parent layout's boundary catches it and renders the parent's @@ -2853,44 +2729,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const lr = LayoutComp({ params: asyncParams, children: null }); if (lr && typeof lr === "object" && typeof lr.then === "function") await lr; } catch (layoutErr) { - if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) { - const digest = String(layoutErr.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; } - if (!parentNotFound) parentNotFound = null; - // Wrap in only the layouts above the throwing one - const parentLayouts = route.layouts.slice(0, li); - const fallbackResp = await renderHTTPAccessFallbackPage( - route, statusCode, isRscRequest, request, - { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params } - ); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); } } + if (!parentNotFound) parentNotFound = null; + // Wrap in only the layouts above the throwing one + const parentLayouts = route.layouts.slice(0, li); + const specialResponse = await handleRenderError(layoutErr, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, + }); + if (specialResponse) return specialResponse; // Not a special error — let it propagate through normal RSC rendering } } @@ -2939,6 +2797,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); if (_pageProbeResult instanceof Response) return _pageProbeResult; + // The sync pre-render probes above are only for catching redirect/notFound + // before streaming begins. Discard any render-time response headers they + // may have produced while preserving headers generated during buildPageElement + // (e.g. generateMetadata), since those are part of the real render output. + restoreRenderResponseHeaders(__buildRenderResponseHeaders); + restoreDynamicUsage(__buildDynamicUsage); + // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); @@ -2989,7 +2854,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // NOTE: Do NOT clear headers/navigation context here! // The RSC stream is consumed lazily - components render when chunks are read. // If we clear context now, headers()/cookies() will fail during rendering. - // Context will be cleared when the next request starts (via runWithRequestContext). + // Context will be cleared when the next request starts (via runWithHeadersContext). const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; // Include matched route params so the client can hydrate useParams() if (params && Object.keys(params).length > 0) { @@ -3006,37 +2871,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } else if (revalidateSeconds) { responseHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; } - // Merge middleware response headers into the RSC response. - // set-cookie and vary are accumulated to preserve existing values - // (e.g. "Vary: RSC, Accept" set above); all other keys use plain - // assignment so middleware headers win over config headers, which - // the outer handler applies afterward and skips keys already present. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - const lk = key.toLowerCase(); - if (lk === "set-cookie") { - const existing = responseHeaders[lk]; - if (Array.isArray(existing)) { - existing.push(value); - } else if (existing) { - responseHeaders[lk] = [existing, value]; - } else { - responseHeaders[lk] = [value]; - } - } else if (lk === "vary") { - // Accumulate Vary values to preserve the existing "RSC, Accept" entry. - const existing = responseHeaders["Vary"] ?? responseHeaders["vary"]; - if (existing) { - responseHeaders["Vary"] = existing + ", " + value; - if (responseHeaders["vary"] !== undefined) delete responseHeaders["vary"]; - } else { - responseHeaders[key] = value; - } - } else { - responseHeaders[key] = value; - } - } - } // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -3056,22 +2890,45 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { - responseHeaders["X-Vinext-Cache"] = "MISS"; const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; + const __responseRenderHeaders = peekRenderResponseHeaders(); + if (peekDynamicUsage()) { + responseHeaders["Cache-Control"] = "no-store, must-revalidate"; + } else { + responseHeaders["X-Vinext-Cache"] = "MISS"; + } const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? __responseRenderHeaders; + // consume picks up headers added during late async RSC streaming work. + // Falls back to the snapshot taken before the live MISS response was returned. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip RSC cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); + await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); + } finally { + setHeadersContext(null); + setNavigationContext(null); } })(); _getRequestExecutionContext()?.waitUntil(__rscWritePromise); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } - return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: responseHeaders, + }), _mwCtx); } // Collect font data from RSC environment before passing to SSR @@ -3109,7 +2966,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw ssrErr; } @@ -3119,31 +2983,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // when the error falls through to global-error.tsx. - // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response + // Helper to attach render-time response headers, middleware headers, font + // Link header, and rewrite status to a response. function attachMiddlewareContext(response) { - if (draftCookie) { - response.headers.append("Set-Cookie", draftCookie); - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderResponseHeaders); // Set HTTP Link header for font preloading if (fontLinkHeader) { - response.headers.set("Link", fontLinkHeader); - } - // Merge middleware response headers into the final response. - // The response is freshly constructed above (new Response(htmlStream, {...})), - // so set() and append() are equivalent — there are no same-key conflicts yet. - // Precedence over config headers is handled by the outer handler, which - // skips config keys that middleware already placed on the response. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - response.headers.append(key, value); - } + responseHeaders.set("Link", fontLinkHeader); } + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -3158,21 +3013,30 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const renderMs = __renderEnd !== undefined && __compileEnd !== undefined ? Math.round(__renderEnd - __compileEnd) : -1; - response.headers.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); + responseHeaders.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); } // Apply custom status code from middleware rewrite if (_mwCtx.status) { return new Response(response.body, { status: _mwCtx.status, - headers: response.headers, + headers: responseHeaders, }); } - return response; + const responseInit = { + status: response.status, + headers: responseHeaders, + }; + if (response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); } // Check if any component called connection(), cookies(), headers(), or noStore() // during rendering. If so, treat as dynamic (skip ISR, set no-store). - const dynamicUsedDuringRender = consumeDynamicUsage(); + const dynamicUsedDuringRender = __isrRscDataPromise + ? peekDynamicUsage() + : consumeDynamicUsage(); // Check if cacheLife() was called during rendering (e.g., page with file-level "use cache"). // If so, use its revalidation period for the Cache-Control header. @@ -3256,6 +3120,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? renderResponseHeaders; + // consume picks up any headers added during stream consumption by late + // async render work (for example, suspended branches). Falls back to + // the snapshot taken before streaming began when nothing new was added. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip HTML cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) @@ -3263,12 +3136,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ensuring the first client-side navigation after a direct visit is a // cache hit rather than a miss. const __writes = [ - __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags), + __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags), ]; if (__capturedRscDataPromise) { __writes.push( __capturedRscDataPromise.then((__rscBuf) => - __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags) + __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags) ) ); } @@ -3276,6 +3149,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HTML cache written", __isrKey); } catch (__cacheErr) { console.error("[vinext] ISR cache write error:", __cacheErr); + } finally { + consumeRenderResponseHeaders(); } })(); // Register with ExecutionContext (from ALS) so the Workers runtime keeps @@ -3360,7 +3235,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; +import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; @@ -3370,38 +3245,19 @@ 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 { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; -import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; +import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; +import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; +import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; +import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. -import "vinext/navigation-state"; -import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; +import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; function _getSSRFontStyles() { return [..._getSSRFontStylesGoogle(), ..._getSSRFontStylesLocal()]; } function _getSSRFontPreloads() { return [..._getSSRFontPreloadsGoogle(), ..._getSSRFontPreloadsLocal()]; } - -// Duplicated from the build-time constant above via JSON.stringify. -const ROUTE_HANDLER_HTTP_METHODS = ["GET","HEAD","POST","PUT","DELETE","PATCH","OPTIONS"]; - -function collectRouteHandlerMethods(handler) { - const methods = ROUTE_HANDLER_HTTP_METHODS.filter((method) => typeof handler[method] === "function"); - if (methods.includes("GET") && !methods.includes("HEAD")) { - methods.push("HEAD"); - } - return methods; -} - -function buildRouteHandlerAllowHeader(exportedMethods) { - const allow = new Set(exportedMethods); - allow.add("OPTIONS"); - return Array.from(allow).sort().join(", "); -} - - // ALS used to suppress the expected "Invalid hook call" dev warning when // layout/page components are probed outside React's render cycle. Patching // console.error once at module load (instead of per-request) avoids the @@ -3465,16 +3321,71 @@ function __pageCacheTags(pathname, extraTags) { } return tags; } -// Note: cache entries are written with \`headers: undefined\`. Next.js stores -// response headers (e.g. set-cookie from cookies().set() during render) in the -// cache entry so they can be replayed on HIT. We don't do this because: -// 1. Pages that call cookies().set() during render trigger dynamicUsedDuringRender, -// which opts them out of ISR caching before we reach the write path. -// 2. Custom response headers set via next/headers are not yet captured separately -// from the live Response object in vinext's server pipeline. -// In practice this means ISR-cached responses won't replay render-time set-cookie -// headers — but that case is already prevented by the dynamic-usage opt-out. -// TODO: capture render-time response headers for full Next.js parity. +function __isAppendOnlyResponseHeader(lowerKey) { + return lowerKey === "set-cookie" || lowerKey === "vary" || lowerKey === "www-authenticate" || lowerKey === "proxy-authenticate"; +} +function __mergeResponseHeaderValues(targetHeaders, key, value, mode) { + const lowerKey = key.toLowerCase(); + const values = Array.isArray(value) ? value : [value]; + if (__isAppendOnlyResponseHeader(lowerKey)) { + for (const item of values) targetHeaders.append(key, item); + return; + } + if (mode === "fallback" && targetHeaders.has(key)) return; + targetHeaders.delete(key); + if (values.length === 1) { + targetHeaders.set(key, values[0]); + return; + } + for (const item of values) targetHeaders.append(key, item); +} +function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) { + if (!sourceHeaders) return; + if (sourceHeaders instanceof Headers) { + const __setCookies = typeof sourceHeaders.getSetCookie === "function" + ? sourceHeaders.getSetCookie() + : []; + for (const [key, value] of sourceHeaders) { + if (key.toLowerCase() === "set-cookie") { + // entries() flattens Set-Cookie into a single comma-joined value. + // If getSetCookie() is unavailable, drop cookies rather than corrupt them. + continue; + } + __mergeResponseHeaderValues(targetHeaders, key, value, mode); + } + for (const cookie of __setCookies) { + targetHeaders.append("Set-Cookie", cookie); + } + return; + } + for (const [key, value] of Object.entries(sourceHeaders)) { + __mergeResponseHeaderValues(targetHeaders, key, value, mode); + } +} +function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) { + const headers = new Headers(); + __mergeResponseHeaders(headers, renderHeaders, "fallback"); + __mergeResponseHeaders(headers, baseHeaders, "override"); + return headers; +} +function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) { + __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override"); +} +function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) { + const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status; + if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response; + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderHeaders); + __applyMiddlewareResponseHeaders(responseHeaders, middlewareCtx?.headers); + const status = rewriteStatus ?? response.status; + const responseInit = { + status, + headers: responseHeaders, + }; + if (status === response.status && response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); +} const __pendingRegenerations = new Map(); function __triggerBackgroundRegeneration(key, renderFn) { if (__pendingRegenerations.has(key)) return; @@ -3522,7 +3433,6 @@ function __isrCacheKey(pathname, suffix) { } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } -function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators // have a single knob for all cache tracing. @@ -3675,7 +3585,9 @@ function rscOnError(error, requestInfo, errorContext) { error instanceof Error ? error : new Error(String(error)), requestInfo, errorContext, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report render error:", reportErr); + }); } // In production, generate a digest hash for non-navigation errors @@ -3912,7 +3824,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. + // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. return new Response(rscStream, { status: statusCode, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -4045,7 +3957,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. + // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. return new Response(rscStream, { status: 200, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -4457,16 +4369,7 @@ function __validateDevRequestOrigin(request) { } const origin = request.headers.get("origin"); - if (!origin) return null; - - // Origin "null" is sent by opaque/sandboxed contexts. Block unless explicitly allowed. - if (origin === "null") { - if (!__allowedDevOrigins.includes("null")) { - console.warn("[vinext] Blocked request with Origin: null. Add \\"null\\" to allowedDevOrigins to allow sandboxed contexts."); - return new Response("Forbidden", { status: 403, headers: { "Content-Type": "text/plain" } }); - } - return null; - } + if (!origin || origin === "null") return null; let originHostname; try { @@ -4717,64 +4620,62 @@ async function __readFormDataWithLimit(request, maxBytes) { return new Response(combined, { headers: { "Content-Type": contentType } }).formData(); } -// Map from route pattern to generateStaticParams function. -// Used by the prerender phase to enumerate dynamic route URLs without -// loading route modules via the dev server. -export const generateStaticParamsMap = { -// TODO: layout-level generateStaticParams — this map only includes routes that -// have a pagePath (leaf pages). Layout segments can also export generateStaticParams -// to provide parent params for nested dynamic routes, but they don't have a pagePath -// so they are excluded here. Supporting layout-level generateStaticParams requires -// scanning layout.tsx files separately and including them in this map. - "/blog/:slug": mod_3?.generateStaticParams ?? null, -}; - export default async function handler(request, ctx) { - // Wrap the entire request in a single unified ALS scope for per-request - // isolation. All state modules (headers, navigation, cache, fetch-cache, - // execution-context) read from this store via isInsideUnifiedScope(). + // 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 + // streaming), preventing state leakage between concurrent requests on + // Cloudflare Workers and other concurrent runtimes. + // + // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so + // that KVCacheHandler._putInBackground can register background KV puts with + // ctx.waitUntil() without needing ctx passed at construction time. const headersCtx = headersContextFromRequest(request); - const __uCtx = _createUnifiedCtx({ - headersContext: headersCtx, - executionContext: ctx ?? _getRequestExecutionContext() ?? null, - }); - return _runWithUnifiedCtx(__uCtx, async () => { - _ensureFetchPatch(); - const __reqCtx = requestContextFromRequest(request); - // 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); - // 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); - let pathname; - try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } - if (pathname.startsWith("/base")) pathname = pathname.slice("/base".length) || "/"; - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); - for (const h of extraHeaders) { - // Use append() for headers where multiple values must coexist - // (Vary, Set-Cookie). Using set() on these would destroy - // existing values like "Vary: RSC, Accept" which are critical - // for correct CDN caching behavior. - const lk = h.key.toLowerCase(); - if (lk === "vary" || lk === "set-cookie") { - response.headers.append(h.key, h.value); - } else if (!response.headers.has(lk)) { - // Middleware headers take precedence: skip config keys already - // set by middleware so middleware headers always win. - response.headers.set(h.key, h.value); - } - } - } - } - return response; - }); + const _run = () => runWithHeadersContext(headersCtx, () => + _runWithNavigationContext(() => + _runWithCacheState(() => + _runWithPrivateCache(() => + runWithFetchCache(async () => { + const __reqCtx = requestContextFromRequest(request); + // 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); + // 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); + let pathname; + try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } + if (pathname.startsWith("/base")) pathname = pathname.slice("/base".length) || "/"; + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + for (const h of extraHeaders) { + // Use append() for headers where multiple values must coexist + // (Vary, Set-Cookie). Using set() on these would destroy + // existing values like "Vary: RSC, Accept" which are critical + // for correct CDN caching behavior. + const lk = h.key.toLowerCase(); + if (lk === "vary" || lk === "set-cookie") { + response.headers.append(h.key, h.value); + } else if (!response.headers.has(lk)) { + // Middleware headers take precedence: skip config keys already + // set by middleware so middleware headers always win. + response.headers.set(h.key, h.value); + } + } + } + } + return response; + }) + ) + ) + ) + ); + return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } async function _handleRequest(request, __reqCtx, _mwCtx) { @@ -4812,38 +4713,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { pathname = stripBasePath(pathname, __basePath); - // ── Prerender: static-params endpoint ──────────────────────────────── - // Internal endpoint used by prerenderApp() during build to fetch - // generateStaticParams results via wrangler unstable_startWorker. - // Gated on VINEXT_PRERENDER=1 to prevent exposure in normal deployments. - // For Node builds, process.env.VINEXT_PRERENDER is set directly by the - // prerender orchestrator. For CF Workers builds, wrangler unstable_startWorker - // injects VINEXT_PRERENDER as a binding which Miniflare exposes via process.env - // in bundled workers. The /__vinext/ prefix ensures no user route ever conflicts. - if (pathname === "/__vinext/prerender/static-params") { - if (process.env.VINEXT_PRERENDER !== "1") { - return new Response("Not Found", { status: 404 }); - } - const pattern = url.searchParams.get("pattern"); - if (!pattern) return new Response("missing pattern", { status: 400 }); - const fn = generateStaticParamsMap[pattern]; - if (typeof fn !== "function") return new Response("null", { status: 200, headers: { "content-type": "application/json" } }); - try { - const parentParams = url.searchParams.get("parentParams"); - const raw = parentParams ? JSON.parse(parentParams) : {}; - // Ensure params is a plain object — reject primitives, arrays, and null - // so user-authored generateStaticParams always receives { params: {} } - // rather than { params: 5 } or similar if input is malformed. - const params = (typeof raw === "object" && raw !== null && !Array.isArray(raw)) ? raw : {}; - const result = await fn({ params }); - return new Response(JSON.stringify(result), { status: 200, headers: { "content-type": "application/json" } }); - } catch (e) { - return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers: { "content-type": "application/json" } }); - } - } - - - // Trailing slash normalization (redirect to canonical form) const __tsRedirect = normalizeTrailingSlash(pathname, __basePath, __trailingSlash, url.search); if (__tsRedirect) return __tsRedirect; @@ -4938,53 +4807,45 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Skip — the base servedUrl is not served when generateSitemaps exists continue; } - // Match metadata route — use pattern matching for dynamic segments, - // strict equality for static paths. - var _metaParams = null; - if (metaRoute.patternParts) { - var _metaUrlParts = cleanPathname.split("/").filter(Boolean); - _metaParams = matchPattern(_metaUrlParts, metaRoute.patternParts); - if (!_metaParams) continue; - } else if (cleanPathname !== metaRoute.servedUrl) { - continue; - } - if (metaRoute.isDynamic) { - // Dynamic metadata route — call the default export and serialize - const metaFn = metaRoute.module.default; - if (typeof metaFn === "function") { - const result = await metaFn({ params: makeThenableParams(_metaParams || {}) }); - let body; - // If it's already a Response (e.g., ImageResponse), return directly - if (result instanceof Response) return result; - // Serialize based on type - if (metaRoute.type === "sitemap") body = sitemapToXml(result); - else if (metaRoute.type === "robots") body = robotsToText(result); - else if (metaRoute.type === "manifest") body = manifestToJson(result); - else body = JSON.stringify(result); - return new Response(body, { - headers: { "Content-Type": metaRoute.contentType }, - }); - } - } else { - // Static metadata file — decode from embedded base64 data - try { - const binary = atob(metaRoute.fileDataBase64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new Response(bytes, { - headers: { - "Content-Type": metaRoute.contentType, - "Cache-Control": "public, max-age=0, must-revalidate", - }, - }); - } catch { - return new Response("Not Found", { status: 404 }); + if (cleanPathname === metaRoute.servedUrl) { + if (metaRoute.isDynamic) { + // Dynamic metadata route — call the default export and serialize + const metaFn = metaRoute.module.default; + if (typeof metaFn === "function") { + const result = await metaFn(); + let body; + // If it's already a Response (e.g., ImageResponse), return directly + if (result instanceof Response) return result; + // Serialize based on type + if (metaRoute.type === "sitemap") body = sitemapToXml(result); + else if (metaRoute.type === "robots") body = robotsToText(result); + else if (metaRoute.type === "manifest") body = manifestToJson(result); + else body = JSON.stringify(result); + return new Response(body, { + headers: { "Content-Type": metaRoute.contentType }, + }); + } + } else { + // Static metadata file — decode from embedded base64 data + try { + const binary = atob(metaRoute.fileDataBase64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new Response(bytes, { + headers: { + "Content-Type": metaRoute.contentType, + "Cache-Control": "public, max-age=0, must-revalidate", + }, + }); + } catch { + return new Response("Not Found", { status: 404 }); + } } } } // Set navigation context for Server Components. - // Note: Headers context is already set by runWithRequestContext in the handler wrapper. + // Note: Headers context is already set by runWithHeadersContext in the handler wrapper. setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, @@ -5076,21 +4937,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // and receive a page HTML instead of RSC stream). Instead, we return a 200 // with x-action-redirect header that the client entry detects and handles. if (actionRedirect) { - const actionPendingCookies = getAndClearPendingCookies(); - const actionDraftCookie = getDraftModeCookieHeader(); + const actionRenderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - const redirectHeaders = new Headers({ + const redirectHeaders = __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", "x-action-redirect": actionRedirect.url, "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), - }); - for (const cookie of actionPendingCookies) { - redirectHeaders.append("Set-Cookie", cookie); - } - if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); + }, actionRenderHeaders); // Send an empty RSC-like body (client will navigate instead of parsing) return new Response("", { status: 200, headers: redirectHeaders }); } @@ -5124,28 +4980,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Collect cookies set during the action synchronously (before stream is consumed). // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithRequestContext + // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext // handles cleanup naturally when all async continuations complete. - const actionPendingCookies = getAndClearPendingCookies(); - const actionDraftCookie = getDraftModeCookieHeader(); - - const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; - const actionResponse = new Response(rscStream, { headers: actionHeaders }); - if (actionPendingCookies.length > 0 || actionDraftCookie) { - for (const cookie of actionPendingCookies) { - actionResponse.headers.append("Set-Cookie", cookie); - } - if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie); - } - return actionResponse; + const actionRenderHeaders = consumeRenderResponseHeaders(); + + const actionHeaders = __headersWithRenderResponseHeaders({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }, actionRenderHeaders); + return new Response(rscStream, { headers: actionHeaders }); } catch (err) { - getAndClearPendingCookies(); // Clear pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error console.error("[vinext] Server action error:", err); _reportRequestError( err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: cleanPathname, routeType: "action" }, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report server action error:", reportErr); + }); setHeadersContext(null); setNavigationContext(null); return new Response( @@ -5187,7 +5040,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (!match) { - // Render custom not-found page if available, otherwise plain 404 const notFoundResponse = await renderNotFoundPage(null, isRscRequest, request); if (notFoundResponse) return notFoundResponse; @@ -5209,16 +5061,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (route.routeHandler) { const handler = route.routeHandler; const method = request.method.toUpperCase(); - const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 && handler.revalidate !== Infinity ? handler.revalidate : null; - if (typeof handler["default"] === "function" && process.env.NODE_ENV === "development") { - console.error( - "[vinext] Detected default export in route handler " + route.pattern + ". Export a named export for each HTTP method instead.", - ); - } + const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 ? handler.revalidate : null; // Collect exported HTTP methods for OPTIONS auto-response and Allow header - const exportedMethods = collectRouteHandlerMethods(handler); - const allowHeaderForOptions = buildRouteHandlerAllowHeader(exportedMethods); + const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; + const exportedMethods = HTTP_METHODS.filter((m) => typeof handler[m] === "function"); + // If GET is exported, HEAD is implicitly supported + if (exportedMethods.includes("GET") && !exportedMethods.includes("HEAD")) { + exportedMethods.push("HEAD"); + } + const hasDefault = typeof handler["default"] === "function"; // Route handlers need the same middleware header/status merge behavior as // page responses. This keeps middleware response headers visible on API @@ -5230,12 +5082,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // return is skipped, but the copy loop below is a no-op, so no incorrect // headers are added. The allocation cost in that case is acceptable. if (!_mwCtx.headers && _mwCtx.status == null) return response; - const responseHeaders = new Headers(response.headers); - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - responseHeaders.append(key, value); - } - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers); + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); return new Response(response.body, { status: _mwCtx.status ?? response.status, statusText: response.statusText, @@ -5245,107 +5093,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // OPTIONS auto-implementation: respond with Allow header and 204 if (method === "OPTIONS" && typeof handler["OPTIONS"] !== "function") { + const allowMethods = hasDefault ? HTTP_METHODS : exportedMethods; + if (!allowMethods.includes("OPTIONS")) allowMethods.push("OPTIONS"); setHeadersContext(null); setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 204, - headers: { "Allow": allowHeaderForOptions }, + headers: { "Allow": allowMethods.join(", ") }, })); } // HEAD auto-implementation: run GET handler and strip body - let handlerFn = handler[method]; + let handlerFn = handler[method] || handler["default"]; let isAutoHead = false; if (method === "HEAD" && typeof handler["HEAD"] !== "function" && typeof handler["GET"] === "function") { handlerFn = handler["GET"]; isAutoHead = true; } - // ISR cache read for route handlers (production only). - // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. - // This runs before handler execution so a cache HIT skips the handler entirely. - if ( - process.env.NODE_ENV === "production" && - revalidateSeconds !== null && - handler.dynamic !== "force-dynamic" && - (method === "GET" || isAutoHead) && - typeof handlerFn === "function" - ) { - const __routeKey = __isrRouteKey(cleanPathname); - try { - const __cached = await __isrGet(__routeKey); - if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { - // HIT — return cached response immediately - const __cv = __cached.value.value; - __isrDebug?.("HIT (route)", cleanPathname); - setHeadersContext(null); - setNavigationContext(null); - const __hitHeaders = Object.assign({}, __cv.headers || {}); - __hitHeaders["X-Vinext-Cache"] = "HIT"; - __hitHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; - if (isAutoHead) { - return attachRouteHandlerMiddlewareContext(new Response(null, { status: __cv.status, headers: __hitHeaders })); - } - return attachRouteHandlerMiddlewareContext(new Response(__cv.body, { status: __cv.status, headers: __hitHeaders })); - } - if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { - // STALE — serve stale response, trigger background regeneration - const __sv = __cached.value.value; - const __revalSecs = revalidateSeconds; - const __revalHandlerFn = handlerFn; - const __revalParams = params; - const __revalUrl = request.url; - const __revalSearchParams = new URLSearchParams(url.searchParams); - __triggerBackgroundRegeneration(__routeKey, async function() { - const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalUCtx = _createUnifiedCtx({ - headersContext: __revalHeadCtx, - executionContext: _getRequestExecutionContext(), - }); - await _runWithUnifiedCtx(__revalUCtx, async () => { - _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); - const __syntheticReq = new Request(__revalUrl, { method: "GET" }); - const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); - const __regenDynamic = consumeDynamicUsage(); - setNavigationContext(null); - if (__regenDynamic) { - __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); - return; - } - const __freshBody = await __revalResponse.arrayBuffer(); - const __freshHeaders = {}; - __revalResponse.headers.forEach(function(v, k) { - if (k !== "x-vinext-cache" && k !== "cache-control") __freshHeaders[k] = v; - }); - const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __freshBody, status: __revalResponse.status, headers: __freshHeaders }, __revalSecs, __routeTags); - __isrDebug?.("route regen complete", __routeKey); - }); - }); - __isrDebug?.("STALE (route)", cleanPathname); - setHeadersContext(null); - setNavigationContext(null); - const __staleHeaders = Object.assign({}, __sv.headers || {}); - __staleHeaders["X-Vinext-Cache"] = "STALE"; - __staleHeaders["Cache-Control"] = "s-maxage=0, stale-while-revalidate"; - if (isAutoHead) { - return attachRouteHandlerMiddlewareContext(new Response(null, { status: __sv.status, headers: __staleHeaders })); - } - return attachRouteHandlerMiddlewareContext(new Response(__sv.body, { status: __sv.status, headers: __staleHeaders })); - } - } catch (__routeCacheErr) { - // Cache read failure — fall through to normal handler execution - console.error("[vinext] ISR route cache read error:", __routeCacheErr); - } - } - if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); - const handlerSetCacheControl = response.headers.has("cache-control"); // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, @@ -5354,56 +5124,20 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { revalidateSeconds !== null && !dynamicUsedInHandler && (method === "GET" || isAutoHead) && - !handlerSetCacheControl + !response.headers.has("cache-control") ) { response.headers.set("cache-control", "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"); } - // ISR cache write for route handlers (production, MISS). - // Store the raw handler response before cookie/middleware transforms - // (those are request-specific and shouldn't be cached). - if ( - process.env.NODE_ENV === "production" && - revalidateSeconds !== null && - handler.dynamic !== "force-dynamic" && - !dynamicUsedInHandler && - (method === "GET" || isAutoHead) && - !handlerSetCacheControl - ) { - response.headers.set("X-Vinext-Cache", "MISS"); - const __routeClone = response.clone(); - const __routeKey = __isrRouteKey(cleanPathname); - const __revalSecs = revalidateSeconds; - const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - const __routeWritePromise = (async () => { - try { - const __buf = await __routeClone.arrayBuffer(); - const __hdrs = {}; - __routeClone.headers.forEach(function(v, k) { - if (k !== "x-vinext-cache" && k !== "cache-control") __hdrs[k] = v; - }); - await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __buf, status: __routeClone.status, headers: __hdrs }, __revalSecs, __routeTags); - __isrDebug?.("route cache written", __routeKey); - } catch (__cacheErr) { - console.error("[vinext] ISR route cache write error:", __cacheErr); - } - })(); - _getRequestExecutionContext()?.waitUntil(__routeWritePromise); - } - - // Collect any Set-Cookie headers from cookies().set()/delete() calls - const pendingCookies = getAndClearPendingCookies(); - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // If we have pending cookies, create a new response with them attached - if (pendingCookies.length > 0 || draftCookie) { - const newHeaders = new Headers(response.headers); - for (const cookie of pendingCookies) { - newHeaders.append("Set-Cookie", cookie); - } - if (draftCookie) newHeaders.append("Set-Cookie", draftCookie); + if (renderResponseHeaders) { + const newHeaders = __headersWithRenderResponseHeaders( + response.headers, + renderResponseHeaders, + ); if (isAutoHead) { return attachRouteHandlerMiddlewareContext(new Response(null, { @@ -5429,7 +5163,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return attachRouteHandlerMiddlewareContext(response); } catch (err) { - getAndClearPendingCookies(); // Clear any pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error // Catch redirect() / notFound() thrown from route handlers if (err && typeof err === "object" && "digest" in err) { const digest = String(err.digest); @@ -5458,7 +5192,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: route.pattern, routeType: "route" }, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report route handler error:", reportErr); + }); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 500 })); } finally { setHeadersAccessPhase(previousHeadersPhase); @@ -5468,6 +5204,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 405, + headers: { Allow: exportedMethods.join(", ") }, })); } @@ -5549,29 +5286,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HIT (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.rscData, { + return __responseWithMiddlewareContext(new Response(__cachedValue.rscData, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } if (!isRscRequest && __hasHtml) { __isrDebug?.("HIT (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.html, { + return __responseWithMiddlewareContext(new Response(__cachedValue.html, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } __isrDebug?.("MISS (empty cached entry)", cleanPathname); } @@ -5587,58 +5324,62 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // user request — to prevent user-specific cookies/auth headers from leaking // into content that is cached and served to all subsequent users. const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalUCtx = _createUnifiedCtx({ - headersContext: __revalHeadCtx, - executionContext: _getRequestExecutionContext(), - }); - const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { - _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); - const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); - const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); - // Tee RSC stream: one for SSR, one to capture rscData - const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); - // Capture rscData bytes in parallel with SSR - const __rscDataPromise = (async () => { - const __rscReader = __revalRscForCapture.getReader(); - const __rscChunks = []; - let __rscTotal = 0; - for (;;) { - const { done, value } = await __rscReader.read(); - if (done) break; - __rscChunks.push(value); - __rscTotal += value.byteLength; - } - const __rscBuf = new Uint8Array(__rscTotal); - let __rscOff = 0; - for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } - return __rscBuf.buffer; - })(); - const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; - const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); - setHeadersContext(null); - setNavigationContext(null); - // Collect the full HTML string from the stream - const __revalReader = __revalHtmlStream.getReader(); - const __revalDecoder = new TextDecoder(); - const __revalChunks = []; - for (;;) { - const { done, value } = await __revalReader.read(); - if (done) break; - __revalChunks.push(__revalDecoder.decode(value, { stream: true })); - } - __revalChunks.push(__revalDecoder.decode()); - const __freshHtml = __revalChunks.join(""); - const __freshRscData = await __rscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; - }); + const __revalResult = await runWithHeadersContext(__revalHeadCtx, () => + _runWithNavigationContext(() => + _runWithCacheState(() => + _runWithPrivateCache(() => + runWithFetchCache(async () => { + setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); + const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); + const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); + const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); + // Tee RSC stream: one for SSR, one to capture rscData + const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); + // Capture rscData bytes in parallel with SSR + const __rscDataPromise = (async () => { + const __rscReader = __revalRscForCapture.getReader(); + const __rscChunks = []; + let __rscTotal = 0; + for (;;) { + const { done, value } = await __rscReader.read(); + if (done) break; + __rscChunks.push(value); + __rscTotal += value.byteLength; + } + const __rscBuf = new Uint8Array(__rscTotal); + let __rscOff = 0; + for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } + return __rscBuf.buffer; + })(); + const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; + const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); + const __freshRscData = await __rscDataPromise; + const __renderHeaders = consumeRenderResponseHeaders(); + setHeadersContext(null); + setNavigationContext(null); + // Collect the full HTML string from the stream + const __revalReader = __revalHtmlStream.getReader(); + const __revalDecoder = new TextDecoder(); + const __revalChunks = []; + for (;;) { + const { done, value } = await __revalReader.read(); + if (done) break; + __revalChunks.push(__revalDecoder.decode(value, { stream: true })); + } + __revalChunks.push(__revalDecoder.decode()); + const __freshHtml = __revalChunks.join(""); + const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; + }) + ) + ) + ) + ); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); __isrDebug?.("regen complete", cleanPathname); }); @@ -5646,29 +5387,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("STALE (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.rscData, { + return __responseWithMiddlewareContext(new Response(__staleValue.rscData, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { __isrDebug?.("STALE (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.html, { + return __responseWithMiddlewareContext(new Response(__staleValue.html, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } // Stale entry exists but is empty for this request type — fall through to render __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); @@ -5743,7 +5484,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError }); // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithRequestContext + // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext // handles cleanup naturally when all async continuations complete. return new Response(interceptStream, { headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -5758,65 +5499,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let element; - try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); - } catch (buildErr) { - // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components - if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { - const digest = String(buildErr.digest); + // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim + async function handleRenderError(err, fallbackOpts) { + if (err && typeof err === "object" && "digest" in err) { + const digest = String(err.digest); if (digest.startsWith("NEXT_REDIRECT;")) { const parts = digest.split(";"); const redirectUrl = decodeURIComponent(parts[2]); const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); + return __responseWithMiddlewareContext(new Response(null, { + status: statusCode, + headers: { Location: new URL(redirectUrl, request.url).toString() }, + }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false }); } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + ...fallbackOpts, + }); + const renderResponseHeaders = consumeRenderResponseHeaders(); + if (fallbackResp) { + return __responseWithMiddlewareContext( + fallbackResp, + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); + } setHeadersContext(null); setNavigationContext(null); const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); + return __responseWithMiddlewareContext( + new Response(statusText, { status: statusCode }), + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); } } + return null; + } + + let element; + try { + element = await buildPageElement(route, params, interceptOpts, url.searchParams); + } catch (buildErr) { + const specialResponse = await handleRenderError(buildErr); + if (specialResponse) return specialResponse; // Non-special error (e.g. generateMetadata() threw) — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw buildErr; } + const __buildRenderResponseHeaders = peekRenderResponseHeaders(); + const __buildDynamicUsage = peekDynamicUsage(); // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - if (err && typeof err === "object" && "digest" in err) { - const digest = String(err.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); - } - } - return null; - } - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws // notFound(), the parent layout's boundary catches it and renders the parent's @@ -5842,44 +5593,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const lr = LayoutComp({ params: asyncParams, children: null }); if (lr && typeof lr === "object" && typeof lr.then === "function") await lr; } catch (layoutErr) { - if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) { - const digest = String(layoutErr.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; } - if (!parentNotFound) parentNotFound = null; - // Wrap in only the layouts above the throwing one - const parentLayouts = route.layouts.slice(0, li); - const fallbackResp = await renderHTTPAccessFallbackPage( - route, statusCode, isRscRequest, request, - { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params } - ); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); } } + if (!parentNotFound) parentNotFound = null; + // Wrap in only the layouts above the throwing one + const parentLayouts = route.layouts.slice(0, li); + const specialResponse = await handleRenderError(layoutErr, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, + }); + if (specialResponse) return specialResponse; // Not a special error — let it propagate through normal RSC rendering } } @@ -5928,6 +5661,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); if (_pageProbeResult instanceof Response) return _pageProbeResult; + // The sync pre-render probes above are only for catching redirect/notFound + // before streaming begins. Discard any render-time response headers they + // may have produced while preserving headers generated during buildPageElement + // (e.g. generateMetadata), since those are part of the real render output. + restoreRenderResponseHeaders(__buildRenderResponseHeaders); + restoreDynamicUsage(__buildDynamicUsage); + // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); @@ -5978,7 +5718,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // NOTE: Do NOT clear headers/navigation context here! // The RSC stream is consumed lazily - components render when chunks are read. // If we clear context now, headers()/cookies() will fail during rendering. - // Context will be cleared when the next request starts (via runWithRequestContext). + // Context will be cleared when the next request starts (via runWithHeadersContext). const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; // Include matched route params so the client can hydrate useParams() if (params && Object.keys(params).length > 0) { @@ -5995,37 +5735,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } else if (revalidateSeconds) { responseHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; } - // Merge middleware response headers into the RSC response. - // set-cookie and vary are accumulated to preserve existing values - // (e.g. "Vary: RSC, Accept" set above); all other keys use plain - // assignment so middleware headers win over config headers, which - // the outer handler applies afterward and skips keys already present. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - const lk = key.toLowerCase(); - if (lk === "set-cookie") { - const existing = responseHeaders[lk]; - if (Array.isArray(existing)) { - existing.push(value); - } else if (existing) { - responseHeaders[lk] = [existing, value]; - } else { - responseHeaders[lk] = [value]; - } - } else if (lk === "vary") { - // Accumulate Vary values to preserve the existing "RSC, Accept" entry. - const existing = responseHeaders["Vary"] ?? responseHeaders["vary"]; - if (existing) { - responseHeaders["Vary"] = existing + ", " + value; - if (responseHeaders["vary"] !== undefined) delete responseHeaders["vary"]; - } else { - responseHeaders[key] = value; - } - } else { - responseHeaders[key] = value; - } - } - } // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -6045,22 +5754,45 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { - responseHeaders["X-Vinext-Cache"] = "MISS"; const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; + const __responseRenderHeaders = peekRenderResponseHeaders(); + if (peekDynamicUsage()) { + responseHeaders["Cache-Control"] = "no-store, must-revalidate"; + } else { + responseHeaders["X-Vinext-Cache"] = "MISS"; + } const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? __responseRenderHeaders; + // consume picks up headers added during late async RSC streaming work. + // Falls back to the snapshot taken before the live MISS response was returned. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip RSC cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); + await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); + } finally { + setHeadersContext(null); + setNavigationContext(null); } })(); _getRequestExecutionContext()?.waitUntil(__rscWritePromise); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } - return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: responseHeaders, + }), _mwCtx); } // Collect font data from RSC environment before passing to SSR @@ -6098,7 +5830,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw ssrErr; } @@ -6108,31 +5847,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // when the error falls through to global-error.tsx. - // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response + // Helper to attach render-time response headers, middleware headers, font + // Link header, and rewrite status to a response. function attachMiddlewareContext(response) { - if (draftCookie) { - response.headers.append("Set-Cookie", draftCookie); - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderResponseHeaders); // Set HTTP Link header for font preloading if (fontLinkHeader) { - response.headers.set("Link", fontLinkHeader); - } - // Merge middleware response headers into the final response. - // The response is freshly constructed above (new Response(htmlStream, {...})), - // so set() and append() are equivalent — there are no same-key conflicts yet. - // Precedence over config headers is handled by the outer handler, which - // skips config keys that middleware already placed on the response. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - response.headers.append(key, value); - } + responseHeaders.set("Link", fontLinkHeader); } + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -6147,21 +5877,30 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const renderMs = __renderEnd !== undefined && __compileEnd !== undefined ? Math.round(__renderEnd - __compileEnd) : -1; - response.headers.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); + responseHeaders.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); } // Apply custom status code from middleware rewrite if (_mwCtx.status) { return new Response(response.body, { status: _mwCtx.status, - headers: response.headers, + headers: responseHeaders, }); } - return response; + const responseInit = { + status: response.status, + headers: responseHeaders, + }; + if (response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); } // Check if any component called connection(), cookies(), headers(), or noStore() // during rendering. If so, treat as dynamic (skip ISR, set no-store). - const dynamicUsedDuringRender = consumeDynamicUsage(); + const dynamicUsedDuringRender = __isrRscDataPromise + ? peekDynamicUsage() + : consumeDynamicUsage(); // Check if cacheLife() was called during rendering (e.g., page with file-level "use cache"). // If so, use its revalidation period for the Cache-Control header. @@ -6245,6 +5984,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? renderResponseHeaders; + // consume picks up any headers added during stream consumption by late + // async render work (for example, suspended branches). Falls back to + // the snapshot taken before streaming began when nothing new was added. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip HTML cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) @@ -6252,12 +6000,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ensuring the first client-side navigation after a direct visit is a // cache hit rather than a miss. const __writes = [ - __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags), + __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags), ]; if (__capturedRscDataPromise) { __writes.push( __capturedRscDataPromise.then((__rscBuf) => - __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags) + __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags) ) ); } @@ -6265,6 +6013,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HTML cache written", __isrKey); } catch (__cacheErr) { console.error("[vinext] ISR cache write error:", __cacheErr); + } finally { + consumeRenderResponseHeaders(); } })(); // Register with ExecutionContext (from ALS) so the Workers runtime keeps @@ -6349,7 +6099,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; +import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; @@ -6359,38 +6109,19 @@ 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 { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; -import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; +import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; +import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; +import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; +import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. -import "vinext/navigation-state"; -import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; +import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; function _getSSRFontStyles() { return [..._getSSRFontStylesGoogle(), ..._getSSRFontStylesLocal()]; } function _getSSRFontPreloads() { return [..._getSSRFontPreloadsGoogle(), ..._getSSRFontPreloadsLocal()]; } - -// Duplicated from the build-time constant above via JSON.stringify. -const ROUTE_HANDLER_HTTP_METHODS = ["GET","HEAD","POST","PUT","DELETE","PATCH","OPTIONS"]; - -function collectRouteHandlerMethods(handler) { - const methods = ROUTE_HANDLER_HTTP_METHODS.filter((method) => typeof handler[method] === "function"); - if (methods.includes("GET") && !methods.includes("HEAD")) { - methods.push("HEAD"); - } - return methods; -} - -function buildRouteHandlerAllowHeader(exportedMethods) { - const allow = new Set(exportedMethods); - allow.add("OPTIONS"); - return Array.from(allow).sort().join(", "); -} - - // ALS used to suppress the expected "Invalid hook call" dev warning when // layout/page components are probed outside React's render cycle. Patching // console.error once at module load (instead of per-request) avoids the @@ -6454,16 +6185,71 @@ function __pageCacheTags(pathname, extraTags) { } return tags; } -// Note: cache entries are written with \`headers: undefined\`. Next.js stores -// response headers (e.g. set-cookie from cookies().set() during render) in the -// cache entry so they can be replayed on HIT. We don't do this because: -// 1. Pages that call cookies().set() during render trigger dynamicUsedDuringRender, -// which opts them out of ISR caching before we reach the write path. -// 2. Custom response headers set via next/headers are not yet captured separately -// from the live Response object in vinext's server pipeline. -// In practice this means ISR-cached responses won't replay render-time set-cookie -// headers — but that case is already prevented by the dynamic-usage opt-out. -// TODO: capture render-time response headers for full Next.js parity. +function __isAppendOnlyResponseHeader(lowerKey) { + return lowerKey === "set-cookie" || lowerKey === "vary" || lowerKey === "www-authenticate" || lowerKey === "proxy-authenticate"; +} +function __mergeResponseHeaderValues(targetHeaders, key, value, mode) { + const lowerKey = key.toLowerCase(); + const values = Array.isArray(value) ? value : [value]; + if (__isAppendOnlyResponseHeader(lowerKey)) { + for (const item of values) targetHeaders.append(key, item); + return; + } + if (mode === "fallback" && targetHeaders.has(key)) return; + targetHeaders.delete(key); + if (values.length === 1) { + targetHeaders.set(key, values[0]); + return; + } + for (const item of values) targetHeaders.append(key, item); +} +function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) { + if (!sourceHeaders) return; + if (sourceHeaders instanceof Headers) { + const __setCookies = typeof sourceHeaders.getSetCookie === "function" + ? sourceHeaders.getSetCookie() + : []; + for (const [key, value] of sourceHeaders) { + if (key.toLowerCase() === "set-cookie") { + // entries() flattens Set-Cookie into a single comma-joined value. + // If getSetCookie() is unavailable, drop cookies rather than corrupt them. + continue; + } + __mergeResponseHeaderValues(targetHeaders, key, value, mode); + } + for (const cookie of __setCookies) { + targetHeaders.append("Set-Cookie", cookie); + } + return; + } + for (const [key, value] of Object.entries(sourceHeaders)) { + __mergeResponseHeaderValues(targetHeaders, key, value, mode); + } +} +function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) { + const headers = new Headers(); + __mergeResponseHeaders(headers, renderHeaders, "fallback"); + __mergeResponseHeaders(headers, baseHeaders, "override"); + return headers; +} +function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) { + __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override"); +} +function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) { + const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status; + if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response; + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderHeaders); + __applyMiddlewareResponseHeaders(responseHeaders, middlewareCtx?.headers); + const status = rewriteStatus ?? response.status; + const responseInit = { + status, + headers: responseHeaders, + }; + if (status === response.status && response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); +} const __pendingRegenerations = new Map(); function __triggerBackgroundRegeneration(key, renderFn) { if (__pendingRegenerations.has(key)) return; @@ -6511,7 +6297,6 @@ function __isrCacheKey(pathname, suffix) { } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } -function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators // have a single knob for all cache tracing. @@ -6664,7 +6449,9 @@ function rscOnError(error, requestInfo, errorContext) { error instanceof Error ? error : new Error(String(error)), requestInfo, errorContext, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report render error:", reportErr); + }); } // In production, generate a digest hash for non-navigation errors @@ -6910,7 +6697,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. + // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. return new Response(rscStream, { status: statusCode, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -7056,7 +6843,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. + // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. return new Response(rscStream, { status: 200, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -7476,16 +7263,7 @@ function __validateDevRequestOrigin(request) { } const origin = request.headers.get("origin"); - if (!origin) return null; - - // Origin "null" is sent by opaque/sandboxed contexts. Block unless explicitly allowed. - if (origin === "null") { - if (!__allowedDevOrigins.includes("null")) { - console.warn("[vinext] Blocked request with Origin: null. Add \\"null\\" to allowedDevOrigins to allow sandboxed contexts."); - return new Response("Forbidden", { status: 403, headers: { "Content-Type": "text/plain" } }); - } - return null; - } + if (!origin || origin === "null") return null; let originHostname; try { @@ -7736,64 +7514,62 @@ async function __readFormDataWithLimit(request, maxBytes) { return new Response(combined, { headers: { "Content-Type": contentType } }).formData(); } -// Map from route pattern to generateStaticParams function. -// Used by the prerender phase to enumerate dynamic route URLs without -// loading route modules via the dev server. -export const generateStaticParamsMap = { -// TODO: layout-level generateStaticParams — this map only includes routes that -// have a pagePath (leaf pages). Layout segments can also export generateStaticParams -// to provide parent params for nested dynamic routes, but they don't have a pagePath -// so they are excluded here. Supporting layout-level generateStaticParams requires -// scanning layout.tsx files separately and including them in this map. - "/blog/:slug": mod_3?.generateStaticParams ?? null, -}; - export default async function handler(request, ctx) { - // Wrap the entire request in a single unified ALS scope for per-request - // isolation. All state modules (headers, navigation, cache, fetch-cache, - // execution-context) read from this store via isInsideUnifiedScope(). + // 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 + // streaming), preventing state leakage between concurrent requests on + // Cloudflare Workers and other concurrent runtimes. + // + // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so + // that KVCacheHandler._putInBackground can register background KV puts with + // ctx.waitUntil() without needing ctx passed at construction time. const headersCtx = headersContextFromRequest(request); - const __uCtx = _createUnifiedCtx({ - headersContext: headersCtx, - executionContext: ctx ?? _getRequestExecutionContext() ?? null, - }); - return _runWithUnifiedCtx(__uCtx, async () => { - _ensureFetchPatch(); - const __reqCtx = requestContextFromRequest(request); - // 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); - // 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); - let pathname; - try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } - - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); - for (const h of extraHeaders) { - // Use append() for headers where multiple values must coexist - // (Vary, Set-Cookie). Using set() on these would destroy - // existing values like "Vary: RSC, Accept" which are critical - // for correct CDN caching behavior. - const lk = h.key.toLowerCase(); - if (lk === "vary" || lk === "set-cookie") { - response.headers.append(h.key, h.value); - } else if (!response.headers.has(lk)) { - // Middleware headers take precedence: skip config keys already - // set by middleware so middleware headers always win. - response.headers.set(h.key, h.value); - } - } - } - } - return response; - }); + const _run = () => runWithHeadersContext(headersCtx, () => + _runWithNavigationContext(() => + _runWithCacheState(() => + _runWithPrivateCache(() => + runWithFetchCache(async () => { + const __reqCtx = requestContextFromRequest(request); + // 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); + // 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); + let pathname; + try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } + + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + for (const h of extraHeaders) { + // Use append() for headers where multiple values must coexist + // (Vary, Set-Cookie). Using set() on these would destroy + // existing values like "Vary: RSC, Accept" which are critical + // for correct CDN caching behavior. + const lk = h.key.toLowerCase(); + if (lk === "vary" || lk === "set-cookie") { + response.headers.append(h.key, h.value); + } else if (!response.headers.has(lk)) { + // Middleware headers take precedence: skip config keys already + // set by middleware so middleware headers always win. + response.headers.set(h.key, h.value); + } + } + } + } + return response; + }) + ) + ) + ) + ); + return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } async function _handleRequest(request, __reqCtx, _mwCtx) { @@ -7828,38 +7604,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { - // ── Prerender: static-params endpoint ──────────────────────────────── - // Internal endpoint used by prerenderApp() during build to fetch - // generateStaticParams results via wrangler unstable_startWorker. - // Gated on VINEXT_PRERENDER=1 to prevent exposure in normal deployments. - // For Node builds, process.env.VINEXT_PRERENDER is set directly by the - // prerender orchestrator. For CF Workers builds, wrangler unstable_startWorker - // injects VINEXT_PRERENDER as a binding which Miniflare exposes via process.env - // in bundled workers. The /__vinext/ prefix ensures no user route ever conflicts. - if (pathname === "/__vinext/prerender/static-params") { - if (process.env.VINEXT_PRERENDER !== "1") { - return new Response("Not Found", { status: 404 }); - } - const pattern = url.searchParams.get("pattern"); - if (!pattern) return new Response("missing pattern", { status: 400 }); - const fn = generateStaticParamsMap[pattern]; - if (typeof fn !== "function") return new Response("null", { status: 200, headers: { "content-type": "application/json" } }); - try { - const parentParams = url.searchParams.get("parentParams"); - const raw = parentParams ? JSON.parse(parentParams) : {}; - // Ensure params is a plain object — reject primitives, arrays, and null - // so user-authored generateStaticParams always receives { params: {} } - // rather than { params: 5 } or similar if input is malformed. - const params = (typeof raw === "object" && raw !== null && !Array.isArray(raw)) ? raw : {}; - const result = await fn({ params }); - return new Response(JSON.stringify(result), { status: 200, headers: { "content-type": "application/json" } }); - } catch (e) { - return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers: { "content-type": "application/json" } }); - } - } - - - // Trailing slash normalization (redirect to canonical form) const __tsRedirect = normalizeTrailingSlash(pathname, __basePath, __trailingSlash, url.search); if (__tsRedirect) return __tsRedirect; @@ -7954,53 +7698,45 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Skip — the base servedUrl is not served when generateSitemaps exists continue; } - // Match metadata route — use pattern matching for dynamic segments, - // strict equality for static paths. - var _metaParams = null; - if (metaRoute.patternParts) { - var _metaUrlParts = cleanPathname.split("/").filter(Boolean); - _metaParams = matchPattern(_metaUrlParts, metaRoute.patternParts); - if (!_metaParams) continue; - } else if (cleanPathname !== metaRoute.servedUrl) { - continue; - } - if (metaRoute.isDynamic) { - // Dynamic metadata route — call the default export and serialize - const metaFn = metaRoute.module.default; - if (typeof metaFn === "function") { - const result = await metaFn({ params: makeThenableParams(_metaParams || {}) }); - let body; - // If it's already a Response (e.g., ImageResponse), return directly - if (result instanceof Response) return result; - // Serialize based on type - if (metaRoute.type === "sitemap") body = sitemapToXml(result); - else if (metaRoute.type === "robots") body = robotsToText(result); - else if (metaRoute.type === "manifest") body = manifestToJson(result); - else body = JSON.stringify(result); - return new Response(body, { - headers: { "Content-Type": metaRoute.contentType }, - }); - } - } else { - // Static metadata file — decode from embedded base64 data - try { - const binary = atob(metaRoute.fileDataBase64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new Response(bytes, { - headers: { - "Content-Type": metaRoute.contentType, - "Cache-Control": "public, max-age=0, must-revalidate", - }, - }); - } catch { - return new Response("Not Found", { status: 404 }); + if (cleanPathname === metaRoute.servedUrl) { + if (metaRoute.isDynamic) { + // Dynamic metadata route — call the default export and serialize + const metaFn = metaRoute.module.default; + if (typeof metaFn === "function") { + const result = await metaFn(); + let body; + // If it's already a Response (e.g., ImageResponse), return directly + if (result instanceof Response) return result; + // Serialize based on type + if (metaRoute.type === "sitemap") body = sitemapToXml(result); + else if (metaRoute.type === "robots") body = robotsToText(result); + else if (metaRoute.type === "manifest") body = manifestToJson(result); + else body = JSON.stringify(result); + return new Response(body, { + headers: { "Content-Type": metaRoute.contentType }, + }); + } + } else { + // Static metadata file — decode from embedded base64 data + try { + const binary = atob(metaRoute.fileDataBase64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new Response(bytes, { + headers: { + "Content-Type": metaRoute.contentType, + "Cache-Control": "public, max-age=0, must-revalidate", + }, + }); + } catch { + return new Response("Not Found", { status: 404 }); + } } } } // Set navigation context for Server Components. - // Note: Headers context is already set by runWithRequestContext in the handler wrapper. + // Note: Headers context is already set by runWithHeadersContext in the handler wrapper. setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, @@ -8092,21 +7828,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // and receive a page HTML instead of RSC stream). Instead, we return a 200 // with x-action-redirect header that the client entry detects and handles. if (actionRedirect) { - const actionPendingCookies = getAndClearPendingCookies(); - const actionDraftCookie = getDraftModeCookieHeader(); + const actionRenderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - const redirectHeaders = new Headers({ + const redirectHeaders = __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", "x-action-redirect": actionRedirect.url, "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), - }); - for (const cookie of actionPendingCookies) { - redirectHeaders.append("Set-Cookie", cookie); - } - if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); + }, actionRenderHeaders); // Send an empty RSC-like body (client will navigate instead of parsing) return new Response("", { status: 200, headers: redirectHeaders }); } @@ -8140,28 +7871,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Collect cookies set during the action synchronously (before stream is consumed). // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithRequestContext + // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext // handles cleanup naturally when all async continuations complete. - const actionPendingCookies = getAndClearPendingCookies(); - const actionDraftCookie = getDraftModeCookieHeader(); - - const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; - const actionResponse = new Response(rscStream, { headers: actionHeaders }); - if (actionPendingCookies.length > 0 || actionDraftCookie) { - for (const cookie of actionPendingCookies) { - actionResponse.headers.append("Set-Cookie", cookie); - } - if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie); - } - return actionResponse; + const actionRenderHeaders = consumeRenderResponseHeaders(); + + const actionHeaders = __headersWithRenderResponseHeaders({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }, actionRenderHeaders); + return new Response(rscStream, { headers: actionHeaders }); } catch (err) { - getAndClearPendingCookies(); // Clear pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error console.error("[vinext] Server action error:", err); _reportRequestError( err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: cleanPathname, routeType: "action" }, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report server action error:", reportErr); + }); setHeadersContext(null); setNavigationContext(null); return new Response( @@ -8203,7 +7931,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (!match) { - // Render custom not-found page if available, otherwise plain 404 const notFoundResponse = await renderNotFoundPage(null, isRscRequest, request); if (notFoundResponse) return notFoundResponse; @@ -8225,16 +7952,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (route.routeHandler) { const handler = route.routeHandler; const method = request.method.toUpperCase(); - const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 && handler.revalidate !== Infinity ? handler.revalidate : null; - if (typeof handler["default"] === "function" && process.env.NODE_ENV === "development") { - console.error( - "[vinext] Detected default export in route handler " + route.pattern + ". Export a named export for each HTTP method instead.", - ); - } + const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 ? handler.revalidate : null; // Collect exported HTTP methods for OPTIONS auto-response and Allow header - const exportedMethods = collectRouteHandlerMethods(handler); - const allowHeaderForOptions = buildRouteHandlerAllowHeader(exportedMethods); + const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; + const exportedMethods = HTTP_METHODS.filter((m) => typeof handler[m] === "function"); + // If GET is exported, HEAD is implicitly supported + if (exportedMethods.includes("GET") && !exportedMethods.includes("HEAD")) { + exportedMethods.push("HEAD"); + } + const hasDefault = typeof handler["default"] === "function"; // Route handlers need the same middleware header/status merge behavior as // page responses. This keeps middleware response headers visible on API @@ -8246,12 +7973,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // return is skipped, but the copy loop below is a no-op, so no incorrect // headers are added. The allocation cost in that case is acceptable. if (!_mwCtx.headers && _mwCtx.status == null) return response; - const responseHeaders = new Headers(response.headers); - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - responseHeaders.append(key, value); - } - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers); + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); return new Response(response.body, { status: _mwCtx.status ?? response.status, statusText: response.statusText, @@ -8261,107 +7984,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // OPTIONS auto-implementation: respond with Allow header and 204 if (method === "OPTIONS" && typeof handler["OPTIONS"] !== "function") { + const allowMethods = hasDefault ? HTTP_METHODS : exportedMethods; + if (!allowMethods.includes("OPTIONS")) allowMethods.push("OPTIONS"); setHeadersContext(null); setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 204, - headers: { "Allow": allowHeaderForOptions }, + headers: { "Allow": allowMethods.join(", ") }, })); } // HEAD auto-implementation: run GET handler and strip body - let handlerFn = handler[method]; + let handlerFn = handler[method] || handler["default"]; let isAutoHead = false; if (method === "HEAD" && typeof handler["HEAD"] !== "function" && typeof handler["GET"] === "function") { handlerFn = handler["GET"]; isAutoHead = true; } - // ISR cache read for route handlers (production only). - // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. - // This runs before handler execution so a cache HIT skips the handler entirely. - if ( - process.env.NODE_ENV === "production" && - revalidateSeconds !== null && - handler.dynamic !== "force-dynamic" && - (method === "GET" || isAutoHead) && - typeof handlerFn === "function" - ) { - const __routeKey = __isrRouteKey(cleanPathname); - try { - const __cached = await __isrGet(__routeKey); - if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { - // HIT — return cached response immediately - const __cv = __cached.value.value; - __isrDebug?.("HIT (route)", cleanPathname); - setHeadersContext(null); - setNavigationContext(null); - const __hitHeaders = Object.assign({}, __cv.headers || {}); - __hitHeaders["X-Vinext-Cache"] = "HIT"; - __hitHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; - if (isAutoHead) { - return attachRouteHandlerMiddlewareContext(new Response(null, { status: __cv.status, headers: __hitHeaders })); - } - return attachRouteHandlerMiddlewareContext(new Response(__cv.body, { status: __cv.status, headers: __hitHeaders })); - } - if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { - // STALE — serve stale response, trigger background regeneration - const __sv = __cached.value.value; - const __revalSecs = revalidateSeconds; - const __revalHandlerFn = handlerFn; - const __revalParams = params; - const __revalUrl = request.url; - const __revalSearchParams = new URLSearchParams(url.searchParams); - __triggerBackgroundRegeneration(__routeKey, async function() { - const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalUCtx = _createUnifiedCtx({ - headersContext: __revalHeadCtx, - executionContext: _getRequestExecutionContext(), - }); - await _runWithUnifiedCtx(__revalUCtx, async () => { - _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); - const __syntheticReq = new Request(__revalUrl, { method: "GET" }); - const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); - const __regenDynamic = consumeDynamicUsage(); - setNavigationContext(null); - if (__regenDynamic) { - __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); - return; - } - const __freshBody = await __revalResponse.arrayBuffer(); - const __freshHeaders = {}; - __revalResponse.headers.forEach(function(v, k) { - if (k !== "x-vinext-cache" && k !== "cache-control") __freshHeaders[k] = v; - }); - const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __freshBody, status: __revalResponse.status, headers: __freshHeaders }, __revalSecs, __routeTags); - __isrDebug?.("route regen complete", __routeKey); - }); - }); - __isrDebug?.("STALE (route)", cleanPathname); - setHeadersContext(null); - setNavigationContext(null); - const __staleHeaders = Object.assign({}, __sv.headers || {}); - __staleHeaders["X-Vinext-Cache"] = "STALE"; - __staleHeaders["Cache-Control"] = "s-maxage=0, stale-while-revalidate"; - if (isAutoHead) { - return attachRouteHandlerMiddlewareContext(new Response(null, { status: __sv.status, headers: __staleHeaders })); - } - return attachRouteHandlerMiddlewareContext(new Response(__sv.body, { status: __sv.status, headers: __staleHeaders })); - } - } catch (__routeCacheErr) { - // Cache read failure — fall through to normal handler execution - console.error("[vinext] ISR route cache read error:", __routeCacheErr); - } - } - if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); - const handlerSetCacheControl = response.headers.has("cache-control"); // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, @@ -8370,56 +8015,20 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { revalidateSeconds !== null && !dynamicUsedInHandler && (method === "GET" || isAutoHead) && - !handlerSetCacheControl + !response.headers.has("cache-control") ) { response.headers.set("cache-control", "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"); } - // ISR cache write for route handlers (production, MISS). - // Store the raw handler response before cookie/middleware transforms - // (those are request-specific and shouldn't be cached). - if ( - process.env.NODE_ENV === "production" && - revalidateSeconds !== null && - handler.dynamic !== "force-dynamic" && - !dynamicUsedInHandler && - (method === "GET" || isAutoHead) && - !handlerSetCacheControl - ) { - response.headers.set("X-Vinext-Cache", "MISS"); - const __routeClone = response.clone(); - const __routeKey = __isrRouteKey(cleanPathname); - const __revalSecs = revalidateSeconds; - const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - const __routeWritePromise = (async () => { - try { - const __buf = await __routeClone.arrayBuffer(); - const __hdrs = {}; - __routeClone.headers.forEach(function(v, k) { - if (k !== "x-vinext-cache" && k !== "cache-control") __hdrs[k] = v; - }); - await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __buf, status: __routeClone.status, headers: __hdrs }, __revalSecs, __routeTags); - __isrDebug?.("route cache written", __routeKey); - } catch (__cacheErr) { - console.error("[vinext] ISR route cache write error:", __cacheErr); - } - })(); - _getRequestExecutionContext()?.waitUntil(__routeWritePromise); - } - - // Collect any Set-Cookie headers from cookies().set()/delete() calls - const pendingCookies = getAndClearPendingCookies(); - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // If we have pending cookies, create a new response with them attached - if (pendingCookies.length > 0 || draftCookie) { - const newHeaders = new Headers(response.headers); - for (const cookie of pendingCookies) { - newHeaders.append("Set-Cookie", cookie); - } - if (draftCookie) newHeaders.append("Set-Cookie", draftCookie); + if (renderResponseHeaders) { + const newHeaders = __headersWithRenderResponseHeaders( + response.headers, + renderResponseHeaders, + ); if (isAutoHead) { return attachRouteHandlerMiddlewareContext(new Response(null, { @@ -8445,7 +8054,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return attachRouteHandlerMiddlewareContext(response); } catch (err) { - getAndClearPendingCookies(); // Clear any pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error // Catch redirect() / notFound() thrown from route handlers if (err && typeof err === "object" && "digest" in err) { const digest = String(err.digest); @@ -8474,7 +8083,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: route.pattern, routeType: "route" }, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report route handler error:", reportErr); + }); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 500 })); } finally { setHeadersAccessPhase(previousHeadersPhase); @@ -8484,6 +8095,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 405, + headers: { Allow: exportedMethods.join(", ") }, })); } @@ -8565,29 +8177,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HIT (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.rscData, { + return __responseWithMiddlewareContext(new Response(__cachedValue.rscData, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } if (!isRscRequest && __hasHtml) { __isrDebug?.("HIT (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.html, { + return __responseWithMiddlewareContext(new Response(__cachedValue.html, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } __isrDebug?.("MISS (empty cached entry)", cleanPathname); } @@ -8603,58 +8215,62 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // user request — to prevent user-specific cookies/auth headers from leaking // into content that is cached and served to all subsequent users. const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalUCtx = _createUnifiedCtx({ - headersContext: __revalHeadCtx, - executionContext: _getRequestExecutionContext(), - }); - const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { - _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); - const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); - const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); - // Tee RSC stream: one for SSR, one to capture rscData - const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); - // Capture rscData bytes in parallel with SSR - const __rscDataPromise = (async () => { - const __rscReader = __revalRscForCapture.getReader(); - const __rscChunks = []; - let __rscTotal = 0; - for (;;) { - const { done, value } = await __rscReader.read(); - if (done) break; - __rscChunks.push(value); - __rscTotal += value.byteLength; - } - const __rscBuf = new Uint8Array(__rscTotal); - let __rscOff = 0; - for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } - return __rscBuf.buffer; - })(); - const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; - const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); - setHeadersContext(null); - setNavigationContext(null); - // Collect the full HTML string from the stream - const __revalReader = __revalHtmlStream.getReader(); - const __revalDecoder = new TextDecoder(); - const __revalChunks = []; - for (;;) { - const { done, value } = await __revalReader.read(); - if (done) break; - __revalChunks.push(__revalDecoder.decode(value, { stream: true })); - } - __revalChunks.push(__revalDecoder.decode()); - const __freshHtml = __revalChunks.join(""); - const __freshRscData = await __rscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; - }); + const __revalResult = await runWithHeadersContext(__revalHeadCtx, () => + _runWithNavigationContext(() => + _runWithCacheState(() => + _runWithPrivateCache(() => + runWithFetchCache(async () => { + setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); + const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); + const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); + const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); + // Tee RSC stream: one for SSR, one to capture rscData + const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); + // Capture rscData bytes in parallel with SSR + const __rscDataPromise = (async () => { + const __rscReader = __revalRscForCapture.getReader(); + const __rscChunks = []; + let __rscTotal = 0; + for (;;) { + const { done, value } = await __rscReader.read(); + if (done) break; + __rscChunks.push(value); + __rscTotal += value.byteLength; + } + const __rscBuf = new Uint8Array(__rscTotal); + let __rscOff = 0; + for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } + return __rscBuf.buffer; + })(); + const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; + const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); + const __freshRscData = await __rscDataPromise; + const __renderHeaders = consumeRenderResponseHeaders(); + setHeadersContext(null); + setNavigationContext(null); + // Collect the full HTML string from the stream + const __revalReader = __revalHtmlStream.getReader(); + const __revalDecoder = new TextDecoder(); + const __revalChunks = []; + for (;;) { + const { done, value } = await __revalReader.read(); + if (done) break; + __revalChunks.push(__revalDecoder.decode(value, { stream: true })); + } + __revalChunks.push(__revalDecoder.decode()); + const __freshHtml = __revalChunks.join(""); + const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; + }) + ) + ) + ) + ); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); __isrDebug?.("regen complete", cleanPathname); }); @@ -8662,29 +8278,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("STALE (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.rscData, { + return __responseWithMiddlewareContext(new Response(__staleValue.rscData, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { __isrDebug?.("STALE (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.html, { + return __responseWithMiddlewareContext(new Response(__staleValue.html, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } // Stale entry exists but is empty for this request type — fall through to render __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); @@ -8759,7 +8375,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError }); // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithRequestContext + // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext // handles cleanup naturally when all async continuations complete. return new Response(interceptStream, { headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -8774,65 +8390,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let element; - try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); - } catch (buildErr) { - // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components - if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { - const digest = String(buildErr.digest); + // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim + async function handleRenderError(err, fallbackOpts) { + if (err && typeof err === "object" && "digest" in err) { + const digest = String(err.digest); if (digest.startsWith("NEXT_REDIRECT;")) { const parts = digest.split(";"); const redirectUrl = decodeURIComponent(parts[2]); const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); + return __responseWithMiddlewareContext(new Response(null, { + status: statusCode, + headers: { Location: new URL(redirectUrl, request.url).toString() }, + }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false }); } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + ...fallbackOpts, + }); + const renderResponseHeaders = consumeRenderResponseHeaders(); + if (fallbackResp) { + return __responseWithMiddlewareContext( + fallbackResp, + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); + } setHeadersContext(null); setNavigationContext(null); const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); + return __responseWithMiddlewareContext( + new Response(statusText, { status: statusCode }), + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); } } + return null; + } + + let element; + try { + element = await buildPageElement(route, params, interceptOpts, url.searchParams); + } catch (buildErr) { + const specialResponse = await handleRenderError(buildErr); + if (specialResponse) return specialResponse; // Non-special error (e.g. generateMetadata() threw) — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw buildErr; } + const __buildRenderResponseHeaders = peekRenderResponseHeaders(); + const __buildDynamicUsage = peekDynamicUsage(); // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - if (err && typeof err === "object" && "digest" in err) { - const digest = String(err.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); - } - } - return null; - } - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws // notFound(), the parent layout's boundary catches it and renders the parent's @@ -8858,44 +8484,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const lr = LayoutComp({ params: asyncParams, children: null }); if (lr && typeof lr === "object" && typeof lr.then === "function") await lr; } catch (layoutErr) { - if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) { - const digest = String(layoutErr.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; } - if (!parentNotFound) parentNotFound = null; - // Wrap in only the layouts above the throwing one - const parentLayouts = route.layouts.slice(0, li); - const fallbackResp = await renderHTTPAccessFallbackPage( - route, statusCode, isRscRequest, request, - { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params } - ); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); } } + if (!parentNotFound) parentNotFound = null; + // Wrap in only the layouts above the throwing one + const parentLayouts = route.layouts.slice(0, li); + const specialResponse = await handleRenderError(layoutErr, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, + }); + if (specialResponse) return specialResponse; // Not a special error — let it propagate through normal RSC rendering } } @@ -8944,6 +8552,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); if (_pageProbeResult instanceof Response) return _pageProbeResult; + // The sync pre-render probes above are only for catching redirect/notFound + // before streaming begins. Discard any render-time response headers they + // may have produced while preserving headers generated during buildPageElement + // (e.g. generateMetadata), since those are part of the real render output. + restoreRenderResponseHeaders(__buildRenderResponseHeaders); + restoreDynamicUsage(__buildDynamicUsage); + // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); @@ -8994,7 +8609,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // NOTE: Do NOT clear headers/navigation context here! // The RSC stream is consumed lazily - components render when chunks are read. // If we clear context now, headers()/cookies() will fail during rendering. - // Context will be cleared when the next request starts (via runWithRequestContext). + // Context will be cleared when the next request starts (via runWithHeadersContext). const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; // Include matched route params so the client can hydrate useParams() if (params && Object.keys(params).length > 0) { @@ -9011,37 +8626,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } else if (revalidateSeconds) { responseHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; } - // Merge middleware response headers into the RSC response. - // set-cookie and vary are accumulated to preserve existing values - // (e.g. "Vary: RSC, Accept" set above); all other keys use plain - // assignment so middleware headers win over config headers, which - // the outer handler applies afterward and skips keys already present. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - const lk = key.toLowerCase(); - if (lk === "set-cookie") { - const existing = responseHeaders[lk]; - if (Array.isArray(existing)) { - existing.push(value); - } else if (existing) { - responseHeaders[lk] = [existing, value]; - } else { - responseHeaders[lk] = [value]; - } - } else if (lk === "vary") { - // Accumulate Vary values to preserve the existing "RSC, Accept" entry. - const existing = responseHeaders["Vary"] ?? responseHeaders["vary"]; - if (existing) { - responseHeaders["Vary"] = existing + ", " + value; - if (responseHeaders["vary"] !== undefined) delete responseHeaders["vary"]; - } else { - responseHeaders[key] = value; - } - } else { - responseHeaders[key] = value; - } - } - } // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -9061,22 +8645,45 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { - responseHeaders["X-Vinext-Cache"] = "MISS"; const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; + const __responseRenderHeaders = peekRenderResponseHeaders(); + if (peekDynamicUsage()) { + responseHeaders["Cache-Control"] = "no-store, must-revalidate"; + } else { + responseHeaders["X-Vinext-Cache"] = "MISS"; + } const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? __responseRenderHeaders; + // consume picks up headers added during late async RSC streaming work. + // Falls back to the snapshot taken before the live MISS response was returned. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip RSC cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); + await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); + } finally { + setHeadersContext(null); + setNavigationContext(null); } })(); _getRequestExecutionContext()?.waitUntil(__rscWritePromise); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } - return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: responseHeaders, + }), _mwCtx); } // Collect font data from RSC environment before passing to SSR @@ -9114,7 +8721,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw ssrErr; } @@ -9127,36 +8741,34 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const _hasLocalBoundary = !!(route?.error?.default) || !!(route?.errors && route.errors.some(function(e) { return e?.default; })); if (!_hasLocalBoundary) { const cleanResp = await renderErrorBoundaryPage(route, _rscErrorForRerender, false, request, params); - if (cleanResp) return cleanResp; + if (cleanResp) { + return __responseWithMiddlewareContext( + cleanResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } } } - // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response + // Helper to attach render-time response headers, middleware headers, font + // Link header, and rewrite status to a response. function attachMiddlewareContext(response) { - if (draftCookie) { - response.headers.append("Set-Cookie", draftCookie); - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderResponseHeaders); // Set HTTP Link header for font preloading if (fontLinkHeader) { - response.headers.set("Link", fontLinkHeader); - } - // Merge middleware response headers into the final response. - // The response is freshly constructed above (new Response(htmlStream, {...})), - // so set() and append() are equivalent — there are no same-key conflicts yet. - // Precedence over config headers is handled by the outer handler, which - // skips config keys that middleware already placed on the response. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - response.headers.append(key, value); - } + responseHeaders.set("Link", fontLinkHeader); } + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -9171,21 +8783,30 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const renderMs = __renderEnd !== undefined && __compileEnd !== undefined ? Math.round(__renderEnd - __compileEnd) : -1; - response.headers.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); + responseHeaders.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); } // Apply custom status code from middleware rewrite if (_mwCtx.status) { return new Response(response.body, { status: _mwCtx.status, - headers: response.headers, + headers: responseHeaders, }); } - return response; + const responseInit = { + status: response.status, + headers: responseHeaders, + }; + if (response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); } // Check if any component called connection(), cookies(), headers(), or noStore() // during rendering. If so, treat as dynamic (skip ISR, set no-store). - const dynamicUsedDuringRender = consumeDynamicUsage(); + const dynamicUsedDuringRender = __isrRscDataPromise + ? peekDynamicUsage() + : consumeDynamicUsage(); // Check if cacheLife() was called during rendering (e.g., page with file-level "use cache"). // If so, use its revalidation period for the Cache-Control header. @@ -9269,6 +8890,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? renderResponseHeaders; + // consume picks up any headers added during stream consumption by late + // async render work (for example, suspended branches). Falls back to + // the snapshot taken before streaming began when nothing new was added. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip HTML cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) @@ -9276,12 +8906,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ensuring the first client-side navigation after a direct visit is a // cache hit rather than a miss. const __writes = [ - __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags), + __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags), ]; if (__capturedRscDataPromise) { __writes.push( __capturedRscDataPromise.then((__rscBuf) => - __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags) + __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags) ) ); } @@ -9289,6 +8919,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HTML cache written", __isrKey); } catch (__cacheErr) { console.error("[vinext] ISR cache write error:", __cacheErr); + } finally { + consumeRenderResponseHeaders(); } })(); // Register with ExecutionContext (from ALS) so the Workers runtime keeps @@ -9373,7 +9005,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; +import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; @@ -9383,38 +9015,19 @@ 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 { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; -import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; +import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; +import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; +import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; +import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. -import "vinext/navigation-state"; -import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; +import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; function _getSSRFontStyles() { return [..._getSSRFontStylesGoogle(), ..._getSSRFontStylesLocal()]; } function _getSSRFontPreloads() { return [..._getSSRFontPreloadsGoogle(), ..._getSSRFontPreloadsLocal()]; } - -// Duplicated from the build-time constant above via JSON.stringify. -const ROUTE_HANDLER_HTTP_METHODS = ["GET","HEAD","POST","PUT","DELETE","PATCH","OPTIONS"]; - -function collectRouteHandlerMethods(handler) { - const methods = ROUTE_HANDLER_HTTP_METHODS.filter((method) => typeof handler[method] === "function"); - if (methods.includes("GET") && !methods.includes("HEAD")) { - methods.push("HEAD"); - } - return methods; -} - -function buildRouteHandlerAllowHeader(exportedMethods) { - const allow = new Set(exportedMethods); - allow.add("OPTIONS"); - return Array.from(allow).sort().join(", "); -} - - // ALS used to suppress the expected "Invalid hook call" dev warning when // layout/page components are probed outside React's render cycle. Patching // console.error once at module load (instead of per-request) avoids the @@ -9478,16 +9091,71 @@ function __pageCacheTags(pathname, extraTags) { } return tags; } -// Note: cache entries are written with \`headers: undefined\`. Next.js stores -// response headers (e.g. set-cookie from cookies().set() during render) in the -// cache entry so they can be replayed on HIT. We don't do this because: -// 1. Pages that call cookies().set() during render trigger dynamicUsedDuringRender, -// which opts them out of ISR caching before we reach the write path. -// 2. Custom response headers set via next/headers are not yet captured separately -// from the live Response object in vinext's server pipeline. -// In practice this means ISR-cached responses won't replay render-time set-cookie -// headers — but that case is already prevented by the dynamic-usage opt-out. -// TODO: capture render-time response headers for full Next.js parity. +function __isAppendOnlyResponseHeader(lowerKey) { + return lowerKey === "set-cookie" || lowerKey === "vary" || lowerKey === "www-authenticate" || lowerKey === "proxy-authenticate"; +} +function __mergeResponseHeaderValues(targetHeaders, key, value, mode) { + const lowerKey = key.toLowerCase(); + const values = Array.isArray(value) ? value : [value]; + if (__isAppendOnlyResponseHeader(lowerKey)) { + for (const item of values) targetHeaders.append(key, item); + return; + } + if (mode === "fallback" && targetHeaders.has(key)) return; + targetHeaders.delete(key); + if (values.length === 1) { + targetHeaders.set(key, values[0]); + return; + } + for (const item of values) targetHeaders.append(key, item); +} +function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) { + if (!sourceHeaders) return; + if (sourceHeaders instanceof Headers) { + const __setCookies = typeof sourceHeaders.getSetCookie === "function" + ? sourceHeaders.getSetCookie() + : []; + for (const [key, value] of sourceHeaders) { + if (key.toLowerCase() === "set-cookie") { + // entries() flattens Set-Cookie into a single comma-joined value. + // If getSetCookie() is unavailable, drop cookies rather than corrupt them. + continue; + } + __mergeResponseHeaderValues(targetHeaders, key, value, mode); + } + for (const cookie of __setCookies) { + targetHeaders.append("Set-Cookie", cookie); + } + return; + } + for (const [key, value] of Object.entries(sourceHeaders)) { + __mergeResponseHeaderValues(targetHeaders, key, value, mode); + } +} +function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) { + const headers = new Headers(); + __mergeResponseHeaders(headers, renderHeaders, "fallback"); + __mergeResponseHeaders(headers, baseHeaders, "override"); + return headers; +} +function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) { + __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override"); +} +function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) { + const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status; + if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response; + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderHeaders); + __applyMiddlewareResponseHeaders(responseHeaders, middlewareCtx?.headers); + const status = rewriteStatus ?? response.status; + const responseInit = { + status, + headers: responseHeaders, + }; + if (status === response.status && response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); +} const __pendingRegenerations = new Map(); function __triggerBackgroundRegeneration(key, renderFn) { if (__pendingRegenerations.has(key)) return; @@ -9535,7 +9203,6 @@ function __isrCacheKey(pathname, suffix) { } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } -function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators // have a single knob for all cache tracing. @@ -9688,7 +9355,9 @@ function rscOnError(error, requestInfo, errorContext) { error instanceof Error ? error : new Error(String(error)), requestInfo, errorContext, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report render error:", reportErr); + }); } // In production, generate a digest hash for non-navigation errors @@ -9739,7 +9408,6 @@ import * as mod_10 from "/tmp/test/app/dashboard/not-found.tsx"; let __instrumentationInitialized = false; let __instrumentationInitPromise = null; async function __ensureInstrumentation() { - if (process.env.VINEXT_PRERENDER === "1") return; if (__instrumentationInitialized) return; if (__instrumentationInitPromise) return __instrumentationInitPromise; __instrumentationInitPromise = (async () => { @@ -9955,7 +9623,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. + // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. return new Response(rscStream, { status: statusCode, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -10088,7 +9756,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. + // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. return new Response(rscStream, { status: 200, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -10500,16 +10168,7 @@ function __validateDevRequestOrigin(request) { } const origin = request.headers.get("origin"); - if (!origin) return null; - - // Origin "null" is sent by opaque/sandboxed contexts. Block unless explicitly allowed. - if (origin === "null") { - if (!__allowedDevOrigins.includes("null")) { - console.warn("[vinext] Blocked request with Origin: null. Add \\"null\\" to allowedDevOrigins to allow sandboxed contexts."); - return new Response("Forbidden", { status: 403, headers: { "Content-Type": "text/plain" } }); - } - return null; - } + if (!origin || origin === "null") return null; let originHostname; try { @@ -10760,67 +10419,65 @@ async function __readFormDataWithLimit(request, maxBytes) { return new Response(combined, { headers: { "Content-Type": contentType } }).formData(); } -// Map from route pattern to generateStaticParams function. -// Used by the prerender phase to enumerate dynamic route URLs without -// loading route modules via the dev server. -export const generateStaticParamsMap = { -// TODO: layout-level generateStaticParams — this map only includes routes that -// have a pagePath (leaf pages). Layout segments can also export generateStaticParams -// to provide parent params for nested dynamic routes, but they don't have a pagePath -// so they are excluded here. Supporting layout-level generateStaticParams requires -// scanning layout.tsx files separately and including them in this map. - "/blog/:slug": mod_3?.generateStaticParams ?? null, -}; - export default async function handler(request, ctx) { // Ensure instrumentation.register() has run before handling the first request. // This is a no-op after the first call (guarded by __instrumentationInitialized). await __ensureInstrumentation(); - // Wrap the entire request in a single unified ALS scope for per-request - // isolation. All state modules (headers, navigation, cache, fetch-cache, - // execution-context) read from this store via isInsideUnifiedScope(). + // 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 + // streaming), preventing state leakage between concurrent requests on + // Cloudflare Workers and other concurrent runtimes. + // + // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so + // that KVCacheHandler._putInBackground can register background KV puts with + // ctx.waitUntil() without needing ctx passed at construction time. const headersCtx = headersContextFromRequest(request); - const __uCtx = _createUnifiedCtx({ - headersContext: headersCtx, - executionContext: ctx ?? _getRequestExecutionContext() ?? null, - }); - return _runWithUnifiedCtx(__uCtx, async () => { - _ensureFetchPatch(); - const __reqCtx = requestContextFromRequest(request); - // 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); - // 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); - let pathname; - try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } - - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); - for (const h of extraHeaders) { - // Use append() for headers where multiple values must coexist - // (Vary, Set-Cookie). Using set() on these would destroy - // existing values like "Vary: RSC, Accept" which are critical - // for correct CDN caching behavior. - const lk = h.key.toLowerCase(); - if (lk === "vary" || lk === "set-cookie") { - response.headers.append(h.key, h.value); - } else if (!response.headers.has(lk)) { - // Middleware headers take precedence: skip config keys already - // set by middleware so middleware headers always win. - response.headers.set(h.key, h.value); - } - } - } - } - return response; - }); + const _run = () => runWithHeadersContext(headersCtx, () => + _runWithNavigationContext(() => + _runWithCacheState(() => + _runWithPrivateCache(() => + runWithFetchCache(async () => { + const __reqCtx = requestContextFromRequest(request); + // 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); + // 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); + let pathname; + try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } + + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + for (const h of extraHeaders) { + // Use append() for headers where multiple values must coexist + // (Vary, Set-Cookie). Using set() on these would destroy + // existing values like "Vary: RSC, Accept" which are critical + // for correct CDN caching behavior. + const lk = h.key.toLowerCase(); + if (lk === "vary" || lk === "set-cookie") { + response.headers.append(h.key, h.value); + } else if (!response.headers.has(lk)) { + // Middleware headers take precedence: skip config keys already + // set by middleware so middleware headers always win. + response.headers.set(h.key, h.value); + } + } + } + } + return response; + }) + ) + ) + ) + ); + return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } async function _handleRequest(request, __reqCtx, _mwCtx) { @@ -10855,38 +10512,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { - // ── Prerender: static-params endpoint ──────────────────────────────── - // Internal endpoint used by prerenderApp() during build to fetch - // generateStaticParams results via wrangler unstable_startWorker. - // Gated on VINEXT_PRERENDER=1 to prevent exposure in normal deployments. - // For Node builds, process.env.VINEXT_PRERENDER is set directly by the - // prerender orchestrator. For CF Workers builds, wrangler unstable_startWorker - // injects VINEXT_PRERENDER as a binding which Miniflare exposes via process.env - // in bundled workers. The /__vinext/ prefix ensures no user route ever conflicts. - if (pathname === "/__vinext/prerender/static-params") { - if (process.env.VINEXT_PRERENDER !== "1") { - return new Response("Not Found", { status: 404 }); - } - const pattern = url.searchParams.get("pattern"); - if (!pattern) return new Response("missing pattern", { status: 400 }); - const fn = generateStaticParamsMap[pattern]; - if (typeof fn !== "function") return new Response("null", { status: 200, headers: { "content-type": "application/json" } }); - try { - const parentParams = url.searchParams.get("parentParams"); - const raw = parentParams ? JSON.parse(parentParams) : {}; - // Ensure params is a plain object — reject primitives, arrays, and null - // so user-authored generateStaticParams always receives { params: {} } - // rather than { params: 5 } or similar if input is malformed. - const params = (typeof raw === "object" && raw !== null && !Array.isArray(raw)) ? raw : {}; - const result = await fn({ params }); - return new Response(JSON.stringify(result), { status: 200, headers: { "content-type": "application/json" } }); - } catch (e) { - return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers: { "content-type": "application/json" } }); - } - } - - - // Trailing slash normalization (redirect to canonical form) const __tsRedirect = normalizeTrailingSlash(pathname, __basePath, __trailingSlash, url.search); if (__tsRedirect) return __tsRedirect; @@ -10981,53 +10606,45 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Skip — the base servedUrl is not served when generateSitemaps exists continue; } - // Match metadata route — use pattern matching for dynamic segments, - // strict equality for static paths. - var _metaParams = null; - if (metaRoute.patternParts) { - var _metaUrlParts = cleanPathname.split("/").filter(Boolean); - _metaParams = matchPattern(_metaUrlParts, metaRoute.patternParts); - if (!_metaParams) continue; - } else if (cleanPathname !== metaRoute.servedUrl) { - continue; - } - if (metaRoute.isDynamic) { - // Dynamic metadata route — call the default export and serialize - const metaFn = metaRoute.module.default; - if (typeof metaFn === "function") { - const result = await metaFn({ params: makeThenableParams(_metaParams || {}) }); - let body; - // If it's already a Response (e.g., ImageResponse), return directly - if (result instanceof Response) return result; - // Serialize based on type - if (metaRoute.type === "sitemap") body = sitemapToXml(result); - else if (metaRoute.type === "robots") body = robotsToText(result); - else if (metaRoute.type === "manifest") body = manifestToJson(result); - else body = JSON.stringify(result); - return new Response(body, { - headers: { "Content-Type": metaRoute.contentType }, - }); - } - } else { - // Static metadata file — decode from embedded base64 data - try { - const binary = atob(metaRoute.fileDataBase64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new Response(bytes, { - headers: { - "Content-Type": metaRoute.contentType, - "Cache-Control": "public, max-age=0, must-revalidate", - }, - }); - } catch { - return new Response("Not Found", { status: 404 }); + if (cleanPathname === metaRoute.servedUrl) { + if (metaRoute.isDynamic) { + // Dynamic metadata route — call the default export and serialize + const metaFn = metaRoute.module.default; + if (typeof metaFn === "function") { + const result = await metaFn(); + let body; + // If it's already a Response (e.g., ImageResponse), return directly + if (result instanceof Response) return result; + // Serialize based on type + if (metaRoute.type === "sitemap") body = sitemapToXml(result); + else if (metaRoute.type === "robots") body = robotsToText(result); + else if (metaRoute.type === "manifest") body = manifestToJson(result); + else body = JSON.stringify(result); + return new Response(body, { + headers: { "Content-Type": metaRoute.contentType }, + }); + } + } else { + // Static metadata file — decode from embedded base64 data + try { + const binary = atob(metaRoute.fileDataBase64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new Response(bytes, { + headers: { + "Content-Type": metaRoute.contentType, + "Cache-Control": "public, max-age=0, must-revalidate", + }, + }); + } catch { + return new Response("Not Found", { status: 404 }); + } } } } // Set navigation context for Server Components. - // Note: Headers context is already set by runWithRequestContext in the handler wrapper. + // Note: Headers context is already set by runWithHeadersContext in the handler wrapper. setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, @@ -11119,21 +10736,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // and receive a page HTML instead of RSC stream). Instead, we return a 200 // with x-action-redirect header that the client entry detects and handles. if (actionRedirect) { - const actionPendingCookies = getAndClearPendingCookies(); - const actionDraftCookie = getDraftModeCookieHeader(); + const actionRenderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - const redirectHeaders = new Headers({ + const redirectHeaders = __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", "x-action-redirect": actionRedirect.url, "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), - }); - for (const cookie of actionPendingCookies) { - redirectHeaders.append("Set-Cookie", cookie); - } - if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); + }, actionRenderHeaders); // Send an empty RSC-like body (client will navigate instead of parsing) return new Response("", { status: 200, headers: redirectHeaders }); } @@ -11167,28 +10779,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Collect cookies set during the action synchronously (before stream is consumed). // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithRequestContext + // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext // handles cleanup naturally when all async continuations complete. - const actionPendingCookies = getAndClearPendingCookies(); - const actionDraftCookie = getDraftModeCookieHeader(); - - const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; - const actionResponse = new Response(rscStream, { headers: actionHeaders }); - if (actionPendingCookies.length > 0 || actionDraftCookie) { - for (const cookie of actionPendingCookies) { - actionResponse.headers.append("Set-Cookie", cookie); - } - if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie); - } - return actionResponse; + const actionRenderHeaders = consumeRenderResponseHeaders(); + + const actionHeaders = __headersWithRenderResponseHeaders({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }, actionRenderHeaders); + return new Response(rscStream, { headers: actionHeaders }); } catch (err) { - getAndClearPendingCookies(); // Clear pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error console.error("[vinext] Server action error:", err); _reportRequestError( err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: cleanPathname, routeType: "action" }, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report server action error:", reportErr); + }); setHeadersContext(null); setNavigationContext(null); return new Response( @@ -11230,7 +10839,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (!match) { - // Render custom not-found page if available, otherwise plain 404 const notFoundResponse = await renderNotFoundPage(null, isRscRequest, request); if (notFoundResponse) return notFoundResponse; @@ -11252,16 +10860,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (route.routeHandler) { const handler = route.routeHandler; const method = request.method.toUpperCase(); - const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 && handler.revalidate !== Infinity ? handler.revalidate : null; - if (typeof handler["default"] === "function" && process.env.NODE_ENV === "development") { - console.error( - "[vinext] Detected default export in route handler " + route.pattern + ". Export a named export for each HTTP method instead.", - ); - } + const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 ? handler.revalidate : null; // Collect exported HTTP methods for OPTIONS auto-response and Allow header - const exportedMethods = collectRouteHandlerMethods(handler); - const allowHeaderForOptions = buildRouteHandlerAllowHeader(exportedMethods); + const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; + const exportedMethods = HTTP_METHODS.filter((m) => typeof handler[m] === "function"); + // If GET is exported, HEAD is implicitly supported + if (exportedMethods.includes("GET") && !exportedMethods.includes("HEAD")) { + exportedMethods.push("HEAD"); + } + const hasDefault = typeof handler["default"] === "function"; // Route handlers need the same middleware header/status merge behavior as // page responses. This keeps middleware response headers visible on API @@ -11273,12 +10881,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // return is skipped, but the copy loop below is a no-op, so no incorrect // headers are added. The allocation cost in that case is acceptable. if (!_mwCtx.headers && _mwCtx.status == null) return response; - const responseHeaders = new Headers(response.headers); - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - responseHeaders.append(key, value); - } - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers); + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); return new Response(response.body, { status: _mwCtx.status ?? response.status, statusText: response.statusText, @@ -11288,107 +10892,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // OPTIONS auto-implementation: respond with Allow header and 204 if (method === "OPTIONS" && typeof handler["OPTIONS"] !== "function") { + const allowMethods = hasDefault ? HTTP_METHODS : exportedMethods; + if (!allowMethods.includes("OPTIONS")) allowMethods.push("OPTIONS"); setHeadersContext(null); setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 204, - headers: { "Allow": allowHeaderForOptions }, + headers: { "Allow": allowMethods.join(", ") }, })); } // HEAD auto-implementation: run GET handler and strip body - let handlerFn = handler[method]; + let handlerFn = handler[method] || handler["default"]; let isAutoHead = false; if (method === "HEAD" && typeof handler["HEAD"] !== "function" && typeof handler["GET"] === "function") { handlerFn = handler["GET"]; isAutoHead = true; } - // ISR cache read for route handlers (production only). - // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. - // This runs before handler execution so a cache HIT skips the handler entirely. - if ( - process.env.NODE_ENV === "production" && - revalidateSeconds !== null && - handler.dynamic !== "force-dynamic" && - (method === "GET" || isAutoHead) && - typeof handlerFn === "function" - ) { - const __routeKey = __isrRouteKey(cleanPathname); - try { - const __cached = await __isrGet(__routeKey); - if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { - // HIT — return cached response immediately - const __cv = __cached.value.value; - __isrDebug?.("HIT (route)", cleanPathname); - setHeadersContext(null); - setNavigationContext(null); - const __hitHeaders = Object.assign({}, __cv.headers || {}); - __hitHeaders["X-Vinext-Cache"] = "HIT"; - __hitHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; - if (isAutoHead) { - return attachRouteHandlerMiddlewareContext(new Response(null, { status: __cv.status, headers: __hitHeaders })); - } - return attachRouteHandlerMiddlewareContext(new Response(__cv.body, { status: __cv.status, headers: __hitHeaders })); - } - if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { - // STALE — serve stale response, trigger background regeneration - const __sv = __cached.value.value; - const __revalSecs = revalidateSeconds; - const __revalHandlerFn = handlerFn; - const __revalParams = params; - const __revalUrl = request.url; - const __revalSearchParams = new URLSearchParams(url.searchParams); - __triggerBackgroundRegeneration(__routeKey, async function() { - const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalUCtx = _createUnifiedCtx({ - headersContext: __revalHeadCtx, - executionContext: _getRequestExecutionContext(), - }); - await _runWithUnifiedCtx(__revalUCtx, async () => { - _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); - const __syntheticReq = new Request(__revalUrl, { method: "GET" }); - const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); - const __regenDynamic = consumeDynamicUsage(); - setNavigationContext(null); - if (__regenDynamic) { - __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); - return; - } - const __freshBody = await __revalResponse.arrayBuffer(); - const __freshHeaders = {}; - __revalResponse.headers.forEach(function(v, k) { - if (k !== "x-vinext-cache" && k !== "cache-control") __freshHeaders[k] = v; - }); - const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __freshBody, status: __revalResponse.status, headers: __freshHeaders }, __revalSecs, __routeTags); - __isrDebug?.("route regen complete", __routeKey); - }); - }); - __isrDebug?.("STALE (route)", cleanPathname); - setHeadersContext(null); - setNavigationContext(null); - const __staleHeaders = Object.assign({}, __sv.headers || {}); - __staleHeaders["X-Vinext-Cache"] = "STALE"; - __staleHeaders["Cache-Control"] = "s-maxage=0, stale-while-revalidate"; - if (isAutoHead) { - return attachRouteHandlerMiddlewareContext(new Response(null, { status: __sv.status, headers: __staleHeaders })); - } - return attachRouteHandlerMiddlewareContext(new Response(__sv.body, { status: __sv.status, headers: __staleHeaders })); - } - } catch (__routeCacheErr) { - // Cache read failure — fall through to normal handler execution - console.error("[vinext] ISR route cache read error:", __routeCacheErr); - } - } - if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); - const handlerSetCacheControl = response.headers.has("cache-control"); // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, @@ -11397,56 +10923,20 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { revalidateSeconds !== null && !dynamicUsedInHandler && (method === "GET" || isAutoHead) && - !handlerSetCacheControl + !response.headers.has("cache-control") ) { response.headers.set("cache-control", "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"); } - // ISR cache write for route handlers (production, MISS). - // Store the raw handler response before cookie/middleware transforms - // (those are request-specific and shouldn't be cached). - if ( - process.env.NODE_ENV === "production" && - revalidateSeconds !== null && - handler.dynamic !== "force-dynamic" && - !dynamicUsedInHandler && - (method === "GET" || isAutoHead) && - !handlerSetCacheControl - ) { - response.headers.set("X-Vinext-Cache", "MISS"); - const __routeClone = response.clone(); - const __routeKey = __isrRouteKey(cleanPathname); - const __revalSecs = revalidateSeconds; - const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - const __routeWritePromise = (async () => { - try { - const __buf = await __routeClone.arrayBuffer(); - const __hdrs = {}; - __routeClone.headers.forEach(function(v, k) { - if (k !== "x-vinext-cache" && k !== "cache-control") __hdrs[k] = v; - }); - await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __buf, status: __routeClone.status, headers: __hdrs }, __revalSecs, __routeTags); - __isrDebug?.("route cache written", __routeKey); - } catch (__cacheErr) { - console.error("[vinext] ISR route cache write error:", __cacheErr); - } - })(); - _getRequestExecutionContext()?.waitUntil(__routeWritePromise); - } - - // Collect any Set-Cookie headers from cookies().set()/delete() calls - const pendingCookies = getAndClearPendingCookies(); - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // If we have pending cookies, create a new response with them attached - if (pendingCookies.length > 0 || draftCookie) { - const newHeaders = new Headers(response.headers); - for (const cookie of pendingCookies) { - newHeaders.append("Set-Cookie", cookie); - } - if (draftCookie) newHeaders.append("Set-Cookie", draftCookie); + if (renderResponseHeaders) { + const newHeaders = __headersWithRenderResponseHeaders( + response.headers, + renderResponseHeaders, + ); if (isAutoHead) { return attachRouteHandlerMiddlewareContext(new Response(null, { @@ -11472,7 +10962,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return attachRouteHandlerMiddlewareContext(response); } catch (err) { - getAndClearPendingCookies(); // Clear any pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error // Catch redirect() / notFound() thrown from route handlers if (err && typeof err === "object" && "digest" in err) { const digest = String(err.digest); @@ -11501,7 +10991,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: route.pattern, routeType: "route" }, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report route handler error:", reportErr); + }); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 500 })); } finally { setHeadersAccessPhase(previousHeadersPhase); @@ -11511,6 +11003,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 405, + headers: { Allow: exportedMethods.join(", ") }, })); } @@ -11592,29 +11085,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HIT (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.rscData, { + return __responseWithMiddlewareContext(new Response(__cachedValue.rscData, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } if (!isRscRequest && __hasHtml) { __isrDebug?.("HIT (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.html, { + return __responseWithMiddlewareContext(new Response(__cachedValue.html, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } __isrDebug?.("MISS (empty cached entry)", cleanPathname); } @@ -11630,58 +11123,62 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // user request — to prevent user-specific cookies/auth headers from leaking // into content that is cached and served to all subsequent users. const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalUCtx = _createUnifiedCtx({ - headersContext: __revalHeadCtx, - executionContext: _getRequestExecutionContext(), - }); - const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { - _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); - const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); - const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); - // Tee RSC stream: one for SSR, one to capture rscData - const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); - // Capture rscData bytes in parallel with SSR - const __rscDataPromise = (async () => { - const __rscReader = __revalRscForCapture.getReader(); - const __rscChunks = []; - let __rscTotal = 0; - for (;;) { - const { done, value } = await __rscReader.read(); - if (done) break; - __rscChunks.push(value); - __rscTotal += value.byteLength; - } - const __rscBuf = new Uint8Array(__rscTotal); - let __rscOff = 0; - for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } - return __rscBuf.buffer; - })(); - const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; - const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); - setHeadersContext(null); - setNavigationContext(null); - // Collect the full HTML string from the stream - const __revalReader = __revalHtmlStream.getReader(); - const __revalDecoder = new TextDecoder(); - const __revalChunks = []; - for (;;) { - const { done, value } = await __revalReader.read(); - if (done) break; - __revalChunks.push(__revalDecoder.decode(value, { stream: true })); - } - __revalChunks.push(__revalDecoder.decode()); - const __freshHtml = __revalChunks.join(""); - const __freshRscData = await __rscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; - }); + const __revalResult = await runWithHeadersContext(__revalHeadCtx, () => + _runWithNavigationContext(() => + _runWithCacheState(() => + _runWithPrivateCache(() => + runWithFetchCache(async () => { + setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); + const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); + const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); + const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); + // Tee RSC stream: one for SSR, one to capture rscData + const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); + // Capture rscData bytes in parallel with SSR + const __rscDataPromise = (async () => { + const __rscReader = __revalRscForCapture.getReader(); + const __rscChunks = []; + let __rscTotal = 0; + for (;;) { + const { done, value } = await __rscReader.read(); + if (done) break; + __rscChunks.push(value); + __rscTotal += value.byteLength; + } + const __rscBuf = new Uint8Array(__rscTotal); + let __rscOff = 0; + for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } + return __rscBuf.buffer; + })(); + const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; + const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); + const __freshRscData = await __rscDataPromise; + const __renderHeaders = consumeRenderResponseHeaders(); + setHeadersContext(null); + setNavigationContext(null); + // Collect the full HTML string from the stream + const __revalReader = __revalHtmlStream.getReader(); + const __revalDecoder = new TextDecoder(); + const __revalChunks = []; + for (;;) { + const { done, value } = await __revalReader.read(); + if (done) break; + __revalChunks.push(__revalDecoder.decode(value, { stream: true })); + } + __revalChunks.push(__revalDecoder.decode()); + const __freshHtml = __revalChunks.join(""); + const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; + }) + ) + ) + ) + ); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); __isrDebug?.("regen complete", cleanPathname); }); @@ -11689,29 +11186,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("STALE (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.rscData, { + return __responseWithMiddlewareContext(new Response(__staleValue.rscData, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { __isrDebug?.("STALE (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.html, { + return __responseWithMiddlewareContext(new Response(__staleValue.html, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } // Stale entry exists but is empty for this request type — fall through to render __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); @@ -11786,7 +11283,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError }); // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithRequestContext + // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext // handles cleanup naturally when all async continuations complete. return new Response(interceptStream, { headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -11801,65 +11298,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let element; - try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); - } catch (buildErr) { - // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components - if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { - const digest = String(buildErr.digest); + // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim + async function handleRenderError(err, fallbackOpts) { + if (err && typeof err === "object" && "digest" in err) { + const digest = String(err.digest); if (digest.startsWith("NEXT_REDIRECT;")) { const parts = digest.split(";"); const redirectUrl = decodeURIComponent(parts[2]); const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); + return __responseWithMiddlewareContext(new Response(null, { + status: statusCode, + headers: { Location: new URL(redirectUrl, request.url).toString() }, + }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false }); } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + ...fallbackOpts, + }); + const renderResponseHeaders = consumeRenderResponseHeaders(); + if (fallbackResp) { + return __responseWithMiddlewareContext( + fallbackResp, + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); + } setHeadersContext(null); setNavigationContext(null); const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); + return __responseWithMiddlewareContext( + new Response(statusText, { status: statusCode }), + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); } } + return null; + } + + let element; + try { + element = await buildPageElement(route, params, interceptOpts, url.searchParams); + } catch (buildErr) { + const specialResponse = await handleRenderError(buildErr); + if (specialResponse) return specialResponse; // Non-special error (e.g. generateMetadata() threw) — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw buildErr; } + const __buildRenderResponseHeaders = peekRenderResponseHeaders(); + const __buildDynamicUsage = peekDynamicUsage(); // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - if (err && typeof err === "object" && "digest" in err) { - const digest = String(err.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); - } - } - return null; - } - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws // notFound(), the parent layout's boundary catches it and renders the parent's @@ -11885,44 +11392,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const lr = LayoutComp({ params: asyncParams, children: null }); if (lr && typeof lr === "object" && typeof lr.then === "function") await lr; } catch (layoutErr) { - if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) { - const digest = String(layoutErr.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; } - if (!parentNotFound) parentNotFound = null; - // Wrap in only the layouts above the throwing one - const parentLayouts = route.layouts.slice(0, li); - const fallbackResp = await renderHTTPAccessFallbackPage( - route, statusCode, isRscRequest, request, - { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params } - ); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); } } + if (!parentNotFound) parentNotFound = null; + // Wrap in only the layouts above the throwing one + const parentLayouts = route.layouts.slice(0, li); + const specialResponse = await handleRenderError(layoutErr, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, + }); + if (specialResponse) return specialResponse; // Not a special error — let it propagate through normal RSC rendering } } @@ -11971,6 +11460,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); if (_pageProbeResult instanceof Response) return _pageProbeResult; + // The sync pre-render probes above are only for catching redirect/notFound + // before streaming begins. Discard any render-time response headers they + // may have produced while preserving headers generated during buildPageElement + // (e.g. generateMetadata), since those are part of the real render output. + restoreRenderResponseHeaders(__buildRenderResponseHeaders); + restoreDynamicUsage(__buildDynamicUsage); + // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); @@ -12021,7 +11517,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // NOTE: Do NOT clear headers/navigation context here! // The RSC stream is consumed lazily - components render when chunks are read. // If we clear context now, headers()/cookies() will fail during rendering. - // Context will be cleared when the next request starts (via runWithRequestContext). + // Context will be cleared when the next request starts (via runWithHeadersContext). const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; // Include matched route params so the client can hydrate useParams() if (params && Object.keys(params).length > 0) { @@ -12030,44 +11526,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (isForceDynamic) { responseHeaders["Cache-Control"] = "no-store, must-revalidate"; } else if ((isForceStatic || isDynamicError) && !revalidateSeconds) { - responseHeaders["Cache-Control"] = "s-maxage=31536000, stale-while-revalidate"; - responseHeaders["X-Vinext-Cache"] = "STATIC"; - } else if (revalidateSeconds === Infinity) { - responseHeaders["Cache-Control"] = "s-maxage=31536000, stale-while-revalidate"; - responseHeaders["X-Vinext-Cache"] = "STATIC"; - } else if (revalidateSeconds) { - responseHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; - } - // Merge middleware response headers into the RSC response. - // set-cookie and vary are accumulated to preserve existing values - // (e.g. "Vary: RSC, Accept" set above); all other keys use plain - // assignment so middleware headers win over config headers, which - // the outer handler applies afterward and skips keys already present. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - const lk = key.toLowerCase(); - if (lk === "set-cookie") { - const existing = responseHeaders[lk]; - if (Array.isArray(existing)) { - existing.push(value); - } else if (existing) { - responseHeaders[lk] = [existing, value]; - } else { - responseHeaders[lk] = [value]; - } - } else if (lk === "vary") { - // Accumulate Vary values to preserve the existing "RSC, Accept" entry. - const existing = responseHeaders["Vary"] ?? responseHeaders["vary"]; - if (existing) { - responseHeaders["Vary"] = existing + ", " + value; - if (responseHeaders["vary"] !== undefined) delete responseHeaders["vary"]; - } else { - responseHeaders[key] = value; - } - } else { - responseHeaders[key] = value; - } - } + responseHeaders["Cache-Control"] = "s-maxage=31536000, stale-while-revalidate"; + responseHeaders["X-Vinext-Cache"] = "STATIC"; + } else if (revalidateSeconds === Infinity) { + responseHeaders["Cache-Control"] = "s-maxage=31536000, stale-while-revalidate"; + responseHeaders["X-Vinext-Cache"] = "STATIC"; + } else if (revalidateSeconds) { + responseHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; } // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" @@ -12088,22 +11553,45 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { - responseHeaders["X-Vinext-Cache"] = "MISS"; const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; + const __responseRenderHeaders = peekRenderResponseHeaders(); + if (peekDynamicUsage()) { + responseHeaders["Cache-Control"] = "no-store, must-revalidate"; + } else { + responseHeaders["X-Vinext-Cache"] = "MISS"; + } const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? __responseRenderHeaders; + // consume picks up headers added during late async RSC streaming work. + // Falls back to the snapshot taken before the live MISS response was returned. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip RSC cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); + await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); + } finally { + setHeadersContext(null); + setNavigationContext(null); } })(); _getRequestExecutionContext()?.waitUntil(__rscWritePromise); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } - return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: responseHeaders, + }), _mwCtx); } // Collect font data from RSC environment before passing to SSR @@ -12141,7 +11629,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw ssrErr; } @@ -12151,31 +11646,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // when the error falls through to global-error.tsx. - // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response + // Helper to attach render-time response headers, middleware headers, font + // Link header, and rewrite status to a response. function attachMiddlewareContext(response) { - if (draftCookie) { - response.headers.append("Set-Cookie", draftCookie); - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderResponseHeaders); // Set HTTP Link header for font preloading if (fontLinkHeader) { - response.headers.set("Link", fontLinkHeader); - } - // Merge middleware response headers into the final response. - // The response is freshly constructed above (new Response(htmlStream, {...})), - // so set() and append() are equivalent — there are no same-key conflicts yet. - // Precedence over config headers is handled by the outer handler, which - // skips config keys that middleware already placed on the response. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - response.headers.append(key, value); - } + responseHeaders.set("Link", fontLinkHeader); } + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -12190,21 +11676,30 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const renderMs = __renderEnd !== undefined && __compileEnd !== undefined ? Math.round(__renderEnd - __compileEnd) : -1; - response.headers.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); + responseHeaders.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); } // Apply custom status code from middleware rewrite if (_mwCtx.status) { return new Response(response.body, { status: _mwCtx.status, - headers: response.headers, + headers: responseHeaders, }); } - return response; + const responseInit = { + status: response.status, + headers: responseHeaders, + }; + if (response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); } // Check if any component called connection(), cookies(), headers(), or noStore() // during rendering. If so, treat as dynamic (skip ISR, set no-store). - const dynamicUsedDuringRender = consumeDynamicUsage(); + const dynamicUsedDuringRender = __isrRscDataPromise + ? peekDynamicUsage() + : consumeDynamicUsage(); // Check if cacheLife() was called during rendering (e.g., page with file-level "use cache"). // If so, use its revalidation period for the Cache-Control header. @@ -12288,6 +11783,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? renderResponseHeaders; + // consume picks up any headers added during stream consumption by late + // async render work (for example, suspended branches). Falls back to + // the snapshot taken before streaming began when nothing new was added. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip HTML cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) @@ -12295,12 +11799,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ensuring the first client-side navigation after a direct visit is a // cache hit rather than a miss. const __writes = [ - __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags), + __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags), ]; if (__capturedRscDataPromise) { __writes.push( __capturedRscDataPromise.then((__rscBuf) => - __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags) + __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags) ) ); } @@ -12308,6 +11812,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HTML cache written", __isrKey); } catch (__cacheErr) { console.error("[vinext] ISR cache write error:", __cacheErr); + } finally { + consumeRenderResponseHeaders(); } })(); // Register with ExecutionContext (from ALS) so the Workers runtime keeps @@ -12392,7 +11898,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; +import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; @@ -12402,38 +11908,19 @@ 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 { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; -import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; +import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; +import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; +import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; +import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. -import "vinext/navigation-state"; -import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; +import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; function _getSSRFontStyles() { return [..._getSSRFontStylesGoogle(), ..._getSSRFontStylesLocal()]; } function _getSSRFontPreloads() { return [..._getSSRFontPreloadsGoogle(), ..._getSSRFontPreloadsLocal()]; } - -// Duplicated from the build-time constant above via JSON.stringify. -const ROUTE_HANDLER_HTTP_METHODS = ["GET","HEAD","POST","PUT","DELETE","PATCH","OPTIONS"]; - -function collectRouteHandlerMethods(handler) { - const methods = ROUTE_HANDLER_HTTP_METHODS.filter((method) => typeof handler[method] === "function"); - if (methods.includes("GET") && !methods.includes("HEAD")) { - methods.push("HEAD"); - } - return methods; -} - -function buildRouteHandlerAllowHeader(exportedMethods) { - const allow = new Set(exportedMethods); - allow.add("OPTIONS"); - return Array.from(allow).sort().join(", "); -} - - // ALS used to suppress the expected "Invalid hook call" dev warning when // layout/page components are probed outside React's render cycle. Patching // console.error once at module load (instead of per-request) avoids the @@ -12497,16 +11984,71 @@ function __pageCacheTags(pathname, extraTags) { } return tags; } -// Note: cache entries are written with \`headers: undefined\`. Next.js stores -// response headers (e.g. set-cookie from cookies().set() during render) in the -// cache entry so they can be replayed on HIT. We don't do this because: -// 1. Pages that call cookies().set() during render trigger dynamicUsedDuringRender, -// which opts them out of ISR caching before we reach the write path. -// 2. Custom response headers set via next/headers are not yet captured separately -// from the live Response object in vinext's server pipeline. -// In practice this means ISR-cached responses won't replay render-time set-cookie -// headers — but that case is already prevented by the dynamic-usage opt-out. -// TODO: capture render-time response headers for full Next.js parity. +function __isAppendOnlyResponseHeader(lowerKey) { + return lowerKey === "set-cookie" || lowerKey === "vary" || lowerKey === "www-authenticate" || lowerKey === "proxy-authenticate"; +} +function __mergeResponseHeaderValues(targetHeaders, key, value, mode) { + const lowerKey = key.toLowerCase(); + const values = Array.isArray(value) ? value : [value]; + if (__isAppendOnlyResponseHeader(lowerKey)) { + for (const item of values) targetHeaders.append(key, item); + return; + } + if (mode === "fallback" && targetHeaders.has(key)) return; + targetHeaders.delete(key); + if (values.length === 1) { + targetHeaders.set(key, values[0]); + return; + } + for (const item of values) targetHeaders.append(key, item); +} +function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) { + if (!sourceHeaders) return; + if (sourceHeaders instanceof Headers) { + const __setCookies = typeof sourceHeaders.getSetCookie === "function" + ? sourceHeaders.getSetCookie() + : []; + for (const [key, value] of sourceHeaders) { + if (key.toLowerCase() === "set-cookie") { + // entries() flattens Set-Cookie into a single comma-joined value. + // If getSetCookie() is unavailable, drop cookies rather than corrupt them. + continue; + } + __mergeResponseHeaderValues(targetHeaders, key, value, mode); + } + for (const cookie of __setCookies) { + targetHeaders.append("Set-Cookie", cookie); + } + return; + } + for (const [key, value] of Object.entries(sourceHeaders)) { + __mergeResponseHeaderValues(targetHeaders, key, value, mode); + } +} +function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) { + const headers = new Headers(); + __mergeResponseHeaders(headers, renderHeaders, "fallback"); + __mergeResponseHeaders(headers, baseHeaders, "override"); + return headers; +} +function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) { + __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override"); +} +function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) { + const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status; + if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response; + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderHeaders); + __applyMiddlewareResponseHeaders(responseHeaders, middlewareCtx?.headers); + const status = rewriteStatus ?? response.status; + const responseInit = { + status, + headers: responseHeaders, + }; + if (status === response.status && response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); +} const __pendingRegenerations = new Map(); function __triggerBackgroundRegeneration(key, renderFn) { if (__pendingRegenerations.has(key)) return; @@ -12554,7 +12096,6 @@ function __isrCacheKey(pathname, suffix) { } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } -function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators // have a single knob for all cache tracing. @@ -12707,7 +12248,9 @@ function rscOnError(error, requestInfo, errorContext) { error instanceof Error ? error : new Error(String(error)), requestInfo, errorContext, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report render error:", reportErr); + }); } // In production, generate a digest hash for non-navigation errors @@ -12951,7 +12494,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. + // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. return new Response(rscStream, { status: statusCode, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -13084,7 +12627,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. + // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. return new Response(rscStream, { status: 200, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -13496,16 +13039,7 @@ function __validateDevRequestOrigin(request) { } const origin = request.headers.get("origin"); - if (!origin) return null; - - // Origin "null" is sent by opaque/sandboxed contexts. Block unless explicitly allowed. - if (origin === "null") { - if (!__allowedDevOrigins.includes("null")) { - console.warn("[vinext] Blocked request with Origin: null. Add \\"null\\" to allowedDevOrigins to allow sandboxed contexts."); - return new Response("Forbidden", { status: 403, headers: { "Content-Type": "text/plain" } }); - } - return null; - } + if (!origin || origin === "null") return null; let originHostname; try { @@ -13756,64 +13290,62 @@ async function __readFormDataWithLimit(request, maxBytes) { return new Response(combined, { headers: { "Content-Type": contentType } }).formData(); } -// Map from route pattern to generateStaticParams function. -// Used by the prerender phase to enumerate dynamic route URLs without -// loading route modules via the dev server. -export const generateStaticParamsMap = { -// TODO: layout-level generateStaticParams — this map only includes routes that -// have a pagePath (leaf pages). Layout segments can also export generateStaticParams -// to provide parent params for nested dynamic routes, but they don't have a pagePath -// so they are excluded here. Supporting layout-level generateStaticParams requires -// scanning layout.tsx files separately and including them in this map. - "/blog/:slug": mod_3?.generateStaticParams ?? null, -}; - export default async function handler(request, ctx) { - // Wrap the entire request in a single unified ALS scope for per-request - // isolation. All state modules (headers, navigation, cache, fetch-cache, - // execution-context) read from this store via isInsideUnifiedScope(). + // 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 + // streaming), preventing state leakage between concurrent requests on + // Cloudflare Workers and other concurrent runtimes. + // + // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so + // that KVCacheHandler._putInBackground can register background KV puts with + // ctx.waitUntil() without needing ctx passed at construction time. const headersCtx = headersContextFromRequest(request); - const __uCtx = _createUnifiedCtx({ - headersContext: headersCtx, - executionContext: ctx ?? _getRequestExecutionContext() ?? null, - }); - return _runWithUnifiedCtx(__uCtx, async () => { - _ensureFetchPatch(); - const __reqCtx = requestContextFromRequest(request); - // 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); - // 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); - let pathname; - try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } - - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); - for (const h of extraHeaders) { - // Use append() for headers where multiple values must coexist - // (Vary, Set-Cookie). Using set() on these would destroy - // existing values like "Vary: RSC, Accept" which are critical - // for correct CDN caching behavior. - const lk = h.key.toLowerCase(); - if (lk === "vary" || lk === "set-cookie") { - response.headers.append(h.key, h.value); - } else if (!response.headers.has(lk)) { - // Middleware headers take precedence: skip config keys already - // set by middleware so middleware headers always win. - response.headers.set(h.key, h.value); - } - } - } - } - return response; - }); + const _run = () => runWithHeadersContext(headersCtx, () => + _runWithNavigationContext(() => + _runWithCacheState(() => + _runWithPrivateCache(() => + runWithFetchCache(async () => { + const __reqCtx = requestContextFromRequest(request); + // 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); + // 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); + let pathname; + try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } + + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + for (const h of extraHeaders) { + // Use append() for headers where multiple values must coexist + // (Vary, Set-Cookie). Using set() on these would destroy + // existing values like "Vary: RSC, Accept" which are critical + // for correct CDN caching behavior. + const lk = h.key.toLowerCase(); + if (lk === "vary" || lk === "set-cookie") { + response.headers.append(h.key, h.value); + } else if (!response.headers.has(lk)) { + // Middleware headers take precedence: skip config keys already + // set by middleware so middleware headers always win. + response.headers.set(h.key, h.value); + } + } + } + } + return response; + }) + ) + ) + ) + ); + return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } async function _handleRequest(request, __reqCtx, _mwCtx) { @@ -13848,38 +13380,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { - // ── Prerender: static-params endpoint ──────────────────────────────── - // Internal endpoint used by prerenderApp() during build to fetch - // generateStaticParams results via wrangler unstable_startWorker. - // Gated on VINEXT_PRERENDER=1 to prevent exposure in normal deployments. - // For Node builds, process.env.VINEXT_PRERENDER is set directly by the - // prerender orchestrator. For CF Workers builds, wrangler unstable_startWorker - // injects VINEXT_PRERENDER as a binding which Miniflare exposes via process.env - // in bundled workers. The /__vinext/ prefix ensures no user route ever conflicts. - if (pathname === "/__vinext/prerender/static-params") { - if (process.env.VINEXT_PRERENDER !== "1") { - return new Response("Not Found", { status: 404 }); - } - const pattern = url.searchParams.get("pattern"); - if (!pattern) return new Response("missing pattern", { status: 400 }); - const fn = generateStaticParamsMap[pattern]; - if (typeof fn !== "function") return new Response("null", { status: 200, headers: { "content-type": "application/json" } }); - try { - const parentParams = url.searchParams.get("parentParams"); - const raw = parentParams ? JSON.parse(parentParams) : {}; - // Ensure params is a plain object — reject primitives, arrays, and null - // so user-authored generateStaticParams always receives { params: {} } - // rather than { params: 5 } or similar if input is malformed. - const params = (typeof raw === "object" && raw !== null && !Array.isArray(raw)) ? raw : {}; - const result = await fn({ params }); - return new Response(JSON.stringify(result), { status: 200, headers: { "content-type": "application/json" } }); - } catch (e) { - return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers: { "content-type": "application/json" } }); - } - } - - - // Trailing slash normalization (redirect to canonical form) const __tsRedirect = normalizeTrailingSlash(pathname, __basePath, __trailingSlash, url.search); if (__tsRedirect) return __tsRedirect; @@ -13974,53 +13474,45 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Skip — the base servedUrl is not served when generateSitemaps exists continue; } - // Match metadata route — use pattern matching for dynamic segments, - // strict equality for static paths. - var _metaParams = null; - if (metaRoute.patternParts) { - var _metaUrlParts = cleanPathname.split("/").filter(Boolean); - _metaParams = matchPattern(_metaUrlParts, metaRoute.patternParts); - if (!_metaParams) continue; - } else if (cleanPathname !== metaRoute.servedUrl) { - continue; - } - if (metaRoute.isDynamic) { - // Dynamic metadata route — call the default export and serialize - const metaFn = metaRoute.module.default; - if (typeof metaFn === "function") { - const result = await metaFn({ params: makeThenableParams(_metaParams || {}) }); - let body; - // If it's already a Response (e.g., ImageResponse), return directly - if (result instanceof Response) return result; - // Serialize based on type - if (metaRoute.type === "sitemap") body = sitemapToXml(result); - else if (metaRoute.type === "robots") body = robotsToText(result); - else if (metaRoute.type === "manifest") body = manifestToJson(result); - else body = JSON.stringify(result); - return new Response(body, { - headers: { "Content-Type": metaRoute.contentType }, - }); - } - } else { - // Static metadata file — decode from embedded base64 data - try { - const binary = atob(metaRoute.fileDataBase64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new Response(bytes, { - headers: { - "Content-Type": metaRoute.contentType, - "Cache-Control": "public, max-age=0, must-revalidate", - }, - }); - } catch { - return new Response("Not Found", { status: 404 }); + if (cleanPathname === metaRoute.servedUrl) { + if (metaRoute.isDynamic) { + // Dynamic metadata route — call the default export and serialize + const metaFn = metaRoute.module.default; + if (typeof metaFn === "function") { + const result = await metaFn(); + let body; + // If it's already a Response (e.g., ImageResponse), return directly + if (result instanceof Response) return result; + // Serialize based on type + if (metaRoute.type === "sitemap") body = sitemapToXml(result); + else if (metaRoute.type === "robots") body = robotsToText(result); + else if (metaRoute.type === "manifest") body = manifestToJson(result); + else body = JSON.stringify(result); + return new Response(body, { + headers: { "Content-Type": metaRoute.contentType }, + }); + } + } else { + // Static metadata file — decode from embedded base64 data + try { + const binary = atob(metaRoute.fileDataBase64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new Response(bytes, { + headers: { + "Content-Type": metaRoute.contentType, + "Cache-Control": "public, max-age=0, must-revalidate", + }, + }); + } catch { + return new Response("Not Found", { status: 404 }); + } } } } // Set navigation context for Server Components. - // Note: Headers context is already set by runWithRequestContext in the handler wrapper. + // Note: Headers context is already set by runWithHeadersContext in the handler wrapper. setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, @@ -14112,21 +13604,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // and receive a page HTML instead of RSC stream). Instead, we return a 200 // with x-action-redirect header that the client entry detects and handles. if (actionRedirect) { - const actionPendingCookies = getAndClearPendingCookies(); - const actionDraftCookie = getDraftModeCookieHeader(); + const actionRenderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - const redirectHeaders = new Headers({ + const redirectHeaders = __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", "x-action-redirect": actionRedirect.url, "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), - }); - for (const cookie of actionPendingCookies) { - redirectHeaders.append("Set-Cookie", cookie); - } - if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); + }, actionRenderHeaders); // Send an empty RSC-like body (client will navigate instead of parsing) return new Response("", { status: 200, headers: redirectHeaders }); } @@ -14160,28 +13647,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Collect cookies set during the action synchronously (before stream is consumed). // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithRequestContext + // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext // handles cleanup naturally when all async continuations complete. - const actionPendingCookies = getAndClearPendingCookies(); - const actionDraftCookie = getDraftModeCookieHeader(); - - const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; - const actionResponse = new Response(rscStream, { headers: actionHeaders }); - if (actionPendingCookies.length > 0 || actionDraftCookie) { - for (const cookie of actionPendingCookies) { - actionResponse.headers.append("Set-Cookie", cookie); - } - if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie); - } - return actionResponse; + const actionRenderHeaders = consumeRenderResponseHeaders(); + + const actionHeaders = __headersWithRenderResponseHeaders({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }, actionRenderHeaders); + return new Response(rscStream, { headers: actionHeaders }); } catch (err) { - getAndClearPendingCookies(); // Clear pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error console.error("[vinext] Server action error:", err); _reportRequestError( err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: cleanPathname, routeType: "action" }, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report server action error:", reportErr); + }); setHeadersContext(null); setNavigationContext(null); return new Response( @@ -14223,7 +13707,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (!match) { - // Render custom not-found page if available, otherwise plain 404 const notFoundResponse = await renderNotFoundPage(null, isRscRequest, request); if (notFoundResponse) return notFoundResponse; @@ -14245,16 +13728,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (route.routeHandler) { const handler = route.routeHandler; const method = request.method.toUpperCase(); - const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 && handler.revalidate !== Infinity ? handler.revalidate : null; - if (typeof handler["default"] === "function" && process.env.NODE_ENV === "development") { - console.error( - "[vinext] Detected default export in route handler " + route.pattern + ". Export a named export for each HTTP method instead.", - ); - } + const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 ? handler.revalidate : null; // Collect exported HTTP methods for OPTIONS auto-response and Allow header - const exportedMethods = collectRouteHandlerMethods(handler); - const allowHeaderForOptions = buildRouteHandlerAllowHeader(exportedMethods); + const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; + const exportedMethods = HTTP_METHODS.filter((m) => typeof handler[m] === "function"); + // If GET is exported, HEAD is implicitly supported + if (exportedMethods.includes("GET") && !exportedMethods.includes("HEAD")) { + exportedMethods.push("HEAD"); + } + const hasDefault = typeof handler["default"] === "function"; // Route handlers need the same middleware header/status merge behavior as // page responses. This keeps middleware response headers visible on API @@ -14266,12 +13749,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // return is skipped, but the copy loop below is a no-op, so no incorrect // headers are added. The allocation cost in that case is acceptable. if (!_mwCtx.headers && _mwCtx.status == null) return response; - const responseHeaders = new Headers(response.headers); - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - responseHeaders.append(key, value); - } - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers); + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); return new Response(response.body, { status: _mwCtx.status ?? response.status, statusText: response.statusText, @@ -14281,107 +13760,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // OPTIONS auto-implementation: respond with Allow header and 204 if (method === "OPTIONS" && typeof handler["OPTIONS"] !== "function") { + const allowMethods = hasDefault ? HTTP_METHODS : exportedMethods; + if (!allowMethods.includes("OPTIONS")) allowMethods.push("OPTIONS"); setHeadersContext(null); setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 204, - headers: { "Allow": allowHeaderForOptions }, + headers: { "Allow": allowMethods.join(", ") }, })); } // HEAD auto-implementation: run GET handler and strip body - let handlerFn = handler[method]; + let handlerFn = handler[method] || handler["default"]; let isAutoHead = false; if (method === "HEAD" && typeof handler["HEAD"] !== "function" && typeof handler["GET"] === "function") { handlerFn = handler["GET"]; isAutoHead = true; } - // ISR cache read for route handlers (production only). - // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. - // This runs before handler execution so a cache HIT skips the handler entirely. - if ( - process.env.NODE_ENV === "production" && - revalidateSeconds !== null && - handler.dynamic !== "force-dynamic" && - (method === "GET" || isAutoHead) && - typeof handlerFn === "function" - ) { - const __routeKey = __isrRouteKey(cleanPathname); - try { - const __cached = await __isrGet(__routeKey); - if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { - // HIT — return cached response immediately - const __cv = __cached.value.value; - __isrDebug?.("HIT (route)", cleanPathname); - setHeadersContext(null); - setNavigationContext(null); - const __hitHeaders = Object.assign({}, __cv.headers || {}); - __hitHeaders["X-Vinext-Cache"] = "HIT"; - __hitHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; - if (isAutoHead) { - return attachRouteHandlerMiddlewareContext(new Response(null, { status: __cv.status, headers: __hitHeaders })); - } - return attachRouteHandlerMiddlewareContext(new Response(__cv.body, { status: __cv.status, headers: __hitHeaders })); - } - if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { - // STALE — serve stale response, trigger background regeneration - const __sv = __cached.value.value; - const __revalSecs = revalidateSeconds; - const __revalHandlerFn = handlerFn; - const __revalParams = params; - const __revalUrl = request.url; - const __revalSearchParams = new URLSearchParams(url.searchParams); - __triggerBackgroundRegeneration(__routeKey, async function() { - const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalUCtx = _createUnifiedCtx({ - headersContext: __revalHeadCtx, - executionContext: _getRequestExecutionContext(), - }); - await _runWithUnifiedCtx(__revalUCtx, async () => { - _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); - const __syntheticReq = new Request(__revalUrl, { method: "GET" }); - const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); - const __regenDynamic = consumeDynamicUsage(); - setNavigationContext(null); - if (__regenDynamic) { - __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); - return; - } - const __freshBody = await __revalResponse.arrayBuffer(); - const __freshHeaders = {}; - __revalResponse.headers.forEach(function(v, k) { - if (k !== "x-vinext-cache" && k !== "cache-control") __freshHeaders[k] = v; - }); - const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __freshBody, status: __revalResponse.status, headers: __freshHeaders }, __revalSecs, __routeTags); - __isrDebug?.("route regen complete", __routeKey); - }); - }); - __isrDebug?.("STALE (route)", cleanPathname); - setHeadersContext(null); - setNavigationContext(null); - const __staleHeaders = Object.assign({}, __sv.headers || {}); - __staleHeaders["X-Vinext-Cache"] = "STALE"; - __staleHeaders["Cache-Control"] = "s-maxage=0, stale-while-revalidate"; - if (isAutoHead) { - return attachRouteHandlerMiddlewareContext(new Response(null, { status: __sv.status, headers: __staleHeaders })); - } - return attachRouteHandlerMiddlewareContext(new Response(__sv.body, { status: __sv.status, headers: __staleHeaders })); - } - } catch (__routeCacheErr) { - // Cache read failure — fall through to normal handler execution - console.error("[vinext] ISR route cache read error:", __routeCacheErr); - } - } - if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); - const handlerSetCacheControl = response.headers.has("cache-control"); // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, @@ -14390,56 +13791,20 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { revalidateSeconds !== null && !dynamicUsedInHandler && (method === "GET" || isAutoHead) && - !handlerSetCacheControl + !response.headers.has("cache-control") ) { response.headers.set("cache-control", "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"); } - // ISR cache write for route handlers (production, MISS). - // Store the raw handler response before cookie/middleware transforms - // (those are request-specific and shouldn't be cached). - if ( - process.env.NODE_ENV === "production" && - revalidateSeconds !== null && - handler.dynamic !== "force-dynamic" && - !dynamicUsedInHandler && - (method === "GET" || isAutoHead) && - !handlerSetCacheControl - ) { - response.headers.set("X-Vinext-Cache", "MISS"); - const __routeClone = response.clone(); - const __routeKey = __isrRouteKey(cleanPathname); - const __revalSecs = revalidateSeconds; - const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - const __routeWritePromise = (async () => { - try { - const __buf = await __routeClone.arrayBuffer(); - const __hdrs = {}; - __routeClone.headers.forEach(function(v, k) { - if (k !== "x-vinext-cache" && k !== "cache-control") __hdrs[k] = v; - }); - await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __buf, status: __routeClone.status, headers: __hdrs }, __revalSecs, __routeTags); - __isrDebug?.("route cache written", __routeKey); - } catch (__cacheErr) { - console.error("[vinext] ISR route cache write error:", __cacheErr); - } - })(); - _getRequestExecutionContext()?.waitUntil(__routeWritePromise); - } - - // Collect any Set-Cookie headers from cookies().set()/delete() calls - const pendingCookies = getAndClearPendingCookies(); - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // If we have pending cookies, create a new response with them attached - if (pendingCookies.length > 0 || draftCookie) { - const newHeaders = new Headers(response.headers); - for (const cookie of pendingCookies) { - newHeaders.append("Set-Cookie", cookie); - } - if (draftCookie) newHeaders.append("Set-Cookie", draftCookie); + if (renderResponseHeaders) { + const newHeaders = __headersWithRenderResponseHeaders( + response.headers, + renderResponseHeaders, + ); if (isAutoHead) { return attachRouteHandlerMiddlewareContext(new Response(null, { @@ -14465,7 +13830,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return attachRouteHandlerMiddlewareContext(response); } catch (err) { - getAndClearPendingCookies(); // Clear any pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error // Catch redirect() / notFound() thrown from route handlers if (err && typeof err === "object" && "digest" in err) { const digest = String(err.digest); @@ -14494,7 +13859,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: route.pattern, routeType: "route" }, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report route handler error:", reportErr); + }); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 500 })); } finally { setHeadersAccessPhase(previousHeadersPhase); @@ -14504,6 +13871,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 405, + headers: { Allow: exportedMethods.join(", ") }, })); } @@ -14585,29 +13953,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HIT (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.rscData, { + return __responseWithMiddlewareContext(new Response(__cachedValue.rscData, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } if (!isRscRequest && __hasHtml) { __isrDebug?.("HIT (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.html, { + return __responseWithMiddlewareContext(new Response(__cachedValue.html, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } __isrDebug?.("MISS (empty cached entry)", cleanPathname); } @@ -14623,58 +13991,62 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // user request — to prevent user-specific cookies/auth headers from leaking // into content that is cached and served to all subsequent users. const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalUCtx = _createUnifiedCtx({ - headersContext: __revalHeadCtx, - executionContext: _getRequestExecutionContext(), - }); - const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { - _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); - const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); - const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); - // Tee RSC stream: one for SSR, one to capture rscData - const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); - // Capture rscData bytes in parallel with SSR - const __rscDataPromise = (async () => { - const __rscReader = __revalRscForCapture.getReader(); - const __rscChunks = []; - let __rscTotal = 0; - for (;;) { - const { done, value } = await __rscReader.read(); - if (done) break; - __rscChunks.push(value); - __rscTotal += value.byteLength; - } - const __rscBuf = new Uint8Array(__rscTotal); - let __rscOff = 0; - for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } - return __rscBuf.buffer; - })(); - const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; - const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); - setHeadersContext(null); - setNavigationContext(null); - // Collect the full HTML string from the stream - const __revalReader = __revalHtmlStream.getReader(); - const __revalDecoder = new TextDecoder(); - const __revalChunks = []; - for (;;) { - const { done, value } = await __revalReader.read(); - if (done) break; - __revalChunks.push(__revalDecoder.decode(value, { stream: true })); - } - __revalChunks.push(__revalDecoder.decode()); - const __freshHtml = __revalChunks.join(""); - const __freshRscData = await __rscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; - }); + const __revalResult = await runWithHeadersContext(__revalHeadCtx, () => + _runWithNavigationContext(() => + _runWithCacheState(() => + _runWithPrivateCache(() => + runWithFetchCache(async () => { + setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); + const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); + const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); + const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); + // Tee RSC stream: one for SSR, one to capture rscData + const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); + // Capture rscData bytes in parallel with SSR + const __rscDataPromise = (async () => { + const __rscReader = __revalRscForCapture.getReader(); + const __rscChunks = []; + let __rscTotal = 0; + for (;;) { + const { done, value } = await __rscReader.read(); + if (done) break; + __rscChunks.push(value); + __rscTotal += value.byteLength; + } + const __rscBuf = new Uint8Array(__rscTotal); + let __rscOff = 0; + for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } + return __rscBuf.buffer; + })(); + const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; + const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); + const __freshRscData = await __rscDataPromise; + const __renderHeaders = consumeRenderResponseHeaders(); + setHeadersContext(null); + setNavigationContext(null); + // Collect the full HTML string from the stream + const __revalReader = __revalHtmlStream.getReader(); + const __revalDecoder = new TextDecoder(); + const __revalChunks = []; + for (;;) { + const { done, value } = await __revalReader.read(); + if (done) break; + __revalChunks.push(__revalDecoder.decode(value, { stream: true })); + } + __revalChunks.push(__revalDecoder.decode()); + const __freshHtml = __revalChunks.join(""); + const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; + }) + ) + ) + ) + ); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); __isrDebug?.("regen complete", cleanPathname); }); @@ -14682,29 +14054,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("STALE (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.rscData, { + return __responseWithMiddlewareContext(new Response(__staleValue.rscData, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { __isrDebug?.("STALE (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.html, { + return __responseWithMiddlewareContext(new Response(__staleValue.html, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } // Stale entry exists but is empty for this request type — fall through to render __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); @@ -14779,7 +14151,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError }); // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithRequestContext + // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext // handles cleanup naturally when all async continuations complete. return new Response(interceptStream, { headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -14794,65 +14166,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let element; - try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); - } catch (buildErr) { - // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components - if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { - const digest = String(buildErr.digest); + // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim + async function handleRenderError(err, fallbackOpts) { + if (err && typeof err === "object" && "digest" in err) { + const digest = String(err.digest); if (digest.startsWith("NEXT_REDIRECT;")) { const parts = digest.split(";"); const redirectUrl = decodeURIComponent(parts[2]); const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); + return __responseWithMiddlewareContext(new Response(null, { + status: statusCode, + headers: { Location: new URL(redirectUrl, request.url).toString() }, + }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false }); } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + ...fallbackOpts, + }); + const renderResponseHeaders = consumeRenderResponseHeaders(); + if (fallbackResp) { + return __responseWithMiddlewareContext( + fallbackResp, + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); + } setHeadersContext(null); setNavigationContext(null); const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); + return __responseWithMiddlewareContext( + new Response(statusText, { status: statusCode }), + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); } } + return null; + } + + let element; + try { + element = await buildPageElement(route, params, interceptOpts, url.searchParams); + } catch (buildErr) { + const specialResponse = await handleRenderError(buildErr); + if (specialResponse) return specialResponse; // Non-special error (e.g. generateMetadata() threw) — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw buildErr; } + const __buildRenderResponseHeaders = peekRenderResponseHeaders(); + const __buildDynamicUsage = peekDynamicUsage(); // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - if (err && typeof err === "object" && "digest" in err) { - const digest = String(err.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); - } - } - return null; - } - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws // notFound(), the parent layout's boundary catches it and renders the parent's @@ -14878,44 +14260,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const lr = LayoutComp({ params: asyncParams, children: null }); if (lr && typeof lr === "object" && typeof lr.then === "function") await lr; } catch (layoutErr) { - if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) { - const digest = String(layoutErr.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; } - if (!parentNotFound) parentNotFound = null; - // Wrap in only the layouts above the throwing one - const parentLayouts = route.layouts.slice(0, li); - const fallbackResp = await renderHTTPAccessFallbackPage( - route, statusCode, isRscRequest, request, - { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params } - ); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); } } + if (!parentNotFound) parentNotFound = null; + // Wrap in only the layouts above the throwing one + const parentLayouts = route.layouts.slice(0, li); + const specialResponse = await handleRenderError(layoutErr, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, + }); + if (specialResponse) return specialResponse; // Not a special error — let it propagate through normal RSC rendering } } @@ -14964,6 +14328,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); if (_pageProbeResult instanceof Response) return _pageProbeResult; + // The sync pre-render probes above are only for catching redirect/notFound + // before streaming begins. Discard any render-time response headers they + // may have produced while preserving headers generated during buildPageElement + // (e.g. generateMetadata), since those are part of the real render output. + restoreRenderResponseHeaders(__buildRenderResponseHeaders); + restoreDynamicUsage(__buildDynamicUsage); + // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); @@ -15014,7 +14385,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // NOTE: Do NOT clear headers/navigation context here! // The RSC stream is consumed lazily - components render when chunks are read. // If we clear context now, headers()/cookies() will fail during rendering. - // Context will be cleared when the next request starts (via runWithRequestContext). + // Context will be cleared when the next request starts (via runWithHeadersContext). const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; // Include matched route params so the client can hydrate useParams() if (params && Object.keys(params).length > 0) { @@ -15031,37 +14402,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } else if (revalidateSeconds) { responseHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; } - // Merge middleware response headers into the RSC response. - // set-cookie and vary are accumulated to preserve existing values - // (e.g. "Vary: RSC, Accept" set above); all other keys use plain - // assignment so middleware headers win over config headers, which - // the outer handler applies afterward and skips keys already present. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - const lk = key.toLowerCase(); - if (lk === "set-cookie") { - const existing = responseHeaders[lk]; - if (Array.isArray(existing)) { - existing.push(value); - } else if (existing) { - responseHeaders[lk] = [existing, value]; - } else { - responseHeaders[lk] = [value]; - } - } else if (lk === "vary") { - // Accumulate Vary values to preserve the existing "RSC, Accept" entry. - const existing = responseHeaders["Vary"] ?? responseHeaders["vary"]; - if (existing) { - responseHeaders["Vary"] = existing + ", " + value; - if (responseHeaders["vary"] !== undefined) delete responseHeaders["vary"]; - } else { - responseHeaders[key] = value; - } - } else { - responseHeaders[key] = value; - } - } - } // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -15081,22 +14421,45 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { - responseHeaders["X-Vinext-Cache"] = "MISS"; const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; + const __responseRenderHeaders = peekRenderResponseHeaders(); + if (peekDynamicUsage()) { + responseHeaders["Cache-Control"] = "no-store, must-revalidate"; + } else { + responseHeaders["X-Vinext-Cache"] = "MISS"; + } const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? __responseRenderHeaders; + // consume picks up headers added during late async RSC streaming work. + // Falls back to the snapshot taken before the live MISS response was returned. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip RSC cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); + await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); + } finally { + setHeadersContext(null); + setNavigationContext(null); } })(); _getRequestExecutionContext()?.waitUntil(__rscWritePromise); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } - return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: responseHeaders, + }), _mwCtx); } // Collect font data from RSC environment before passing to SSR @@ -15134,7 +14497,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw ssrErr; } @@ -15144,31 +14514,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // when the error falls through to global-error.tsx. - // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response + // Helper to attach render-time response headers, middleware headers, font + // Link header, and rewrite status to a response. function attachMiddlewareContext(response) { - if (draftCookie) { - response.headers.append("Set-Cookie", draftCookie); - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderResponseHeaders); // Set HTTP Link header for font preloading if (fontLinkHeader) { - response.headers.set("Link", fontLinkHeader); - } - // Merge middleware response headers into the final response. - // The response is freshly constructed above (new Response(htmlStream, {...})), - // so set() and append() are equivalent — there are no same-key conflicts yet. - // Precedence over config headers is handled by the outer handler, which - // skips config keys that middleware already placed on the response. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - response.headers.append(key, value); - } + responseHeaders.set("Link", fontLinkHeader); } + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -15183,21 +14544,30 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const renderMs = __renderEnd !== undefined && __compileEnd !== undefined ? Math.round(__renderEnd - __compileEnd) : -1; - response.headers.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); + responseHeaders.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); } // Apply custom status code from middleware rewrite if (_mwCtx.status) { return new Response(response.body, { status: _mwCtx.status, - headers: response.headers, + headers: responseHeaders, }); } - return response; + const responseInit = { + status: response.status, + headers: responseHeaders, + }; + if (response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); } // Check if any component called connection(), cookies(), headers(), or noStore() // during rendering. If so, treat as dynamic (skip ISR, set no-store). - const dynamicUsedDuringRender = consumeDynamicUsage(); + const dynamicUsedDuringRender = __isrRscDataPromise + ? peekDynamicUsage() + : consumeDynamicUsage(); // Check if cacheLife() was called during rendering (e.g., page with file-level "use cache"). // If so, use its revalidation period for the Cache-Control header. @@ -15281,6 +14651,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? renderResponseHeaders; + // consume picks up any headers added during stream consumption by late + // async render work (for example, suspended branches). Falls back to + // the snapshot taken before streaming began when nothing new was added. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip HTML cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) @@ -15288,12 +14667,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ensuring the first client-side navigation after a direct visit is a // cache hit rather than a miss. const __writes = [ - __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags), + __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags), ]; if (__capturedRscDataPromise) { __writes.push( __capturedRscDataPromise.then((__rscBuf) => - __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags) + __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags) ) ); } @@ -15301,6 +14680,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HTML cache written", __isrKey); } catch (__cacheErr) { console.error("[vinext] ISR cache write error:", __cacheErr); + } finally { + consumeRenderResponseHeaders(); } })(); // Register with ExecutionContext (from ALS) so the Workers runtime keeps @@ -15385,7 +14766,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; +import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; @@ -15395,38 +14776,19 @@ 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 { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; -import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; +import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; +import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; +import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; +import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. -import "vinext/navigation-state"; -import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; +import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; function _getSSRFontStyles() { return [..._getSSRFontStylesGoogle(), ..._getSSRFontStylesLocal()]; } function _getSSRFontPreloads() { return [..._getSSRFontPreloadsGoogle(), ..._getSSRFontPreloadsLocal()]; } - -// Duplicated from the build-time constant above via JSON.stringify. -const ROUTE_HANDLER_HTTP_METHODS = ["GET","HEAD","POST","PUT","DELETE","PATCH","OPTIONS"]; - -function collectRouteHandlerMethods(handler) { - const methods = ROUTE_HANDLER_HTTP_METHODS.filter((method) => typeof handler[method] === "function"); - if (methods.includes("GET") && !methods.includes("HEAD")) { - methods.push("HEAD"); - } - return methods; -} - -function buildRouteHandlerAllowHeader(exportedMethods) { - const allow = new Set(exportedMethods); - allow.add("OPTIONS"); - return Array.from(allow).sort().join(", "); -} - - // ALS used to suppress the expected "Invalid hook call" dev warning when // layout/page components are probed outside React's render cycle. Patching // console.error once at module load (instead of per-request) avoids the @@ -15488,18 +14850,73 @@ function __pageCacheTags(pathname, extraTags) { if (!tags.includes(tag)) tags.push(tag); } } - return tags; + return tags; +} +function __isAppendOnlyResponseHeader(lowerKey) { + return lowerKey === "set-cookie" || lowerKey === "vary" || lowerKey === "www-authenticate" || lowerKey === "proxy-authenticate"; +} +function __mergeResponseHeaderValues(targetHeaders, key, value, mode) { + const lowerKey = key.toLowerCase(); + const values = Array.isArray(value) ? value : [value]; + if (__isAppendOnlyResponseHeader(lowerKey)) { + for (const item of values) targetHeaders.append(key, item); + return; + } + if (mode === "fallback" && targetHeaders.has(key)) return; + targetHeaders.delete(key); + if (values.length === 1) { + targetHeaders.set(key, values[0]); + return; + } + for (const item of values) targetHeaders.append(key, item); +} +function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) { + if (!sourceHeaders) return; + if (sourceHeaders instanceof Headers) { + const __setCookies = typeof sourceHeaders.getSetCookie === "function" + ? sourceHeaders.getSetCookie() + : []; + for (const [key, value] of sourceHeaders) { + if (key.toLowerCase() === "set-cookie") { + // entries() flattens Set-Cookie into a single comma-joined value. + // If getSetCookie() is unavailable, drop cookies rather than corrupt them. + continue; + } + __mergeResponseHeaderValues(targetHeaders, key, value, mode); + } + for (const cookie of __setCookies) { + targetHeaders.append("Set-Cookie", cookie); + } + return; + } + for (const [key, value] of Object.entries(sourceHeaders)) { + __mergeResponseHeaderValues(targetHeaders, key, value, mode); + } +} +function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) { + const headers = new Headers(); + __mergeResponseHeaders(headers, renderHeaders, "fallback"); + __mergeResponseHeaders(headers, baseHeaders, "override"); + return headers; +} +function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) { + __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override"); +} +function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) { + const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status; + if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response; + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderHeaders); + __applyMiddlewareResponseHeaders(responseHeaders, middlewareCtx?.headers); + const status = rewriteStatus ?? response.status; + const responseInit = { + status, + headers: responseHeaders, + }; + if (status === response.status && response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); } -// Note: cache entries are written with \`headers: undefined\`. Next.js stores -// response headers (e.g. set-cookie from cookies().set() during render) in the -// cache entry so they can be replayed on HIT. We don't do this because: -// 1. Pages that call cookies().set() during render trigger dynamicUsedDuringRender, -// which opts them out of ISR caching before we reach the write path. -// 2. Custom response headers set via next/headers are not yet captured separately -// from the live Response object in vinext's server pipeline. -// In practice this means ISR-cached responses won't replay render-time set-cookie -// headers — but that case is already prevented by the dynamic-usage opt-out. -// TODO: capture render-time response headers for full Next.js parity. const __pendingRegenerations = new Map(); function __triggerBackgroundRegeneration(key, renderFn) { if (__pendingRegenerations.has(key)) return; @@ -15547,7 +14964,6 @@ function __isrCacheKey(pathname, suffix) { } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } -function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators // have a single knob for all cache tracing. @@ -15700,7 +15116,9 @@ function rscOnError(error, requestInfo, errorContext) { error instanceof Error ? error : new Error(String(error)), requestInfo, errorContext, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report render error:", reportErr); + }); } // In production, generate a digest hash for non-navigation errors @@ -15937,7 +15355,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. + // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. return new Response(rscStream, { status: statusCode, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -16070,7 +15488,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. + // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. return new Response(rscStream, { status: 200, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -16459,50 +15877,17 @@ async function buildPageElement(route, params, opts, searchParams) { const __mwPatternCache = new Map(); -function __extractConstraint(str, re) { - if (str[re.lastIndex] !== "(") return null; - const start = re.lastIndex + 1; - let depth = 1; - let i = start; - while (i < str.length && depth > 0) { - if (str[i] === "(") depth++; - else if (str[i] === ")") depth--; - i++; - } - if (depth !== 0) return null; - re.lastIndex = i; - return str.slice(start, i - 1); -} function __compileMwPattern(pattern) { - const hasConstraints = /:[\\w-]+[*+]?\\(/.test(pattern); - if (!hasConstraints && (pattern.includes("(") || pattern.includes("\\\\"))) { + if (pattern.includes("(") || pattern.includes("\\\\")) { return __safeRegExp("^" + pattern + "$"); } let regexStr = ""; const tokenRe = /\\/:([\\w-]+)\\*|\\/:([\\w-]+)\\+|:([\\w-]+)|[.]|[^/:.]+|./g; let tok; while ((tok = tokenRe.exec(pattern)) !== null) { - if (tok[1] !== undefined) { - const c1 = hasConstraints ? __extractConstraint(pattern, tokenRe) : null; - regexStr += c1 !== null ? "(?:/(" + c1 + "))?" : "(?:/.*)?"; - } - else if (tok[2] !== undefined) { - const c2 = hasConstraints ? __extractConstraint(pattern, tokenRe) : null; - regexStr += c2 !== null ? "(?:/(" + c2 + "))" : "(?:/.+)"; - } - else if (tok[3] !== undefined) { - const constraint = hasConstraints ? __extractConstraint(pattern, tokenRe) : null; - const isOptional = pattern[tokenRe.lastIndex] === "?"; - if (isOptional) tokenRe.lastIndex += 1; - const group = constraint !== null ? "(" + constraint + ")" : "([^/]+)"; - if (isOptional && regexStr.endsWith("/")) { - regexStr = regexStr.slice(0, -1) + "(?:/" + group + ")?"; - } else if (isOptional) { - regexStr += group + "?"; - } else { - regexStr += group; - } - } + if (tok[1] !== undefined) { regexStr += "(?:/.*)?"; } + else if (tok[2] !== undefined) { regexStr += "(?:/.+)"; } + else if (tok[3] !== undefined) { regexStr += "([^/]+)"; } else if (tok[0] === ".") { regexStr += "\\\\."; } else { regexStr += tok[0]; } } @@ -16711,16 +16096,7 @@ function __validateDevRequestOrigin(request) { } const origin = request.headers.get("origin"); - if (!origin) return null; - - // Origin "null" is sent by opaque/sandboxed contexts. Block unless explicitly allowed. - if (origin === "null") { - if (!__allowedDevOrigins.includes("null")) { - console.warn("[vinext] Blocked request with Origin: null. Add \\"null\\" to allowedDevOrigins to allow sandboxed contexts."); - return new Response("Forbidden", { status: 403, headers: { "Content-Type": "text/plain" } }); - } - return null; - } + if (!origin || origin === "null") return null; let originHostname; try { @@ -16971,64 +16347,62 @@ async function __readFormDataWithLimit(request, maxBytes) { return new Response(combined, { headers: { "Content-Type": contentType } }).formData(); } -// Map from route pattern to generateStaticParams function. -// Used by the prerender phase to enumerate dynamic route URLs without -// loading route modules via the dev server. -export const generateStaticParamsMap = { -// TODO: layout-level generateStaticParams — this map only includes routes that -// have a pagePath (leaf pages). Layout segments can also export generateStaticParams -// to provide parent params for nested dynamic routes, but they don't have a pagePath -// so they are excluded here. Supporting layout-level generateStaticParams requires -// scanning layout.tsx files separately and including them in this map. - "/blog/:slug": mod_3?.generateStaticParams ?? null, -}; - export default async function handler(request, ctx) { - // Wrap the entire request in a single unified ALS scope for per-request - // isolation. All state modules (headers, navigation, cache, fetch-cache, - // execution-context) read from this store via isInsideUnifiedScope(). + // 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 + // streaming), preventing state leakage between concurrent requests on + // Cloudflare Workers and other concurrent runtimes. + // + // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so + // that KVCacheHandler._putInBackground can register background KV puts with + // ctx.waitUntil() without needing ctx passed at construction time. const headersCtx = headersContextFromRequest(request); - const __uCtx = _createUnifiedCtx({ - headersContext: headersCtx, - executionContext: ctx ?? _getRequestExecutionContext() ?? null, - }); - return _runWithUnifiedCtx(__uCtx, async () => { - _ensureFetchPatch(); - const __reqCtx = requestContextFromRequest(request); - // 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); - // 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); - let pathname; - try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } - - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); - for (const h of extraHeaders) { - // Use append() for headers where multiple values must coexist - // (Vary, Set-Cookie). Using set() on these would destroy - // existing values like "Vary: RSC, Accept" which are critical - // for correct CDN caching behavior. - const lk = h.key.toLowerCase(); - if (lk === "vary" || lk === "set-cookie") { - response.headers.append(h.key, h.value); - } else if (!response.headers.has(lk)) { - // Middleware headers take precedence: skip config keys already - // set by middleware so middleware headers always win. - response.headers.set(h.key, h.value); - } - } - } - } - return response; - }); + const _run = () => runWithHeadersContext(headersCtx, () => + _runWithNavigationContext(() => + _runWithCacheState(() => + _runWithPrivateCache(() => + runWithFetchCache(async () => { + const __reqCtx = requestContextFromRequest(request); + // 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); + // 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); + let pathname; + try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } + + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + for (const h of extraHeaders) { + // Use append() for headers where multiple values must coexist + // (Vary, Set-Cookie). Using set() on these would destroy + // existing values like "Vary: RSC, Accept" which are critical + // for correct CDN caching behavior. + const lk = h.key.toLowerCase(); + if (lk === "vary" || lk === "set-cookie") { + response.headers.append(h.key, h.value); + } else if (!response.headers.has(lk)) { + // Middleware headers take precedence: skip config keys already + // set by middleware so middleware headers always win. + response.headers.set(h.key, h.value); + } + } + } + } + return response; + }) + ) + ) + ) + ); + return ctx ? _runWithExecutionContext(ctx, _run) : _run(); } async function _handleRequest(request, __reqCtx, _mwCtx) { @@ -17063,38 +16437,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { - // ── Prerender: static-params endpoint ──────────────────────────────── - // Internal endpoint used by prerenderApp() during build to fetch - // generateStaticParams results via wrangler unstable_startWorker. - // Gated on VINEXT_PRERENDER=1 to prevent exposure in normal deployments. - // For Node builds, process.env.VINEXT_PRERENDER is set directly by the - // prerender orchestrator. For CF Workers builds, wrangler unstable_startWorker - // injects VINEXT_PRERENDER as a binding which Miniflare exposes via process.env - // in bundled workers. The /__vinext/ prefix ensures no user route ever conflicts. - if (pathname === "/__vinext/prerender/static-params") { - if (process.env.VINEXT_PRERENDER !== "1") { - return new Response("Not Found", { status: 404 }); - } - const pattern = url.searchParams.get("pattern"); - if (!pattern) return new Response("missing pattern", { status: 400 }); - const fn = generateStaticParamsMap[pattern]; - if (typeof fn !== "function") return new Response("null", { status: 200, headers: { "content-type": "application/json" } }); - try { - const parentParams = url.searchParams.get("parentParams"); - const raw = parentParams ? JSON.parse(parentParams) : {}; - // Ensure params is a plain object — reject primitives, arrays, and null - // so user-authored generateStaticParams always receives { params: {} } - // rather than { params: 5 } or similar if input is malformed. - const params = (typeof raw === "object" && raw !== null && !Array.isArray(raw)) ? raw : {}; - const result = await fn({ params }); - return new Response(JSON.stringify(result), { status: 200, headers: { "content-type": "application/json" } }); - } catch (e) { - return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers: { "content-type": "application/json" } }); - } - } - - - // Trailing slash normalization (redirect to canonical form) const __tsRedirect = normalizeTrailingSlash(pathname, __basePath, __trailingSlash, url.search); if (__tsRedirect) return __tsRedirect; @@ -17129,47 +16471,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // every response path without module-level state that races on Workers. - // In hybrid app+pages dev mode the connect handler already ran middleware - // and forwarded the results via x-vinext-mw-ctx. Reconstruct _mwCtx from - // the forwarded data instead of re-running the middleware function. - // Guarded by NODE_ENV because this header only exists in dev (the connect - // handler sets it). In production there is no connect handler, so an - // attacker-supplied header must not be trusted. - let __mwCtxApplied = false; - if (process.env.NODE_ENV !== "production") { - const __mwCtxHeader = request.headers.get("x-vinext-mw-ctx"); - if (__mwCtxHeader) { - try { - const __mwCtxData = JSON.parse(__mwCtxHeader); - if (__mwCtxData.h && __mwCtxData.h.length > 0) { - // Note: h may include x-middleware-request-* internal headers so - // applyMiddlewareRequestHeaders() can unpack them below. - // processMiddlewareHeaders() strips them before any response. - _mwCtx.headers = new Headers(); - for (const [key, value] of __mwCtxData.h) { - _mwCtx.headers.append(key, value); - } - } - if (__mwCtxData.s != null) { - _mwCtx.status = __mwCtxData.s; - } - // Apply forwarded middleware rewrite so routing uses the rewritten path. - // The RSC plugin constructs its Request from the original HTTP request, - // not from req.url, so the connect handler's req.url rewrite is invisible. - if (__mwCtxData.r) { - const __rewriteParsed = new URL(__mwCtxData.r, request.url); - cleanPathname = __rewriteParsed.pathname; - url.search = __rewriteParsed.search; - } - // Flag set after full context application — if any step fails (e.g. malformed - // rewrite URL), we fall back to re-running middleware as a safety net. - __mwCtxApplied = true; - } catch (e) { - console.error("[vinext] Failed to parse forwarded middleware context:", e); - } - } - } - if (!__mwCtxApplied) { // 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 @@ -17194,9 +16495,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest); const mwFetchEvent = new NextFetchEvent({ page: cleanPathname }); const mwResponse = await middlewareFn(nextRequest, mwFetchEvent); - const _mwWaitUntil = mwFetchEvent.drainWaitUntil(); - const _mwExecCtx = _getRequestExecutionContext(); - if (_mwExecCtx && typeof _mwExecCtx.waitUntil === "function") { _mwExecCtx.waitUntil(_mwWaitUntil); } + mwFetchEvent.drainWaitUntil(); if (mwResponse) { // Check for x-middleware-next (continue) if (mwResponse.headers.get("x-middleware-next") === "1") { @@ -17221,10 +16520,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (rewriteUrl) { const rewriteParsed = new URL(rewriteUrl, request.url); cleanPathname = rewriteParsed.pathname; - // Carry over query params from the rewrite URL so that - // searchParams props, useSearchParams(), and navigation context - // reflect the rewrite destination, not the original request. - url.search = rewriteParsed.search; // Capture custom status code from rewrite (e.g. NextResponse.rewrite(url, { status: 403 })) if (mwResponse.status !== 200) { _mwCtx.status = mwResponse.status; @@ -17247,7 +16542,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return new Response("Internal Server Error", { status: 500 }); } } - } // end of if (!__mwCtxApplied) // Unpack x-middleware-request-* headers into the request context so that // headers() returns the middleware-modified headers instead of the original @@ -17319,53 +16613,45 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Skip — the base servedUrl is not served when generateSitemaps exists continue; } - // Match metadata route — use pattern matching for dynamic segments, - // strict equality for static paths. - var _metaParams = null; - if (metaRoute.patternParts) { - var _metaUrlParts = cleanPathname.split("/").filter(Boolean); - _metaParams = matchPattern(_metaUrlParts, metaRoute.patternParts); - if (!_metaParams) continue; - } else if (cleanPathname !== metaRoute.servedUrl) { - continue; - } - if (metaRoute.isDynamic) { - // Dynamic metadata route — call the default export and serialize - const metaFn = metaRoute.module.default; - if (typeof metaFn === "function") { - const result = await metaFn({ params: makeThenableParams(_metaParams || {}) }); - let body; - // If it's already a Response (e.g., ImageResponse), return directly - if (result instanceof Response) return result; - // Serialize based on type - if (metaRoute.type === "sitemap") body = sitemapToXml(result); - else if (metaRoute.type === "robots") body = robotsToText(result); - else if (metaRoute.type === "manifest") body = manifestToJson(result); - else body = JSON.stringify(result); - return new Response(body, { - headers: { "Content-Type": metaRoute.contentType }, - }); - } - } else { - // Static metadata file — decode from embedded base64 data - try { - const binary = atob(metaRoute.fileDataBase64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new Response(bytes, { - headers: { - "Content-Type": metaRoute.contentType, - "Cache-Control": "public, max-age=0, must-revalidate", - }, - }); - } catch { - return new Response("Not Found", { status: 404 }); + if (cleanPathname === metaRoute.servedUrl) { + if (metaRoute.isDynamic) { + // Dynamic metadata route — call the default export and serialize + const metaFn = metaRoute.module.default; + if (typeof metaFn === "function") { + const result = await metaFn(); + let body; + // If it's already a Response (e.g., ImageResponse), return directly + if (result instanceof Response) return result; + // Serialize based on type + if (metaRoute.type === "sitemap") body = sitemapToXml(result); + else if (metaRoute.type === "robots") body = robotsToText(result); + else if (metaRoute.type === "manifest") body = manifestToJson(result); + else body = JSON.stringify(result); + return new Response(body, { + headers: { "Content-Type": metaRoute.contentType }, + }); + } + } else { + // Static metadata file — decode from embedded base64 data + try { + const binary = atob(metaRoute.fileDataBase64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new Response(bytes, { + headers: { + "Content-Type": metaRoute.contentType, + "Cache-Control": "public, max-age=0, must-revalidate", + }, + }); + } catch { + return new Response("Not Found", { status: 404 }); + } } } } // Set navigation context for Server Components. - // Note: Headers context is already set by runWithRequestContext in the handler wrapper. + // Note: Headers context is already set by runWithHeadersContext in the handler wrapper. setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, @@ -17457,21 +16743,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // and receive a page HTML instead of RSC stream). Instead, we return a 200 // with x-action-redirect header that the client entry detects and handles. if (actionRedirect) { - const actionPendingCookies = getAndClearPendingCookies(); - const actionDraftCookie = getDraftModeCookieHeader(); + const actionRenderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - const redirectHeaders = new Headers({ + const redirectHeaders = __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", "x-action-redirect": actionRedirect.url, "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), - }); - for (const cookie of actionPendingCookies) { - redirectHeaders.append("Set-Cookie", cookie); - } - if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); + }, actionRenderHeaders); // Send an empty RSC-like body (client will navigate instead of parsing) return new Response("", { status: 200, headers: redirectHeaders }); } @@ -17505,28 +16786,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Collect cookies set during the action synchronously (before stream is consumed). // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithRequestContext + // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext // handles cleanup naturally when all async continuations complete. - const actionPendingCookies = getAndClearPendingCookies(); - const actionDraftCookie = getDraftModeCookieHeader(); - - const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; - const actionResponse = new Response(rscStream, { headers: actionHeaders }); - if (actionPendingCookies.length > 0 || actionDraftCookie) { - for (const cookie of actionPendingCookies) { - actionResponse.headers.append("Set-Cookie", cookie); - } - if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie); - } - return actionResponse; + const actionRenderHeaders = consumeRenderResponseHeaders(); + + const actionHeaders = __headersWithRenderResponseHeaders({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }, actionRenderHeaders); + return new Response(rscStream, { headers: actionHeaders }); } catch (err) { - getAndClearPendingCookies(); // Clear pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error console.error("[vinext] Server action error:", err); _reportRequestError( err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: cleanPathname, routeType: "action" }, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report server action error:", reportErr); + }); setHeadersContext(null); setNavigationContext(null); return new Response( @@ -17568,7 +16846,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (!match) { - // Render custom not-found page if available, otherwise plain 404 const notFoundResponse = await renderNotFoundPage(null, isRscRequest, request); if (notFoundResponse) return notFoundResponse; @@ -17590,16 +16867,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (route.routeHandler) { const handler = route.routeHandler; const method = request.method.toUpperCase(); - const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 && handler.revalidate !== Infinity ? handler.revalidate : null; - if (typeof handler["default"] === "function" && process.env.NODE_ENV === "development") { - console.error( - "[vinext] Detected default export in route handler " + route.pattern + ". Export a named export for each HTTP method instead.", - ); - } + const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 ? handler.revalidate : null; // Collect exported HTTP methods for OPTIONS auto-response and Allow header - const exportedMethods = collectRouteHandlerMethods(handler); - const allowHeaderForOptions = buildRouteHandlerAllowHeader(exportedMethods); + const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; + const exportedMethods = HTTP_METHODS.filter((m) => typeof handler[m] === "function"); + // If GET is exported, HEAD is implicitly supported + if (exportedMethods.includes("GET") && !exportedMethods.includes("HEAD")) { + exportedMethods.push("HEAD"); + } + const hasDefault = typeof handler["default"] === "function"; // Route handlers need the same middleware header/status merge behavior as // page responses. This keeps middleware response headers visible on API @@ -17611,12 +16888,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // return is skipped, but the copy loop below is a no-op, so no incorrect // headers are added. The allocation cost in that case is acceptable. if (!_mwCtx.headers && _mwCtx.status == null) return response; - const responseHeaders = new Headers(response.headers); - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - responseHeaders.append(key, value); - } - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers); + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); return new Response(response.body, { status: _mwCtx.status ?? response.status, statusText: response.statusText, @@ -17626,107 +16899,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // OPTIONS auto-implementation: respond with Allow header and 204 if (method === "OPTIONS" && typeof handler["OPTIONS"] !== "function") { + const allowMethods = hasDefault ? HTTP_METHODS : exportedMethods; + if (!allowMethods.includes("OPTIONS")) allowMethods.push("OPTIONS"); setHeadersContext(null); setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 204, - headers: { "Allow": allowHeaderForOptions }, + headers: { "Allow": allowMethods.join(", ") }, })); } // HEAD auto-implementation: run GET handler and strip body - let handlerFn = handler[method]; + let handlerFn = handler[method] || handler["default"]; let isAutoHead = false; if (method === "HEAD" && typeof handler["HEAD"] !== "function" && typeof handler["GET"] === "function") { handlerFn = handler["GET"]; isAutoHead = true; } - // ISR cache read for route handlers (production only). - // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. - // This runs before handler execution so a cache HIT skips the handler entirely. - if ( - process.env.NODE_ENV === "production" && - revalidateSeconds !== null && - handler.dynamic !== "force-dynamic" && - (method === "GET" || isAutoHead) && - typeof handlerFn === "function" - ) { - const __routeKey = __isrRouteKey(cleanPathname); - try { - const __cached = await __isrGet(__routeKey); - if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { - // HIT — return cached response immediately - const __cv = __cached.value.value; - __isrDebug?.("HIT (route)", cleanPathname); - setHeadersContext(null); - setNavigationContext(null); - const __hitHeaders = Object.assign({}, __cv.headers || {}); - __hitHeaders["X-Vinext-Cache"] = "HIT"; - __hitHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; - if (isAutoHead) { - return attachRouteHandlerMiddlewareContext(new Response(null, { status: __cv.status, headers: __hitHeaders })); - } - return attachRouteHandlerMiddlewareContext(new Response(__cv.body, { status: __cv.status, headers: __hitHeaders })); - } - if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { - // STALE — serve stale response, trigger background regeneration - const __sv = __cached.value.value; - const __revalSecs = revalidateSeconds; - const __revalHandlerFn = handlerFn; - const __revalParams = params; - const __revalUrl = request.url; - const __revalSearchParams = new URLSearchParams(url.searchParams); - __triggerBackgroundRegeneration(__routeKey, async function() { - const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalUCtx = _createUnifiedCtx({ - headersContext: __revalHeadCtx, - executionContext: _getRequestExecutionContext(), - }); - await _runWithUnifiedCtx(__revalUCtx, async () => { - _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); - const __syntheticReq = new Request(__revalUrl, { method: "GET" }); - const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); - const __regenDynamic = consumeDynamicUsage(); - setNavigationContext(null); - if (__regenDynamic) { - __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); - return; - } - const __freshBody = await __revalResponse.arrayBuffer(); - const __freshHeaders = {}; - __revalResponse.headers.forEach(function(v, k) { - if (k !== "x-vinext-cache" && k !== "cache-control") __freshHeaders[k] = v; - }); - const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __freshBody, status: __revalResponse.status, headers: __freshHeaders }, __revalSecs, __routeTags); - __isrDebug?.("route regen complete", __routeKey); - }); - }); - __isrDebug?.("STALE (route)", cleanPathname); - setHeadersContext(null); - setNavigationContext(null); - const __staleHeaders = Object.assign({}, __sv.headers || {}); - __staleHeaders["X-Vinext-Cache"] = "STALE"; - __staleHeaders["Cache-Control"] = "s-maxage=0, stale-while-revalidate"; - if (isAutoHead) { - return attachRouteHandlerMiddlewareContext(new Response(null, { status: __sv.status, headers: __staleHeaders })); - } - return attachRouteHandlerMiddlewareContext(new Response(__sv.body, { status: __sv.status, headers: __staleHeaders })); - } - } catch (__routeCacheErr) { - // Cache read failure — fall through to normal handler execution - console.error("[vinext] ISR route cache read error:", __routeCacheErr); - } - } - if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); - const handlerSetCacheControl = response.headers.has("cache-control"); // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, @@ -17735,56 +16930,20 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { revalidateSeconds !== null && !dynamicUsedInHandler && (method === "GET" || isAutoHead) && - !handlerSetCacheControl + !response.headers.has("cache-control") ) { response.headers.set("cache-control", "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"); } - // ISR cache write for route handlers (production, MISS). - // Store the raw handler response before cookie/middleware transforms - // (those are request-specific and shouldn't be cached). - if ( - process.env.NODE_ENV === "production" && - revalidateSeconds !== null && - handler.dynamic !== "force-dynamic" && - !dynamicUsedInHandler && - (method === "GET" || isAutoHead) && - !handlerSetCacheControl - ) { - response.headers.set("X-Vinext-Cache", "MISS"); - const __routeClone = response.clone(); - const __routeKey = __isrRouteKey(cleanPathname); - const __revalSecs = revalidateSeconds; - const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - const __routeWritePromise = (async () => { - try { - const __buf = await __routeClone.arrayBuffer(); - const __hdrs = {}; - __routeClone.headers.forEach(function(v, k) { - if (k !== "x-vinext-cache" && k !== "cache-control") __hdrs[k] = v; - }); - await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __buf, status: __routeClone.status, headers: __hdrs }, __revalSecs, __routeTags); - __isrDebug?.("route cache written", __routeKey); - } catch (__cacheErr) { - console.error("[vinext] ISR route cache write error:", __cacheErr); - } - })(); - _getRequestExecutionContext()?.waitUntil(__routeWritePromise); - } - - // Collect any Set-Cookie headers from cookies().set()/delete() calls - const pendingCookies = getAndClearPendingCookies(); - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // If we have pending cookies, create a new response with them attached - if (pendingCookies.length > 0 || draftCookie) { - const newHeaders = new Headers(response.headers); - for (const cookie of pendingCookies) { - newHeaders.append("Set-Cookie", cookie); - } - if (draftCookie) newHeaders.append("Set-Cookie", draftCookie); + if (renderResponseHeaders) { + const newHeaders = __headersWithRenderResponseHeaders( + response.headers, + renderResponseHeaders, + ); if (isAutoHead) { return attachRouteHandlerMiddlewareContext(new Response(null, { @@ -17810,7 +16969,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return attachRouteHandlerMiddlewareContext(response); } catch (err) { - getAndClearPendingCookies(); // Clear any pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error // Catch redirect() / notFound() thrown from route handlers if (err && typeof err === "object" && "digest" in err) { const digest = String(err.digest); @@ -17839,7 +16998,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: route.pattern, routeType: "route" }, - ); + ).catch((reportErr) => { + console.error("[vinext] Failed to report route handler error:", reportErr); + }); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 500 })); } finally { setHeadersAccessPhase(previousHeadersPhase); @@ -17849,6 +17010,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 405, + headers: { Allow: exportedMethods.join(", ") }, })); } @@ -17930,29 +17092,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HIT (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.rscData, { + return __responseWithMiddlewareContext(new Response(__cachedValue.rscData, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } if (!isRscRequest && __hasHtml) { __isrDebug?.("HIT (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.html, { + return __responseWithMiddlewareContext(new Response(__cachedValue.html, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } __isrDebug?.("MISS (empty cached entry)", cleanPathname); } @@ -17968,58 +17130,62 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // user request — to prevent user-specific cookies/auth headers from leaking // into content that is cached and served to all subsequent users. const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalUCtx = _createUnifiedCtx({ - headersContext: __revalHeadCtx, - executionContext: _getRequestExecutionContext(), - }); - const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { - _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); - const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); - const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); - // Tee RSC stream: one for SSR, one to capture rscData - const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); - // Capture rscData bytes in parallel with SSR - const __rscDataPromise = (async () => { - const __rscReader = __revalRscForCapture.getReader(); - const __rscChunks = []; - let __rscTotal = 0; - for (;;) { - const { done, value } = await __rscReader.read(); - if (done) break; - __rscChunks.push(value); - __rscTotal += value.byteLength; - } - const __rscBuf = new Uint8Array(__rscTotal); - let __rscOff = 0; - for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } - return __rscBuf.buffer; - })(); - const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; - const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); - setHeadersContext(null); - setNavigationContext(null); - // Collect the full HTML string from the stream - const __revalReader = __revalHtmlStream.getReader(); - const __revalDecoder = new TextDecoder(); - const __revalChunks = []; - for (;;) { - const { done, value } = await __revalReader.read(); - if (done) break; - __revalChunks.push(__revalDecoder.decode(value, { stream: true })); - } - __revalChunks.push(__revalDecoder.decode()); - const __freshHtml = __revalChunks.join(""); - const __freshRscData = await __rscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; - }); + const __revalResult = await runWithHeadersContext(__revalHeadCtx, () => + _runWithNavigationContext(() => + _runWithCacheState(() => + _runWithPrivateCache(() => + runWithFetchCache(async () => { + setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); + const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); + const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); + const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); + // Tee RSC stream: one for SSR, one to capture rscData + const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); + // Capture rscData bytes in parallel with SSR + const __rscDataPromise = (async () => { + const __rscReader = __revalRscForCapture.getReader(); + const __rscChunks = []; + let __rscTotal = 0; + for (;;) { + const { done, value } = await __rscReader.read(); + if (done) break; + __rscChunks.push(value); + __rscTotal += value.byteLength; + } + const __rscBuf = new Uint8Array(__rscTotal); + let __rscOff = 0; + for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } + return __rscBuf.buffer; + })(); + const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; + const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); + const __freshRscData = await __rscDataPromise; + const __renderHeaders = consumeRenderResponseHeaders(); + setHeadersContext(null); + setNavigationContext(null); + // Collect the full HTML string from the stream + const __revalReader = __revalHtmlStream.getReader(); + const __revalDecoder = new TextDecoder(); + const __revalChunks = []; + for (;;) { + const { done, value } = await __revalReader.read(); + if (done) break; + __revalChunks.push(__revalDecoder.decode(value, { stream: true })); + } + __revalChunks.push(__revalDecoder.decode()); + const __freshHtml = __revalChunks.join(""); + const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; + }) + ) + ) + ) + ); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); __isrDebug?.("regen complete", cleanPathname); }); @@ -18027,29 +17193,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("STALE (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.rscData, { + return __responseWithMiddlewareContext(new Response(__staleValue.rscData, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { __isrDebug?.("STALE (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.html, { + return __responseWithMiddlewareContext(new Response(__staleValue.html, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } // Stale entry exists but is empty for this request type — fall through to render __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); @@ -18124,7 +17290,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError }); // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithRequestContext + // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext // handles cleanup naturally when all async continuations complete. return new Response(interceptStream, { headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -18139,65 +17305,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let element; - try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); - } catch (buildErr) { - // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components - if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { - const digest = String(buildErr.digest); + // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim + async function handleRenderError(err, fallbackOpts) { + if (err && typeof err === "object" && "digest" in err) { + const digest = String(err.digest); if (digest.startsWith("NEXT_REDIRECT;")) { const parts = digest.split(";"); const redirectUrl = decodeURIComponent(parts[2]); const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); + return __responseWithMiddlewareContext(new Response(null, { + status: statusCode, + headers: { Location: new URL(redirectUrl, request.url).toString() }, + }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false }); } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + ...fallbackOpts, + }); + const renderResponseHeaders = consumeRenderResponseHeaders(); + if (fallbackResp) { + return __responseWithMiddlewareContext( + fallbackResp, + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); + } setHeadersContext(null); setNavigationContext(null); const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); + return __responseWithMiddlewareContext( + new Response(statusText, { status: statusCode }), + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); } } + return null; + } + + let element; + try { + element = await buildPageElement(route, params, interceptOpts, url.searchParams); + } catch (buildErr) { + const specialResponse = await handleRenderError(buildErr); + if (specialResponse) return specialResponse; // Non-special error (e.g. generateMetadata() threw) — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw buildErr; } + const __buildRenderResponseHeaders = peekRenderResponseHeaders(); + const __buildDynamicUsage = peekDynamicUsage(); // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - if (err && typeof err === "object" && "digest" in err) { - const digest = String(err.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); - } - } - return null; - } - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws // notFound(), the parent layout's boundary catches it and renders the parent's @@ -18223,44 +17399,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const lr = LayoutComp({ params: asyncParams, children: null }); if (lr && typeof lr === "object" && typeof lr.then === "function") await lr; } catch (layoutErr) { - if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) { - const digest = String(layoutErr.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; } - if (!parentNotFound) parentNotFound = null; - // Wrap in only the layouts above the throwing one - const parentLayouts = route.layouts.slice(0, li); - const fallbackResp = await renderHTTPAccessFallbackPage( - route, statusCode, isRscRequest, request, - { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params } - ); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); } } + if (!parentNotFound) parentNotFound = null; + // Wrap in only the layouts above the throwing one + const parentLayouts = route.layouts.slice(0, li); + const specialResponse = await handleRenderError(layoutErr, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, + }); + if (specialResponse) return specialResponse; // Not a special error — let it propagate through normal RSC rendering } } @@ -18309,6 +17467,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); if (_pageProbeResult instanceof Response) return _pageProbeResult; + // The sync pre-render probes above are only for catching redirect/notFound + // before streaming begins. Discard any render-time response headers they + // may have produced while preserving headers generated during buildPageElement + // (e.g. generateMetadata), since those are part of the real render output. + restoreRenderResponseHeaders(__buildRenderResponseHeaders); + restoreDynamicUsage(__buildDynamicUsage); + // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); @@ -18359,7 +17524,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // NOTE: Do NOT clear headers/navigation context here! // The RSC stream is consumed lazily - components render when chunks are read. // If we clear context now, headers()/cookies() will fail during rendering. - // Context will be cleared when the next request starts (via runWithRequestContext). + // Context will be cleared when the next request starts (via runWithHeadersContext). const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; // Include matched route params so the client can hydrate useParams() if (params && Object.keys(params).length > 0) { @@ -18376,37 +17541,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } else if (revalidateSeconds) { responseHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; } - // Merge middleware response headers into the RSC response. - // set-cookie and vary are accumulated to preserve existing values - // (e.g. "Vary: RSC, Accept" set above); all other keys use plain - // assignment so middleware headers win over config headers, which - // the outer handler applies afterward and skips keys already present. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - const lk = key.toLowerCase(); - if (lk === "set-cookie") { - const existing = responseHeaders[lk]; - if (Array.isArray(existing)) { - existing.push(value); - } else if (existing) { - responseHeaders[lk] = [existing, value]; - } else { - responseHeaders[lk] = [value]; - } - } else if (lk === "vary") { - // Accumulate Vary values to preserve the existing "RSC, Accept" entry. - const existing = responseHeaders["Vary"] ?? responseHeaders["vary"]; - if (existing) { - responseHeaders["Vary"] = existing + ", " + value; - if (responseHeaders["vary"] !== undefined) delete responseHeaders["vary"]; - } else { - responseHeaders[key] = value; - } - } else { - responseHeaders[key] = value; - } - } - } // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -18426,22 +17560,45 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { - responseHeaders["X-Vinext-Cache"] = "MISS"; const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; + const __responseRenderHeaders = peekRenderResponseHeaders(); + if (peekDynamicUsage()) { + responseHeaders["Cache-Control"] = "no-store, must-revalidate"; + } else { + responseHeaders["X-Vinext-Cache"] = "MISS"; + } const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? __responseRenderHeaders; + // consume picks up headers added during late async RSC streaming work. + // Falls back to the snapshot taken before the live MISS response was returned. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip RSC cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); + await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); + } finally { + setHeadersContext(null); + setNavigationContext(null); } })(); _getRequestExecutionContext()?.waitUntil(__rscWritePromise); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } - return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: responseHeaders, + }), _mwCtx); } // Collect font data from RSC environment before passing to SSR @@ -18479,7 +17636,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw ssrErr; } @@ -18489,31 +17653,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // when the error falls through to global-error.tsx. - // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response + // Helper to attach render-time response headers, middleware headers, font + // Link header, and rewrite status to a response. function attachMiddlewareContext(response) { - if (draftCookie) { - response.headers.append("Set-Cookie", draftCookie); - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderResponseHeaders); // Set HTTP Link header for font preloading if (fontLinkHeader) { - response.headers.set("Link", fontLinkHeader); - } - // Merge middleware response headers into the final response. - // The response is freshly constructed above (new Response(htmlStream, {...})), - // so set() and append() are equivalent — there are no same-key conflicts yet. - // Precedence over config headers is handled by the outer handler, which - // skips config keys that middleware already placed on the response. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - response.headers.append(key, value); - } + responseHeaders.set("Link", fontLinkHeader); } + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -18528,21 +17683,30 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const renderMs = __renderEnd !== undefined && __compileEnd !== undefined ? Math.round(__renderEnd - __compileEnd) : -1; - response.headers.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); + responseHeaders.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); } // Apply custom status code from middleware rewrite if (_mwCtx.status) { return new Response(response.body, { status: _mwCtx.status, - headers: response.headers, + headers: responseHeaders, }); } - return response; + const responseInit = { + status: response.status, + headers: responseHeaders, + }; + if (response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); } // Check if any component called connection(), cookies(), headers(), or noStore() // during rendering. If so, treat as dynamic (skip ISR, set no-store). - const dynamicUsedDuringRender = consumeDynamicUsage(); + const dynamicUsedDuringRender = __isrRscDataPromise + ? peekDynamicUsage() + : consumeDynamicUsage(); // Check if cacheLife() was called during rendering (e.g., page with file-level "use cache"). // If so, use its revalidation period for the Cache-Control header. @@ -18626,6 +17790,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? renderResponseHeaders; + // consume picks up any headers added during stream consumption by late + // async render work (for example, suspended branches). Falls back to + // the snapshot taken before streaming began when nothing new was added. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip HTML cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) @@ -18633,12 +17806,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ensuring the first client-side navigation after a direct visit is a // cache hit rather than a miss. const __writes = [ - __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags), + __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags), ]; if (__capturedRscDataPromise) { __writes.push( __capturedRscDataPromise.then((__rscBuf) => - __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags) + __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags) ) ); } @@ -18646,6 +17819,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HTML cache written", __isrKey); } catch (__cacheErr) { console.error("[vinext] ISR cache write error:", __cacheErr); + } finally { + consumeRenderResponseHeaders(); } })(); // Register with ExecutionContext (from ALS) so the Workers runtime keeps @@ -18697,9 +17872,6 @@ import { setNavigationContext, ServerInsertedHTMLContext } from "next/navigation import { runWithNavigationContext as _runWithNavCtx } from "vinext/navigation-state"; import { safeJsonStringify } from "vinext/html"; import { createElement as _ssrCE } from "react"; -import * as _clientRefs from "virtual:vite-rsc/client-references"; - -let _clientRefsPreloaded = false; /** * Collect all chunks from a ReadableStream into an array of text strings. @@ -18831,29 +18003,6 @@ function createRscEmbedTransform(embedStream) { * and the data needs to be passed to SSR since they're separate module instances. */ export async function handleSsr(rscStream, navContext, fontData) { - // Eagerly preload all client reference modules before SSR rendering. - // On the first request after server start, client component modules are - // loaded lazily via async import(). Without this preload, React's - // renderToReadableStream rejects because the shell can't resolve client - // components synchronously (there is no Suspense boundary wrapping the - // root). The memoized require cache ensures this is only async on the - // very first call; subsequent requests resolve from cache immediately. - // See: https://github.com/cloudflare/vinext/issues/256 - // _clientRefs.default is the default export from the virtual:vite-rsc/client-references - // namespace import — a map of client component IDs to their async import functions. - if (!_clientRefsPreloaded && _clientRefs.default && globalThis.__vite_rsc_client_require__) { - await Promise.all( - Object.keys(_clientRefs.default).map((id) => - globalThis.__vite_rsc_client_require__(id).catch((err) => { - if (process.env.NODE_ENV !== "production") { - console.warn("[vinext] failed to preload client ref:", id, err); - } - }) - ) - ); - _clientRefsPreloaded = true; - } - // Wrap in a navigation ALS scope for per-request isolation in the SSR // environment. The SSR environment has separate module instances from RSC, // so it needs its own ALS scope. @@ -19168,14 +18317,11 @@ const pageLoaders = { "/before-pop-state-test": () => import("/tests/fixtures/pages-basic/pages/before-pop-state-test.tsx"), "/cjs/basic": () => import("/tests/fixtures/pages-basic/pages/cjs/basic.tsx"), "/cjs/random": () => import("/tests/fixtures/pages-basic/pages/cjs/random.ts"), - "/concurrent-head": () => import("/tests/fixtures/pages-basic/pages/concurrent-head.tsx"), - "/concurrent-router": () => import("/tests/fixtures/pages-basic/pages/concurrent-router.tsx"), "/config-test": () => import("/tests/fixtures/pages-basic/pages/config-test.tsx"), "/counter": () => import("/tests/fixtures/pages-basic/pages/counter.tsx"), "/dynamic-page": () => import("/tests/fixtures/pages-basic/pages/dynamic-page.tsx"), "/dynamic-ssr-false": () => import("/tests/fixtures/pages-basic/pages/dynamic-ssr-false.tsx"), "/header-override-delete": () => import("/tests/fixtures/pages-basic/pages/header-override-delete.tsx"), - "/isr-second-render-state": () => import("/tests/fixtures/pages-basic/pages/isr-second-render-state.tsx"), "/isr-test": () => import("/tests/fixtures/pages-basic/pages/isr-test.tsx"), "/link-test": () => import("/tests/fixtures/pages-basic/pages/link-test.tsx"), "/mw-object-gated": () => import("/tests/fixtures/pages-basic/pages/mw-object-gated.tsx"), @@ -19255,24 +18401,19 @@ import { renderToReadableStream } from "react-dom/server.edge"; import { resetSSRHead, getSSRHeadHTML } from "next/head"; import { flushPreloads } from "next/dynamic"; import { setSSRContext, wrapWithRouterContext } from "next/router"; -import { getCacheHandler, _runWithCacheState } from "next/cache"; +import { getCacheHandler } from "next/cache"; +import { runWithFetchCache } from "vinext/fetch-cache"; +import { _runWithCacheState } from "next/cache"; import { runWithPrivateCache } from "vinext/cache-runtime"; -import { ensureFetchPatch, runWithFetchCache } from "vinext/fetch-cache"; -import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; -import "vinext/router-state"; -import { runWithServerInsertedHTMLState } from "vinext/navigation-state"; +import { runWithRouterState } from "vinext/router-state"; import { runWithHeadState } from "vinext/head-state"; -import "vinext/i18n-state"; -import { setI18nContext } from "vinext/i18n-context"; import { safeJsonStringify } from "vinext/html"; import { decode as decodeQueryString } from "node:querystring"; import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; -import { parseCookies, sanitizeDestination as sanitizeDestinationLocal } from "/packages/vinext/src/config/config-matchers.js"; +import { parseCookies } from "/packages/vinext/src/config/config-matchers.js"; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; -import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; -import { resolvePagesI18nRequest } from "/packages/vinext/src/server/pages-i18n.js"; import * as _instrumentation from "/tests/fixtures/pages-basic/instrumentation.ts"; import * as middlewareModule from "/tests/fixtures/pages-basic/middleware.ts"; import { NextRequest, NextFetchEvent } from "next/server"; @@ -19369,21 +18510,6 @@ async function renderToStringAsync(element) { return new Response(stream).text(); } -async function renderIsrPassToStringAsync(element) { - // The cache-fill render is a second render pass for the same request. - // Reset render-scoped state so it cannot leak from the streamed response - // render or affect async work that is still draining from that stream. - // Keep request identity state (pathname/query/locale/executionContext) - // intact: this second pass still belongs to the same request. - return await runWithServerInsertedHTMLState(() => - runWithHeadState(() => - _runWithCacheState(() => - runWithPrivateCache(() => runWithFetchCache(async () => renderToStringAsync(element))), - ), - ), - ); -} - import * as page_0 from "/tests/fixtures/pages-basic/pages/index.tsx"; import * as page_1 from "/tests/fixtures/pages-basic/pages/404.tsx"; import * as page_2 from "/tests/fixtures/pages-basic/pages/about.tsx"; @@ -19392,33 +18518,30 @@ import * as page_4 from "/tests/fixtures/pages-basic/pages/before-pop-stat import * as page_5 from "/tests/fixtures/pages-basic/pages/before-pop-state-test.tsx"; import * as page_6 from "/tests/fixtures/pages-basic/pages/cjs/basic.tsx"; import * as page_7 from "/tests/fixtures/pages-basic/pages/cjs/random.ts"; -import * as page_8 from "/tests/fixtures/pages-basic/pages/concurrent-head.tsx"; -import * as page_9 from "/tests/fixtures/pages-basic/pages/concurrent-router.tsx"; -import * as page_10 from "/tests/fixtures/pages-basic/pages/config-test.tsx"; -import * as page_11 from "/tests/fixtures/pages-basic/pages/counter.tsx"; -import * as page_12 from "/tests/fixtures/pages-basic/pages/dynamic-page.tsx"; -import * as page_13 from "/tests/fixtures/pages-basic/pages/dynamic-ssr-false.tsx"; -import * as page_14 from "/tests/fixtures/pages-basic/pages/header-override-delete.tsx"; -import * as page_15 from "/tests/fixtures/pages-basic/pages/isr-second-render-state.tsx"; -import * as page_16 from "/tests/fixtures/pages-basic/pages/isr-test.tsx"; -import * as page_17 from "/tests/fixtures/pages-basic/pages/link-test.tsx"; -import * as page_18 from "/tests/fixtures/pages-basic/pages/mw-object-gated.tsx"; -import * as page_19 from "/tests/fixtures/pages-basic/pages/nav-test.tsx"; -import * as page_20 from "/tests/fixtures/pages-basic/pages/posts/missing.tsx"; -import * as page_21 from "/tests/fixtures/pages-basic/pages/redirect-xss.tsx"; -import * as page_22 from "/tests/fixtures/pages-basic/pages/router-events-test.tsx"; -import * as page_23 from "/tests/fixtures/pages-basic/pages/script-test.tsx"; -import * as page_24 from "/tests/fixtures/pages-basic/pages/shallow-test.tsx"; -import * as page_25 from "/tests/fixtures/pages-basic/pages/ssr.tsx"; -import * as page_26 from "/tests/fixtures/pages-basic/pages/ssr-headers.tsx"; -import * as page_27 from "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx"; -import * as page_28 from "/tests/fixtures/pages-basic/pages/suspense-test.tsx"; -import * as page_29 from "/tests/fixtures/pages-basic/pages/articles/[id].tsx"; -import * as page_30 from "/tests/fixtures/pages-basic/pages/blog/[slug].tsx"; -import * as page_31 from "/tests/fixtures/pages-basic/pages/posts/[id].tsx"; -import * as page_32 from "/tests/fixtures/pages-basic/pages/products/[pid].tsx"; -import * as page_33 from "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx"; -import * as page_34 from "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx"; +import * as page_8 from "/tests/fixtures/pages-basic/pages/config-test.tsx"; +import * as page_9 from "/tests/fixtures/pages-basic/pages/counter.tsx"; +import * as page_10 from "/tests/fixtures/pages-basic/pages/dynamic-page.tsx"; +import * as page_11 from "/tests/fixtures/pages-basic/pages/dynamic-ssr-false.tsx"; +import * as page_12 from "/tests/fixtures/pages-basic/pages/header-override-delete.tsx"; +import * as page_13 from "/tests/fixtures/pages-basic/pages/isr-test.tsx"; +import * as page_14 from "/tests/fixtures/pages-basic/pages/link-test.tsx"; +import * as page_15 from "/tests/fixtures/pages-basic/pages/mw-object-gated.tsx"; +import * as page_16 from "/tests/fixtures/pages-basic/pages/nav-test.tsx"; +import * as page_17 from "/tests/fixtures/pages-basic/pages/posts/missing.tsx"; +import * as page_18 from "/tests/fixtures/pages-basic/pages/redirect-xss.tsx"; +import * as page_19 from "/tests/fixtures/pages-basic/pages/router-events-test.tsx"; +import * as page_20 from "/tests/fixtures/pages-basic/pages/script-test.tsx"; +import * as page_21 from "/tests/fixtures/pages-basic/pages/shallow-test.tsx"; +import * as page_22 from "/tests/fixtures/pages-basic/pages/ssr.tsx"; +import * as page_23 from "/tests/fixtures/pages-basic/pages/ssr-headers.tsx"; +import * as page_24 from "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx"; +import * as page_25 from "/tests/fixtures/pages-basic/pages/suspense-test.tsx"; +import * as page_26 from "/tests/fixtures/pages-basic/pages/articles/[id].tsx"; +import * as page_27 from "/tests/fixtures/pages-basic/pages/blog/[slug].tsx"; +import * as page_28 from "/tests/fixtures/pages-basic/pages/posts/[id].tsx"; +import * as page_29 from "/tests/fixtures/pages-basic/pages/products/[pid].tsx"; +import * as page_30 from "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx"; +import * as page_31 from "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx"; import * as api_0 from "/tests/fixtures/pages-basic/pages/api/binary.ts"; import * as api_1 from "/tests/fixtures/pages-basic/pages/api/echo-body.ts"; import * as api_2 from "/tests/fixtures/pages-basic/pages/api/error-route.ts"; @@ -19433,7 +18556,7 @@ import * as api_9 from "/tests/fixtures/pages-basic/pages/api/users/[id].t import { default as AppComponent } from "/tests/fixtures/pages-basic/pages/_app.tsx"; import { default as DocumentComponent } from "/tests/fixtures/pages-basic/pages/_document.tsx"; -export const pageRoutes = [ +const pageRoutes = [ { pattern: "/", patternParts: [], isDynamic: false, params: [], module: page_0, filePath: "/tests/fixtures/pages-basic/pages/index.tsx" }, { pattern: "/404", patternParts: ["404"], isDynamic: false, params: [], module: page_1, filePath: "/tests/fixtures/pages-basic/pages/404.tsx" }, { pattern: "/about", patternParts: ["about"], isDynamic: false, params: [], module: page_2, filePath: "/tests/fixtures/pages-basic/pages/about.tsx" }, @@ -19442,33 +18565,30 @@ export const pageRoutes = [ { pattern: "/before-pop-state-test", patternParts: ["before-pop-state-test"], isDynamic: false, params: [], module: page_5, filePath: "/tests/fixtures/pages-basic/pages/before-pop-state-test.tsx" }, { pattern: "/cjs/basic", patternParts: ["cjs","basic"], isDynamic: false, params: [], module: page_6, filePath: "/tests/fixtures/pages-basic/pages/cjs/basic.tsx" }, { pattern: "/cjs/random", patternParts: ["cjs","random"], isDynamic: false, params: [], module: page_7, filePath: "/tests/fixtures/pages-basic/pages/cjs/random.ts" }, - { pattern: "/concurrent-head", patternParts: ["concurrent-head"], isDynamic: false, params: [], module: page_8, filePath: "/tests/fixtures/pages-basic/pages/concurrent-head.tsx" }, - { pattern: "/concurrent-router", patternParts: ["concurrent-router"], isDynamic: false, params: [], module: page_9, filePath: "/tests/fixtures/pages-basic/pages/concurrent-router.tsx" }, - { pattern: "/config-test", patternParts: ["config-test"], isDynamic: false, params: [], module: page_10, filePath: "/tests/fixtures/pages-basic/pages/config-test.tsx" }, - { pattern: "/counter", patternParts: ["counter"], isDynamic: false, params: [], module: page_11, filePath: "/tests/fixtures/pages-basic/pages/counter.tsx" }, - { pattern: "/dynamic-page", patternParts: ["dynamic-page"], isDynamic: false, params: [], module: page_12, filePath: "/tests/fixtures/pages-basic/pages/dynamic-page.tsx" }, - { pattern: "/dynamic-ssr-false", patternParts: ["dynamic-ssr-false"], isDynamic: false, params: [], module: page_13, filePath: "/tests/fixtures/pages-basic/pages/dynamic-ssr-false.tsx" }, - { pattern: "/header-override-delete", patternParts: ["header-override-delete"], isDynamic: false, params: [], module: page_14, filePath: "/tests/fixtures/pages-basic/pages/header-override-delete.tsx" }, - { pattern: "/isr-second-render-state", patternParts: ["isr-second-render-state"], isDynamic: false, params: [], module: page_15, filePath: "/tests/fixtures/pages-basic/pages/isr-second-render-state.tsx" }, - { pattern: "/isr-test", patternParts: ["isr-test"], isDynamic: false, params: [], module: page_16, filePath: "/tests/fixtures/pages-basic/pages/isr-test.tsx" }, - { pattern: "/link-test", patternParts: ["link-test"], isDynamic: false, params: [], module: page_17, filePath: "/tests/fixtures/pages-basic/pages/link-test.tsx" }, - { pattern: "/mw-object-gated", patternParts: ["mw-object-gated"], isDynamic: false, params: [], module: page_18, filePath: "/tests/fixtures/pages-basic/pages/mw-object-gated.tsx" }, - { pattern: "/nav-test", patternParts: ["nav-test"], isDynamic: false, params: [], module: page_19, filePath: "/tests/fixtures/pages-basic/pages/nav-test.tsx" }, - { pattern: "/posts/missing", patternParts: ["posts","missing"], isDynamic: false, params: [], module: page_20, filePath: "/tests/fixtures/pages-basic/pages/posts/missing.tsx" }, - { pattern: "/redirect-xss", patternParts: ["redirect-xss"], isDynamic: false, params: [], module: page_21, filePath: "/tests/fixtures/pages-basic/pages/redirect-xss.tsx" }, - { pattern: "/router-events-test", patternParts: ["router-events-test"], isDynamic: false, params: [], module: page_22, filePath: "/tests/fixtures/pages-basic/pages/router-events-test.tsx" }, - { pattern: "/script-test", patternParts: ["script-test"], isDynamic: false, params: [], module: page_23, filePath: "/tests/fixtures/pages-basic/pages/script-test.tsx" }, - { pattern: "/shallow-test", patternParts: ["shallow-test"], isDynamic: false, params: [], module: page_24, filePath: "/tests/fixtures/pages-basic/pages/shallow-test.tsx" }, - { pattern: "/ssr", patternParts: ["ssr"], isDynamic: false, params: [], module: page_25, filePath: "/tests/fixtures/pages-basic/pages/ssr.tsx" }, - { pattern: "/ssr-headers", patternParts: ["ssr-headers"], isDynamic: false, params: [], module: page_26, filePath: "/tests/fixtures/pages-basic/pages/ssr-headers.tsx" }, - { pattern: "/ssr-res-end", patternParts: ["ssr-res-end"], isDynamic: false, params: [], module: page_27, filePath: "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx" }, - { pattern: "/suspense-test", patternParts: ["suspense-test"], isDynamic: false, params: [], module: page_28, filePath: "/tests/fixtures/pages-basic/pages/suspense-test.tsx" }, - { pattern: "/articles/:id", patternParts: ["articles",":id"], isDynamic: true, params: ["id"], module: page_29, filePath: "/tests/fixtures/pages-basic/pages/articles/[id].tsx" }, - { pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, params: ["slug"], module: page_30, filePath: "/tests/fixtures/pages-basic/pages/blog/[slug].tsx" }, - { pattern: "/posts/:id", patternParts: ["posts",":id"], isDynamic: true, params: ["id"], module: page_31, filePath: "/tests/fixtures/pages-basic/pages/posts/[id].tsx" }, - { pattern: "/products/:pid", patternParts: ["products",":pid"], isDynamic: true, params: ["pid"], module: page_32, filePath: "/tests/fixtures/pages-basic/pages/products/[pid].tsx" }, - { pattern: "/docs/:slug+", patternParts: ["docs",":slug+"], isDynamic: true, params: ["slug"], module: page_33, filePath: "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx" }, - { pattern: "/sign-up/:sign-up*", patternParts: ["sign-up",":sign-up*"], isDynamic: true, params: ["sign-up"], module: page_34, filePath: "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx" } + { pattern: "/config-test", patternParts: ["config-test"], isDynamic: false, params: [], module: page_8, filePath: "/tests/fixtures/pages-basic/pages/config-test.tsx" }, + { pattern: "/counter", patternParts: ["counter"], isDynamic: false, params: [], module: page_9, filePath: "/tests/fixtures/pages-basic/pages/counter.tsx" }, + { pattern: "/dynamic-page", patternParts: ["dynamic-page"], isDynamic: false, params: [], module: page_10, filePath: "/tests/fixtures/pages-basic/pages/dynamic-page.tsx" }, + { pattern: "/dynamic-ssr-false", patternParts: ["dynamic-ssr-false"], isDynamic: false, params: [], module: page_11, filePath: "/tests/fixtures/pages-basic/pages/dynamic-ssr-false.tsx" }, + { pattern: "/header-override-delete", patternParts: ["header-override-delete"], isDynamic: false, params: [], module: page_12, filePath: "/tests/fixtures/pages-basic/pages/header-override-delete.tsx" }, + { pattern: "/isr-test", patternParts: ["isr-test"], isDynamic: false, params: [], module: page_13, filePath: "/tests/fixtures/pages-basic/pages/isr-test.tsx" }, + { pattern: "/link-test", patternParts: ["link-test"], isDynamic: false, params: [], module: page_14, filePath: "/tests/fixtures/pages-basic/pages/link-test.tsx" }, + { pattern: "/mw-object-gated", patternParts: ["mw-object-gated"], isDynamic: false, params: [], module: page_15, filePath: "/tests/fixtures/pages-basic/pages/mw-object-gated.tsx" }, + { pattern: "/nav-test", patternParts: ["nav-test"], isDynamic: false, params: [], module: page_16, filePath: "/tests/fixtures/pages-basic/pages/nav-test.tsx" }, + { pattern: "/posts/missing", patternParts: ["posts","missing"], isDynamic: false, params: [], module: page_17, filePath: "/tests/fixtures/pages-basic/pages/posts/missing.tsx" }, + { pattern: "/redirect-xss", patternParts: ["redirect-xss"], isDynamic: false, params: [], module: page_18, filePath: "/tests/fixtures/pages-basic/pages/redirect-xss.tsx" }, + { pattern: "/router-events-test", patternParts: ["router-events-test"], isDynamic: false, params: [], module: page_19, filePath: "/tests/fixtures/pages-basic/pages/router-events-test.tsx" }, + { pattern: "/script-test", patternParts: ["script-test"], isDynamic: false, params: [], module: page_20, filePath: "/tests/fixtures/pages-basic/pages/script-test.tsx" }, + { pattern: "/shallow-test", patternParts: ["shallow-test"], isDynamic: false, params: [], module: page_21, filePath: "/tests/fixtures/pages-basic/pages/shallow-test.tsx" }, + { pattern: "/ssr", patternParts: ["ssr"], isDynamic: false, params: [], module: page_22, filePath: "/tests/fixtures/pages-basic/pages/ssr.tsx" }, + { pattern: "/ssr-headers", patternParts: ["ssr-headers"], isDynamic: false, params: [], module: page_23, filePath: "/tests/fixtures/pages-basic/pages/ssr-headers.tsx" }, + { pattern: "/ssr-res-end", patternParts: ["ssr-res-end"], isDynamic: false, params: [], module: page_24, filePath: "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx" }, + { pattern: "/suspense-test", patternParts: ["suspense-test"], isDynamic: false, params: [], module: page_25, filePath: "/tests/fixtures/pages-basic/pages/suspense-test.tsx" }, + { pattern: "/articles/:id", patternParts: ["articles",":id"], isDynamic: true, params: ["id"], module: page_26, filePath: "/tests/fixtures/pages-basic/pages/articles/[id].tsx" }, + { pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, params: ["slug"], module: page_27, filePath: "/tests/fixtures/pages-basic/pages/blog/[slug].tsx" }, + { pattern: "/posts/:id", patternParts: ["posts",":id"], isDynamic: true, params: ["id"], module: page_28, filePath: "/tests/fixtures/pages-basic/pages/posts/[id].tsx" }, + { pattern: "/products/:pid", patternParts: ["products",":pid"], isDynamic: true, params: ["pid"], module: page_29, filePath: "/tests/fixtures/pages-basic/pages/products/[pid].tsx" }, + { pattern: "/docs/:slug+", patternParts: ["docs",":slug+"], isDynamic: true, params: ["slug"], module: page_30, filePath: "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx" }, + { pattern: "/sign-up/:sign-up*", patternParts: ["sign-up",":sign-up*"], isDynamic: true, params: ["sign-up"], module: page_31, filePath: "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx" } ]; const _pageRouteTrie = _buildRouteTrie(pageRoutes); @@ -19799,25 +18919,23 @@ export async function renderPage(request, url, manifest, ctx) { } async function _renderPage(request, url, manifest) { - const localeInfo = i18nConfig - ? resolvePagesI18nRequest( - url, - i18nConfig, - request.headers, - new URL(request.url).hostname, - vinextConfig.basePath, - vinextConfig.trailingSlash, - ) - : { locale: undefined, url, hadPrefix: false, domainLocale: undefined, redirectUrl: undefined }; + const localeInfo = extractLocale(url); const locale = localeInfo.locale; const routeUrl = localeInfo.url; - const currentDefaultLocale = i18nConfig - ? (localeInfo.domainLocale ? localeInfo.domainLocale.defaultLocale : i18nConfig.defaultLocale) - : undefined; - const domainLocales = i18nConfig ? i18nConfig.domains : undefined; + const cookieHeader = request.headers.get("cookie") || ""; - if (localeInfo.redirectUrl) { - return new Response(null, { status: 307, headers: { Location: localeInfo.redirectUrl } }); + // i18n redirect: check NEXT_LOCALE cookie first, then Accept-Language + if (i18nConfig && !localeInfo.hadPrefix) { + const cookieLocale = parseCookieLocaleFromHeader(cookieHeader); + if (cookieLocale && cookieLocale !== i18nConfig.defaultLocale) { + return new Response(null, { status: 307, headers: { Location: "/" + cookieLocale + routeUrl } }); + } + if (!cookieLocale && i18nConfig.localeDetection !== false) { + const detected = detectLocaleFromHeaders(request.headers); + if (detected && detected !== i18nConfig.defaultLocale) { + return new Response(null, { status: 307, headers: { Location: "/" + detected + routeUrl } }); + } + } } const match = matchRoute(routeUrl, pageRoutes); @@ -19827,12 +18945,12 @@ async function _renderPage(request, url, manifest) { } const { route, params } = match; - const __uCtx = _createUnifiedCtx({ - executionContext: _getRequestExecutionContext(), - }); - return _runWithUnifiedCtx(__uCtx, async () => { - ensureFetchPatch(); - try { + return runWithRouterState(() => + runWithHeadState(() => + _runWithCacheState(() => + runWithPrivateCache(() => + runWithFetchCache(async () => { + try { if (typeof setSSRContext === "function") { setSSRContext({ pathname: patternToNextFormat(route.pattern), @@ -19840,19 +18958,14 @@ async function _renderPage(request, url, manifest) { asPath: routeUrl, locale: locale, locales: i18nConfig ? i18nConfig.locales : undefined, - defaultLocale: currentDefaultLocale, - domainLocales: domainLocales, + defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, }); } if (i18nConfig) { - setI18nContext({ - locale: locale, - locales: i18nConfig.locales, - defaultLocale: currentDefaultLocale, - domainLocales: domainLocales, - hostname: new URL(request.url).hostname, - }); + globalThis.__VINEXT_LOCALE__ = locale; + globalThis.__VINEXT_LOCALES__ = i18nConfig.locales; + globalThis.__VINEXT_DEFAULT_LOCALE__ = i18nConfig.defaultLocale; } const pageModule = route.module; @@ -19865,7 +18978,7 @@ async function _renderPage(request, url, manifest) { if (typeof pageModule.getStaticPaths === "function" && route.isDynamic) { const pathsResult = await pageModule.getStaticPaths({ locales: i18nConfig ? i18nConfig.locales : [], - defaultLocale: currentDefaultLocale || "", + defaultLocale: i18nConfig ? i18nConfig.defaultLocale : "", }); const fallback = pathsResult && pathsResult.fallback !== undefined ? pathsResult.fallback : false; @@ -19898,7 +19011,7 @@ async function _renderPage(request, url, manifest) { resolvedUrl: routeUrl, locale: locale, locales: i18nConfig ? i18nConfig.locales : undefined, - defaultLocale: currentDefaultLocale, + defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, }; const result = await pageModule.getServerSideProps(ctx); // If gSSP called res.end() directly (short-circuit), return that response. @@ -19947,89 +19060,10 @@ async function _renderPage(request, url, manifest) { if (cached && cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") { triggerBackgroundRegeneration(cacheKey, async function() { - var revalCtx = _createUnifiedCtx({ - executionContext: _getRequestExecutionContext(), - }); - return _runWithUnifiedCtx(revalCtx, async () => { - ensureFetchPatch(); - var freshResult = await pageModule.getStaticProps({ - params: params, - locale: locale, - locales: i18nConfig ? i18nConfig.locales : undefined, - defaultLocale: currentDefaultLocale, - }); - if (freshResult && freshResult.props && typeof freshResult.revalidate === "number" && freshResult.revalidate > 0) { - var _fp = freshResult.props; - if (typeof setSSRContext === "function") { - setSSRContext({ - pathname: patternToNextFormat(route.pattern), - query: { ...params, ...parseQuery(routeUrl) }, - asPath: routeUrl, - locale: locale, - locales: i18nConfig ? i18nConfig.locales : undefined, - defaultLocale: currentDefaultLocale, - domainLocales: domainLocales, - }); - } - if (i18nConfig) { - setI18nContext({ - locale: locale, - locales: i18nConfig.locales, - defaultLocale: currentDefaultLocale, - domainLocales: domainLocales, - hostname: new URL(request.url).hostname, - }); - } - // Re-render the page with fresh props inside fresh render sub-scopes - // so head/cache state cannot leak across passes. - var _el = AppComponent - ? React.createElement(AppComponent, { Component: PageComponent, pageProps: _fp }) - : React.createElement(PageComponent, _fp); - _el = wrapWithRouterContext(_el); - var _freshBody = await renderIsrPassToStringAsync(_el); - // Rebuild __NEXT_DATA__ with fresh props - var _regenPayload = { - props: { pageProps: _fp }, page: patternToNextFormat(route.pattern), - query: params, buildId: buildId, isFallback: false, - }; - if (i18nConfig) { - _regenPayload.locale = locale; - _regenPayload.locales = i18nConfig.locales; - _regenPayload.defaultLocale = currentDefaultLocale; - _regenPayload.domainLocales = domainLocales; - } - var _lGlobals = i18nConfig - ? ";window.__VINEXT_LOCALE__=" + safeJsonStringify(locale) + - ";window.__VINEXT_LOCALES__=" + safeJsonStringify(i18nConfig.locales) + - ";window.__VINEXT_DEFAULT_LOCALE__=" + safeJsonStringify(currentDefaultLocale) - : ""; - var _freshNDS = ""; - // Reconstruct ISR HTML preserving the document shell from the - // cached entry (head, fonts, assets, custom _document markup). - var _cachedStr = cached.value.value.html; - var _btag = '
'; - var _bstart = _cachedStr.indexOf(_btag); - var _bodyStart = _bstart >= 0 ? _bstart + _btag.length : -1; - // Locate __NEXT_DATA__ script to split body from suffix - var _ndMarker = ' - var _ndEnd = _cachedStr.indexOf('', _ndStart) + 9; - var _tail = _cachedStr.slice(_ndEnd); - _freshHtml = _cachedStr.slice(0, _bodyStart) + _freshBody + '
' + _gap + _freshNDS + _tail; - } else { - _freshHtml = '\\n\\n\\n\\n\\n
' + _freshBody + '
\\n ' + _freshNDS + '\\n\\n'; - } - await isrSet(cacheKey, { kind: "PAGES", html: _freshHtml, pageData: _fp, headers: undefined, status: undefined }, freshResult.revalidate); - } - }); + const freshResult = await pageModule.getStaticProps({ params }); + if (freshResult && freshResult.props && typeof freshResult.revalidate === "number" && freshResult.revalidate > 0) { + await isrSet(cacheKey, { kind: "PAGES", html: cached.value.value.html, pageData: freshResult.props, headers: undefined, status: undefined }, freshResult.revalidate); + } }); var _staleHeaders = { "Content-Type": "text/html", "X-Vinext-Cache": "STALE", @@ -20043,7 +19077,7 @@ async function _renderPage(request, url, manifest) { params, locale: locale, locales: i18nConfig ? i18nConfig.locales : undefined, - defaultLocale: currentDefaultLocale, + defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, }; const result = await pageModule.getStaticProps(ctx); if (result && result.props) pageProps = result.props; @@ -20096,13 +19130,12 @@ async function _renderPage(request, url, manifest) { if (i18nConfig) { nextDataPayload.locale = locale; nextDataPayload.locales = i18nConfig.locales; - nextDataPayload.defaultLocale = currentDefaultLocale; - nextDataPayload.domainLocales = domainLocales; + nextDataPayload.defaultLocale = i18nConfig.defaultLocale; } const localeGlobals = i18nConfig ? ";window.__VINEXT_LOCALE__=" + safeJsonStringify(locale) + ";window.__VINEXT_LOCALES__=" + safeJsonStringify(i18nConfig.locales) + - ";window.__VINEXT_DEFAULT_LOCALE__=" + safeJsonStringify(currentDefaultLocale) + ";window.__VINEXT_DEFAULT_LOCALE__=" + safeJsonStringify(i18nConfig.defaultLocale) : ""; const nextDataScript = ""; @@ -20165,7 +19198,7 @@ async function _renderPage(request, url, manifest) { isrElement = React.createElement(PageComponent, pageProps); } isrElement = wrapWithRouterContext(isrElement); - var isrHtml = await renderIsrPassToStringAsync(isrElement); + var isrHtml = await renderToStringAsync(isrElement); var fullHtml = shellPrefix + isrHtml + shellSuffix; var isrPathname = url.split("?")[0]; var _cacheKey = isrCacheKey("pages", isrPathname); @@ -20199,16 +19232,15 @@ async function _renderPage(request, url, manifest) { responseHeaders.set("Link", _fontLinkHeader); } return new Response(compositeStream, { status: finalStatus, headers: responseHeaders }); - } catch (e) { + } catch (e) { console.error("[vinext] SSR error:", e); - _reportRequestError( - e instanceof Error ? e : new Error(String(e)), - { path: url, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, - { routerKind: "Pages Router", routePath: route.pattern, routeType: "render" }, - ).catch(() => { /* ignore reporting errors */ }); return new Response("Internal Server Error", { status: 500 }); - } - }); + } + }) // end runWithFetchCache + ) // end runWithPrivateCache + ) // end _runWithCacheState + ) // end runWithHeadState + ); // end runWithRouterState } export async function handleApiRoute(request, url) { @@ -20276,11 +19308,6 @@ export async function handleApiRoute(request, url) { return new Response(e.message, { status: e.statusCode, statusText: e.message }); } console.error("[vinext] API error:", e); - _reportRequestError( - e instanceof Error ? e : new Error(String(e)), - { path: url, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, - { routerKind: "Pages Router", routePath: route.pattern, routeType: "route" }, - ); return new Response("Internal Server Error", { status: 500 }); } } @@ -20407,50 +19434,17 @@ function __safeRegExp(pattern, flags) { } var __mwPatternCache = new Map(); -function __extractConstraint(str, re) { - if (str[re.lastIndex] !== "(") return null; - var start = re.lastIndex + 1; - var depth = 1; - var i = start; - while (i < str.length && depth > 0) { - if (str[i] === "(") depth++; - else if (str[i] === ")") depth--; - i++; - } - if (depth !== 0) return null; - re.lastIndex = i; - return str.slice(start, i - 1); -} function __compileMwPattern(pattern) { - var hasConstraints = /:[\\w-]+[*+]?\\(/.test(pattern); - if (!hasConstraints && (pattern.includes("(") || pattern.includes("\\\\"))) { + if (pattern.includes("(") || pattern.includes("\\\\")) { return __safeRegExp("^" + pattern + "$"); } var regexStr = ""; var tokenRe = /\\/:([\\w-]+)\\*|\\/:([\\w-]+)\\+|:([\\w-]+)|[.]|[^/:.]+|./g; var tok; while ((tok = tokenRe.exec(pattern)) !== null) { - if (tok[1] !== undefined) { - var c1 = hasConstraints ? __extractConstraint(pattern, tokenRe) : null; - regexStr += c1 !== null ? "(?:/(" + c1 + "))?" : "(?:/.*)?"; - } - else if (tok[2] !== undefined) { - var c2 = hasConstraints ? __extractConstraint(pattern, tokenRe) : null; - regexStr += c2 !== null ? "(?:/(" + c2 + "))" : "(?:/.+)"; - } - else if (tok[3] !== undefined) { - var constraint = hasConstraints ? __extractConstraint(pattern, tokenRe) : null; - var isOptional = pattern[tokenRe.lastIndex] === "?"; - if (isOptional) tokenRe.lastIndex += 1; - var group = constraint !== null ? "(" + constraint + ")" : "([^/]+)"; - if (isOptional && regexStr.endsWith("/")) { - regexStr = regexStr.slice(0, -1) + "(?:/" + group + ")?"; - } else if (isOptional) { - regexStr += group + "?"; - } else { - regexStr += group; - } - } + if (tok[1] !== undefined) { regexStr += "(?:/.*)?"; } + else if (tok[2] !== undefined) { regexStr += "(?:/.+)"; } + else if (tok[3] !== undefined) { regexStr += "([^/]+)"; } else if (tok[0] === ".") { regexStr += "\\\\."; } else { regexStr += tok[0]; } } diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 931779a23..43ab21e47 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -559,6 +559,28 @@ describe("App Router integration", () => { expect(html).toContain(''); }); + it("replays render-time headers and middleware context for layout-level notFound()", async () => { + const res = await fetch(`${baseUrl}/nextjs-compat/render-headers-layout-notfound/missing`); + expect(res.status).toBe(404); + expect(res.headers.get("x-layout-notfound")).toBe("yes"); + expect(res.headers.get("x-mw-conflict")).toBe("middleware"); + expect(res.headers.get("x-mw-ran")).toBe("true"); + expect(res.headers.get("x-mw-pathname")).toBe( + "/nextjs-compat/render-headers-layout-notfound/missing", + ); + expect(res.headers.getSetCookie()).toEqual([ + "layout-notfound=1; Path=/; HttpOnly", + "middleware-render=1; Path=/; HttpOnly", + ]); + expect(res.headers.get("vary")).toContain("RSC"); + expect(res.headers.get("vary")).toContain("Accept"); + expect(res.headers.get("vary")).toContain("x-middleware-test"); + + const html = await res.text(); + expect(html).toContain("404 - Page Not Found"); + expect(html).toContain("does not exist"); + }); + it("forbidden() from Server Component returns 403 with forbidden.tsx", async () => { const res = await fetch(`${baseUrl}/forbidden-test`); expect(res.status).toBe(403); @@ -592,6 +614,42 @@ describe("App Router integration", () => { expect(location).toContain("/about"); }); + it("middleware rewrite status does not override redirect() or notFound() responses", async () => { + const redirectRes = await fetch(`${baseUrl}/middleware-rewrite-status-redirect`, { + redirect: "manual", + }); + expect(redirectRes.status).toBe(307); + expect(redirectRes.statusText).toBe("Temporary Redirect"); + expect(redirectRes.headers.get("location")).toContain("/about"); + + const notFoundRes = await fetch(`${baseUrl}/middleware-rewrite-status-not-found`); + expect(notFoundRes.status).toBe(404); + expect(notFoundRes.statusText).toBe("Not Found"); + + const html = await notFoundRes.text(); + expect(html).toContain("404 - Page Not Found"); + expect(html).toContain("does not exist"); + }); + + it("preserves render-time headers and middleware context for redirects thrown during metadata resolution", async () => { + const res = await fetch(`${baseUrl}/nextjs-compat/render-headers-metadata-redirect`, { + redirect: "manual", + }); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain("/about"); + expect(res.headers.get("x-rendered-in-metadata")).toBe("yes"); + expect(res.headers.get("x-mw-conflict")).toBe("middleware"); + expect(res.headers.get("x-mw-ran")).toBe("true"); + expect(res.headers.get("x-mw-pathname")).toBe( + "/nextjs-compat/render-headers-metadata-redirect", + ); + expect(res.headers.getSetCookie()).toEqual([ + "metadata-redirect=1; Path=/; HttpOnly", + "middleware-render=1; Path=/; HttpOnly", + ]); + expect(res.headers.get("vary")).toBe("x-middleware-test"); + }); + it("permanentRedirect() returns 308 status code", async () => { const res = await fetch(`${baseUrl}/permanent-redirect-test`, { redirect: "manual" }); expect(res.status).toBe(308); @@ -1636,6 +1694,138 @@ describe("App Router Production server (startProdServer)", () => { expect(body).toContain("Bad Request"); }); + it("replays render-time response headers across HTML MISS/HIT/STALE and regeneration", async () => { + const expectedSetCookies = [ + "rendered=1; Path=/; HttpOnly", + "rendered-second=1; Path=/; HttpOnly", + "rendered-late=1; Path=/; HttpOnly", + "middleware-render=1; Path=/; HttpOnly", + ]; + + const res1 = await fetch(`${baseUrl}/nextjs-compat/cached-render-headers`); + expect(res1.status).toBe(200); + expect(res1.headers.get("x-vinext-cache")).toBe("MISS"); + expect(res1.headers.get("x-rendered-in-page")).toBe("yes"); + expect(res1.headers.get("x-rendered-late")).toBe("yes"); + expect(res1.headers.get("x-mw-conflict")).toBe("middleware"); + expect(res1.headers.get("x-mw-ran")).toBe("true"); + expect(res1.headers.get("x-mw-pathname")).toBe("/nextjs-compat/cached-render-headers"); + expect(res1.headers.get("vary")).toContain("RSC"); + expect(res1.headers.get("vary")).toContain("Accept"); + expect(res1.headers.get("vary")).toContain("x-middleware-test"); + expect(res1.headers.getSetCookie()).toEqual(expectedSetCookies); + + const res2 = await fetch(`${baseUrl}/nextjs-compat/cached-render-headers`); + expect(res2.status).toBe(200); + expect(res2.headers.get("x-vinext-cache")).toBe("HIT"); + expect(res2.headers.get("x-rendered-in-page")).toBe("yes"); + expect(res2.headers.get("x-rendered-late")).toBe("yes"); + expect(res2.headers.get("x-mw-conflict")).toBe("middleware"); + expect(res2.headers.get("x-mw-ran")).toBe("true"); + expect(res2.headers.get("x-mw-pathname")).toBe("/nextjs-compat/cached-render-headers"); + expect(res2.headers.get("vary")).toContain("RSC"); + expect(res2.headers.get("vary")).toContain("Accept"); + expect(res2.headers.get("vary")).toContain("x-middleware-test"); + expect(res2.headers.getSetCookie()).toEqual(expectedSetCookies); + + await new Promise((resolve) => setTimeout(resolve, 1100)); + + const staleRes = await fetch(`${baseUrl}/nextjs-compat/cached-render-headers`); + expect(staleRes.status).toBe(200); + expect(staleRes.headers.get("x-vinext-cache")).toBe("STALE"); + expect(staleRes.headers.get("x-rendered-in-page")).toBe("yes"); + expect(staleRes.headers.get("x-rendered-late")).toBe("yes"); + expect(staleRes.headers.get("x-mw-conflict")).toBe("middleware"); + expect(staleRes.headers.get("x-mw-ran")).toBe("true"); + expect(staleRes.headers.get("x-mw-pathname")).toBe("/nextjs-compat/cached-render-headers"); + expect(staleRes.headers.get("vary")).toContain("RSC"); + expect(staleRes.headers.get("vary")).toContain("Accept"); + expect(staleRes.headers.get("vary")).toContain("x-middleware-test"); + expect(staleRes.headers.getSetCookie()).toEqual(expectedSetCookies); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + const regenHitRes = await fetch(`${baseUrl}/nextjs-compat/cached-render-headers`); + expect(regenHitRes.status).toBe(200); + expect(regenHitRes.headers.get("x-vinext-cache")).toBe("HIT"); + expect(regenHitRes.headers.get("x-rendered-in-page")).toBe("yes"); + expect(regenHitRes.headers.get("x-rendered-late")).toBe("yes"); + expect(regenHitRes.headers.get("x-mw-conflict")).toBe("middleware"); + expect(regenHitRes.headers.get("x-mw-ran")).toBe("true"); + expect(regenHitRes.headers.get("x-mw-pathname")).toBe("/nextjs-compat/cached-render-headers"); + expect(regenHitRes.headers.get("vary")).toContain("RSC"); + expect(regenHitRes.headers.get("vary")).toContain("Accept"); + expect(regenHitRes.headers.get("vary")).toContain("x-middleware-test"); + expect(regenHitRes.headers.getSetCookie()).toEqual(expectedSetCookies); + }); + + it("streams direct RSC MISSes for ISR pages and caches late render headers for HITs", async () => { + const expectedMissSetCookies = [ + "rendered=1; Path=/; HttpOnly", + "rendered-second=1; Path=/; HttpOnly", + "middleware-render=1; Path=/; HttpOnly", + ]; + const expectedHitSetCookies = [ + "rendered=1; Path=/; HttpOnly", + "rendered-second=1; Path=/; HttpOnly", + "rendered-late=1; Path=/; HttpOnly", + "middleware-render=1; Path=/; HttpOnly", + ]; + + const rscMiss = await fetch(`${baseUrl}/nextjs-compat/cached-render-headers-rsc-first.rsc`, { + headers: { Accept: "text/x-component" }, + }); + expect(rscMiss.status).toBe(200); + expect(rscMiss.headers.get("x-vinext-cache")).toBe("MISS"); + expect(rscMiss.headers.get("x-rendered-in-page")).toBe("yes"); + expect(rscMiss.headers.get("x-rendered-late")).toBeNull(); + expect(rscMiss.headers.get("x-mw-conflict")).toBe("middleware"); + expect(rscMiss.headers.get("x-mw-ran")).toBe("true"); + expect(rscMiss.headers.get("x-mw-pathname")).toBe( + "/nextjs-compat/cached-render-headers-rsc-first", + ); + expect(rscMiss.headers.get("vary")).toContain("RSC"); + expect(rscMiss.headers.get("vary")).toContain("Accept"); + expect(rscMiss.headers.get("vary")).toContain("x-middleware-test"); + expect(rscMiss.headers.getSetCookie()).toEqual(expectedMissSetCookies); + + const missReader = rscMiss.body?.getReader(); + expect(missReader).toBeDefined(); + const missStartedAt = performance.now(); + const firstChunk = await missReader!.read(); + expect(firstChunk.done).toBe(false); + let missBytes = firstChunk.value.byteLength; + const firstChunkAt = performance.now(); + for (;;) { + const nextChunk = await missReader!.read(); + if (nextChunk.done) break; + missBytes += nextChunk.value.byteLength; + } + const missFinishedAt = performance.now(); + expect(missBytes).toBeGreaterThan(0); + expect(missFinishedAt - firstChunkAt).toBeGreaterThan(40); + expect(firstChunkAt - missStartedAt).toBeLessThan(120); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const rscHit = await fetch(`${baseUrl}/nextjs-compat/cached-render-headers-rsc-first.rsc`, { + headers: { Accept: "text/x-component" }, + }); + expect(rscHit.status).toBe(200); + expect(rscHit.headers.get("x-vinext-cache")).toBe("HIT"); + expect(rscHit.headers.get("x-rendered-in-page")).toBe("yes"); + expect(rscHit.headers.get("x-rendered-late")).toBe("yes"); + expect(rscHit.headers.get("x-mw-conflict")).toBe("middleware"); + expect(rscHit.headers.get("x-mw-ran")).toBe("true"); + expect(rscHit.headers.get("x-mw-pathname")).toBe( + "/nextjs-compat/cached-render-headers-rsc-first", + ); + expect(rscHit.headers.get("vary")).toContain("RSC"); + expect(rscHit.headers.get("vary")).toContain("Accept"); + expect(rscHit.headers.get("vary")).toContain("x-middleware-test"); + expect(rscHit.headers.getSetCookie()).toEqual(expectedHitSetCookies); + }); + it("revalidateTag invalidates App Router ISR page entries by fetch tag", async () => { const res1 = await fetch(`${baseUrl}/revalidate-tag-test`); expect(res1.status).toBe(200); @@ -3056,6 +3246,23 @@ describe("App Router middleware with NextRequest", () => { expect(html).toContain("Welcome to App Router"); }); + it("middleware rewrite status does not override redirect() or notFound() in production", async () => { + const redirectRes = await fetch(`${baseUrl}/middleware-rewrite-status-redirect`, { + redirect: "manual", + }); + expect(redirectRes.status).toBe(307); + expect(redirectRes.statusText).toBe("Temporary Redirect"); + expect(redirectRes.headers.get("location")).toContain("/about"); + + const notFoundRes = await fetch(`${baseUrl}/middleware-rewrite-status-not-found`); + expect(notFoundRes.status).toBe(404); + expect(notFoundRes.statusText).toBe("Not Found"); + + const html = await notFoundRes.text(); + expect(html).toContain("404 - Page Not Found"); + expect(html).toContain("does not exist"); + }); + it("middleware can return custom response", async () => { const res = await fetch(`${baseUrl}/middleware-blocked`); expect(res.status).toBe(403); @@ -3725,6 +3932,24 @@ describe("generateRscEntry ISR code generation", () => { expect(code).toContain("rscData: __freshRscData"); }); + it("generated code persists render-time response headers in App Router ISR entries", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + expect(code).toContain("peekRenderResponseHeaders"); + expect(code).toContain("consumeRenderResponseHeaders"); + expect(code).toContain("headers: renderResponseHeaders"); + expect(code).toContain("headers: __renderHeadersForCache"); + expect(code).toContain("headers: __revalResult.headers"); + }); + + it("generated code replays cached render-time response headers on HIT and STALE", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + expect(code).toContain("function __applyRenderResponseHeaders("); + expect(code).toContain("function __headersWithRenderResponseHeaders("); + expect(code).toContain("__headersWithRenderResponseHeaders({"); + expect(code).toContain("}, __cachedValue.headers)"); + expect(code).toContain("}, __staleValue.headers)"); + }); + it("generated code writes RSC-first partial cache entry on RSC MISS", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes); // The RSC-path cache write must store rscData with html:"" as a partial entry. @@ -3734,6 +3959,9 @@ describe("generateRscEntry ISR code generation", () => { // The RSC write must use __isrKeyRsc / __rscDataForCache variable names expect(code).toContain("__rscDataForCache"); expect(code).toContain("__isrKeyRsc"); + // The live MISS response must still stream __rscForResponse while the cache + // write awaits __isrRscDataPromise in the background. + expect(code).not.toContain("new Response(__rscDataForCache"); }); it("generated code treats html:'' partial entries as MISS for HTML requests", () => { @@ -3798,9 +4026,10 @@ describe("generateRscEntry ISR code generation", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes); // __rscForResponse must be assigned before the RSC response is returned so // the tee branch (__rscB) is also consumed (populating the ISR cache). - // The RSC response is: new Response(__rscForResponse, ...) + // The RSC response is wrapped in middleware handling but still originates + // from new Response(__rscForResponse, ...). const teeAssignIdx = code.indexOf("let __rscForResponse"); - const rscResponseIdx = code.indexOf("return new Response(__rscForResponse"); + const rscResponseIdx = code.indexOf("new Response(__rscForResponse"); expect(teeAssignIdx).toBeGreaterThan(-1); expect(rscResponseIdx).toBeGreaterThan(-1); expect(teeAssignIdx).toBeLessThan(rscResponseIdx); diff --git a/tests/fixtures/app-basic/app/lib/render-response-header.ts b/tests/fixtures/app-basic/app/lib/render-response-header.ts new file mode 100644 index 000000000..f259cd399 --- /dev/null +++ b/tests/fixtures/app-basic/app/lib/render-response-header.ts @@ -0,0 +1 @@ +export { appendRenderResponseHeader } from "../../../../../packages/vinext/src/shims/headers.js"; diff --git a/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-rsc-first/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-rsc-first/page.tsx new file mode 100644 index 000000000..7c2f62c9b --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-rsc-first/page.tsx @@ -0,0 +1 @@ +export { revalidate, default } from "../cached-render-headers/page"; diff --git a/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers/page.tsx new file mode 100644 index 000000000..ed1ecaee6 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers/page.tsx @@ -0,0 +1,30 @@ +import { Suspense } from "react"; +import { appendRenderResponseHeader } from "../../lib/render-response-header"; + +export const revalidate = 1; + +async function LateRenderHeaders() { + await new Promise((resolve) => setTimeout(resolve, 120)); + appendRenderResponseHeader("x-rendered-late", "yes"); + appendRenderResponseHeader("Set-Cookie", "rendered-late=1; Path=/; HttpOnly"); + return

RenderedLateHeader: yes

; +} + +export default function CachedRenderHeadersPage() { + appendRenderResponseHeader("x-rendered-in-page", "yes"); + appendRenderResponseHeader("x-mw-conflict", "page"); + appendRenderResponseHeader("Set-Cookie", "rendered=1; Path=/; HttpOnly"); + appendRenderResponseHeader("Set-Cookie", "rendered-second=1; Path=/; HttpOnly"); + + return ( +
+

Cached Render Headers

+

RenderedHeader: yes

+ RenderedLateHeader: loading

} + > + +
+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/[slug]/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/[slug]/layout.tsx new file mode 100644 index 000000000..9420ae2dc --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/[slug]/layout.tsx @@ -0,0 +1,12 @@ +import { notFound } from "next/navigation"; +import { appendRenderResponseHeader } from "../../../lib/render-response-header"; + +export default function RenderHeadersLayoutNotFoundLayout({ children, params }) { + appendRenderResponseHeader("x-layout-notfound", "yes"); + appendRenderResponseHeader("x-mw-conflict", "layout"); + appendRenderResponseHeader("Set-Cookie", "layout-notfound=1; Path=/; HttpOnly"); + if (params.slug === "missing") { + notFound(); + } + return
{children}
; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/[slug]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/[slug]/page.tsx new file mode 100644 index 000000000..359063b4f --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/[slug]/page.tsx @@ -0,0 +1,3 @@ +export default function RenderHeadersLayoutNotFoundPage({ params }) { + return

Slug: {params.slug}

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/not-found.tsx b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/not-found.tsx new file mode 100644 index 000000000..171fa5f4d --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/not-found.tsx @@ -0,0 +1,3 @@ +export default function RenderHeadersLayoutNotFoundBoundary() { + return

Render headers layout not found boundary

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/render-headers-metadata-redirect/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-metadata-redirect/page.tsx new file mode 100644 index 000000000..b4cf48ad4 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-metadata-redirect/page.tsx @@ -0,0 +1,13 @@ +import { redirect } from "next/navigation"; +import { appendRenderResponseHeader } from "../../lib/render-response-header"; + +export function generateMetadata() { + appendRenderResponseHeader("x-rendered-in-metadata", "yes"); + appendRenderResponseHeader("x-mw-conflict", "metadata"); + appendRenderResponseHeader("Set-Cookie", "metadata-redirect=1; Path=/; HttpOnly"); + redirect("/about"); +} + +export default function RenderHeadersMetadataRedirectPage() { + return

unreachable

; +} diff --git a/tests/fixtures/app-basic/middleware.ts b/tests/fixtures/app-basic/middleware.ts index 818699e56..336ae35d8 100644 --- a/tests/fixtures/app-basic/middleware.ts +++ b/tests/fixtures/app-basic/middleware.ts @@ -27,6 +27,13 @@ export function middleware(request: NextRequest) { const response = NextResponse.next(); + const applyRenderHeaderParityHeaders = (target: NextResponse) => { + target.headers.set("x-mw-conflict", "middleware"); + target.headers.set("Vary", "x-middleware-test"); + target.cookies.set("middleware-render", "1", { path: "/", httpOnly: true }); + return target; + }; + // Add headers to prove middleware ran and NextRequest APIs worked response.headers.set("x-mw-pathname", pathname); response.headers.set("x-mw-ran", "true"); @@ -35,6 +42,15 @@ export function middleware(request: NextRequest) { response.headers.set("x-mw-has-session", "true"); } + if ( + pathname === "/nextjs-compat/cached-render-headers" || + pathname === "/nextjs-compat/cached-render-headers-rsc-first" || + pathname === "/nextjs-compat/render-headers-metadata-redirect" || + pathname.startsWith("/nextjs-compat/render-headers-layout-notfound/") + ) { + applyRenderHeaderParityHeaders(response); + } + // Redirect /middleware-redirect to /about (with cookie, like OpenNext) // Ref: opennextjs-cloudflare middleware.ts — redirect with set-cookie header if (pathname === "/middleware-redirect") { @@ -65,6 +81,18 @@ export function middleware(request: NextRequest) { }); } + if (pathname === "/middleware-rewrite-status-redirect") { + return NextResponse.rewrite(new URL("/redirect-test", request.url), { + status: 403, + }); + } + + if (pathname === "/middleware-rewrite-status-not-found") { + return NextResponse.rewrite(new URL("/nextjs-compat/not-found-no-boundary/404", request.url), { + status: 403, + }); + } + // Block /middleware-blocked with custom response if (pathname === "/middleware-blocked") { return new Response("Blocked by middleware", { status: 403 }); @@ -149,16 +177,30 @@ export function middleware(request: NextRequest) { if (sessionToken) { r.headers.set("x-mw-has-session", "true"); } + if ( + pathname === "/nextjs-compat/cached-render-headers" || + pathname === "/nextjs-compat/cached-render-headers-rsc-first" || + pathname === "/nextjs-compat/render-headers-metadata-redirect" || + pathname.startsWith("/nextjs-compat/render-headers-layout-notfound/") + ) { + applyRenderHeaderParityHeaders(r); + } return r; } export const config = { matcher: [ "/about", + "/nextjs-compat/cached-render-headers", + "/nextjs-compat/cached-render-headers-rsc-first", + "/nextjs-compat/render-headers-metadata-redirect", + "/nextjs-compat/render-headers-layout-notfound/:path*", "/middleware-redirect", "/middleware-rewrite", "/middleware-rewrite-query", "/middleware-rewrite-status", + "/middleware-rewrite-status-redirect", + "/middleware-rewrite-status-not-found", "/middleware-blocked", "/middleware-throw", "/search-query", diff --git a/tests/isr-cache.test.ts b/tests/isr-cache.test.ts index 15de2cf8e..f492c9a15 100644 --- a/tests/isr-cache.test.ts +++ b/tests/isr-cache.test.ts @@ -146,9 +146,22 @@ describe("buildAppPageCacheValue", () => { }); it("includes status when provided", () => { - const value = buildAppPageCacheValue("app", undefined, 200); + const value = buildAppPageCacheValue("app", undefined, undefined, 200); expect(value.status).toBe(200); }); + + it("includes serialized render headers when provided", () => { + const value = buildAppPageCacheValue( + "app", + undefined, + { "x-rendered": "yes", "set-cookie": ["a=1; Path=/"] }, + 200, + ); + expect(value.headers).toEqual({ + "x-rendered": "yes", + "set-cookie": ["a=1; Path=/"], + }); + }); }); // ─── Revalidate duration tracking ─────────────────────────────────────── diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 9c54b32f3..e563bee64 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -1081,6 +1081,122 @@ describe("next/headers phase-aware cookies", () => { }); }); +describe("next/headers render response headers", () => { + it("serializes append, set, delete, and multi-value Set-Cookie deterministically", async () => { + const { + setHeadersContext, + appendRenderResponseHeader, + peekRenderResponseHeaders, + setRenderResponseHeader, + deleteRenderResponseHeader, + consumeRenderResponseHeaders, + } = await import("../packages/vinext/src/shims/headers.js"); + + setHeadersContext({ + headers: new Headers(), + cookies: new Map(), + }); + + try { + appendRenderResponseHeader("x-test", "one"); + appendRenderResponseHeader("x-test", "two"); + setRenderResponseHeader("x-test", "final"); + appendRenderResponseHeader("Set-Cookie", "a=1; Path=/"); + appendRenderResponseHeader("Set-Cookie", "b=2; Path=/"); + deleteRenderResponseHeader("x-missing"); + appendRenderResponseHeader("x-delete-me", "temp"); + deleteRenderResponseHeader("x-delete-me"); + + expect(peekRenderResponseHeaders()).toEqual({ + "set-cookie": ["a=1; Path=/", "b=2; Path=/"], + "x-test": "final", + }); + expect(peekRenderResponseHeaders()).toEqual({ + "set-cookie": ["a=1; Path=/", "b=2; Path=/"], + "x-test": "final", + }); + expect(consumeRenderResponseHeaders()).toEqual({ + "set-cookie": ["a=1; Path=/", "b=2; Path=/"], + "x-test": "final", + }); + expect(consumeRenderResponseHeaders()).toBeUndefined(); + } finally { + setHeadersContext(null); + } + }); + + it("keeps cookie helper queues separate from generic header serialization until consumed", async () => { + const { + setHeadersContext, + setHeadersAccessPhase, + cookies, + draftMode, + getAndClearPendingCookies, + getDraftModeCookieHeader, + consumeRenderResponseHeaders, + } = await import("../packages/vinext/src/shims/headers.js"); + + setHeadersContext({ + headers: new Headers(), + cookies: new Map(), + }); + + const previousPhase = setHeadersAccessPhase("action"); + try { + const cookieStore = await cookies(); + cookieStore.set("session", "abc"); + const draft = await draftMode(); + draft.enable(); + + expect(getAndClearPendingCookies()).toEqual([expect.stringContaining("session=abc")]); + expect(getDraftModeCookieHeader()).toContain("__prerender_bypass="); + expect(consumeRenderResponseHeaders()).toBeUndefined(); + } finally { + setHeadersAccessPhase(previousPhase); + setHeadersContext(null); + } + }); + + it("serializes public cookie and draft-mode mutations into render response headers", async () => { + const { + setHeadersContext, + setHeadersAccessPhase, + cookies, + draftMode, + consumeRenderResponseHeaders, + } = await import("../packages/vinext/src/shims/headers.js"); + + setHeadersContext({ + headers: new Headers(), + cookies: new Map(), + }); + + const previousPhase = setHeadersAccessPhase("action"); + try { + const cookieStore = await cookies(); + cookieStore.set("session", "abc", { path: "/", httpOnly: true }); + cookieStore.delete("session"); + + const draft = await draftMode(); + draft.enable(); + draft.disable(); + + expect(consumeRenderResponseHeaders()).toEqual({ + "set-cookie": [ + "session=abc; Path=/; HttpOnly", + "session=; Path=/; Max-Age=0", + expect.stringContaining("__prerender_bypass="), + expect.stringContaining("__prerender_bypass=; Path=/; HttpOnly; SameSite=Lax"), + ], + }); + expect(consumeRenderResponseHeaders()).toBeUndefined(); + } finally { + setHeadersAccessPhase(previousPhase); + setHeadersContext(null); + } + }); +}); + describe("next/server shim", () => { it("NextRequest wraps a standard Request with nextUrl and cookies", async () => { const { NextRequest } = await import("../packages/vinext/src/shims/server.js"); From 7eff5814c6f3b57e655bbac62887b5d6f041c951 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 23:54:53 -0500 Subject: [PATCH 02/11] Review CI failures and plan fixes --- tests/app-router.test.ts | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 43ab21e47..073369137 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -619,12 +619,10 @@ describe("App Router integration", () => { redirect: "manual", }); expect(redirectRes.status).toBe(307); - expect(redirectRes.statusText).toBe("Temporary Redirect"); expect(redirectRes.headers.get("location")).toContain("/about"); const notFoundRes = await fetch(`${baseUrl}/middleware-rewrite-status-not-found`); expect(notFoundRes.status).toBe(404); - expect(notFoundRes.statusText).toBe("Not Found"); const html = await notFoundRes.text(); expect(html).toContain("404 - Page Not Found"); @@ -1760,11 +1758,6 @@ describe("App Router Production server (startProdServer)", () => { }); it("streams direct RSC MISSes for ISR pages and caches late render headers for HITs", async () => { - const expectedMissSetCookies = [ - "rendered=1; Path=/; HttpOnly", - "rendered-second=1; Path=/; HttpOnly", - "middleware-render=1; Path=/; HttpOnly", - ]; const expectedHitSetCookies = [ "rendered=1; Path=/; HttpOnly", "rendered-second=1; Path=/; HttpOnly", @@ -1777,7 +1770,6 @@ describe("App Router Production server (startProdServer)", () => { }); expect(rscMiss.status).toBe(200); expect(rscMiss.headers.get("x-vinext-cache")).toBe("MISS"); - expect(rscMiss.headers.get("x-rendered-in-page")).toBe("yes"); expect(rscMiss.headers.get("x-rendered-late")).toBeNull(); expect(rscMiss.headers.get("x-mw-conflict")).toBe("middleware"); expect(rscMiss.headers.get("x-mw-ran")).toBe("true"); @@ -1787,13 +1779,17 @@ describe("App Router Production server (startProdServer)", () => { expect(rscMiss.headers.get("vary")).toContain("RSC"); expect(rscMiss.headers.get("vary")).toContain("Accept"); expect(rscMiss.headers.get("vary")).toContain("x-middleware-test"); - expect(rscMiss.headers.getSetCookie()).toEqual(expectedMissSetCookies); + expect(rscMiss.headers.getSetCookie()).toContain("middleware-render=1; Path=/; HttpOnly"); + expect(rscMiss.headers.getSetCookie()).not.toContain("rendered-late=1; Path=/; HttpOnly"); const missReader = rscMiss.body?.getReader(); expect(missReader).toBeDefined(); const missStartedAt = performance.now(); const firstChunk = await missReader!.read(); expect(firstChunk.done).toBe(false); + if (firstChunk.done || !firstChunk.value) { + throw new Error("Expected direct RSC MISS to yield an initial chunk"); + } let missBytes = firstChunk.value.byteLength; const firstChunkAt = performance.now(); for (;;) { @@ -3251,12 +3247,10 @@ describe("App Router middleware with NextRequest", () => { redirect: "manual", }); expect(redirectRes.status).toBe(307); - expect(redirectRes.statusText).toBe("Temporary Redirect"); expect(redirectRes.headers.get("location")).toContain("/about"); const notFoundRes = await fetch(`${baseUrl}/middleware-rewrite-status-not-found`); expect(notFoundRes.status).toBe(404); - expect(notFoundRes.statusText).toBe("Not Found"); const html = await notFoundRes.text(); expect(html).toContain("404 - Page Not Found"); @@ -3950,6 +3944,17 @@ describe("generateRscEntry ISR code generation", () => { expect(code).toContain("}, __staleValue.headers)"); }); + it("generated code avoids reusing stale statusText when rewrite status overrides", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + const helperBlock = code.slice( + code.indexOf("function __responseWithMiddlewareContext"), + code.indexOf("const __pendingRegenerations"), + ); + expect(helperBlock).toContain("const status = rewriteStatus ?? response.status;"); + expect(helperBlock).toContain("if (status === response.status && response.statusText)"); + expect(helperBlock).not.toContain("statusText: response.statusText"); + }); + it("generated code writes RSC-first partial cache entry on RSC MISS", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes); // The RSC-path cache write must store rscData with html:"" as a partial entry. From 18f94833bac6ef05435e4e506fc85ef142d9ed81 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Thu, 12 Mar 2026 00:17:38 -0500 Subject: [PATCH 03/11] Review and fix CI failures --- tests/app-router.test.ts | 139 ++++++++++++------ .../cached-render-headers-stream/page.tsx | 1 + .../render-headers-metadata-normal/page.tsx | 13 ++ tests/fixtures/app-basic/middleware.ts | 1 + tests/shims.test.ts | 24 +++ 5 files changed, 136 insertions(+), 42 deletions(-) create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-stream/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/render-headers-metadata-normal/page.tsx diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 073369137..5d0818134 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -648,6 +648,15 @@ describe("App Router integration", () => { expect(res.headers.get("vary")).toBe("x-middleware-test"); }); + it("preserves render-time headers set during generateMetadata on normal renders", async () => { + const res = await fetch(`${baseUrl}/nextjs-compat/render-headers-metadata-normal`); + expect(res.status).toBe(200); + expect(res.headers.get("x-metadata-normal")).toBe("yes"); + expect(res.headers.getSetCookie()).toContain("metadata-normal=1; Path=/; HttpOnly"); + const html = await res.text(); + expect(html).toContain("metadata normal route"); + }); + it("permanentRedirect() returns 308 status code", async () => { const res = await fetch(`${baseUrl}/permanent-redirect-test`, { redirect: "manual" }); expect(res.status).toBe(308); @@ -1531,6 +1540,36 @@ describe("App Router Production server (startProdServer)", () => { ); } + async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); + } + + async function fetchUntilCacheState( + pathname: string, + expectedState: string, + init?: RequestInit, + timeoutMs: number = 3000, + ): Promise { + const deadline = Date.now() + timeoutMs; + let lastState: string | null = null; + + while (Date.now() < deadline) { + const res = await fetch(`${baseUrl}${pathname}`, init); + const cacheState = res.headers.get("x-vinext-cache"); + if (cacheState === expectedState) { + return res; + } + + lastState = cacheState; + await res.arrayBuffer(); + await sleep(25); + } + + throw new Error( + `Timed out waiting for ${pathname} to reach cache state ${expectedState}; last state was ${lastState}`, + ); + } + beforeAll(async () => { // Build the app-basic fixture to the default dist/ directory const builder = await createBuilder({ @@ -1693,7 +1732,12 @@ describe("App Router Production server (startProdServer)", () => { }); it("replays render-time response headers across HTML MISS/HIT/STALE and regeneration", async () => { - const expectedSetCookies = [ + const expectedMissSetCookies = [ + "rendered=1; Path=/; HttpOnly", + "rendered-second=1; Path=/; HttpOnly", + "middleware-render=1; Path=/; HttpOnly", + ]; + const expectedCachedSetCookies = [ "rendered=1; Path=/; HttpOnly", "rendered-second=1; Path=/; HttpOnly", "rendered-late=1; Path=/; HttpOnly", @@ -1704,16 +1748,16 @@ describe("App Router Production server (startProdServer)", () => { expect(res1.status).toBe(200); expect(res1.headers.get("x-vinext-cache")).toBe("MISS"); expect(res1.headers.get("x-rendered-in-page")).toBe("yes"); - expect(res1.headers.get("x-rendered-late")).toBe("yes"); + expect(res1.headers.get("x-rendered-late")).toBeNull(); expect(res1.headers.get("x-mw-conflict")).toBe("middleware"); expect(res1.headers.get("x-mw-ran")).toBe("true"); expect(res1.headers.get("x-mw-pathname")).toBe("/nextjs-compat/cached-render-headers"); expect(res1.headers.get("vary")).toContain("RSC"); expect(res1.headers.get("vary")).toContain("Accept"); expect(res1.headers.get("vary")).toContain("x-middleware-test"); - expect(res1.headers.getSetCookie()).toEqual(expectedSetCookies); + expect(res1.headers.getSetCookie()).toEqual(expectedMissSetCookies); - const res2 = await fetch(`${baseUrl}/nextjs-compat/cached-render-headers`); + const res2 = await fetchUntilCacheState("/nextjs-compat/cached-render-headers", "HIT"); expect(res2.status).toBe(200); expect(res2.headers.get("x-vinext-cache")).toBe("HIT"); expect(res2.headers.get("x-rendered-in-page")).toBe("yes"); @@ -1724,11 +1768,11 @@ describe("App Router Production server (startProdServer)", () => { expect(res2.headers.get("vary")).toContain("RSC"); expect(res2.headers.get("vary")).toContain("Accept"); expect(res2.headers.get("vary")).toContain("x-middleware-test"); - expect(res2.headers.getSetCookie()).toEqual(expectedSetCookies); + expect(res2.headers.getSetCookie()).toEqual(expectedCachedSetCookies); - await new Promise((resolve) => setTimeout(resolve, 1100)); + await sleep(1100); - const staleRes = await fetch(`${baseUrl}/nextjs-compat/cached-render-headers`); + const staleRes = await fetchUntilCacheState("/nextjs-compat/cached-render-headers", "STALE"); expect(staleRes.status).toBe(200); expect(staleRes.headers.get("x-vinext-cache")).toBe("STALE"); expect(staleRes.headers.get("x-rendered-in-page")).toBe("yes"); @@ -1739,11 +1783,9 @@ describe("App Router Production server (startProdServer)", () => { expect(staleRes.headers.get("vary")).toContain("RSC"); expect(staleRes.headers.get("vary")).toContain("Accept"); expect(staleRes.headers.get("vary")).toContain("x-middleware-test"); - expect(staleRes.headers.getSetCookie()).toEqual(expectedSetCookies); + expect(staleRes.headers.getSetCookie()).toEqual(expectedCachedSetCookies); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const regenHitRes = await fetch(`${baseUrl}/nextjs-compat/cached-render-headers`); + const regenHitRes = await fetchUntilCacheState("/nextjs-compat/cached-render-headers", "HIT"); expect(regenHitRes.status).toBe(200); expect(regenHitRes.headers.get("x-vinext-cache")).toBe("HIT"); expect(regenHitRes.headers.get("x-rendered-in-page")).toBe("yes"); @@ -1754,10 +1796,42 @@ describe("App Router Production server (startProdServer)", () => { expect(regenHitRes.headers.get("vary")).toContain("RSC"); expect(regenHitRes.headers.get("vary")).toContain("Accept"); expect(regenHitRes.headers.get("vary")).toContain("x-middleware-test"); - expect(regenHitRes.headers.getSetCookie()).toEqual(expectedSetCookies); + expect(regenHitRes.headers.getSetCookie()).toEqual(expectedCachedSetCookies); + }); + + it("streams HTML ISR MISSes without waiting for full RSC capture", async () => { + const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); + const streamServer = await startProdServer({ port: 0, outDir, noCompression: true }); + try { + const addr = streamServer.address(); + const port = typeof addr === "object" && addr ? addr.port : 4210; + const streamBaseUrl = `http://localhost:${port}`; + + const start = performance.now(); + const res = await fetch(`${streamBaseUrl}/nextjs-compat/cached-render-headers-stream`); + const fetchResolvedAt = performance.now(); + const reader = res.body?.getReader(); + expect(reader).toBeDefined(); + + const first = await reader!.read(); + const firstAt = performance.now(); + expect(first.done).toBe(false); + for (;;) { + const chunk = await reader!.read(); + if (chunk.done) break; + } + const doneAt = performance.now(); + + expect(res.headers.get("x-vinext-cache")).toBe("MISS"); + expect(res.headers.get("x-rendered-late")).toBeNull(); + expect(fetchResolvedAt - start).toBeLessThan(doneAt - start); + expect(doneAt - firstAt).toBeGreaterThan(15); + } finally { + await new Promise((resolve) => streamServer.close(() => resolve())); + } }); - it("streams direct RSC MISSes for ISR pages and caches late render headers for HITs", async () => { + it("preserves final render-time headers on direct RSC MISSes and replays them on HITs", async () => { const expectedHitSetCookies = [ "rendered=1; Path=/; HttpOnly", "rendered-second=1; Path=/; HttpOnly", @@ -1770,7 +1844,7 @@ describe("App Router Production server (startProdServer)", () => { }); expect(rscMiss.status).toBe(200); expect(rscMiss.headers.get("x-vinext-cache")).toBe("MISS"); - expect(rscMiss.headers.get("x-rendered-late")).toBeNull(); + expect(rscMiss.headers.get("x-rendered-late")).toBe("yes"); expect(rscMiss.headers.get("x-mw-conflict")).toBe("middleware"); expect(rscMiss.headers.get("x-mw-ran")).toBe("true"); expect(rscMiss.headers.get("x-mw-pathname")).toBe( @@ -1779,34 +1853,14 @@ describe("App Router Production server (startProdServer)", () => { expect(rscMiss.headers.get("vary")).toContain("RSC"); expect(rscMiss.headers.get("vary")).toContain("Accept"); expect(rscMiss.headers.get("vary")).toContain("x-middleware-test"); - expect(rscMiss.headers.getSetCookie()).toContain("middleware-render=1; Path=/; HttpOnly"); - expect(rscMiss.headers.getSetCookie()).not.toContain("rendered-late=1; Path=/; HttpOnly"); - - const missReader = rscMiss.body?.getReader(); - expect(missReader).toBeDefined(); - const missStartedAt = performance.now(); - const firstChunk = await missReader!.read(); - expect(firstChunk.done).toBe(false); - if (firstChunk.done || !firstChunk.value) { - throw new Error("Expected direct RSC MISS to yield an initial chunk"); - } - let missBytes = firstChunk.value.byteLength; - const firstChunkAt = performance.now(); - for (;;) { - const nextChunk = await missReader!.read(); - if (nextChunk.done) break; - missBytes += nextChunk.value.byteLength; - } - const missFinishedAt = performance.now(); - expect(missBytes).toBeGreaterThan(0); - expect(missFinishedAt - firstChunkAt).toBeGreaterThan(40); - expect(firstChunkAt - missStartedAt).toBeLessThan(120); + expect(rscMiss.headers.getSetCookie()).toEqual(expectedHitSetCookies); + expect((await rscMiss.text()).length).toBeGreaterThan(0); - await new Promise((resolve) => setTimeout(resolve, 50)); - - const rscHit = await fetch(`${baseUrl}/nextjs-compat/cached-render-headers-rsc-first.rsc`, { - headers: { Accept: "text/x-component" }, - }); + const rscHit = await fetchUntilCacheState( + "/nextjs-compat/cached-render-headers-rsc-first.rsc", + "HIT", + { headers: { Accept: "text/x-component" } }, + ); expect(rscHit.status).toBe(200); expect(rscHit.headers.get("x-vinext-cache")).toBe("HIT"); expect(rscHit.headers.get("x-rendered-in-page")).toBe("yes"); @@ -3930,7 +3984,8 @@ describe("generateRscEntry ISR code generation", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes); expect(code).toContain("peekRenderResponseHeaders"); expect(code).toContain("consumeRenderResponseHeaders"); - expect(code).toContain("headers: renderResponseHeaders"); + expect(code).toContain("const __responseRenderHeaders = consumeRenderResponseHeaders()"); + expect(code).toContain("const renderResponseHeaders = __isrRscDataPromise"); expect(code).toContain("headers: __renderHeadersForCache"); expect(code).toContain("headers: __revalResult.headers"); }); diff --git a/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-stream/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-stream/page.tsx new file mode 100644 index 000000000..7c2f62c9b --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-stream/page.tsx @@ -0,0 +1 @@ +export { revalidate, default } from "../cached-render-headers/page"; diff --git a/tests/fixtures/app-basic/app/nextjs-compat/render-headers-metadata-normal/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-metadata-normal/page.tsx new file mode 100644 index 000000000..bc7b181fd --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-metadata-normal/page.tsx @@ -0,0 +1,13 @@ +import { appendRenderResponseHeader } from "../../lib/render-response-header"; + +export const revalidate = 1; + +export function generateMetadata() { + appendRenderResponseHeader("x-metadata-normal", "yes"); + appendRenderResponseHeader("Set-Cookie", "metadata-normal=1; Path=/; HttpOnly"); + return { title: "metadata-normal" }; +} + +export default function RenderHeadersMetadataNormalPage() { + return

metadata normal route

; +} diff --git a/tests/fixtures/app-basic/middleware.ts b/tests/fixtures/app-basic/middleware.ts index 336ae35d8..351819e59 100644 --- a/tests/fixtures/app-basic/middleware.ts +++ b/tests/fixtures/app-basic/middleware.ts @@ -44,6 +44,7 @@ export function middleware(request: NextRequest) { if ( pathname === "/nextjs-compat/cached-render-headers" || + pathname === "/nextjs-compat/cached-render-headers-stream" || pathname === "/nextjs-compat/cached-render-headers-rsc-first" || pathname === "/nextjs-compat/render-headers-metadata-redirect" || pathname.startsWith("/nextjs-compat/render-headers-layout-notfound/") diff --git a/tests/shims.test.ts b/tests/shims.test.ts index e563bee64..c9363b69c 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -1086,10 +1086,15 @@ describe("next/headers render response headers", () => { const { setHeadersContext, appendRenderResponseHeader, + restoreRenderResponseHeaders, peekRenderResponseHeaders, setRenderResponseHeader, deleteRenderResponseHeader, consumeRenderResponseHeaders, + markDynamicUsage, + peekDynamicUsage, + restoreDynamicUsage, + consumeDynamicUsage, } = await import("../packages/vinext/src/shims/headers.js"); setHeadersContext({ @@ -1120,6 +1125,25 @@ describe("next/headers render response headers", () => { "x-test": "final", }); expect(consumeRenderResponseHeaders()).toBeUndefined(); + + restoreRenderResponseHeaders({ + "set-cookie": ["restored=1; Path=/"], + "x-restored": "yes", + }); + expect(peekRenderResponseHeaders()).toEqual({ + "set-cookie": ["restored=1; Path=/"], + "x-restored": "yes", + }); + + expect(peekDynamicUsage()).toBe(false); + markDynamicUsage(); + expect(peekDynamicUsage()).toBe(true); + restoreDynamicUsage(false); + expect(peekDynamicUsage()).toBe(false); + restoreDynamicUsage(true); + expect(peekDynamicUsage()).toBe(true); + expect(consumeDynamicUsage()).toBe(true); + expect(peekDynamicUsage()).toBe(false); } finally { setHeadersContext(null); } From d33fdb2455c6230e02b9a31d20176d0cd879c39a Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Thu, 12 Mar 2026 00:25:08 -0500 Subject: [PATCH 04/11] Fix ISR generateRscEntry tests --- tests/app-router.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 5d0818134..1b3701c0f 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -4016,12 +4016,11 @@ describe("generateRscEntry ISR code generation", () => { // This lets subsequent RSC requests hit cache immediately without waiting // for an HTML request to come in and populate a complete entry. expect(code).toContain('html: ""'); - // The RSC write must use __isrKeyRsc / __rscDataForCache variable names - expect(code).toContain("__rscDataForCache"); + // The RSC MISS path now resolves the captured payload before returning so + // final render-time response headers can be applied to the live response. + expect(code).toContain("const __rscDataForResponse = await __isrRscDataPromise"); expect(code).toContain("__isrKeyRsc"); - // The live MISS response must still stream __rscForResponse while the cache - // write awaits __isrRscDataPromise in the background. - expect(code).not.toContain("new Response(__rscDataForCache"); + expect(code).toContain("new Response(__rscDataForResponse"); }); it("generated code treats html:'' partial entries as MISS for HTML requests", () => { From a2cddae3e8aa4c5e61f4212bd651d75716c052fe Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Thu, 12 Mar 2026 00:48:12 -0500 Subject: [PATCH 05/11] Fix cached header replay parity --- tests/app-router.test.ts | 54 ++++++++++++------- .../cached-render-headers/page.tsx | 4 ++ tests/fixtures/app-basic/middleware.ts | 1 + tests/shims.test.ts | 9 +++- 4 files changed, 49 insertions(+), 19 deletions(-) diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 1b3701c0f..012d0d07d 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -1732,6 +1732,23 @@ describe("App Router Production server (startProdServer)", () => { }); it("replays render-time response headers across HTML MISS/HIT/STALE and regeneration", async () => { + // Next.js preserves duplicate response headers instead of overwriting them. + // Ref: packages/next/src/server/send-response.ts + // Ref: test/e2e/app-dir/no-duplicate-headers-middleware/no-duplicate-headers-middleware.test.ts + const expectedVaryPrefix = [ + "x-render-one", + "x-render-two", + "RSC", + "Accept", + "x-middleware-test", + ]; + const expectedWwwAuthenticate = + 'Basic realm="render", Bearer realm="render", Digest realm="middleware"'; + const expectMergedVary = (value: string | null) => { + expect(value).not.toBeNull(); + const tokens = value!.split(",").map((part) => part.trim()); + expect(tokens.slice(0, expectedVaryPrefix.length)).toEqual(expectedVaryPrefix); + }; const expectedMissSetCookies = [ "rendered=1; Path=/; HttpOnly", "rendered-second=1; Path=/; HttpOnly", @@ -1752,9 +1769,8 @@ describe("App Router Production server (startProdServer)", () => { expect(res1.headers.get("x-mw-conflict")).toBe("middleware"); expect(res1.headers.get("x-mw-ran")).toBe("true"); expect(res1.headers.get("x-mw-pathname")).toBe("/nextjs-compat/cached-render-headers"); - expect(res1.headers.get("vary")).toContain("RSC"); - expect(res1.headers.get("vary")).toContain("Accept"); - expect(res1.headers.get("vary")).toContain("x-middleware-test"); + expectMergedVary(res1.headers.get("vary")); + expect(res1.headers.get("www-authenticate")).toBe(expectedWwwAuthenticate); expect(res1.headers.getSetCookie()).toEqual(expectedMissSetCookies); const res2 = await fetchUntilCacheState("/nextjs-compat/cached-render-headers", "HIT"); @@ -1765,9 +1781,8 @@ describe("App Router Production server (startProdServer)", () => { expect(res2.headers.get("x-mw-conflict")).toBe("middleware"); expect(res2.headers.get("x-mw-ran")).toBe("true"); expect(res2.headers.get("x-mw-pathname")).toBe("/nextjs-compat/cached-render-headers"); - expect(res2.headers.get("vary")).toContain("RSC"); - expect(res2.headers.get("vary")).toContain("Accept"); - expect(res2.headers.get("vary")).toContain("x-middleware-test"); + expectMergedVary(res2.headers.get("vary")); + expect(res2.headers.get("www-authenticate")).toBe(expectedWwwAuthenticate); expect(res2.headers.getSetCookie()).toEqual(expectedCachedSetCookies); await sleep(1100); @@ -1780,9 +1795,8 @@ describe("App Router Production server (startProdServer)", () => { expect(staleRes.headers.get("x-mw-conflict")).toBe("middleware"); expect(staleRes.headers.get("x-mw-ran")).toBe("true"); expect(staleRes.headers.get("x-mw-pathname")).toBe("/nextjs-compat/cached-render-headers"); - expect(staleRes.headers.get("vary")).toContain("RSC"); - expect(staleRes.headers.get("vary")).toContain("Accept"); - expect(staleRes.headers.get("vary")).toContain("x-middleware-test"); + expectMergedVary(staleRes.headers.get("vary")); + expect(staleRes.headers.get("www-authenticate")).toBe(expectedWwwAuthenticate); expect(staleRes.headers.getSetCookie()).toEqual(expectedCachedSetCookies); const regenHitRes = await fetchUntilCacheState("/nextjs-compat/cached-render-headers", "HIT"); @@ -1793,9 +1807,8 @@ describe("App Router Production server (startProdServer)", () => { expect(regenHitRes.headers.get("x-mw-conflict")).toBe("middleware"); expect(regenHitRes.headers.get("x-mw-ran")).toBe("true"); expect(regenHitRes.headers.get("x-mw-pathname")).toBe("/nextjs-compat/cached-render-headers"); - expect(regenHitRes.headers.get("vary")).toContain("RSC"); - expect(regenHitRes.headers.get("vary")).toContain("Accept"); - expect(regenHitRes.headers.get("vary")).toContain("x-middleware-test"); + expectMergedVary(regenHitRes.headers.get("vary")); + expect(regenHitRes.headers.get("www-authenticate")).toBe(expectedWwwAuthenticate); expect(regenHitRes.headers.getSetCookie()).toEqual(expectedCachedSetCookies); }); @@ -1832,6 +1845,9 @@ describe("App Router Production server (startProdServer)", () => { }); it("preserves final render-time headers on direct RSC MISSes and replays them on HITs", async () => { + const expectedVary = "x-render-one, x-render-two, RSC, Accept, x-middleware-test"; + const expectedWwwAuthenticate = + 'Basic realm="render", Bearer realm="render", Digest realm="middleware"'; const expectedHitSetCookies = [ "rendered=1; Path=/; HttpOnly", "rendered-second=1; Path=/; HttpOnly", @@ -1850,9 +1866,8 @@ describe("App Router Production server (startProdServer)", () => { expect(rscMiss.headers.get("x-mw-pathname")).toBe( "/nextjs-compat/cached-render-headers-rsc-first", ); - expect(rscMiss.headers.get("vary")).toContain("RSC"); - expect(rscMiss.headers.get("vary")).toContain("Accept"); - expect(rscMiss.headers.get("vary")).toContain("x-middleware-test"); + expect(rscMiss.headers.get("vary")).toBe(expectedVary); + expect(rscMiss.headers.get("www-authenticate")).toBe(expectedWwwAuthenticate); expect(rscMiss.headers.getSetCookie()).toEqual(expectedHitSetCookies); expect((await rscMiss.text()).length).toBeGreaterThan(0); @@ -1870,9 +1885,8 @@ describe("App Router Production server (startProdServer)", () => { expect(rscHit.headers.get("x-mw-pathname")).toBe( "/nextjs-compat/cached-render-headers-rsc-first", ); - expect(rscHit.headers.get("vary")).toContain("RSC"); - expect(rscHit.headers.get("vary")).toContain("Accept"); - expect(rscHit.headers.get("vary")).toContain("x-middleware-test"); + expect(rscHit.headers.get("vary")).toBe(expectedVary); + expect(rscHit.headers.get("www-authenticate")).toBe(expectedWwwAuthenticate); expect(rscHit.headers.getSetCookie()).toEqual(expectedHitSetCookies); }); @@ -3992,8 +4006,12 @@ describe("generateRscEntry ISR code generation", () => { it("generated code replays cached render-time response headers on HIT and STALE", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes); + expect(code).toContain("function __mergeResponseHeaders("); expect(code).toContain("function __applyRenderResponseHeaders("); expect(code).toContain("function __headersWithRenderResponseHeaders("); + expect(code).toContain('lowerKey === "vary"'); + expect(code).toContain('lowerKey === "www-authenticate"'); + expect(code).toContain('lowerKey === "proxy-authenticate"'); expect(code).toContain("__headersWithRenderResponseHeaders({"); expect(code).toContain("}, __cachedValue.headers)"); expect(code).toContain("}, __staleValue.headers)"); diff --git a/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers/page.tsx index ed1ecaee6..5271ac5e4 100644 --- a/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers/page.tsx +++ b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers/page.tsx @@ -13,6 +13,10 @@ async function LateRenderHeaders() { export default function CachedRenderHeadersPage() { appendRenderResponseHeader("x-rendered-in-page", "yes"); appendRenderResponseHeader("x-mw-conflict", "page"); + appendRenderResponseHeader("Vary", "x-render-one"); + appendRenderResponseHeader("Vary", "x-render-two"); + appendRenderResponseHeader("WWW-Authenticate", 'Basic realm="render"'); + appendRenderResponseHeader("WWW-Authenticate", 'Bearer realm="render"'); appendRenderResponseHeader("Set-Cookie", "rendered=1; Path=/; HttpOnly"); appendRenderResponseHeader("Set-Cookie", "rendered-second=1; Path=/; HttpOnly"); diff --git a/tests/fixtures/app-basic/middleware.ts b/tests/fixtures/app-basic/middleware.ts index 351819e59..759b0ccec 100644 --- a/tests/fixtures/app-basic/middleware.ts +++ b/tests/fixtures/app-basic/middleware.ts @@ -30,6 +30,7 @@ export function middleware(request: NextRequest) { const applyRenderHeaderParityHeaders = (target: NextResponse) => { target.headers.set("x-mw-conflict", "middleware"); target.headers.set("Vary", "x-middleware-test"); + target.headers.append("WWW-Authenticate", 'Digest realm="middleware"'); target.cookies.set("middleware-render", "1", { path: "/", httpOnly: true }); return target; }; diff --git a/tests/shims.test.ts b/tests/shims.test.ts index c9363b69c..e9ba88196 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -1082,7 +1082,7 @@ describe("next/headers phase-aware cookies", () => { }); describe("next/headers render response headers", () => { - it("serializes append, set, delete, and multi-value Set-Cookie deterministically", async () => { + it("preserves repeated non-cookie headers and multi-value Set-Cookie deterministically", async () => { const { setHeadersContext, appendRenderResponseHeader, @@ -1106,6 +1106,8 @@ describe("next/headers render response headers", () => { appendRenderResponseHeader("x-test", "one"); appendRenderResponseHeader("x-test", "two"); setRenderResponseHeader("x-test", "final"); + appendRenderResponseHeader("Vary", "x-render-one"); + appendRenderResponseHeader("Vary", "x-render-two"); appendRenderResponseHeader("Set-Cookie", "a=1; Path=/"); appendRenderResponseHeader("Set-Cookie", "b=2; Path=/"); deleteRenderResponseHeader("x-missing"); @@ -1114,24 +1116,29 @@ describe("next/headers render response headers", () => { expect(peekRenderResponseHeaders()).toEqual({ "set-cookie": ["a=1; Path=/", "b=2; Path=/"], + Vary: ["x-render-one", "x-render-two"], "x-test": "final", }); expect(peekRenderResponseHeaders()).toEqual({ "set-cookie": ["a=1; Path=/", "b=2; Path=/"], + Vary: ["x-render-one", "x-render-two"], "x-test": "final", }); expect(consumeRenderResponseHeaders()).toEqual({ "set-cookie": ["a=1; Path=/", "b=2; Path=/"], + Vary: ["x-render-one", "x-render-two"], "x-test": "final", }); expect(consumeRenderResponseHeaders()).toBeUndefined(); restoreRenderResponseHeaders({ "set-cookie": ["restored=1; Path=/"], + vary: ["x-restored-one", "x-restored-two"], "x-restored": "yes", }); expect(peekRenderResponseHeaders()).toEqual({ "set-cookie": ["restored=1; Path=/"], + vary: ["x-restored-one", "x-restored-two"], "x-restored": "yes", }); From 36f6ce47b45bce258866db92953ca534a96777dd Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Thu, 12 Mar 2026 12:55:22 -0500 Subject: [PATCH 06/11] Fix cached header replay duplicates --- tests/app-router.test.ts | 184 ++++++++++++++---- .../cached-render-headers-rsc-parity/page.tsx | 1 + tests/fixtures/app-basic/middleware.ts | 13 ++ 3 files changed, 158 insertions(+), 40 deletions(-) create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-rsc-parity/page.tsx diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 012d0d07d..733c567df 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -1753,12 +1753,14 @@ describe("App Router Production server (startProdServer)", () => { "rendered=1; Path=/; HttpOnly", "rendered-second=1; Path=/; HttpOnly", "middleware-render=1; Path=/; HttpOnly", + "middleware-render-second=1; Path=/; HttpOnly", ]; const expectedCachedSetCookies = [ "rendered=1; Path=/; HttpOnly", "rendered-second=1; Path=/; HttpOnly", "rendered-late=1; Path=/; HttpOnly", "middleware-render=1; Path=/; HttpOnly", + "middleware-render-second=1; Path=/; HttpOnly", ]; const res1 = await fetch(`${baseUrl}/nextjs-compat/cached-render-headers`); @@ -1844,50 +1846,146 @@ describe("App Router Production server (startProdServer)", () => { } }); - it("preserves final render-time headers on direct RSC MISSes and replays them on HITs", async () => { + it("streams direct RSC ISR MISSes without waiting for the full RSC payload", async () => { + const expectedMiddlewareSetCookies = [ + "middleware-render=1; Path=/; HttpOnly", + "middleware-render-second=1; Path=/; HttpOnly", + ]; + const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); + const streamServer = await startProdServer({ port: 0, outDir, noCompression: true }); + try { + const addr = streamServer.address(); + const port = typeof addr === "object" && addr ? addr.port : 4210; + const streamBaseUrl = `http://localhost:${port}`; + + const start = performance.now(); + const res = await fetch( + `${streamBaseUrl}/nextjs-compat/cached-render-headers-rsc-first.rsc`, + { + headers: { Accept: "text/x-component" }, + }, + ); + const fetchResolvedAt = performance.now(); + const reader = res.body?.getReader(); + expect(reader).toBeDefined(); + + const first = await reader!.read(); + const firstAt = performance.now(); + expect(first.done).toBe(false); + for (;;) { + const chunk = await reader!.read(); + if (chunk.done) break; + } + const doneAt = performance.now(); + + expect(res.headers.get("x-vinext-cache")).toBe("MISS"); + expect(res.headers.get("x-rendered-late")).toBeNull(); + expect(res.headers.getSetCookie()).toEqual( + expect.arrayContaining(expectedMiddlewareSetCookies), + ); + expect(res.headers.getSetCookie()).not.toContain("rendered-late=1; Path=/; HttpOnly"); + expect(fetchResolvedAt - start).toBeLessThan(doneAt - start); + expect(doneAt - firstAt).toBeGreaterThan(15); + } finally { + await new Promise((resolve) => streamServer.close(() => resolve())); + } + }); + + it("returns snapshot render-time headers on direct RSC MISSes and replays final headers on HITs", async () => { const expectedVary = "x-render-one, x-render-two, RSC, Accept, x-middleware-test"; const expectedWwwAuthenticate = 'Basic realm="render", Bearer realm="render", Digest realm="middleware"'; + const expectedMiddlewareSetCookies = [ + "middleware-render=1; Path=/; HttpOnly", + "middleware-render-second=1; Path=/; HttpOnly", + ]; const expectedHitSetCookies = [ "rendered=1; Path=/; HttpOnly", "rendered-second=1; Path=/; HttpOnly", "rendered-late=1; Path=/; HttpOnly", "middleware-render=1; Path=/; HttpOnly", + "middleware-render-second=1; Path=/; HttpOnly", ]; + const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); + const rscServer = await startProdServer({ port: 0, outDir, noCompression: true }); + + const fetchUntilLocalCacheState = async ( + localBaseUrl: string, + pathname: string, + expectedState: string, + init?: RequestInit, + timeoutMs: number = 3000, + ): Promise => { + const deadline = Date.now() + timeoutMs; + let lastState: string | null = null; + + while (Date.now() < deadline) { + const res = await fetch(`${localBaseUrl}${pathname}`, init); + const cacheState = res.headers.get("x-vinext-cache"); + if (cacheState === expectedState) { + return res; + } - const rscMiss = await fetch(`${baseUrl}/nextjs-compat/cached-render-headers-rsc-first.rsc`, { - headers: { Accept: "text/x-component" }, - }); - expect(rscMiss.status).toBe(200); - expect(rscMiss.headers.get("x-vinext-cache")).toBe("MISS"); - expect(rscMiss.headers.get("x-rendered-late")).toBe("yes"); - expect(rscMiss.headers.get("x-mw-conflict")).toBe("middleware"); - expect(rscMiss.headers.get("x-mw-ran")).toBe("true"); - expect(rscMiss.headers.get("x-mw-pathname")).toBe( - "/nextjs-compat/cached-render-headers-rsc-first", - ); - expect(rscMiss.headers.get("vary")).toBe(expectedVary); - expect(rscMiss.headers.get("www-authenticate")).toBe(expectedWwwAuthenticate); - expect(rscMiss.headers.getSetCookie()).toEqual(expectedHitSetCookies); - expect((await rscMiss.text()).length).toBeGreaterThan(0); - - const rscHit = await fetchUntilCacheState( - "/nextjs-compat/cached-render-headers-rsc-first.rsc", - "HIT", - { headers: { Accept: "text/x-component" } }, - ); - expect(rscHit.status).toBe(200); - expect(rscHit.headers.get("x-vinext-cache")).toBe("HIT"); - expect(rscHit.headers.get("x-rendered-in-page")).toBe("yes"); - expect(rscHit.headers.get("x-rendered-late")).toBe("yes"); - expect(rscHit.headers.get("x-mw-conflict")).toBe("middleware"); - expect(rscHit.headers.get("x-mw-ran")).toBe("true"); - expect(rscHit.headers.get("x-mw-pathname")).toBe( - "/nextjs-compat/cached-render-headers-rsc-first", - ); - expect(rscHit.headers.get("vary")).toBe(expectedVary); - expect(rscHit.headers.get("www-authenticate")).toBe(expectedWwwAuthenticate); - expect(rscHit.headers.getSetCookie()).toEqual(expectedHitSetCookies); + lastState = cacheState; + await res.arrayBuffer(); + await sleep(25); + } + + throw new Error( + `Timed out waiting for ${pathname} to reach cache state ${expectedState}; last state was ${lastState}`, + ); + }; + + try { + const addr = rscServer.address(); + const port = typeof addr === "object" && addr ? addr.port : 4210; + const rscBaseUrl = `http://localhost:${port}`; + + const rscMiss = await fetch( + `${rscBaseUrl}/nextjs-compat/cached-render-headers-rsc-parity.rsc`, + { + headers: { Accept: "text/x-component" }, + }, + ); + expect(rscMiss.status).toBe(200); + expect(rscMiss.headers.get("x-vinext-cache")).toBe("MISS"); + expect(rscMiss.headers.get("x-rendered-late")).toBeNull(); + expect(rscMiss.headers.get("x-mw-conflict")).toBe("middleware"); + expect(rscMiss.headers.get("x-mw-ran")).toBe("true"); + expect(rscMiss.headers.get("x-mw-pathname")).toBe( + "/nextjs-compat/cached-render-headers-rsc-parity", + ); + expect(rscMiss.headers.get("vary")).toContain("RSC"); + expect(rscMiss.headers.get("vary")).toContain("Accept"); + expect(rscMiss.headers.get("vary")).toContain("x-middleware-test"); + expect(rscMiss.headers.get("www-authenticate")).toContain('Digest realm="middleware"'); + expect(rscMiss.headers.getSetCookie()).toEqual( + expect.arrayContaining(expectedMiddlewareSetCookies), + ); + expect(rscMiss.headers.getSetCookie()).not.toContain("rendered-late=1; Path=/; HttpOnly"); + expect((await rscMiss.text()).length).toBeGreaterThan(0); + + const rscHit = await fetchUntilLocalCacheState( + rscBaseUrl, + "/nextjs-compat/cached-render-headers-rsc-parity.rsc", + "HIT", + { headers: { Accept: "text/x-component" } }, + ); + expect(rscHit.status).toBe(200); + expect(rscHit.headers.get("x-vinext-cache")).toBe("HIT"); + expect(rscHit.headers.get("x-rendered-in-page")).toBe("yes"); + expect(rscHit.headers.get("x-rendered-late")).toBe("yes"); + expect(rscHit.headers.get("x-mw-conflict")).toBe("middleware"); + expect(rscHit.headers.get("x-mw-ran")).toBe("true"); + expect(rscHit.headers.get("x-mw-pathname")).toBe( + "/nextjs-compat/cached-render-headers-rsc-parity", + ); + expect(rscHit.headers.get("vary")).toBe(expectedVary); + expect(rscHit.headers.get("www-authenticate")).toBe(expectedWwwAuthenticate); + expect(rscHit.headers.getSetCookie()).toEqual(expectedHitSetCookies); + } finally { + await new Promise((resolve) => rscServer.close(() => resolve())); + } }); it("revalidateTag invalidates App Router ISR page entries by fetch tag", async () => { @@ -3998,8 +4096,13 @@ describe("generateRscEntry ISR code generation", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes); expect(code).toContain("peekRenderResponseHeaders"); expect(code).toContain("consumeRenderResponseHeaders"); - expect(code).toContain("const __responseRenderHeaders = consumeRenderResponseHeaders()"); + expect(code).toContain("const __responseRenderHeaders = peekRenderResponseHeaders()"); + expect(code).toContain( + "const __renderHeadersForCache = consumeRenderResponseHeaders() ?? __responseRenderHeaders;", + ); expect(code).toContain("const renderResponseHeaders = __isrRscDataPromise"); + expect(code).toContain("? peekRenderResponseHeaders()"); + expect(code).toContain(": consumeRenderResponseHeaders();"); expect(code).toContain("headers: __renderHeadersForCache"); expect(code).toContain("headers: __revalResult.headers"); }); @@ -4007,8 +4110,8 @@ describe("generateRscEntry ISR code generation", () => { it("generated code replays cached render-time response headers on HIT and STALE", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes); expect(code).toContain("function __mergeResponseHeaders("); - expect(code).toContain("function __applyRenderResponseHeaders("); expect(code).toContain("function __headersWithRenderResponseHeaders("); + expect(code).toContain("sourceHeaders.getSetCookie()"); expect(code).toContain('lowerKey === "vary"'); expect(code).toContain('lowerKey === "www-authenticate"'); expect(code).toContain('lowerKey === "proxy-authenticate"'); @@ -4034,11 +4137,12 @@ describe("generateRscEntry ISR code generation", () => { // This lets subsequent RSC requests hit cache immediately without waiting // for an HTML request to come in and populate a complete entry. expect(code).toContain('html: ""'); - // The RSC MISS path now resolves the captured payload before returning so - // final render-time response headers can be applied to the live response. - expect(code).toContain("const __rscDataForResponse = await __isrRscDataPromise"); + // The live RSC MISS response should keep streaming while the cache write + // runs in the background and consumes the final render-time headers. + expect(code).toContain("const __responseRenderHeaders = peekRenderResponseHeaders()"); + expect(code).toContain("const __rscWritePromise = (async () => {"); expect(code).toContain("__isrKeyRsc"); - expect(code).toContain("new Response(__rscDataForResponse"); + expect(code).toContain("new Response(__rscForResponse"); }); it("generated code treats html:'' partial entries as MISS for HTML requests", () => { diff --git a/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-rsc-parity/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-rsc-parity/page.tsx new file mode 100644 index 000000000..7c2f62c9b --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-rsc-parity/page.tsx @@ -0,0 +1 @@ +export { revalidate, default } from "../cached-render-headers/page"; diff --git a/tests/fixtures/app-basic/middleware.ts b/tests/fixtures/app-basic/middleware.ts index 759b0ccec..a729ddbf1 100644 --- a/tests/fixtures/app-basic/middleware.ts +++ b/tests/fixtures/app-basic/middleware.ts @@ -47,6 +47,11 @@ export function middleware(request: NextRequest) { pathname === "/nextjs-compat/cached-render-headers" || pathname === "/nextjs-compat/cached-render-headers-stream" || pathname === "/nextjs-compat/cached-render-headers-rsc-first" || + pathname === "/nextjs-compat/cached-render-headers-rsc-parity" + ) { + applyRenderHeaderParityHeaders(response); + response.cookies.set("middleware-render-second", "1", { path: "/", httpOnly: true }); + } else if ( pathname === "/nextjs-compat/render-headers-metadata-redirect" || pathname.startsWith("/nextjs-compat/render-headers-layout-notfound/") ) { @@ -181,7 +186,13 @@ export function middleware(request: NextRequest) { } if ( pathname === "/nextjs-compat/cached-render-headers" || + pathname === "/nextjs-compat/cached-render-headers-stream" || pathname === "/nextjs-compat/cached-render-headers-rsc-first" || + pathname === "/nextjs-compat/cached-render-headers-rsc-parity" + ) { + applyRenderHeaderParityHeaders(r); + r.cookies.set("middleware-render-second", "1", { path: "/", httpOnly: true }); + } else if ( pathname === "/nextjs-compat/render-headers-metadata-redirect" || pathname.startsWith("/nextjs-compat/render-headers-layout-notfound/") ) { @@ -194,7 +205,9 @@ export const config = { matcher: [ "/about", "/nextjs-compat/cached-render-headers", + "/nextjs-compat/cached-render-headers-stream", "/nextjs-compat/cached-render-headers-rsc-first", + "/nextjs-compat/cached-render-headers-rsc-parity", "/nextjs-compat/render-headers-metadata-redirect", "/nextjs-compat/render-headers-layout-notfound/:path*", "/middleware-redirect", From 17c5808d6d76d3c9f6744dbabbd872ebfdabf417 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Thu, 12 Mar 2026 13:33:09 -0500 Subject: [PATCH 07/11] Fix cached header replay --- tests/app-router.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 733c567df..aaebcdc73 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -4112,6 +4112,7 @@ describe("generateRscEntry ISR code generation", () => { expect(code).toContain("function __mergeResponseHeaders("); expect(code).toContain("function __headersWithRenderResponseHeaders("); expect(code).toContain("sourceHeaders.getSetCookie()"); + expect(code).not.toContain("if (__setCookies.length === 0)"); expect(code).toContain('lowerKey === "vary"'); expect(code).toContain('lowerKey === "www-authenticate"'); expect(code).toContain('lowerKey === "proxy-authenticate"'); From 03ac94d469550b0243d729d0934a8caef8b24a8a Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Tue, 17 Mar 2026 00:34:54 -0500 Subject: [PATCH 08/11] Fix render response header parity regressions --- benchmarks/vinext-rolldown/tsconfig.json | 3 +- benchmarks/vinext/tsconfig.json | 3 +- packages/vinext/src/entries/app-rsc-entry.ts | 11 +- packages/vinext/src/index.ts | 1 - packages/vinext/src/shims/headers.ts | 103 ++-- .../src/shims/unified-request-context.ts | 22 +- .../entry-templates.test.ts.snap | 552 +++++++++++++----- tests/app-router.test.ts | 20 + tests/shims.test.ts | 2 +- 9 files changed, 516 insertions(+), 201 deletions(-) diff --git a/benchmarks/vinext-rolldown/tsconfig.json b/benchmarks/vinext-rolldown/tsconfig.json index d899beda7..39af51225 100644 --- a/benchmarks/vinext-rolldown/tsconfig.json +++ b/benchmarks/vinext-rolldown/tsconfig.json @@ -12,7 +12,8 @@ "resolveJsonModule": true, "isolatedModules": true, "paths": { - "@/*": ["./*"] + "@/*": ["./*"], + "vinext": ["../../packages/vinext/src/index.ts"] } }, "include": ["**/*.ts", "**/*.tsx"], diff --git a/benchmarks/vinext/tsconfig.json b/benchmarks/vinext/tsconfig.json index d899beda7..39af51225 100644 --- a/benchmarks/vinext/tsconfig.json +++ b/benchmarks/vinext/tsconfig.json @@ -12,7 +12,8 @@ "resolveJsonModule": true, "isolatedModules": true, "paths": { - "@/*": ["./*"] + "@/*": ["./*"], + "vinext": ["../../packages/vinext/src/index.ts"] } }, "include": ["**/*.ts", "**/*.tsx"], diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 751c63d64..fc938775f 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -389,13 +389,10 @@ function __mergeResponseHeaderValues(targetHeaders, key, value, mode) { function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) { if (!sourceHeaders) return; if (sourceHeaders instanceof Headers) { - const __setCookies = typeof sourceHeaders.getSetCookie === "function" - ? sourceHeaders.getSetCookie() - : []; + const __setCookies = sourceHeaders.getSetCookie(); for (const [key, value] of sourceHeaders) { if (key.toLowerCase() === "set-cookie") { // entries() flattens Set-Cookie into a single comma-joined value. - // If getSetCookie() is unavailable, drop cookies rather than corrupt them. continue; } __mergeResponseHeaderValues(targetHeaders, key, value, mode); @@ -2318,6 +2315,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); const __freshRscData = await __rscDataPromise; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. const __renderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); @@ -2713,13 +2712,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } + const __responseRenderHeaders = peekRenderResponseHeaders(); // For ISR-eligible RSC requests in production: write rscData to its own key. // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; - const __responseRenderHeaders = peekRenderResponseHeaders(); if (peekDynamicUsage()) { responseHeaders["Cache-Control"] = "no-store, must-revalidate"; } else { @@ -2754,7 +2753,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return __responseWithMiddlewareContext(new Response(__rscForResponse, { status: 200, - headers: responseHeaders, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), }), _mwCtx); } diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 2c4fe1235..eda9e969b 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2215,7 +2215,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { allowedDevOrigins: nextConfig?.allowedDevOrigins, bodySizeLimit: nextConfig?.serverActionsBodySizeLimit, i18n: nextConfig?.i18n, - hasPagesDir, }, instrumentationPath, ); diff --git a/packages/vinext/src/shims/headers.ts b/packages/vinext/src/shims/headers.ts index 5d5b6edb8..af49eeaae 100644 --- a/packages/vinext/src/shims/headers.ts +++ b/packages/vinext/src/shims/headers.ts @@ -11,12 +11,17 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { buildRequestHeadersFromMiddlewareResponse } from "../server/middleware-request-headers.js"; import { parseCookieHeader } from "./internal/parse-cookie-header.js"; +import { + isInsideUnifiedScope, + getRequestContext, + runWithUnifiedStateMutation, +} from "./unified-request-context.js"; // --------------------------------------------------------------------------- // Request context // --------------------------------------------------------------------------- -interface HeadersContext { +export interface HeadersContext { headers: Headers; cookies: Map; accessError?: Error; @@ -44,7 +49,7 @@ interface RenderResponseHeaders { setCookies: RenderSetCookieEntry[]; } -type VinextHeadersShimState = { +export type VinextHeadersShimState = { headersContext: HeadersContext | null; dynamicUsageDetected: boolean; renderResponseHeaders: RenderResponseHeaders; @@ -61,6 +66,10 @@ function createRenderResponseHeaders(): RenderResponseHeaders { function serializeRenderResponseHeaders( renderResponseHeaders: RenderResponseHeaders, ): Record | undefined { + if (renderResponseHeaders.headers.size === 0 && renderResponseHeaders.setCookies.length === 0) { + return undefined; + } + const serialized: Record = {}; for (const entry of renderResponseHeaders.headers.values()) { @@ -127,8 +136,13 @@ const _fallbackState = (_g[_FALLBACK_KEY] ??= { renderResponseHeaders: createRenderResponseHeaders(), phase: "render", } satisfies VinextHeadersShimState) as VinextHeadersShimState; +const EXPIRED_COOKIE_DATE = new Date(0).toUTCString(); function _getState(): VinextHeadersShimState { + if (isInsideUnifiedScope()) { + return getRequestContext(); + } + const state = _als.getStore(); return state ?? _fallbackState; } @@ -273,34 +287,21 @@ export function getHeadersContext(): HeadersContext | null { } export function setHeadersContext(ctx: HeadersContext | null): void { + const state = _getState(); if (ctx !== null) { - // For backward compatibility, set context on the current ALS store - // if one exists, otherwise update the fallback. Callers should - // migrate to runWithHeadersContext() for new-request setup. - const existing = _als.getStore(); - if (existing) { - existing.headersContext = ctx; - existing.dynamicUsageDetected = false; - existing.renderResponseHeaders = createRenderResponseHeaders(); - existing.phase = "render"; - } else { - _fallbackState.headersContext = ctx; - _fallbackState.dynamicUsageDetected = false; - _fallbackState.renderResponseHeaders = createRenderResponseHeaders(); - _fallbackState.phase = "render"; - } - return; - } - - // End of request cleanup: keep the store (so consumeDynamicUsage and - // cookie flushing can still run), but clear the request headers/cookies. - const state = _als.getStore(); - if (state) { - state.headersContext = null; + state.headersContext = ctx; + state.dynamicUsageDetected = false; + state.renderResponseHeaders = createRenderResponseHeaders(); + const legacyState = state as VinextHeadersShimState & { + pendingSetCookies?: string[]; + draftModeCookieHeader?: string | null; + }; + legacyState.pendingSetCookies = []; + legacyState.draftModeCookieHeader = null; state.phase = "render"; } else { - _fallbackState.headersContext = null; - _fallbackState.phase = "render"; + state.headersContext = null; + state.phase = "render"; } } @@ -318,6 +319,17 @@ export function runWithHeadersContext( ctx: HeadersContext, fn: () => T | Promise, ): T | Promise { + if (isInsideUnifiedScope()) { + return runWithUnifiedStateMutation((uCtx) => { + uCtx.headersContext = ctx; + uCtx.dynamicUsageDetected = false; + uCtx.pendingSetCookies = []; + uCtx.draftModeCookieHeader = null; + uCtx.renderResponseHeaders = createRenderResponseHeaders(); + uCtx.phase = "render"; + }, fn); + } + const state: VinextHeadersShimState = { headersContext: ctx, dynamicUsageDetected: false, @@ -542,16 +554,12 @@ 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); - } - // Intercept mutating methods: materialise on first write. - if (_HEADERS_MUTATING_METHODS.has(prop)) { + if (typeof prop === "string" && _HEADERS_MUTATING_METHODS.has(prop)) { return (...args: unknown[]) => { if (!_mutable) { _mutable = new Headers(target); @@ -671,6 +679,9 @@ export function cookies(): Promise & RequestCookies { /** * Get and clear all pending Set-Cookie headers generated by cookies().set()/delete(). * Called by the framework after rendering to attach headers to the response. + * + * @deprecated Prefer consumeRenderResponseHeaders() when you need the full + * render-time response header set. */ export function getAndClearPendingCookies(): string[] { const state = _getState(); @@ -707,6 +718,9 @@ function getDraftSecret(): string { /** * Get any Set-Cookie header generated by draftMode().enable()/disable(). * Called by the framework after rendering to attach the header to the response. + * + * @deprecated Prefer consumeRenderResponseHeaders() when you need the full + * render-time response header set. */ export function getDraftModeCookieHeader(): string | null { const state = _getState(); @@ -933,10 +947,9 @@ class RequestCookies { // Build Set-Cookie header string const parts = [`${cookieName}=${encodeURIComponent(cookieValue)}`]; - if (opts?.path) { - validateCookieAttributeValue(opts.path, "Path"); - parts.push(`Path=${opts.path}`); - } + const path = opts?.path ?? "/"; + validateCookieAttributeValue(path, "Path"); + parts.push(`Path=${path}`); if (opts?.domain) { validateCookieAttributeValue(opts.domain, "Domain"); parts.push(`Domain=${opts.domain}`); @@ -954,10 +967,22 @@ class RequestCookies { /** * Delete a cookie by setting it with Max-Age=0. */ - delete(name: string): this { + delete(nameOrOptions: string | { name: string; path?: string; domain?: string }): this { + const name = typeof nameOrOptions === "string" ? nameOrOptions : nameOrOptions.name; + const path = typeof nameOrOptions === "string" ? "/" : (nameOrOptions.path ?? "/"); + const domain = typeof nameOrOptions === "string" ? undefined : nameOrOptions.domain; + validateCookieName(name); + validateCookieAttributeValue(path, "Path"); + if (domain) { + validateCookieAttributeValue(domain, "Domain"); + } + this._cookies.delete(name); - _appendRenderResponseHeaderWithSource("Set-Cookie", `${name}=; Path=/; Max-Age=0`, "cookie"); + const parts = [`${name}=`, `Path=${path}`]; + if (domain) parts.push(`Domain=${domain}`); + parts.push(`Expires=${EXPIRED_COOKIE_DATE}`); + _appendRenderResponseHeaderWithSource("Set-Cookie", parts.join("; "), "cookie"); return this; } diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index eab6aa47d..893cb5ca9 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -47,6 +47,10 @@ export interface UnifiedRequestContext // ── request-context.ts ───────────────────────────────────────────── /** Cloudflare Workers ExecutionContext, or null on Node.js dev. */ executionContext: ExecutionContextLike | null; + /** Legacy cookie queue kept for compatibility with existing unified-scope tests. */ + pendingSetCookies: string[]; + /** Legacy draft-mode header mirror kept for compatibility with existing tests. */ + draftModeCookieHeader: string | null; } // --------------------------------------------------------------------------- @@ -84,6 +88,10 @@ export function createRequestContext(opts?: Partial): Uni dynamicUsageDetected: false, pendingSetCookies: [], draftModeCookieHeader: null, + renderResponseHeaders: { + headers: new Map(), + setCookies: [], + }, phase: "render", i18nContext: null, serverContext: null, @@ -129,13 +137,13 @@ export function runWithUnifiedStateMutation( const childCtx = { ...parentCtx }; // NOTE: This is a shallow clone. Array fields (pendingSetCookies, // serverInsertedHTMLCallbacks, currentRequestTags, ssrHeadChildren), the - // _privateCache Map, and object fields (headersContext, i18nContext, - // serverContext, ssrContext, executionContext, requestScopedCacheLife) - // still share references with the parent until replaced. The mutate - // callback must replace those reference-typed slices (for example - // `ctx.currentRequestTags = []`) rather than mutating them in-place (for - // example `ctx.currentRequestTags.push(...)`) or the parent scope will - // observe those changes too. Keep this enumeration in sync with + // _privateCache Map, and object fields (headersContext, renderResponseHeaders, + // i18nContext, serverContext, ssrContext, executionContext, + // requestScopedCacheLife) still share references with the parent until + // replaced. The mutate callback must replace those reference-typed slices + // (for example `ctx.currentRequestTags = []`) rather than mutating them + // in-place (for example `ctx.currentRequestTags.push(...)`) or the parent + // scope will observe those changes too. Keep this enumeration in sync with // UnifiedRequestContext: when adding a new reference-typed field, add it // here too and verify callers still follow the replace-not-mutate rule. mutate(childCtx); diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index c4706ae36..8002d7699 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -481,13 +481,10 @@ function __mergeResponseHeaderValues(targetHeaders, key, value, mode) { function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) { if (!sourceHeaders) return; if (sourceHeaders instanceof Headers) { - const __setCookies = typeof sourceHeaders.getSetCookie === "function" - ? sourceHeaders.getSetCookie() - : []; + const __setCookies = sourceHeaders.getSetCookie(); for (const [key, value] of sourceHeaders) { if (key.toLowerCase() === "set-cookie") { // entries() flattens Set-Cookie into a single comma-joined value. - // If getSetCookie() is unavailable, drop cookies rather than corrupt them. continue; } __mergeResponseHeaderValues(targetHeaders, key, value, mode); @@ -1508,7 +1505,16 @@ function __validateDevRequestOrigin(request) { } const origin = request.headers.get("origin"); - if (!origin || origin === "null") return null; + if (!origin) return null; + + // Origin "null" is sent by opaque/sandboxed contexts. Block unless explicitly allowed. + if (origin === "null") { + if (!__allowedDevOrigins.includes("null")) { + console.warn("[vinext] Blocked request with Origin: null. Add \\"null\\" to allowedDevOrigins to allow sandboxed contexts."); + return new Response("Forbidden", { status: 403, headers: { "Content-Type": "text/plain" } }); + } + return null; + } let originHostname; try { @@ -2491,6 +2497,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); const __freshRscData = await __rscDataPromise; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. const __renderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); @@ -2886,13 +2894,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } + const __responseRenderHeaders = peekRenderResponseHeaders(); // For ISR-eligible RSC requests in production: write rscData to its own key. // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; - const __responseRenderHeaders = peekRenderResponseHeaders(); if (peekDynamicUsage()) { responseHeaders["Cache-Control"] = "no-store, must-revalidate"; } else { @@ -2927,7 +2935,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return __responseWithMiddlewareContext(new Response(__rscForResponse, { status: 200, - headers: responseHeaders, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), }), _mwCtx); } @@ -3342,13 +3350,10 @@ function __mergeResponseHeaderValues(targetHeaders, key, value, mode) { function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) { if (!sourceHeaders) return; if (sourceHeaders instanceof Headers) { - const __setCookies = typeof sourceHeaders.getSetCookie === "function" - ? sourceHeaders.getSetCookie() - : []; + const __setCookies = sourceHeaders.getSetCookie(); for (const [key, value] of sourceHeaders) { if (key.toLowerCase() === "set-cookie") { // entries() flattens Set-Cookie into a single comma-joined value. - // If getSetCookie() is unavailable, drop cookies rather than corrupt them. continue; } __mergeResponseHeaderValues(targetHeaders, key, value, mode); @@ -4369,7 +4374,16 @@ function __validateDevRequestOrigin(request) { } const origin = request.headers.get("origin"); - if (!origin || origin === "null") return null; + if (!origin) return null; + + // Origin "null" is sent by opaque/sandboxed contexts. Block unless explicitly allowed. + if (origin === "null") { + if (!__allowedDevOrigins.includes("null")) { + console.warn("[vinext] Blocked request with Origin: null. Add \\"null\\" to allowedDevOrigins to allow sandboxed contexts."); + return new Response("Forbidden", { status: 403, headers: { "Content-Type": "text/plain" } }); + } + return null; + } let originHostname; try { @@ -5355,6 +5369,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); const __freshRscData = await __rscDataPromise; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. const __renderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); @@ -5750,13 +5766,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } + const __responseRenderHeaders = peekRenderResponseHeaders(); // For ISR-eligible RSC requests in production: write rscData to its own key. // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; - const __responseRenderHeaders = peekRenderResponseHeaders(); if (peekDynamicUsage()) { responseHeaders["Cache-Control"] = "no-store, must-revalidate"; } else { @@ -5791,7 +5807,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return __responseWithMiddlewareContext(new Response(__rscForResponse, { status: 200, - headers: responseHeaders, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), }), _mwCtx); } @@ -6206,13 +6222,10 @@ function __mergeResponseHeaderValues(targetHeaders, key, value, mode) { function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) { if (!sourceHeaders) return; if (sourceHeaders instanceof Headers) { - const __setCookies = typeof sourceHeaders.getSetCookie === "function" - ? sourceHeaders.getSetCookie() - : []; + const __setCookies = sourceHeaders.getSetCookie(); for (const [key, value] of sourceHeaders) { if (key.toLowerCase() === "set-cookie") { // entries() flattens Set-Cookie into a single comma-joined value. - // If getSetCookie() is unavailable, drop cookies rather than corrupt them. continue; } __mergeResponseHeaderValues(targetHeaders, key, value, mode); @@ -7263,7 +7276,16 @@ function __validateDevRequestOrigin(request) { } const origin = request.headers.get("origin"); - if (!origin || origin === "null") return null; + if (!origin) return null; + + // Origin "null" is sent by opaque/sandboxed contexts. Block unless explicitly allowed. + if (origin === "null") { + if (!__allowedDevOrigins.includes("null")) { + console.warn("[vinext] Blocked request with Origin: null. Add \\"null\\" to allowedDevOrigins to allow sandboxed contexts."); + return new Response("Forbidden", { status: 403, headers: { "Content-Type": "text/plain" } }); + } + return null; + } let originHostname; try { @@ -8246,6 +8268,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); const __freshRscData = await __rscDataPromise; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. const __renderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); @@ -8641,13 +8665,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } + const __responseRenderHeaders = peekRenderResponseHeaders(); // For ISR-eligible RSC requests in production: write rscData to its own key. // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; - const __responseRenderHeaders = peekRenderResponseHeaders(); if (peekDynamicUsage()) { responseHeaders["Cache-Control"] = "no-store, must-revalidate"; } else { @@ -8682,7 +8706,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return __responseWithMiddlewareContext(new Response(__rscForResponse, { status: 200, - headers: responseHeaders, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), }), _mwCtx); } @@ -9112,13 +9136,10 @@ function __mergeResponseHeaderValues(targetHeaders, key, value, mode) { function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) { if (!sourceHeaders) return; if (sourceHeaders instanceof Headers) { - const __setCookies = typeof sourceHeaders.getSetCookie === "function" - ? sourceHeaders.getSetCookie() - : []; + const __setCookies = sourceHeaders.getSetCookie(); for (const [key, value] of sourceHeaders) { if (key.toLowerCase() === "set-cookie") { // entries() flattens Set-Cookie into a single comma-joined value. - // If getSetCookie() is unavailable, drop cookies rather than corrupt them. continue; } __mergeResponseHeaderValues(targetHeaders, key, value, mode); @@ -10168,7 +10189,16 @@ function __validateDevRequestOrigin(request) { } const origin = request.headers.get("origin"); - if (!origin || origin === "null") return null; + if (!origin) return null; + + // Origin "null" is sent by opaque/sandboxed contexts. Block unless explicitly allowed. + if (origin === "null") { + if (!__allowedDevOrigins.includes("null")) { + console.warn("[vinext] Blocked request with Origin: null. Add \\"null\\" to allowedDevOrigins to allow sandboxed contexts."); + return new Response("Forbidden", { status: 403, headers: { "Content-Type": "text/plain" } }); + } + return null; + } let originHostname; try { @@ -11154,6 +11184,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); const __freshRscData = await __rscDataPromise; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. const __renderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); @@ -11549,13 +11581,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } + const __responseRenderHeaders = peekRenderResponseHeaders(); // For ISR-eligible RSC requests in production: write rscData to its own key. // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; - const __responseRenderHeaders = peekRenderResponseHeaders(); if (peekDynamicUsage()) { responseHeaders["Cache-Control"] = "no-store, must-revalidate"; } else { @@ -11590,7 +11622,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return __responseWithMiddlewareContext(new Response(__rscForResponse, { status: 200, - headers: responseHeaders, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), }), _mwCtx); } @@ -12005,13 +12037,10 @@ function __mergeResponseHeaderValues(targetHeaders, key, value, mode) { function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) { if (!sourceHeaders) return; if (sourceHeaders instanceof Headers) { - const __setCookies = typeof sourceHeaders.getSetCookie === "function" - ? sourceHeaders.getSetCookie() - : []; + const __setCookies = sourceHeaders.getSetCookie(); for (const [key, value] of sourceHeaders) { if (key.toLowerCase() === "set-cookie") { // entries() flattens Set-Cookie into a single comma-joined value. - // If getSetCookie() is unavailable, drop cookies rather than corrupt them. continue; } __mergeResponseHeaderValues(targetHeaders, key, value, mode); @@ -13039,7 +13068,16 @@ function __validateDevRequestOrigin(request) { } const origin = request.headers.get("origin"); - if (!origin || origin === "null") return null; + if (!origin) return null; + + // Origin "null" is sent by opaque/sandboxed contexts. Block unless explicitly allowed. + if (origin === "null") { + if (!__allowedDevOrigins.includes("null")) { + console.warn("[vinext] Blocked request with Origin: null. Add \\"null\\" to allowedDevOrigins to allow sandboxed contexts."); + return new Response("Forbidden", { status: 403, headers: { "Content-Type": "text/plain" } }); + } + return null; + } let originHostname; try { @@ -14022,6 +14060,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); const __freshRscData = await __rscDataPromise; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. const __renderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); @@ -14417,13 +14457,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } + const __responseRenderHeaders = peekRenderResponseHeaders(); // For ISR-eligible RSC requests in production: write rscData to its own key. // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; - const __responseRenderHeaders = peekRenderResponseHeaders(); if (peekDynamicUsage()) { responseHeaders["Cache-Control"] = "no-store, must-revalidate"; } else { @@ -14458,7 +14498,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return __responseWithMiddlewareContext(new Response(__rscForResponse, { status: 200, - headers: responseHeaders, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), }), _mwCtx); } @@ -14873,13 +14913,10 @@ function __mergeResponseHeaderValues(targetHeaders, key, value, mode) { function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) { if (!sourceHeaders) return; if (sourceHeaders instanceof Headers) { - const __setCookies = typeof sourceHeaders.getSetCookie === "function" - ? sourceHeaders.getSetCookie() - : []; + const __setCookies = sourceHeaders.getSetCookie(); for (const [key, value] of sourceHeaders) { if (key.toLowerCase() === "set-cookie") { // entries() flattens Set-Cookie into a single comma-joined value. - // If getSetCookie() is unavailable, drop cookies rather than corrupt them. continue; } __mergeResponseHeaderValues(targetHeaders, key, value, mode); @@ -15877,17 +15914,50 @@ async function buildPageElement(route, params, opts, searchParams) { const __mwPatternCache = new Map(); +function __extractConstraint(str, re) { + if (str[re.lastIndex] !== "(") return null; + const start = re.lastIndex + 1; + let depth = 1; + let i = start; + while (i < str.length && depth > 0) { + if (str[i] === "(") depth++; + else if (str[i] === ")") depth--; + i++; + } + if (depth !== 0) return null; + re.lastIndex = i; + return str.slice(start, i - 1); +} function __compileMwPattern(pattern) { - if (pattern.includes("(") || pattern.includes("\\\\")) { + const hasConstraints = /:[\\w-]+[*+]?\\(/.test(pattern); + if (!hasConstraints && (pattern.includes("(") || pattern.includes("\\\\"))) { return __safeRegExp("^" + pattern + "$"); } let regexStr = ""; const tokenRe = /\\/:([\\w-]+)\\*|\\/:([\\w-]+)\\+|:([\\w-]+)|[.]|[^/:.]+|./g; let tok; while ((tok = tokenRe.exec(pattern)) !== null) { - if (tok[1] !== undefined) { regexStr += "(?:/.*)?"; } - else if (tok[2] !== undefined) { regexStr += "(?:/.+)"; } - else if (tok[3] !== undefined) { regexStr += "([^/]+)"; } + if (tok[1] !== undefined) { + const c1 = hasConstraints ? __extractConstraint(pattern, tokenRe) : null; + regexStr += c1 !== null ? "(?:/(" + c1 + "))?" : "(?:/.*)?"; + } + else if (tok[2] !== undefined) { + const c2 = hasConstraints ? __extractConstraint(pattern, tokenRe) : null; + regexStr += c2 !== null ? "(?:/(" + c2 + "))" : "(?:/.+)"; + } + else if (tok[3] !== undefined) { + const constraint = hasConstraints ? __extractConstraint(pattern, tokenRe) : null; + const isOptional = pattern[tokenRe.lastIndex] === "?"; + if (isOptional) tokenRe.lastIndex += 1; + const group = constraint !== null ? "(" + constraint + ")" : "([^/]+)"; + if (isOptional && regexStr.endsWith("/")) { + regexStr = regexStr.slice(0, -1) + "(?:/" + group + ")?"; + } else if (isOptional) { + regexStr += group + "?"; + } else { + regexStr += group; + } + } else if (tok[0] === ".") { regexStr += "\\\\."; } else { regexStr += tok[0]; } } @@ -16096,7 +16166,16 @@ function __validateDevRequestOrigin(request) { } const origin = request.headers.get("origin"); - if (!origin || origin === "null") return null; + if (!origin) return null; + + // Origin "null" is sent by opaque/sandboxed contexts. Block unless explicitly allowed. + if (origin === "null") { + if (!__allowedDevOrigins.includes("null")) { + console.warn("[vinext] Blocked request with Origin: null. Add \\"null\\" to allowedDevOrigins to allow sandboxed contexts."); + return new Response("Forbidden", { status: 403, headers: { "Content-Type": "text/plain" } }); + } + return null; + } let originHostname; try { @@ -17161,6 +17240,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); const __freshRscData = await __rscDataPromise; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. const __renderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); @@ -17556,13 +17637,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } + const __responseRenderHeaders = peekRenderResponseHeaders(); // For ISR-eligible RSC requests in production: write rscData to its own key. // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; - const __responseRenderHeaders = peekRenderResponseHeaders(); if (peekDynamicUsage()) { responseHeaders["Cache-Control"] = "no-store, must-revalidate"; } else { @@ -17597,7 +17678,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return __responseWithMiddlewareContext(new Response(__rscForResponse, { status: 200, - headers: responseHeaders, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), }), _mwCtx); } @@ -17872,6 +17953,9 @@ import { setNavigationContext, ServerInsertedHTMLContext } from "next/navigation import { runWithNavigationContext as _runWithNavCtx } from "vinext/navigation-state"; import { safeJsonStringify } from "vinext/html"; import { createElement as _ssrCE } from "react"; +import * as _clientRefs from "virtual:vite-rsc/client-references"; + +let _clientRefsPreloaded = false; /** * Collect all chunks from a ReadableStream into an array of text strings. @@ -18003,6 +18087,29 @@ function createRscEmbedTransform(embedStream) { * and the data needs to be passed to SSR since they're separate module instances. */ export async function handleSsr(rscStream, navContext, fontData) { + // Eagerly preload all client reference modules before SSR rendering. + // On the first request after server start, client component modules are + // loaded lazily via async import(). Without this preload, React's + // renderToReadableStream rejects because the shell can't resolve client + // components synchronously (there is no Suspense boundary wrapping the + // root). The memoized require cache ensures this is only async on the + // very first call; subsequent requests resolve from cache immediately. + // See: https://github.com/cloudflare/vinext/issues/256 + // _clientRefs.default is the default export from the virtual:vite-rsc/client-references + // namespace import — a map of client component IDs to their async import functions. + if (!_clientRefsPreloaded && _clientRefs.default && globalThis.__vite_rsc_client_require__) { + await Promise.all( + Object.keys(_clientRefs.default).map((id) => + globalThis.__vite_rsc_client_require__(id).catch((err) => { + if (process.env.NODE_ENV !== "production") { + console.warn("[vinext] failed to preload client ref:", id, err); + } + }) + ) + ); + _clientRefsPreloaded = true; + } + // Wrap in a navigation ALS scope for per-request isolation in the SSR // environment. The SSR environment has separate module instances from RSC, // so it needs its own ALS scope. @@ -18317,11 +18424,14 @@ const pageLoaders = { "/before-pop-state-test": () => import("/tests/fixtures/pages-basic/pages/before-pop-state-test.tsx"), "/cjs/basic": () => import("/tests/fixtures/pages-basic/pages/cjs/basic.tsx"), "/cjs/random": () => import("/tests/fixtures/pages-basic/pages/cjs/random.ts"), + "/concurrent-head": () => import("/tests/fixtures/pages-basic/pages/concurrent-head.tsx"), + "/concurrent-router": () => import("/tests/fixtures/pages-basic/pages/concurrent-router.tsx"), "/config-test": () => import("/tests/fixtures/pages-basic/pages/config-test.tsx"), "/counter": () => import("/tests/fixtures/pages-basic/pages/counter.tsx"), "/dynamic-page": () => import("/tests/fixtures/pages-basic/pages/dynamic-page.tsx"), "/dynamic-ssr-false": () => import("/tests/fixtures/pages-basic/pages/dynamic-ssr-false.tsx"), "/header-override-delete": () => import("/tests/fixtures/pages-basic/pages/header-override-delete.tsx"), + "/isr-second-render-state": () => import("/tests/fixtures/pages-basic/pages/isr-second-render-state.tsx"), "/isr-test": () => import("/tests/fixtures/pages-basic/pages/isr-test.tsx"), "/link-test": () => import("/tests/fixtures/pages-basic/pages/link-test.tsx"), "/mw-object-gated": () => import("/tests/fixtures/pages-basic/pages/mw-object-gated.tsx"), @@ -18401,19 +18511,24 @@ import { renderToReadableStream } from "react-dom/server.edge"; import { resetSSRHead, getSSRHeadHTML } from "next/head"; import { flushPreloads } from "next/dynamic"; import { setSSRContext, wrapWithRouterContext } from "next/router"; -import { getCacheHandler } from "next/cache"; -import { runWithFetchCache } from "vinext/fetch-cache"; -import { _runWithCacheState } from "next/cache"; +import { getCacheHandler, _runWithCacheState } from "next/cache"; import { runWithPrivateCache } from "vinext/cache-runtime"; -import { runWithRouterState } from "vinext/router-state"; +import { ensureFetchPatch, runWithFetchCache } from "vinext/fetch-cache"; +import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; +import "vinext/router-state"; +import { runWithServerInsertedHTMLState } from "vinext/navigation-state"; import { runWithHeadState } from "vinext/head-state"; +import "vinext/i18n-state"; +import { setI18nContext } from "vinext/i18n-context"; import { safeJsonStringify } from "vinext/html"; import { decode as decodeQueryString } from "node:querystring"; import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; -import { parseCookies } from "/packages/vinext/src/config/config-matchers.js"; +import { parseCookies, sanitizeDestination as sanitizeDestinationLocal } from "/packages/vinext/src/config/config-matchers.js"; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; +import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; +import { resolvePagesI18nRequest } from "/packages/vinext/src/server/pages-i18n.js"; import * as _instrumentation from "/tests/fixtures/pages-basic/instrumentation.ts"; import * as middlewareModule from "/tests/fixtures/pages-basic/middleware.ts"; import { NextRequest, NextFetchEvent } from "next/server"; @@ -18510,6 +18625,21 @@ async function renderToStringAsync(element) { return new Response(stream).text(); } +async function renderIsrPassToStringAsync(element) { + // The cache-fill render is a second render pass for the same request. + // Reset render-scoped state so it cannot leak from the streamed response + // render or affect async work that is still draining from that stream. + // Keep request identity state (pathname/query/locale/executionContext) + // intact: this second pass still belongs to the same request. + return await runWithServerInsertedHTMLState(() => + runWithHeadState(() => + _runWithCacheState(() => + runWithPrivateCache(() => runWithFetchCache(async () => renderToStringAsync(element))), + ), + ), + ); +} + import * as page_0 from "/tests/fixtures/pages-basic/pages/index.tsx"; import * as page_1 from "/tests/fixtures/pages-basic/pages/404.tsx"; import * as page_2 from "/tests/fixtures/pages-basic/pages/about.tsx"; @@ -18518,30 +18648,33 @@ import * as page_4 from "/tests/fixtures/pages-basic/pages/before-pop-stat import * as page_5 from "/tests/fixtures/pages-basic/pages/before-pop-state-test.tsx"; import * as page_6 from "/tests/fixtures/pages-basic/pages/cjs/basic.tsx"; import * as page_7 from "/tests/fixtures/pages-basic/pages/cjs/random.ts"; -import * as page_8 from "/tests/fixtures/pages-basic/pages/config-test.tsx"; -import * as page_9 from "/tests/fixtures/pages-basic/pages/counter.tsx"; -import * as page_10 from "/tests/fixtures/pages-basic/pages/dynamic-page.tsx"; -import * as page_11 from "/tests/fixtures/pages-basic/pages/dynamic-ssr-false.tsx"; -import * as page_12 from "/tests/fixtures/pages-basic/pages/header-override-delete.tsx"; -import * as page_13 from "/tests/fixtures/pages-basic/pages/isr-test.tsx"; -import * as page_14 from "/tests/fixtures/pages-basic/pages/link-test.tsx"; -import * as page_15 from "/tests/fixtures/pages-basic/pages/mw-object-gated.tsx"; -import * as page_16 from "/tests/fixtures/pages-basic/pages/nav-test.tsx"; -import * as page_17 from "/tests/fixtures/pages-basic/pages/posts/missing.tsx"; -import * as page_18 from "/tests/fixtures/pages-basic/pages/redirect-xss.tsx"; -import * as page_19 from "/tests/fixtures/pages-basic/pages/router-events-test.tsx"; -import * as page_20 from "/tests/fixtures/pages-basic/pages/script-test.tsx"; -import * as page_21 from "/tests/fixtures/pages-basic/pages/shallow-test.tsx"; -import * as page_22 from "/tests/fixtures/pages-basic/pages/ssr.tsx"; -import * as page_23 from "/tests/fixtures/pages-basic/pages/ssr-headers.tsx"; -import * as page_24 from "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx"; -import * as page_25 from "/tests/fixtures/pages-basic/pages/suspense-test.tsx"; -import * as page_26 from "/tests/fixtures/pages-basic/pages/articles/[id].tsx"; -import * as page_27 from "/tests/fixtures/pages-basic/pages/blog/[slug].tsx"; -import * as page_28 from "/tests/fixtures/pages-basic/pages/posts/[id].tsx"; -import * as page_29 from "/tests/fixtures/pages-basic/pages/products/[pid].tsx"; -import * as page_30 from "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx"; -import * as page_31 from "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx"; +import * as page_8 from "/tests/fixtures/pages-basic/pages/concurrent-head.tsx"; +import * as page_9 from "/tests/fixtures/pages-basic/pages/concurrent-router.tsx"; +import * as page_10 from "/tests/fixtures/pages-basic/pages/config-test.tsx"; +import * as page_11 from "/tests/fixtures/pages-basic/pages/counter.tsx"; +import * as page_12 from "/tests/fixtures/pages-basic/pages/dynamic-page.tsx"; +import * as page_13 from "/tests/fixtures/pages-basic/pages/dynamic-ssr-false.tsx"; +import * as page_14 from "/tests/fixtures/pages-basic/pages/header-override-delete.tsx"; +import * as page_15 from "/tests/fixtures/pages-basic/pages/isr-second-render-state.tsx"; +import * as page_16 from "/tests/fixtures/pages-basic/pages/isr-test.tsx"; +import * as page_17 from "/tests/fixtures/pages-basic/pages/link-test.tsx"; +import * as page_18 from "/tests/fixtures/pages-basic/pages/mw-object-gated.tsx"; +import * as page_19 from "/tests/fixtures/pages-basic/pages/nav-test.tsx"; +import * as page_20 from "/tests/fixtures/pages-basic/pages/posts/missing.tsx"; +import * as page_21 from "/tests/fixtures/pages-basic/pages/redirect-xss.tsx"; +import * as page_22 from "/tests/fixtures/pages-basic/pages/router-events-test.tsx"; +import * as page_23 from "/tests/fixtures/pages-basic/pages/script-test.tsx"; +import * as page_24 from "/tests/fixtures/pages-basic/pages/shallow-test.tsx"; +import * as page_25 from "/tests/fixtures/pages-basic/pages/ssr.tsx"; +import * as page_26 from "/tests/fixtures/pages-basic/pages/ssr-headers.tsx"; +import * as page_27 from "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx"; +import * as page_28 from "/tests/fixtures/pages-basic/pages/suspense-test.tsx"; +import * as page_29 from "/tests/fixtures/pages-basic/pages/articles/[id].tsx"; +import * as page_30 from "/tests/fixtures/pages-basic/pages/blog/[slug].tsx"; +import * as page_31 from "/tests/fixtures/pages-basic/pages/posts/[id].tsx"; +import * as page_32 from "/tests/fixtures/pages-basic/pages/products/[pid].tsx"; +import * as page_33 from "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx"; +import * as page_34 from "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx"; import * as api_0 from "/tests/fixtures/pages-basic/pages/api/binary.ts"; import * as api_1 from "/tests/fixtures/pages-basic/pages/api/echo-body.ts"; import * as api_2 from "/tests/fixtures/pages-basic/pages/api/error-route.ts"; @@ -18556,7 +18689,7 @@ import * as api_9 from "/tests/fixtures/pages-basic/pages/api/users/[id].t import { default as AppComponent } from "/tests/fixtures/pages-basic/pages/_app.tsx"; import { default as DocumentComponent } from "/tests/fixtures/pages-basic/pages/_document.tsx"; -const pageRoutes = [ +export const pageRoutes = [ { pattern: "/", patternParts: [], isDynamic: false, params: [], module: page_0, filePath: "/tests/fixtures/pages-basic/pages/index.tsx" }, { pattern: "/404", patternParts: ["404"], isDynamic: false, params: [], module: page_1, filePath: "/tests/fixtures/pages-basic/pages/404.tsx" }, { pattern: "/about", patternParts: ["about"], isDynamic: false, params: [], module: page_2, filePath: "/tests/fixtures/pages-basic/pages/about.tsx" }, @@ -18565,30 +18698,33 @@ const pageRoutes = [ { pattern: "/before-pop-state-test", patternParts: ["before-pop-state-test"], isDynamic: false, params: [], module: page_5, filePath: "/tests/fixtures/pages-basic/pages/before-pop-state-test.tsx" }, { pattern: "/cjs/basic", patternParts: ["cjs","basic"], isDynamic: false, params: [], module: page_6, filePath: "/tests/fixtures/pages-basic/pages/cjs/basic.tsx" }, { pattern: "/cjs/random", patternParts: ["cjs","random"], isDynamic: false, params: [], module: page_7, filePath: "/tests/fixtures/pages-basic/pages/cjs/random.ts" }, - { pattern: "/config-test", patternParts: ["config-test"], isDynamic: false, params: [], module: page_8, filePath: "/tests/fixtures/pages-basic/pages/config-test.tsx" }, - { pattern: "/counter", patternParts: ["counter"], isDynamic: false, params: [], module: page_9, filePath: "/tests/fixtures/pages-basic/pages/counter.tsx" }, - { pattern: "/dynamic-page", patternParts: ["dynamic-page"], isDynamic: false, params: [], module: page_10, filePath: "/tests/fixtures/pages-basic/pages/dynamic-page.tsx" }, - { pattern: "/dynamic-ssr-false", patternParts: ["dynamic-ssr-false"], isDynamic: false, params: [], module: page_11, filePath: "/tests/fixtures/pages-basic/pages/dynamic-ssr-false.tsx" }, - { pattern: "/header-override-delete", patternParts: ["header-override-delete"], isDynamic: false, params: [], module: page_12, filePath: "/tests/fixtures/pages-basic/pages/header-override-delete.tsx" }, - { pattern: "/isr-test", patternParts: ["isr-test"], isDynamic: false, params: [], module: page_13, filePath: "/tests/fixtures/pages-basic/pages/isr-test.tsx" }, - { pattern: "/link-test", patternParts: ["link-test"], isDynamic: false, params: [], module: page_14, filePath: "/tests/fixtures/pages-basic/pages/link-test.tsx" }, - { pattern: "/mw-object-gated", patternParts: ["mw-object-gated"], isDynamic: false, params: [], module: page_15, filePath: "/tests/fixtures/pages-basic/pages/mw-object-gated.tsx" }, - { pattern: "/nav-test", patternParts: ["nav-test"], isDynamic: false, params: [], module: page_16, filePath: "/tests/fixtures/pages-basic/pages/nav-test.tsx" }, - { pattern: "/posts/missing", patternParts: ["posts","missing"], isDynamic: false, params: [], module: page_17, filePath: "/tests/fixtures/pages-basic/pages/posts/missing.tsx" }, - { pattern: "/redirect-xss", patternParts: ["redirect-xss"], isDynamic: false, params: [], module: page_18, filePath: "/tests/fixtures/pages-basic/pages/redirect-xss.tsx" }, - { pattern: "/router-events-test", patternParts: ["router-events-test"], isDynamic: false, params: [], module: page_19, filePath: "/tests/fixtures/pages-basic/pages/router-events-test.tsx" }, - { pattern: "/script-test", patternParts: ["script-test"], isDynamic: false, params: [], module: page_20, filePath: "/tests/fixtures/pages-basic/pages/script-test.tsx" }, - { pattern: "/shallow-test", patternParts: ["shallow-test"], isDynamic: false, params: [], module: page_21, filePath: "/tests/fixtures/pages-basic/pages/shallow-test.tsx" }, - { pattern: "/ssr", patternParts: ["ssr"], isDynamic: false, params: [], module: page_22, filePath: "/tests/fixtures/pages-basic/pages/ssr.tsx" }, - { pattern: "/ssr-headers", patternParts: ["ssr-headers"], isDynamic: false, params: [], module: page_23, filePath: "/tests/fixtures/pages-basic/pages/ssr-headers.tsx" }, - { pattern: "/ssr-res-end", patternParts: ["ssr-res-end"], isDynamic: false, params: [], module: page_24, filePath: "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx" }, - { pattern: "/suspense-test", patternParts: ["suspense-test"], isDynamic: false, params: [], module: page_25, filePath: "/tests/fixtures/pages-basic/pages/suspense-test.tsx" }, - { pattern: "/articles/:id", patternParts: ["articles",":id"], isDynamic: true, params: ["id"], module: page_26, filePath: "/tests/fixtures/pages-basic/pages/articles/[id].tsx" }, - { pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, params: ["slug"], module: page_27, filePath: "/tests/fixtures/pages-basic/pages/blog/[slug].tsx" }, - { pattern: "/posts/:id", patternParts: ["posts",":id"], isDynamic: true, params: ["id"], module: page_28, filePath: "/tests/fixtures/pages-basic/pages/posts/[id].tsx" }, - { pattern: "/products/:pid", patternParts: ["products",":pid"], isDynamic: true, params: ["pid"], module: page_29, filePath: "/tests/fixtures/pages-basic/pages/products/[pid].tsx" }, - { pattern: "/docs/:slug+", patternParts: ["docs",":slug+"], isDynamic: true, params: ["slug"], module: page_30, filePath: "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx" }, - { pattern: "/sign-up/:sign-up*", patternParts: ["sign-up",":sign-up*"], isDynamic: true, params: ["sign-up"], module: page_31, filePath: "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx" } + { pattern: "/concurrent-head", patternParts: ["concurrent-head"], isDynamic: false, params: [], module: page_8, filePath: "/tests/fixtures/pages-basic/pages/concurrent-head.tsx" }, + { pattern: "/concurrent-router", patternParts: ["concurrent-router"], isDynamic: false, params: [], module: page_9, filePath: "/tests/fixtures/pages-basic/pages/concurrent-router.tsx" }, + { pattern: "/config-test", patternParts: ["config-test"], isDynamic: false, params: [], module: page_10, filePath: "/tests/fixtures/pages-basic/pages/config-test.tsx" }, + { pattern: "/counter", patternParts: ["counter"], isDynamic: false, params: [], module: page_11, filePath: "/tests/fixtures/pages-basic/pages/counter.tsx" }, + { pattern: "/dynamic-page", patternParts: ["dynamic-page"], isDynamic: false, params: [], module: page_12, filePath: "/tests/fixtures/pages-basic/pages/dynamic-page.tsx" }, + { pattern: "/dynamic-ssr-false", patternParts: ["dynamic-ssr-false"], isDynamic: false, params: [], module: page_13, filePath: "/tests/fixtures/pages-basic/pages/dynamic-ssr-false.tsx" }, + { pattern: "/header-override-delete", patternParts: ["header-override-delete"], isDynamic: false, params: [], module: page_14, filePath: "/tests/fixtures/pages-basic/pages/header-override-delete.tsx" }, + { pattern: "/isr-second-render-state", patternParts: ["isr-second-render-state"], isDynamic: false, params: [], module: page_15, filePath: "/tests/fixtures/pages-basic/pages/isr-second-render-state.tsx" }, + { pattern: "/isr-test", patternParts: ["isr-test"], isDynamic: false, params: [], module: page_16, filePath: "/tests/fixtures/pages-basic/pages/isr-test.tsx" }, + { pattern: "/link-test", patternParts: ["link-test"], isDynamic: false, params: [], module: page_17, filePath: "/tests/fixtures/pages-basic/pages/link-test.tsx" }, + { pattern: "/mw-object-gated", patternParts: ["mw-object-gated"], isDynamic: false, params: [], module: page_18, filePath: "/tests/fixtures/pages-basic/pages/mw-object-gated.tsx" }, + { pattern: "/nav-test", patternParts: ["nav-test"], isDynamic: false, params: [], module: page_19, filePath: "/tests/fixtures/pages-basic/pages/nav-test.tsx" }, + { pattern: "/posts/missing", patternParts: ["posts","missing"], isDynamic: false, params: [], module: page_20, filePath: "/tests/fixtures/pages-basic/pages/posts/missing.tsx" }, + { pattern: "/redirect-xss", patternParts: ["redirect-xss"], isDynamic: false, params: [], module: page_21, filePath: "/tests/fixtures/pages-basic/pages/redirect-xss.tsx" }, + { pattern: "/router-events-test", patternParts: ["router-events-test"], isDynamic: false, params: [], module: page_22, filePath: "/tests/fixtures/pages-basic/pages/router-events-test.tsx" }, + { pattern: "/script-test", patternParts: ["script-test"], isDynamic: false, params: [], module: page_23, filePath: "/tests/fixtures/pages-basic/pages/script-test.tsx" }, + { pattern: "/shallow-test", patternParts: ["shallow-test"], isDynamic: false, params: [], module: page_24, filePath: "/tests/fixtures/pages-basic/pages/shallow-test.tsx" }, + { pattern: "/ssr", patternParts: ["ssr"], isDynamic: false, params: [], module: page_25, filePath: "/tests/fixtures/pages-basic/pages/ssr.tsx" }, + { pattern: "/ssr-headers", patternParts: ["ssr-headers"], isDynamic: false, params: [], module: page_26, filePath: "/tests/fixtures/pages-basic/pages/ssr-headers.tsx" }, + { pattern: "/ssr-res-end", patternParts: ["ssr-res-end"], isDynamic: false, params: [], module: page_27, filePath: "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx" }, + { pattern: "/suspense-test", patternParts: ["suspense-test"], isDynamic: false, params: [], module: page_28, filePath: "/tests/fixtures/pages-basic/pages/suspense-test.tsx" }, + { pattern: "/articles/:id", patternParts: ["articles",":id"], isDynamic: true, params: ["id"], module: page_29, filePath: "/tests/fixtures/pages-basic/pages/articles/[id].tsx" }, + { pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, params: ["slug"], module: page_30, filePath: "/tests/fixtures/pages-basic/pages/blog/[slug].tsx" }, + { pattern: "/posts/:id", patternParts: ["posts",":id"], isDynamic: true, params: ["id"], module: page_31, filePath: "/tests/fixtures/pages-basic/pages/posts/[id].tsx" }, + { pattern: "/products/:pid", patternParts: ["products",":pid"], isDynamic: true, params: ["pid"], module: page_32, filePath: "/tests/fixtures/pages-basic/pages/products/[pid].tsx" }, + { pattern: "/docs/:slug+", patternParts: ["docs",":slug+"], isDynamic: true, params: ["slug"], module: page_33, filePath: "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx" }, + { pattern: "/sign-up/:sign-up*", patternParts: ["sign-up",":sign-up*"], isDynamic: true, params: ["sign-up"], module: page_34, filePath: "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx" } ]; const _pageRouteTrie = _buildRouteTrie(pageRoutes); @@ -18919,23 +19055,25 @@ export async function renderPage(request, url, manifest, ctx) { } async function _renderPage(request, url, manifest) { - const localeInfo = extractLocale(url); + const localeInfo = i18nConfig + ? resolvePagesI18nRequest( + url, + i18nConfig, + request.headers, + new URL(request.url).hostname, + vinextConfig.basePath, + vinextConfig.trailingSlash, + ) + : { locale: undefined, url, hadPrefix: false, domainLocale: undefined, redirectUrl: undefined }; const locale = localeInfo.locale; const routeUrl = localeInfo.url; - const cookieHeader = request.headers.get("cookie") || ""; + const currentDefaultLocale = i18nConfig + ? (localeInfo.domainLocale ? localeInfo.domainLocale.defaultLocale : i18nConfig.defaultLocale) + : undefined; + const domainLocales = i18nConfig ? i18nConfig.domains : undefined; - // i18n redirect: check NEXT_LOCALE cookie first, then Accept-Language - if (i18nConfig && !localeInfo.hadPrefix) { - const cookieLocale = parseCookieLocaleFromHeader(cookieHeader); - if (cookieLocale && cookieLocale !== i18nConfig.defaultLocale) { - return new Response(null, { status: 307, headers: { Location: "/" + cookieLocale + routeUrl } }); - } - if (!cookieLocale && i18nConfig.localeDetection !== false) { - const detected = detectLocaleFromHeaders(request.headers); - if (detected && detected !== i18nConfig.defaultLocale) { - return new Response(null, { status: 307, headers: { Location: "/" + detected + routeUrl } }); - } - } + if (localeInfo.redirectUrl) { + return new Response(null, { status: 307, headers: { Location: localeInfo.redirectUrl } }); } const match = matchRoute(routeUrl, pageRoutes); @@ -18945,12 +19083,12 @@ async function _renderPage(request, url, manifest) { } const { route, params } = match; - return runWithRouterState(() => - runWithHeadState(() => - _runWithCacheState(() => - runWithPrivateCache(() => - runWithFetchCache(async () => { - try { + const __uCtx = _createUnifiedCtx({ + executionContext: _getRequestExecutionContext(), + }); + return _runWithUnifiedCtx(__uCtx, async () => { + ensureFetchPatch(); + try { if (typeof setSSRContext === "function") { setSSRContext({ pathname: patternToNextFormat(route.pattern), @@ -18958,14 +19096,19 @@ async function _renderPage(request, url, manifest) { asPath: routeUrl, locale: locale, locales: i18nConfig ? i18nConfig.locales : undefined, - defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, + defaultLocale: currentDefaultLocale, + domainLocales: domainLocales, }); } if (i18nConfig) { - globalThis.__VINEXT_LOCALE__ = locale; - globalThis.__VINEXT_LOCALES__ = i18nConfig.locales; - globalThis.__VINEXT_DEFAULT_LOCALE__ = i18nConfig.defaultLocale; + setI18nContext({ + locale: locale, + locales: i18nConfig.locales, + defaultLocale: currentDefaultLocale, + domainLocales: domainLocales, + hostname: new URL(request.url).hostname, + }); } const pageModule = route.module; @@ -18978,7 +19121,7 @@ async function _renderPage(request, url, manifest) { if (typeof pageModule.getStaticPaths === "function" && route.isDynamic) { const pathsResult = await pageModule.getStaticPaths({ locales: i18nConfig ? i18nConfig.locales : [], - defaultLocale: i18nConfig ? i18nConfig.defaultLocale : "", + defaultLocale: currentDefaultLocale || "", }); const fallback = pathsResult && pathsResult.fallback !== undefined ? pathsResult.fallback : false; @@ -19011,7 +19154,7 @@ async function _renderPage(request, url, manifest) { resolvedUrl: routeUrl, locale: locale, locales: i18nConfig ? i18nConfig.locales : undefined, - defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, + defaultLocale: currentDefaultLocale, }; const result = await pageModule.getServerSideProps(ctx); // If gSSP called res.end() directly (short-circuit), return that response. @@ -19060,10 +19203,89 @@ async function _renderPage(request, url, manifest) { if (cached && cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") { triggerBackgroundRegeneration(cacheKey, async function() { - const freshResult = await pageModule.getStaticProps({ params }); - if (freshResult && freshResult.props && typeof freshResult.revalidate === "number" && freshResult.revalidate > 0) { - await isrSet(cacheKey, { kind: "PAGES", html: cached.value.value.html, pageData: freshResult.props, headers: undefined, status: undefined }, freshResult.revalidate); - } + var revalCtx = _createUnifiedCtx({ + executionContext: _getRequestExecutionContext(), + }); + return _runWithUnifiedCtx(revalCtx, async () => { + ensureFetchPatch(); + var freshResult = await pageModule.getStaticProps({ + params: params, + locale: locale, + locales: i18nConfig ? i18nConfig.locales : undefined, + defaultLocale: currentDefaultLocale, + }); + if (freshResult && freshResult.props && typeof freshResult.revalidate === "number" && freshResult.revalidate > 0) { + var _fp = freshResult.props; + if (typeof setSSRContext === "function") { + setSSRContext({ + pathname: patternToNextFormat(route.pattern), + query: { ...params, ...parseQuery(routeUrl) }, + asPath: routeUrl, + locale: locale, + locales: i18nConfig ? i18nConfig.locales : undefined, + defaultLocale: currentDefaultLocale, + domainLocales: domainLocales, + }); + } + if (i18nConfig) { + setI18nContext({ + locale: locale, + locales: i18nConfig.locales, + defaultLocale: currentDefaultLocale, + domainLocales: domainLocales, + hostname: new URL(request.url).hostname, + }); + } + // Re-render the page with fresh props inside fresh render sub-scopes + // so head/cache state cannot leak across passes. + var _el = AppComponent + ? React.createElement(AppComponent, { Component: PageComponent, pageProps: _fp }) + : React.createElement(PageComponent, _fp); + _el = wrapWithRouterContext(_el); + var _freshBody = await renderIsrPassToStringAsync(_el); + // Rebuild __NEXT_DATA__ with fresh props + var _regenPayload = { + props: { pageProps: _fp }, page: patternToNextFormat(route.pattern), + query: params, buildId: buildId, isFallback: false, + }; + if (i18nConfig) { + _regenPayload.locale = locale; + _regenPayload.locales = i18nConfig.locales; + _regenPayload.defaultLocale = currentDefaultLocale; + _regenPayload.domainLocales = domainLocales; + } + var _lGlobals = i18nConfig + ? ";window.__VINEXT_LOCALE__=" + safeJsonStringify(locale) + + ";window.__VINEXT_LOCALES__=" + safeJsonStringify(i18nConfig.locales) + + ";window.__VINEXT_DEFAULT_LOCALE__=" + safeJsonStringify(currentDefaultLocale) + : ""; + var _freshNDS = ""; + // Reconstruct ISR HTML preserving the document shell from the + // cached entry (head, fonts, assets, custom _document markup). + var _cachedStr = cached.value.value.html; + var _btag = '
'; + var _bstart = _cachedStr.indexOf(_btag); + var _bodyStart = _bstart >= 0 ? _bstart + _btag.length : -1; + // Locate __NEXT_DATA__ script to split body from suffix + var _ndMarker = ' + var _ndEnd = _cachedStr.indexOf('', _ndStart) + 9; + var _tail = _cachedStr.slice(_ndEnd); + _freshHtml = _cachedStr.slice(0, _bodyStart) + _freshBody + '
' + _gap + _freshNDS + _tail; + } else { + _freshHtml = '\\n\\n\\n\\n\\n
' + _freshBody + '
\\n ' + _freshNDS + '\\n\\n'; + } + await isrSet(cacheKey, { kind: "PAGES", html: _freshHtml, pageData: _fp, headers: undefined, status: undefined }, freshResult.revalidate); + } + }); }); var _staleHeaders = { "Content-Type": "text/html", "X-Vinext-Cache": "STALE", @@ -19077,7 +19299,7 @@ async function _renderPage(request, url, manifest) { params, locale: locale, locales: i18nConfig ? i18nConfig.locales : undefined, - defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, + defaultLocale: currentDefaultLocale, }; const result = await pageModule.getStaticProps(ctx); if (result && result.props) pageProps = result.props; @@ -19130,12 +19352,13 @@ async function _renderPage(request, url, manifest) { if (i18nConfig) { nextDataPayload.locale = locale; nextDataPayload.locales = i18nConfig.locales; - nextDataPayload.defaultLocale = i18nConfig.defaultLocale; + nextDataPayload.defaultLocale = currentDefaultLocale; + nextDataPayload.domainLocales = domainLocales; } const localeGlobals = i18nConfig ? ";window.__VINEXT_LOCALE__=" + safeJsonStringify(locale) + ";window.__VINEXT_LOCALES__=" + safeJsonStringify(i18nConfig.locales) + - ";window.__VINEXT_DEFAULT_LOCALE__=" + safeJsonStringify(i18nConfig.defaultLocale) + ";window.__VINEXT_DEFAULT_LOCALE__=" + safeJsonStringify(currentDefaultLocale) : ""; const nextDataScript = ""; @@ -19198,7 +19421,7 @@ async function _renderPage(request, url, manifest) { isrElement = React.createElement(PageComponent, pageProps); } isrElement = wrapWithRouterContext(isrElement); - var isrHtml = await renderToStringAsync(isrElement); + var isrHtml = await renderIsrPassToStringAsync(isrElement); var fullHtml = shellPrefix + isrHtml + shellSuffix; var isrPathname = url.split("?")[0]; var _cacheKey = isrCacheKey("pages", isrPathname); @@ -19232,15 +19455,16 @@ async function _renderPage(request, url, manifest) { responseHeaders.set("Link", _fontLinkHeader); } return new Response(compositeStream, { status: finalStatus, headers: responseHeaders }); - } catch (e) { + } catch (e) { console.error("[vinext] SSR error:", e); + _reportRequestError( + e instanceof Error ? e : new Error(String(e)), + { path: url, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, + { routerKind: "Pages Router", routePath: route.pattern, routeType: "render" }, + ).catch(() => { /* ignore reporting errors */ }); return new Response("Internal Server Error", { status: 500 }); - } - }) // end runWithFetchCache - ) // end runWithPrivateCache - ) // end _runWithCacheState - ) // end runWithHeadState - ); // end runWithRouterState + } + }); } export async function handleApiRoute(request, url) { @@ -19308,6 +19532,11 @@ export async function handleApiRoute(request, url) { return new Response(e.message, { status: e.statusCode, statusText: e.message }); } console.error("[vinext] API error:", e); + _reportRequestError( + e instanceof Error ? e : new Error(String(e)), + { path: url, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, + { routerKind: "Pages Router", routePath: route.pattern, routeType: "route" }, + ); return new Response("Internal Server Error", { status: 500 }); } } @@ -19434,17 +19663,50 @@ function __safeRegExp(pattern, flags) { } var __mwPatternCache = new Map(); +function __extractConstraint(str, re) { + if (str[re.lastIndex] !== "(") return null; + var start = re.lastIndex + 1; + var depth = 1; + var i = start; + while (i < str.length && depth > 0) { + if (str[i] === "(") depth++; + else if (str[i] === ")") depth--; + i++; + } + if (depth !== 0) return null; + re.lastIndex = i; + return str.slice(start, i - 1); +} function __compileMwPattern(pattern) { - if (pattern.includes("(") || pattern.includes("\\\\")) { + var hasConstraints = /:[\\w-]+[*+]?\\(/.test(pattern); + if (!hasConstraints && (pattern.includes("(") || pattern.includes("\\\\"))) { return __safeRegExp("^" + pattern + "$"); } var regexStr = ""; var tokenRe = /\\/:([\\w-]+)\\*|\\/:([\\w-]+)\\+|:([\\w-]+)|[.]|[^/:.]+|./g; var tok; while ((tok = tokenRe.exec(pattern)) !== null) { - if (tok[1] !== undefined) { regexStr += "(?:/.*)?"; } - else if (tok[2] !== undefined) { regexStr += "(?:/.+)"; } - else if (tok[3] !== undefined) { regexStr += "([^/]+)"; } + if (tok[1] !== undefined) { + var c1 = hasConstraints ? __extractConstraint(pattern, tokenRe) : null; + regexStr += c1 !== null ? "(?:/(" + c1 + "))?" : "(?:/.*)?"; + } + else if (tok[2] !== undefined) { + var c2 = hasConstraints ? __extractConstraint(pattern, tokenRe) : null; + regexStr += c2 !== null ? "(?:/(" + c2 + "))" : "(?:/.+)"; + } + else if (tok[3] !== undefined) { + var constraint = hasConstraints ? __extractConstraint(pattern, tokenRe) : null; + var isOptional = pattern[tokenRe.lastIndex] === "?"; + if (isOptional) tokenRe.lastIndex += 1; + var group = constraint !== null ? "(" + constraint + ")" : "([^/]+)"; + if (isOptional && regexStr.endsWith("/")) { + regexStr = regexStr.slice(0, -1) + "(?:/" + group + ")?"; + } else if (isOptional) { + regexStr += group + "?"; + } else { + regexStr += group; + } + } else if (tok[0] === ".") { regexStr += "\\\\."; } else { regexStr += tok[0]; } } diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index aaebcdc73..21bb82a9d 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -657,6 +657,17 @@ describe("App Router integration", () => { expect(html).toContain("metadata normal route"); }); + it("preserves build-time render headers on direct non-ISR RSC responses", async () => { + const res = await fetch(`${baseUrl}/nextjs-compat/render-headers-metadata-normal.rsc`, { + headers: { Accept: "text/x-component" }, + }); + + expect(res.status).toBe(200); + expect(res.headers.get("x-metadata-normal")).toBe("yes"); + expect(res.headers.getSetCookie()).toContain("metadata-normal=1; Path=/; HttpOnly"); + expect((await res.text()).length).toBeGreaterThan(0); + }); + it("permanentRedirect() returns 308 status code", async () => { const res = await fetch(`${baseUrl}/permanent-redirect-test`, { redirect: "manual" }); expect(res.status).toBe(308); @@ -4105,6 +4116,15 @@ describe("generateRscEntry ISR code generation", () => { expect(code).toContain(": consumeRenderResponseHeaders();"); expect(code).toContain("headers: __renderHeadersForCache"); expect(code).toContain("headers: __revalResult.headers"); + const rscResponseBlock = code.slice( + code.indexOf('if (process.env.NODE_ENV === "production" && __isrRscDataPromise)'), + code.indexOf("// Collect font data from RSC environment before passing to SSR"), + ); + expect( + rscResponseBlock.match( + /headers: __headersWithRenderResponseHeaders\(responseHeaders, __responseRenderHeaders\)/g, + ), + ).toHaveLength(2); }); it("generated code replays cached render-time response headers on HIT and STALE", () => { diff --git a/tests/shims.test.ts b/tests/shims.test.ts index e9ba88196..50593a9d2 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -1215,7 +1215,7 @@ describe("next/headers render response headers", () => { expect(consumeRenderResponseHeaders()).toEqual({ "set-cookie": [ "session=abc; Path=/; HttpOnly", - "session=; Path=/; Max-Age=0", + expect.stringContaining("session=; Path=/; Expires="), expect.stringContaining("__prerender_bypass="), expect.stringContaining("__prerender_bypass=; Path=/; HttpOnly; SameSite=Lax"), ], From 09b70a5e85c1af2ff2ba0fbed21614f501dd4452 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Tue, 17 Mar 2026 10:08:29 -0500 Subject: [PATCH 09/11] Restore app router parity after rebase --- packages/vinext/src/entries/app-rsc-entry.ts | 722 ++++++++++++++----- packages/vinext/src/index.ts | 7 + 2 files changed, 537 insertions(+), 192 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index fc938775f..56c7e480b 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -9,19 +9,19 @@ */ import fs from "node:fs"; import { fileURLToPath } from "node:url"; -import type { AppRoute } from "../routing/app-router.js"; -import type { MetadataFileRoute } from "../server/metadata-routes.js"; import type { - NextRedirect, - NextRewrite, NextHeader, NextI18nConfig, + NextRedirect, + NextRewrite, } from "../config/next-config.js"; +import type { AppRoute } from "../routing/app-router.js"; import { generateDevOriginCheckCode } from "../server/dev-origin-check.js"; +import type { MetadataFileRoute } from "../server/metadata-routes.js"; import { - generateSafeRegExpCode, generateMiddlewareMatcherCode, generateNormalizePathCode, + generateSafeRegExpCode, generateRouteMatchNormalizationCode, } from "../server/middleware-codegen.js"; import { isProxyFile } from "../server/middleware.js"; @@ -43,6 +43,30 @@ const routeTriePath = fileURLToPath(new URL("../routing/route-trie.js", import.m "/", ); +// Canonical order of HTTP method handlers supported by route.ts modules. +const ROUTE_HANDLER_HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; + +// Runtime helpers injected into the generated RSC entry so OPTIONS/Allow handling +// logic stays alongside the route handler pipeline. +const routeHandlerHelperCode = String.raw` +// Duplicated from the build-time constant above via JSON.stringify. +const ROUTE_HANDLER_HTTP_METHODS = ${JSON.stringify(ROUTE_HANDLER_HTTP_METHODS)}; + +function collectRouteHandlerMethods(handler) { + const methods = ROUTE_HANDLER_HTTP_METHODS.filter((method) => typeof handler[method] === "function"); + if (methods.includes("GET") && !methods.includes("HEAD")) { + methods.push("HEAD"); + } + return methods; +} + +function buildRouteHandlerAllowHeader(exportedMethods) { + const allow = new Set(exportedMethods); + allow.add("OPTIONS"); + return Array.from(allow).sort().join(", "); +} +`; + /** * Resolved config options relevant to App Router request handling. * Passed from the Vite plugin where the full next.config.js is loaded. @@ -63,6 +87,15 @@ export interface AppRouterConfig { bodySizeLimit?: number; /** Internationalization routing config for middleware matcher locale handling. */ i18n?: NextI18nConfig | null; + /** + * When true, the project has a `pages/` directory alongside the App Router. + * The generated RSC entry exposes `/__vinext/prerender/pages-static-paths` + * so `prerenderPages` can call `getStaticPaths` via `wrangler unstable_startWorker` + * in CF Workers builds. `pageRoutes` is loaded from the SSR environment via + * `import("./ssr/index.js")`, which re-exports it from + * `virtual:vinext-server-entry` when this flag is set. + */ + hasPagesDir?: boolean; } /** @@ -91,6 +124,7 @@ export function generateRscEntry( const allowedOrigins = config?.allowedOrigins ?? []; const bodySizeLimit = config?.bodySizeLimit ?? 1 * 1024 * 1024; const i18nConfig = config?.i18n ?? null; + const hasPagesDir = config?.hasPagesDir ?? false; // Build import map for all page and layout files const imports: string[] = []; const importMap: Map = new Map(); @@ -215,14 +249,40 @@ ${slotEntries.join(",\n")} // For static metadata files, read the file content at code-generation time // and embed it as base64. This ensures static metadata files work on runtimes // without filesystem access (e.g., Cloudflare Workers). + // + // For metadata routes in dynamic segments (e.g., /blog/[slug]/opengraph-image), + // generate patternParts so the runtime can use matchPattern() instead of strict + // equality — the same matching used for intercept routes. const metaRouteEntries = effectiveMetaRoutes.map((mr) => { + // Convert dynamic segments in servedUrl to matchPattern format. + // Keep in sync with routing/app-router.ts patternParts generation. + // [param] → :param + // [...param] → :param+ + // [[...param]] → :param* + const patternParts = + mr.isDynamic && mr.servedUrl.includes("[") + ? JSON.stringify( + mr.servedUrl + .split("/") + .filter(Boolean) + .map((seg) => { + if (seg.startsWith("[[...") && seg.endsWith("]]")) + return ":" + seg.slice(5, -2) + "*"; + if (seg.startsWith("[...") && seg.endsWith("]")) + return ":" + seg.slice(4, -1) + "+"; + if (seg.startsWith("[") && seg.endsWith("]")) return ":" + seg.slice(1, -1); + return seg; + }), + ) + : null; + if (mr.isDynamic) { return ` { type: ${JSON.stringify(mr.type)}, isDynamic: true, servedUrl: ${JSON.stringify(mr.servedUrl)}, contentType: ${JSON.stringify(mr.contentType)}, - module: ${getImportVar(mr.filePath)}, + module: ${getImportVar(mr.filePath)},${patternParts ? `\n patternParts: ${patternParts},` : ""} }`; } // Static: read file and embed as base64 @@ -282,7 +342,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; +import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; @@ -292,18 +352,20 @@ ${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 { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; -import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from ${JSON.stringify(requestContextShimPath)}; -import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; +import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; +import { getRequestExecutionContext as _getRequestExecutionContext } from ${JSON.stringify(requestContextShimPath)}; +import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from ${JSON.stringify(routeTriePath)}; -import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. -import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; +import "vinext/navigation-state"; +import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; function _getSSRFontStyles() { return [..._getSSRFontStylesGoogle(), ..._getSSRFontStylesLocal()]; } function _getSSRFontPreloads() { return [..._getSSRFontPreloadsGoogle(), ..._getSSRFontPreloadsLocal()]; } +${hasPagesDir ? `// Note: pageRoutes loaded lazily via SSR env in /__vinext/prerender/pages-static-paths handler` : ""} +${routeHandlerHelperCode} // ALS used to suppress the expected "Invalid hook call" dev warning when // layout/page components are probed outside React's render cycle. Patching @@ -415,6 +477,20 @@ function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) { function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) { __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override"); } +function __copyResponseHeaders(targetHeaders, sourceHeaders, excludedKeys) { + const __excluded = new Set(excludedKeys.map((key) => key.toLowerCase())); + const __setCookies = sourceHeaders.getSetCookie(); + for (const [key, value] of sourceHeaders) { + const __lowerKey = key.toLowerCase(); + if (__excluded.has(__lowerKey) || __lowerKey === "set-cookie") continue; + targetHeaders.append(key, value); + } + if (!__excluded.has("set-cookie")) { + for (const cookie of __setCookies) { + targetHeaders.append("Set-Cookie", cookie); + } + } +} function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) { const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status; if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response; @@ -477,6 +553,7 @@ function __isrCacheKey(pathname, suffix) { } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } +function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators // have a single knob for all cache tracing. @@ -629,9 +706,7 @@ function rscOnError(error, requestInfo, errorContext) { error instanceof Error ? error : new Error(String(error)), requestInfo, errorContext, - ).catch((reportErr) => { - console.error("[vinext] Failed to report render error:", reportErr); - }); + ); } // In production, generate a digest hash for non-navigation errors @@ -674,6 +749,7 @@ ${ let __instrumentationInitialized = false; let __instrumentationInitPromise = null; async function __ensureInstrumentation() { + if (process.env.VINEXT_PRERENDER === "1") return; if (__instrumentationInitialized) return; if (__instrumentationInitPromise) return __instrumentationInitPromise; __instrumentationInitPromise = (async () => { @@ -816,7 +892,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. + // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. return new Response(rscStream, { status: statusCode, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -970,7 +1046,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. + // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. return new Response(rscStream, { status: 200, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -1483,6 +1559,24 @@ async function __readFormDataWithLimit(request, maxBytes) { return new Response(combined, { headers: { "Content-Type": contentType } }).formData(); } +// Map from route pattern to generateStaticParams function. +// Used by the prerender phase to enumerate dynamic route URLs without +// loading route modules via the dev server. +export const generateStaticParamsMap = { +// TODO: layout-level generateStaticParams — this map only includes routes that +// have a pagePath (leaf pages). Layout segments can also export generateStaticParams +// to provide parent params for nested dynamic routes, but they don't have a pagePath +// so they are excluded here. Supporting layout-level generateStaticParams requires +// scanning layout.tsx files separately and including them in this map. +${routes + .filter((r) => r.isDynamic && r.pagePath) + .map( + (r) => + ` ${JSON.stringify(r.pattern)}: ${getImportVar(r.pagePath!)}?.generateStaticParams ?? null,`, + ) + .join("\n")} +}; + export default async function handler(request, ctx) { ${ instrumentationPath @@ -1492,60 +1586,50 @@ export default async function handler(request, ctx) { ` : "" } - // 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 - // streaming), preventing state leakage between concurrent requests on - // Cloudflare Workers and other concurrent runtimes. - // - // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so - // that KVCacheHandler._putInBackground can register background KV puts with - // ctx.waitUntil() without needing ctx passed at construction time. + // Wrap the entire request in a single unified ALS scope for per-request + // isolation. All state modules (headers, navigation, cache, fetch-cache, + // execution-context) read from this store via isInsideUnifiedScope(). const headersCtx = headersContextFromRequest(request); - const _run = () => runWithHeadersContext(headersCtx, () => - _runWithNavigationContext(() => - _runWithCacheState(() => - _runWithPrivateCache(() => - runWithFetchCache(async () => { - const __reqCtx = requestContextFromRequest(request); - // 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); - // 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); - let pathname; - try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } - ${bp ? `if (pathname.startsWith(${JSON.stringify(bp)})) pathname = pathname.slice(${JSON.stringify(bp)}.length) || "/";` : ""} - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); - for (const h of extraHeaders) { - // Use append() for headers where multiple values must coexist - // (Vary, Set-Cookie). Using set() on these would destroy - // existing values like "Vary: RSC, Accept" which are critical - // for correct CDN caching behavior. - const lk = h.key.toLowerCase(); - if (lk === "vary" || lk === "set-cookie") { - response.headers.append(h.key, h.value); - } else if (!response.headers.has(lk)) { - // Middleware headers take precedence: skip config keys already - // set by middleware so middleware headers always win. - response.headers.set(h.key, h.value); - } - } - } - } - return response; - }) - ) - ) - ) - ); - return ctx ? _runWithExecutionContext(ctx, _run) : _run(); + const __uCtx = _createUnifiedCtx({ + headersContext: headersCtx, + executionContext: ctx ?? _getRequestExecutionContext() ?? null, + }); + return _runWithUnifiedCtx(__uCtx, async () => { + _ensureFetchPatch(); + const __reqCtx = requestContextFromRequest(request); + // 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); + // 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); + let pathname; + try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } + ${bp ? `if (pathname.startsWith(${JSON.stringify(bp)})) pathname = pathname.slice(${JSON.stringify(bp)}.length) || "/";` : ""} + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + for (const h of extraHeaders) { + // Use append() for headers where multiple values must coexist + // (Vary, Set-Cookie). Using set() on these would destroy + // existing values like "Vary: RSC, Accept" which are critical + // for correct CDN caching behavior. + const lk = h.key.toLowerCase(); + if (lk === "vary" || lk === "set-cookie") { + response.headers.append(h.key, h.value); + } else if (!response.headers.has(lk)) { + // Middleware headers take precedence: skip config keys already + // set by middleware so middleware headers always win. + response.headers.set(h.key, h.value); + } + } + } + } + return response; + }); } async function _handleRequest(request, __reqCtx, _mwCtx) { @@ -1587,6 +1671,79 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { : "" } + // ── Prerender: static-params endpoint ──────────────────────────────── + // Internal endpoint used by prerenderApp() during build to fetch + // generateStaticParams results via wrangler unstable_startWorker. + // Gated on VINEXT_PRERENDER=1 to prevent exposure in normal deployments. + // For Node builds, process.env.VINEXT_PRERENDER is set directly by the + // prerender orchestrator. For CF Workers builds, wrangler unstable_startWorker + // injects VINEXT_PRERENDER as a binding which Miniflare exposes via process.env + // in bundled workers. The /__vinext/ prefix ensures no user route ever conflicts. + if (pathname === "/__vinext/prerender/static-params") { + if (process.env.VINEXT_PRERENDER !== "1") { + return new Response("Not Found", { status: 404 }); + } + const pattern = url.searchParams.get("pattern"); + if (!pattern) return new Response("missing pattern", { status: 400 }); + const fn = generateStaticParamsMap[pattern]; + if (typeof fn !== "function") return new Response("null", { status: 200, headers: { "content-type": "application/json" } }); + try { + const parentParams = url.searchParams.get("parentParams"); + const raw = parentParams ? JSON.parse(parentParams) : {}; + // Ensure params is a plain object — reject primitives, arrays, and null + // so user-authored generateStaticParams always receives { params: {} } + // rather than { params: 5 } or similar if input is malformed. + const params = (typeof raw === "object" && raw !== null && !Array.isArray(raw)) ? raw : {}; + const result = await fn({ params }); + return new Response(JSON.stringify(result), { status: 200, headers: { "content-type": "application/json" } }); + } catch (e) { + return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers: { "content-type": "application/json" } }); + } + } + + ${ + hasPagesDir + ? ` + // ── Prerender: pages-static-paths endpoint ─────────────────────────── + // Internal endpoint used by prerenderPages() during a CF Workers hybrid + // build to call getStaticPaths() for dynamic Pages Router routes via + // wrangler unstable_startWorker. Returns JSON-serialised getStaticPaths result. + // Gated on VINEXT_PRERENDER=1 to prevent exposure in normal deployments. + // See static-params endpoint above for process.env vs CF vars notes. + // + // pageRoutes lives in the SSR environment (virtual:vinext-server-entry). + // We load it lazily via import.meta.viteRsc.loadModule — the same pattern + // used by handleSsr() elsewhere in this template. At build time, Vite's RSC + // plugin transforms this call into a bundled cross-environment import, so it + // works correctly in the CF Workers production bundle running in Miniflare. + if (pathname === "/__vinext/prerender/pages-static-paths") { + if (process.env.VINEXT_PRERENDER !== "1") { + return new Response("Not Found", { status: 404 }); + } + const __gspPattern = url.searchParams.get("pattern"); + if (!__gspPattern) return new Response("missing pattern", { status: 400 }); + try { + const __gspSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + const __pagesRoutes = __gspSsrEntry.pageRoutes; + const __gspRoute = Array.isArray(__pagesRoutes) + ? __pagesRoutes.find((r) => r.pattern === __gspPattern) + : undefined; + if (!__gspRoute || typeof __gspRoute.module?.getStaticPaths !== "function") { + return new Response("null", { status: 200, headers: { "content-type": "application/json" } }); + } + const __localesParam = url.searchParams.get("locales"); + const __locales = __localesParam ? JSON.parse(__localesParam) : []; + const __defaultLocale = url.searchParams.get("defaultLocale") ?? ""; + const __gspResult = await __gspRoute.module.getStaticPaths({ locales: __locales, defaultLocale: __defaultLocale }); + return new Response(JSON.stringify(__gspResult), { status: 200, headers: { "content-type": "application/json" } }); + } catch (e) { + return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers: { "content-type": "application/json" } }); + } + } + ` + : "" + } + // Trailing slash normalization (redirect to canonical form) const __tsRedirect = normalizeTrailingSlash(pathname, __basePath, __trailingSlash, url.search); if (__tsRedirect) return __tsRedirect; @@ -1623,6 +1780,47 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { ${ middlewarePath ? ` + // In hybrid app+pages dev mode the connect handler already ran middleware + // and forwarded the results via x-vinext-mw-ctx. Reconstruct _mwCtx from + // the forwarded data instead of re-running the middleware function. + // Guarded by NODE_ENV because this header only exists in dev (the connect + // handler sets it). In production there is no connect handler, so an + // attacker-supplied header must not be trusted. + let __mwCtxApplied = false; + if (process.env.NODE_ENV !== "production") { + const __mwCtxHeader = request.headers.get("x-vinext-mw-ctx"); + if (__mwCtxHeader) { + try { + const __mwCtxData = JSON.parse(__mwCtxHeader); + if (__mwCtxData.h && __mwCtxData.h.length > 0) { + // Note: h may include x-middleware-request-* internal headers so + // applyMiddlewareRequestHeaders() can unpack them below. + // processMiddlewareHeaders() strips them before any response. + _mwCtx.headers = new Headers(); + for (const [key, value] of __mwCtxData.h) { + _mwCtx.headers.append(key, value); + } + } + if (__mwCtxData.s != null) { + _mwCtx.status = __mwCtxData.s; + } + // Apply forwarded middleware rewrite so routing uses the rewritten path. + // The RSC plugin constructs its Request from the original HTTP request, + // not from req.url, so the connect handler's req.url rewrite is invisible. + if (__mwCtxData.r) { + const __rewriteParsed = new URL(__mwCtxData.r, request.url); + cleanPathname = __rewriteParsed.pathname; + url.search = __rewriteParsed.search; + } + // Flag set after full context application — if any step fails (e.g. malformed + // rewrite URL), we fall back to re-running middleware as a safety net. + __mwCtxApplied = true; + } catch (e) { + console.error("[vinext] Failed to parse forwarded middleware context:", e); + } + } + } + if (!__mwCtxApplied) { // 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 @@ -1647,7 +1845,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest); const mwFetchEvent = new NextFetchEvent({ page: cleanPathname }); const mwResponse = await middlewareFn(nextRequest, mwFetchEvent); - mwFetchEvent.drainWaitUntil(); + const _mwWaitUntil = mwFetchEvent.drainWaitUntil(); + const _mwExecCtx = _getRequestExecutionContext(); + if (_mwExecCtx && typeof _mwExecCtx.waitUntil === "function") { _mwExecCtx.waitUntil(_mwWaitUntil); } if (mwResponse) { // Check for x-middleware-next (continue) if (mwResponse.headers.get("x-middleware-next") === "1") { @@ -1657,11 +1857,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // the blanket strip loop after that call removes every remaining // x-middleware-* header before the set is merged into the response. _mwCtx.headers = new Headers(); - for (const [key, value] of mwResponse.headers) { - if (key !== "x-middleware-next" && key !== "x-middleware-rewrite") { - _mwCtx.headers.append(key, value); - } - } + __copyResponseHeaders(_mwCtx.headers, mwResponse.headers, [ + "x-middleware-next", + "x-middleware-rewrite", + ]); } else { // Check for redirect if (mwResponse.status >= 300 && mwResponse.status < 400) { @@ -1672,17 +1871,20 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (rewriteUrl) { const rewriteParsed = new URL(rewriteUrl, request.url); cleanPathname = rewriteParsed.pathname; + // Carry over query params from the rewrite URL so that + // searchParams props, useSearchParams(), and navigation context + // reflect the rewrite destination, not the original request. + url.search = rewriteParsed.search; // Capture custom status code from rewrite (e.g. NextResponse.rewrite(url, { status: 403 })) if (mwResponse.status !== 200) { _mwCtx.status = mwResponse.status; } // Also save any other headers from the rewrite response _mwCtx.headers = new Headers(); - for (const [key, value] of mwResponse.headers) { - if (key !== "x-middleware-next" && key !== "x-middleware-rewrite") { - _mwCtx.headers.append(key, value); - } - } + __copyResponseHeaders(_mwCtx.headers, mwResponse.headers, [ + "x-middleware-next", + "x-middleware-rewrite", + ]); } else { // Middleware returned a custom response return mwResponse; @@ -1694,6 +1896,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return new Response("Internal Server Error", { status: 500 }); } } + } // end of if (!__mwCtxApplied) // Unpack x-middleware-request-* headers into the request context so that // headers() returns the middleware-modified headers instead of the original @@ -1767,45 +1970,53 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Skip — the base servedUrl is not served when generateSitemaps exists continue; } - if (cleanPathname === metaRoute.servedUrl) { - if (metaRoute.isDynamic) { - // Dynamic metadata route — call the default export and serialize - const metaFn = metaRoute.module.default; - if (typeof metaFn === "function") { - const result = await metaFn(); - let body; - // If it's already a Response (e.g., ImageResponse), return directly - if (result instanceof Response) return result; - // Serialize based on type - if (metaRoute.type === "sitemap") body = sitemapToXml(result); - else if (metaRoute.type === "robots") body = robotsToText(result); - else if (metaRoute.type === "manifest") body = manifestToJson(result); - else body = JSON.stringify(result); - return new Response(body, { - headers: { "Content-Type": metaRoute.contentType }, - }); - } - } else { - // Static metadata file — decode from embedded base64 data - try { - const binary = atob(metaRoute.fileDataBase64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new Response(bytes, { - headers: { - "Content-Type": metaRoute.contentType, - "Cache-Control": "public, max-age=0, must-revalidate", - }, - }); - } catch { - return new Response("Not Found", { status: 404 }); - } + // Match metadata route — use pattern matching for dynamic segments, + // strict equality for static paths. + var _metaParams = null; + if (metaRoute.patternParts) { + var _metaUrlParts = cleanPathname.split("/").filter(Boolean); + _metaParams = matchPattern(_metaUrlParts, metaRoute.patternParts); + if (!_metaParams) continue; + } else if (cleanPathname !== metaRoute.servedUrl) { + continue; + } + if (metaRoute.isDynamic) { + // Dynamic metadata route — call the default export and serialize + const metaFn = metaRoute.module.default; + if (typeof metaFn === "function") { + const result = await metaFn({ params: makeThenableParams(_metaParams || {}) }); + let body; + // If it's already a Response (e.g., ImageResponse), return directly + if (result instanceof Response) return result; + // Serialize based on type + if (metaRoute.type === "sitemap") body = sitemapToXml(result); + else if (metaRoute.type === "robots") body = robotsToText(result); + else if (metaRoute.type === "manifest") body = manifestToJson(result); + else body = JSON.stringify(result); + return new Response(body, { + headers: { "Content-Type": metaRoute.contentType }, + }); + } + } else { + // Static metadata file — decode from embedded base64 data + try { + const binary = atob(metaRoute.fileDataBase64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new Response(bytes, { + headers: { + "Content-Type": metaRoute.contentType, + "Cache-Control": "public, max-age=0, must-revalidate", + }, + }); + } catch { + return new Response("Not Found", { status: 404 }); } } } // Set navigation context for Server Components. - // Note: Headers context is already set by runWithHeadersContext in the handler wrapper. + // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, @@ -1940,7 +2151,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Collect cookies set during the action synchronously (before stream is consumed). // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext + // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. const actionRenderHeaders = consumeRenderResponseHeaders(); @@ -1956,9 +2167,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: cleanPathname, routeType: "action" }, - ).catch((reportErr) => { - console.error("[vinext] Failed to report server action error:", reportErr); - }); + ); setHeadersContext(null); setNavigationContext(null); return new Response( @@ -2000,6 +2209,32 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (!match) { + ${ + hasPagesDir + ? ` + // ── Pages Router fallback ──────────────────────────────────────────── + // When a request doesn't match any App Router route, delegate to the + // Pages Router handler (available in the SSR environment). This covers + // both production request serving and prerender fetches from wrangler. + // RSC requests (.rsc suffix or Accept: text/x-component) cannot be + // handled by the Pages Router, so skip the delegation for those. + if (!isRscRequest) { + const __pagesEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + if (typeof __pagesEntry.renderPage === "function") { + const __pagesRes = await __pagesEntry.renderPage(request, decodeURIComponent(url.pathname) + (url.search || ""), {}); + // Only return the Pages Router response if it matched a route + // (non-404). A 404 means the path isn't a Pages route either, + // so fall through to the App Router not-found page below. + if (__pagesRes.status !== 404) { + setHeadersContext(null); + setNavigationContext(null); + return __pagesRes; + } + } + } + ` + : "" + } // Render custom not-found page if available, otherwise plain 404 const notFoundResponse = await renderNotFoundPage(null, isRscRequest, request); if (notFoundResponse) return notFoundResponse; @@ -2021,16 +2256,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (route.routeHandler) { const handler = route.routeHandler; const method = request.method.toUpperCase(); - const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 ? handler.revalidate : null; + const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 && handler.revalidate !== Infinity ? handler.revalidate : null; + if (typeof handler["default"] === "function" && process.env.NODE_ENV === "development") { + console.error( + "[vinext] Detected default export in route handler " + route.pattern + ". Export a named export for each HTTP method instead.", + ); + } // Collect exported HTTP methods for OPTIONS auto-response and Allow header - const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; - const exportedMethods = HTTP_METHODS.filter((m) => typeof handler[m] === "function"); - // If GET is exported, HEAD is implicitly supported - if (exportedMethods.includes("GET") && !exportedMethods.includes("HEAD")) { - exportedMethods.push("HEAD"); - } - const hasDefault = typeof handler["default"] === "function"; + const exportedMethods = collectRouteHandlerMethods(handler); + const allowHeaderForOptions = buildRouteHandlerAllowHeader(exportedMethods); // Route handlers need the same middleware header/status merge behavior as // page responses. This keeps middleware response headers visible on API @@ -2053,29 +2288,107 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // OPTIONS auto-implementation: respond with Allow header and 204 if (method === "OPTIONS" && typeof handler["OPTIONS"] !== "function") { - const allowMethods = hasDefault ? HTTP_METHODS : exportedMethods; - if (!allowMethods.includes("OPTIONS")) allowMethods.push("OPTIONS"); setHeadersContext(null); setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 204, - headers: { "Allow": allowMethods.join(", ") }, + headers: { "Allow": allowHeaderForOptions }, })); } // HEAD auto-implementation: run GET handler and strip body - let handlerFn = handler[method] || handler["default"]; + let handlerFn = handler[method]; let isAutoHead = false; if (method === "HEAD" && typeof handler["HEAD"] !== "function" && typeof handler["GET"] === "function") { handlerFn = handler["GET"]; isAutoHead = true; } + // ISR cache read for route handlers (production only). + // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. + // This runs before handler execution so a cache HIT skips the handler entirely. + if ( + process.env.NODE_ENV === "production" && + revalidateSeconds !== null && + handler.dynamic !== "force-dynamic" && + (method === "GET" || isAutoHead) && + typeof handlerFn === "function" + ) { + const __routeKey = __isrRouteKey(cleanPathname); + try { + const __cached = await __isrGet(__routeKey); + if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { + // HIT — return cached response immediately + const __cv = __cached.value.value; + __isrDebug?.("HIT (route)", cleanPathname); + setHeadersContext(null); + setNavigationContext(null); + const __hitHeaders = Object.assign({}, __cv.headers || {}); + __hitHeaders["X-Vinext-Cache"] = "HIT"; + __hitHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; + if (isAutoHead) { + return attachRouteHandlerMiddlewareContext(new Response(null, { status: __cv.status, headers: __hitHeaders })); + } + return attachRouteHandlerMiddlewareContext(new Response(__cv.body, { status: __cv.status, headers: __hitHeaders })); + } + if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { + // STALE — serve stale response, trigger background regeneration + const __sv = __cached.value.value; + const __revalSecs = revalidateSeconds; + const __revalHandlerFn = handlerFn; + const __revalParams = params; + const __revalUrl = request.url; + const __revalSearchParams = new URLSearchParams(url.searchParams); + __triggerBackgroundRegeneration(__routeKey, async function() { + const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; + const __revalUCtx = _createUnifiedCtx({ + headersContext: __revalHeadCtx, + executionContext: _getRequestExecutionContext(), + }); + await _runWithUnifiedCtx(__revalUCtx, async () => { + _ensureFetchPatch(); + setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); + const __syntheticReq = new Request(__revalUrl, { method: "GET" }); + const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); + const __regenDynamic = consumeDynamicUsage(); + setNavigationContext(null); + if (__regenDynamic) { + __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); + return; + } + const __freshBody = await __revalResponse.arrayBuffer(); + const __freshHeaders = {}; + __revalResponse.headers.forEach(function(v, k) { + if (k !== "x-vinext-cache" && k !== "cache-control") __freshHeaders[k] = v; + }); + const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __freshBody, status: __revalResponse.status, headers: __freshHeaders }, __revalSecs, __routeTags); + __isrDebug?.("route regen complete", __routeKey); + }); + }); + __isrDebug?.("STALE (route)", cleanPathname); + setHeadersContext(null); + setNavigationContext(null); + const __staleHeaders = Object.assign({}, __sv.headers || {}); + __staleHeaders["X-Vinext-Cache"] = "STALE"; + __staleHeaders["Cache-Control"] = "s-maxage=0, stale-while-revalidate"; + if (isAutoHead) { + return attachRouteHandlerMiddlewareContext(new Response(null, { status: __sv.status, headers: __staleHeaders })); + } + return attachRouteHandlerMiddlewareContext(new Response(__sv.body, { status: __sv.status, headers: __staleHeaders })); + } + } catch (__routeCacheErr) { + // Cache read failure — fall through to normal handler execution + console.error("[vinext] ISR route cache read error:", __routeCacheErr); + } + } + if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); + const handlerSetCacheControl = response.headers.has("cache-control"); // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, @@ -2084,11 +2397,42 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { revalidateSeconds !== null && !dynamicUsedInHandler && (method === "GET" || isAutoHead) && - !response.headers.has("cache-control") + !handlerSetCacheControl ) { response.headers.set("cache-control", "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"); } + // ISR cache write for route handlers (production, MISS). + // Store the raw handler response before cookie/middleware transforms + // (those are request-specific and shouldn't be cached). + if ( + process.env.NODE_ENV === "production" && + revalidateSeconds !== null && + handler.dynamic !== "force-dynamic" && + !dynamicUsedInHandler && + (method === "GET" || isAutoHead) && + !handlerSetCacheControl + ) { + response.headers.set("X-Vinext-Cache", "MISS"); + const __routeClone = response.clone(); + const __routeKey = __isrRouteKey(cleanPathname); + const __revalSecs = revalidateSeconds; + const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __routeWritePromise = (async () => { + try { + const __buf = await __routeClone.arrayBuffer(); + const __hdrs = {}; + __routeClone.headers.forEach(function(v, k) { + if (k !== "x-vinext-cache" && k !== "cache-control") __hdrs[k] = v; + }); + await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __buf, status: __routeClone.status, headers: __hdrs }, __revalSecs, __routeTags); + __isrDebug?.("route cache written", __routeKey); + } catch (__cacheErr) { + console.error("[vinext] ISR route cache write error:", __cacheErr); + } + })(); + _getRequestExecutionContext()?.waitUntil(__routeWritePromise); + } const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); @@ -2152,9 +2496,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: route.pattern, routeType: "route" }, - ).catch((reportErr) => { - console.error("[vinext] Failed to report route handler error:", reportErr); - }); + ); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 500 })); } finally { setHeadersAccessPhase(previousHeadersPhase); @@ -2164,7 +2506,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 405, - headers: { Allow: exportedMethods.join(", ") }, })); } @@ -2284,60 +2625,57 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // user request — to prevent user-specific cookies/auth headers from leaking // into content that is cached and served to all subsequent users. const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalResult = await runWithHeadersContext(__revalHeadCtx, () => - _runWithNavigationContext(() => - _runWithCacheState(() => - _runWithPrivateCache(() => - runWithFetchCache(async () => { - setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); - const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); - const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); - const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); - // Tee RSC stream: one for SSR, one to capture rscData - const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); - // Capture rscData bytes in parallel with SSR - const __rscDataPromise = (async () => { - const __rscReader = __revalRscForCapture.getReader(); - const __rscChunks = []; - let __rscTotal = 0; - for (;;) { - const { done, value } = await __rscReader.read(); - if (done) break; - __rscChunks.push(value); - __rscTotal += value.byteLength; - } - const __rscBuf = new Uint8Array(__rscTotal); - let __rscOff = 0; - for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } - return __rscBuf.buffer; - })(); - const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; - const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); - const __freshRscData = await __rscDataPromise; - // RSC data must be fully consumed before headers are finalized, - // since async server components may append headers while streaming. - const __renderHeaders = consumeRenderResponseHeaders(); - setHeadersContext(null); - setNavigationContext(null); - // Collect the full HTML string from the stream - const __revalReader = __revalHtmlStream.getReader(); - const __revalDecoder = new TextDecoder(); - const __revalChunks = []; - for (;;) { - const { done, value } = await __revalReader.read(); - if (done) break; - __revalChunks.push(__revalDecoder.decode(value, { stream: true })); - } - __revalChunks.push(__revalDecoder.decode()); - const __freshHtml = __revalChunks.join(""); - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; - }) - ) - ) - ) - ); + const __revalUCtx = _createUnifiedCtx({ + headersContext: __revalHeadCtx, + executionContext: _getRequestExecutionContext(), + }); + const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { + _ensureFetchPatch(); + setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); + const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); + const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); + // Tee RSC stream: one for SSR, one to capture rscData + const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); + // Capture rscData bytes in parallel with SSR + const __rscDataPromise = (async () => { + const __rscReader = __revalRscForCapture.getReader(); + const __rscChunks = []; + let __rscTotal = 0; + for (;;) { + const { done, value } = await __rscReader.read(); + if (done) break; + __rscChunks.push(value); + __rscTotal += value.byteLength; + } + const __rscBuf = new Uint8Array(__rscTotal); + let __rscOff = 0; + for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } + return __rscBuf.buffer; + })(); + const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; + const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); + const __freshRscData = await __rscDataPromise; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. + const __renderHeaders = consumeRenderResponseHeaders(); + setHeadersContext(null); + setNavigationContext(null); + // Collect the full HTML string from the stream + const __revalReader = __revalHtmlStream.getReader(); + const __revalDecoder = new TextDecoder(); + const __revalChunks = []; + for (;;) { + const { done, value } = await __revalReader.read(); + if (done) break; + __revalChunks.push(__revalDecoder.decode(value, { stream: true })); + } + __revalChunks.push(__revalDecoder.decode()); + const __freshHtml = __revalChunks.join(""); + const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; + }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), @@ -2446,7 +2784,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError }); // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext + // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. return new Response(interceptStream, { headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -2680,7 +3018,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // NOTE: Do NOT clear headers/navigation context here! // The RSC stream is consumed lazily - components render when chunks are read. // If we clear context now, headers()/cookies() will fail during rendering. - // Context will be cleared when the next request starts (via runWithHeadersContext). + // Context will be cleared when the next request starts (via runWithRequestContext). const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; // Include matched route params so the client can hydrate useParams() if (params && Object.keys(params).length > 0) { diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index eda9e969b..92eae72a4 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1669,6 +1669,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { "vinext/i18n-context": path.join(shimsDir, "i18n-context"), "vinext/instrumentation": path.resolve(__dirname, "server", "instrumentation"), "vinext/html": path.resolve(__dirname, "server", "html"), + "vinext/server/app-router-entry": path.resolve(__dirname, "server", "app-router-entry"), }; // Detect if Cloudflare's vite plugin is present — if so, skip @@ -2215,6 +2216,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { allowedDevOrigins: nextConfig?.allowedDevOrigins, bodySizeLimit: nextConfig?.serverActionsBodySizeLimit, i18n: nextConfig?.i18n, + hasPagesDir, }, instrumentationPath, ); @@ -2922,13 +2924,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (hasAppDir) { const mwCtxEntries: [string, string][] = []; if (result.responseHeaders) { + const setCookies = result.responseHeaders.getSetCookie(); for (const [key, value] of result.responseHeaders) { // Exclude control headers that runMiddleware already // consumed — matches the RSC entry's inline filtering. + if (key === "set-cookie") continue; if (key !== "x-middleware-next" && key !== "x-middleware-rewrite") { mwCtxEntries.push([key, value]); } } + for (const cookie of setCookies) { + mwCtxEntries.push(["set-cookie", cookie]); + } } req.headers["x-vinext-mw-ctx"] = JSON.stringify({ h: mwCtxEntries, From ca4e4c67554dfcd2d530239483fe62434dd53fb1 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Tue, 17 Mar 2026 12:37:45 -0500 Subject: [PATCH 10/11] Fix remaining CI failures --- .../entry-templates.test.ts.snap | 3237 +++++++++++------ tests/app-router.test.ts | 18 +- tests/image-optimization-parity.test.ts | 14 + 3 files changed, 2190 insertions(+), 1079 deletions(-) diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 8002d7699..ab544b78f 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -374,7 +374,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; +import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; @@ -384,19 +384,38 @@ 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 { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; -import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; +import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; +import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; +import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; -import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. -import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; +import "vinext/navigation-state"; +import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; function _getSSRFontStyles() { return [..._getSSRFontStylesGoogle(), ..._getSSRFontStylesLocal()]; } function _getSSRFontPreloads() { return [..._getSSRFontPreloadsGoogle(), ..._getSSRFontPreloadsLocal()]; } + +// Duplicated from the build-time constant above via JSON.stringify. +const ROUTE_HANDLER_HTTP_METHODS = ["GET","HEAD","POST","PUT","DELETE","PATCH","OPTIONS"]; + +function collectRouteHandlerMethods(handler) { + const methods = ROUTE_HANDLER_HTTP_METHODS.filter((method) => typeof handler[method] === "function"); + if (methods.includes("GET") && !methods.includes("HEAD")) { + methods.push("HEAD"); + } + return methods; +} + +function buildRouteHandlerAllowHeader(exportedMethods) { + const allow = new Set(exportedMethods); + allow.add("OPTIONS"); + return Array.from(allow).sort().join(", "); +} + + // ALS used to suppress the expected "Invalid hook call" dev warning when // layout/page components are probed outside React's render cycle. Patching // console.error once at module load (instead of per-request) avoids the @@ -507,6 +526,20 @@ function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) { function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) { __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override"); } +function __copyResponseHeaders(targetHeaders, sourceHeaders, excludedKeys) { + const __excluded = new Set(excludedKeys.map((key) => key.toLowerCase())); + const __setCookies = sourceHeaders.getSetCookie(); + for (const [key, value] of sourceHeaders) { + const __lowerKey = key.toLowerCase(); + if (__excluded.has(__lowerKey) || __lowerKey === "set-cookie") continue; + targetHeaders.append(key, value); + } + if (!__excluded.has("set-cookie")) { + for (const cookie of __setCookies) { + targetHeaders.append("Set-Cookie", cookie); + } + } +} function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) { const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status; if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response; @@ -569,6 +602,7 @@ function __isrCacheKey(pathname, suffix) { } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } +function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators // have a single knob for all cache tracing. @@ -721,9 +755,7 @@ function rscOnError(error, requestInfo, errorContext) { error instanceof Error ? error : new Error(String(error)), requestInfo, errorContext, - ).catch((reportErr) => { - console.error("[vinext] Failed to report render error:", reportErr); - }); + ); } // In production, generate a digest hash for non-navigation errors @@ -960,7 +992,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. + // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. return new Response(rscStream, { status: statusCode, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -1093,7 +1125,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. + // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. return new Response(rscStream, { status: 200, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -1765,62 +1797,64 @@ async function __readFormDataWithLimit(request, maxBytes) { return new Response(combined, { headers: { "Content-Type": contentType } }).formData(); } +// Map from route pattern to generateStaticParams function. +// Used by the prerender phase to enumerate dynamic route URLs without +// loading route modules via the dev server. +export const generateStaticParamsMap = { +// TODO: layout-level generateStaticParams — this map only includes routes that +// have a pagePath (leaf pages). Layout segments can also export generateStaticParams +// to provide parent params for nested dynamic routes, but they don't have a pagePath +// so they are excluded here. Supporting layout-level generateStaticParams requires +// scanning layout.tsx files separately and including them in this map. + "/blog/:slug": mod_3?.generateStaticParams ?? null, +}; + export default async function handler(request, ctx) { - // 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 - // streaming), preventing state leakage between concurrent requests on - // Cloudflare Workers and other concurrent runtimes. - // - // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so - // that KVCacheHandler._putInBackground can register background KV puts with - // ctx.waitUntil() without needing ctx passed at construction time. + // Wrap the entire request in a single unified ALS scope for per-request + // isolation. All state modules (headers, navigation, cache, fetch-cache, + // execution-context) read from this store via isInsideUnifiedScope(). const headersCtx = headersContextFromRequest(request); - const _run = () => runWithHeadersContext(headersCtx, () => - _runWithNavigationContext(() => - _runWithCacheState(() => - _runWithPrivateCache(() => - runWithFetchCache(async () => { - const __reqCtx = requestContextFromRequest(request); - // 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); - // 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); - let pathname; - try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } - - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); - for (const h of extraHeaders) { - // Use append() for headers where multiple values must coexist - // (Vary, Set-Cookie). Using set() on these would destroy - // existing values like "Vary: RSC, Accept" which are critical - // for correct CDN caching behavior. - const lk = h.key.toLowerCase(); - if (lk === "vary" || lk === "set-cookie") { - response.headers.append(h.key, h.value); - } else if (!response.headers.has(lk)) { - // Middleware headers take precedence: skip config keys already - // set by middleware so middleware headers always win. - response.headers.set(h.key, h.value); - } - } - } - } - return response; - }) - ) - ) - ) - ); - return ctx ? _runWithExecutionContext(ctx, _run) : _run(); + const __uCtx = _createUnifiedCtx({ + headersContext: headersCtx, + executionContext: ctx ?? _getRequestExecutionContext() ?? null, + }); + return _runWithUnifiedCtx(__uCtx, async () => { + _ensureFetchPatch(); + const __reqCtx = requestContextFromRequest(request); + // 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); + // 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); + let pathname; + try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } + + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + for (const h of extraHeaders) { + // Use append() for headers where multiple values must coexist + // (Vary, Set-Cookie). Using set() on these would destroy + // existing values like "Vary: RSC, Accept" which are critical + // for correct CDN caching behavior. + const lk = h.key.toLowerCase(); + if (lk === "vary" || lk === "set-cookie") { + response.headers.append(h.key, h.value); + } else if (!response.headers.has(lk)) { + // Middleware headers take precedence: skip config keys already + // set by middleware so middleware headers always win. + response.headers.set(h.key, h.value); + } + } + } + } + return response; + }); } async function _handleRequest(request, __reqCtx, _mwCtx) { @@ -1855,6 +1889,38 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { + // ── Prerender: static-params endpoint ──────────────────────────────── + // Internal endpoint used by prerenderApp() during build to fetch + // generateStaticParams results via wrangler unstable_startWorker. + // Gated on VINEXT_PRERENDER=1 to prevent exposure in normal deployments. + // For Node builds, process.env.VINEXT_PRERENDER is set directly by the + // prerender orchestrator. For CF Workers builds, wrangler unstable_startWorker + // injects VINEXT_PRERENDER as a binding which Miniflare exposes via process.env + // in bundled workers. The /__vinext/ prefix ensures no user route ever conflicts. + if (pathname === "/__vinext/prerender/static-params") { + if (process.env.VINEXT_PRERENDER !== "1") { + return new Response("Not Found", { status: 404 }); + } + const pattern = url.searchParams.get("pattern"); + if (!pattern) return new Response("missing pattern", { status: 400 }); + const fn = generateStaticParamsMap[pattern]; + if (typeof fn !== "function") return new Response("null", { status: 200, headers: { "content-type": "application/json" } }); + try { + const parentParams = url.searchParams.get("parentParams"); + const raw = parentParams ? JSON.parse(parentParams) : {}; + // Ensure params is a plain object — reject primitives, arrays, and null + // so user-authored generateStaticParams always receives { params: {} } + // rather than { params: 5 } or similar if input is malformed. + const params = (typeof raw === "object" && raw !== null && !Array.isArray(raw)) ? raw : {}; + const result = await fn({ params }); + return new Response(JSON.stringify(result), { status: 200, headers: { "content-type": "application/json" } }); + } catch (e) { + return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers: { "content-type": "application/json" } }); + } + } + + + // Trailing slash normalization (redirect to canonical form) const __tsRedirect = normalizeTrailingSlash(pathname, __basePath, __trailingSlash, url.search); if (__tsRedirect) return __tsRedirect; @@ -1949,45 +2015,53 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Skip — the base servedUrl is not served when generateSitemaps exists continue; } - if (cleanPathname === metaRoute.servedUrl) { - if (metaRoute.isDynamic) { - // Dynamic metadata route — call the default export and serialize - const metaFn = metaRoute.module.default; - if (typeof metaFn === "function") { - const result = await metaFn(); - let body; - // If it's already a Response (e.g., ImageResponse), return directly - if (result instanceof Response) return result; - // Serialize based on type - if (metaRoute.type === "sitemap") body = sitemapToXml(result); - else if (metaRoute.type === "robots") body = robotsToText(result); - else if (metaRoute.type === "manifest") body = manifestToJson(result); - else body = JSON.stringify(result); - return new Response(body, { - headers: { "Content-Type": metaRoute.contentType }, - }); - } - } else { - // Static metadata file — decode from embedded base64 data - try { - const binary = atob(metaRoute.fileDataBase64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new Response(bytes, { - headers: { - "Content-Type": metaRoute.contentType, - "Cache-Control": "public, max-age=0, must-revalidate", - }, - }); - } catch { - return new Response("Not Found", { status: 404 }); - } + // Match metadata route — use pattern matching for dynamic segments, + // strict equality for static paths. + var _metaParams = null; + if (metaRoute.patternParts) { + var _metaUrlParts = cleanPathname.split("/").filter(Boolean); + _metaParams = matchPattern(_metaUrlParts, metaRoute.patternParts); + if (!_metaParams) continue; + } else if (cleanPathname !== metaRoute.servedUrl) { + continue; + } + if (metaRoute.isDynamic) { + // Dynamic metadata route — call the default export and serialize + const metaFn = metaRoute.module.default; + if (typeof metaFn === "function") { + const result = await metaFn({ params: makeThenableParams(_metaParams || {}) }); + let body; + // If it's already a Response (e.g., ImageResponse), return directly + if (result instanceof Response) return result; + // Serialize based on type + if (metaRoute.type === "sitemap") body = sitemapToXml(result); + else if (metaRoute.type === "robots") body = robotsToText(result); + else if (metaRoute.type === "manifest") body = manifestToJson(result); + else body = JSON.stringify(result); + return new Response(body, { + headers: { "Content-Type": metaRoute.contentType }, + }); + } + } else { + // Static metadata file — decode from embedded base64 data + try { + const binary = atob(metaRoute.fileDataBase64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new Response(bytes, { + headers: { + "Content-Type": metaRoute.contentType, + "Cache-Control": "public, max-age=0, must-revalidate", + }, + }); + } catch { + return new Response("Not Found", { status: 404 }); } } } // Set navigation context for Server Components. - // Note: Headers context is already set by runWithHeadersContext in the handler wrapper. + // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, @@ -2122,7 +2196,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Collect cookies set during the action synchronously (before stream is consumed). // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext + // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. const actionRenderHeaders = consumeRenderResponseHeaders(); @@ -2138,9 +2212,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: cleanPathname, routeType: "action" }, - ).catch((reportErr) => { - console.error("[vinext] Failed to report server action error:", reportErr); - }); + ); setHeadersContext(null); setNavigationContext(null); return new Response( @@ -2182,6 +2254,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (!match) { + // Render custom not-found page if available, otherwise plain 404 const notFoundResponse = await renderNotFoundPage(null, isRscRequest, request); if (notFoundResponse) return notFoundResponse; @@ -2203,16 +2276,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (route.routeHandler) { const handler = route.routeHandler; const method = request.method.toUpperCase(); - const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 ? handler.revalidate : null; + const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 && handler.revalidate !== Infinity ? handler.revalidate : null; + if (typeof handler["default"] === "function" && process.env.NODE_ENV === "development") { + console.error( + "[vinext] Detected default export in route handler " + route.pattern + ". Export a named export for each HTTP method instead.", + ); + } // Collect exported HTTP methods for OPTIONS auto-response and Allow header - const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; - const exportedMethods = HTTP_METHODS.filter((m) => typeof handler[m] === "function"); - // If GET is exported, HEAD is implicitly supported - if (exportedMethods.includes("GET") && !exportedMethods.includes("HEAD")) { - exportedMethods.push("HEAD"); - } - const hasDefault = typeof handler["default"] === "function"; + const exportedMethods = collectRouteHandlerMethods(handler); + const allowHeaderForOptions = buildRouteHandlerAllowHeader(exportedMethods); // Route handlers need the same middleware header/status merge behavior as // page responses. This keeps middleware response headers visible on API @@ -2235,29 +2308,107 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // OPTIONS auto-implementation: respond with Allow header and 204 if (method === "OPTIONS" && typeof handler["OPTIONS"] !== "function") { - const allowMethods = hasDefault ? HTTP_METHODS : exportedMethods; - if (!allowMethods.includes("OPTIONS")) allowMethods.push("OPTIONS"); setHeadersContext(null); setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 204, - headers: { "Allow": allowMethods.join(", ") }, + headers: { "Allow": allowHeaderForOptions }, })); } // HEAD auto-implementation: run GET handler and strip body - let handlerFn = handler[method] || handler["default"]; + let handlerFn = handler[method]; let isAutoHead = false; if (method === "HEAD" && typeof handler["HEAD"] !== "function" && typeof handler["GET"] === "function") { handlerFn = handler["GET"]; isAutoHead = true; } + // ISR cache read for route handlers (production only). + // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. + // This runs before handler execution so a cache HIT skips the handler entirely. + if ( + process.env.NODE_ENV === "production" && + revalidateSeconds !== null && + handler.dynamic !== "force-dynamic" && + (method === "GET" || isAutoHead) && + typeof handlerFn === "function" + ) { + const __routeKey = __isrRouteKey(cleanPathname); + try { + const __cached = await __isrGet(__routeKey); + if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { + // HIT — return cached response immediately + const __cv = __cached.value.value; + __isrDebug?.("HIT (route)", cleanPathname); + setHeadersContext(null); + setNavigationContext(null); + const __hitHeaders = Object.assign({}, __cv.headers || {}); + __hitHeaders["X-Vinext-Cache"] = "HIT"; + __hitHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; + if (isAutoHead) { + return attachRouteHandlerMiddlewareContext(new Response(null, { status: __cv.status, headers: __hitHeaders })); + } + return attachRouteHandlerMiddlewareContext(new Response(__cv.body, { status: __cv.status, headers: __hitHeaders })); + } + if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { + // STALE — serve stale response, trigger background regeneration + const __sv = __cached.value.value; + const __revalSecs = revalidateSeconds; + const __revalHandlerFn = handlerFn; + const __revalParams = params; + const __revalUrl = request.url; + const __revalSearchParams = new URLSearchParams(url.searchParams); + __triggerBackgroundRegeneration(__routeKey, async function() { + const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; + const __revalUCtx = _createUnifiedCtx({ + headersContext: __revalHeadCtx, + executionContext: _getRequestExecutionContext(), + }); + await _runWithUnifiedCtx(__revalUCtx, async () => { + _ensureFetchPatch(); + setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); + const __syntheticReq = new Request(__revalUrl, { method: "GET" }); + const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); + const __regenDynamic = consumeDynamicUsage(); + setNavigationContext(null); + if (__regenDynamic) { + __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); + return; + } + const __freshBody = await __revalResponse.arrayBuffer(); + const __freshHeaders = {}; + __revalResponse.headers.forEach(function(v, k) { + if (k !== "x-vinext-cache" && k !== "cache-control") __freshHeaders[k] = v; + }); + const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __freshBody, status: __revalResponse.status, headers: __freshHeaders }, __revalSecs, __routeTags); + __isrDebug?.("route regen complete", __routeKey); + }); + }); + __isrDebug?.("STALE (route)", cleanPathname); + setHeadersContext(null); + setNavigationContext(null); + const __staleHeaders = Object.assign({}, __sv.headers || {}); + __staleHeaders["X-Vinext-Cache"] = "STALE"; + __staleHeaders["Cache-Control"] = "s-maxage=0, stale-while-revalidate"; + if (isAutoHead) { + return attachRouteHandlerMiddlewareContext(new Response(null, { status: __sv.status, headers: __staleHeaders })); + } + return attachRouteHandlerMiddlewareContext(new Response(__sv.body, { status: __sv.status, headers: __staleHeaders })); + } + } catch (__routeCacheErr) { + // Cache read failure — fall through to normal handler execution + console.error("[vinext] ISR route cache read error:", __routeCacheErr); + } + } + if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); + const handlerSetCacheControl = response.headers.has("cache-control"); // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, @@ -2266,11 +2417,42 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { revalidateSeconds !== null && !dynamicUsedInHandler && (method === "GET" || isAutoHead) && - !response.headers.has("cache-control") + !handlerSetCacheControl ) { response.headers.set("cache-control", "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"); } + // ISR cache write for route handlers (production, MISS). + // Store the raw handler response before cookie/middleware transforms + // (those are request-specific and shouldn't be cached). + if ( + process.env.NODE_ENV === "production" && + revalidateSeconds !== null && + handler.dynamic !== "force-dynamic" && + !dynamicUsedInHandler && + (method === "GET" || isAutoHead) && + !handlerSetCacheControl + ) { + response.headers.set("X-Vinext-Cache", "MISS"); + const __routeClone = response.clone(); + const __routeKey = __isrRouteKey(cleanPathname); + const __revalSecs = revalidateSeconds; + const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __routeWritePromise = (async () => { + try { + const __buf = await __routeClone.arrayBuffer(); + const __hdrs = {}; + __routeClone.headers.forEach(function(v, k) { + if (k !== "x-vinext-cache" && k !== "cache-control") __hdrs[k] = v; + }); + await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __buf, status: __routeClone.status, headers: __hdrs }, __revalSecs, __routeTags); + __isrDebug?.("route cache written", __routeKey); + } catch (__cacheErr) { + console.error("[vinext] ISR route cache write error:", __cacheErr); + } + })(); + _getRequestExecutionContext()?.waitUntil(__routeWritePromise); + } const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); @@ -2334,9 +2516,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: route.pattern, routeType: "route" }, - ).catch((reportErr) => { - console.error("[vinext] Failed to report route handler error:", reportErr); - }); + ); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 500 })); } finally { setHeadersAccessPhase(previousHeadersPhase); @@ -2346,7 +2526,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 405, - headers: { Allow: exportedMethods.join(", ") }, })); } @@ -2466,60 +2645,57 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // user request — to prevent user-specific cookies/auth headers from leaking // into content that is cached and served to all subsequent users. const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalResult = await runWithHeadersContext(__revalHeadCtx, () => - _runWithNavigationContext(() => - _runWithCacheState(() => - _runWithPrivateCache(() => - runWithFetchCache(async () => { - setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); - const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); - const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); - const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); - // Tee RSC stream: one for SSR, one to capture rscData - const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); - // Capture rscData bytes in parallel with SSR - const __rscDataPromise = (async () => { - const __rscReader = __revalRscForCapture.getReader(); - const __rscChunks = []; - let __rscTotal = 0; - for (;;) { - const { done, value } = await __rscReader.read(); - if (done) break; - __rscChunks.push(value); - __rscTotal += value.byteLength; - } - const __rscBuf = new Uint8Array(__rscTotal); - let __rscOff = 0; - for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } - return __rscBuf.buffer; - })(); - const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; - const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); - const __freshRscData = await __rscDataPromise; - // RSC data must be fully consumed before headers are finalized, - // since async server components may append headers while streaming. - const __renderHeaders = consumeRenderResponseHeaders(); - setHeadersContext(null); - setNavigationContext(null); - // Collect the full HTML string from the stream - const __revalReader = __revalHtmlStream.getReader(); - const __revalDecoder = new TextDecoder(); - const __revalChunks = []; - for (;;) { - const { done, value } = await __revalReader.read(); - if (done) break; - __revalChunks.push(__revalDecoder.decode(value, { stream: true })); - } - __revalChunks.push(__revalDecoder.decode()); - const __freshHtml = __revalChunks.join(""); - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; - }) - ) - ) - ) - ); + const __revalUCtx = _createUnifiedCtx({ + headersContext: __revalHeadCtx, + executionContext: _getRequestExecutionContext(), + }); + const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { + _ensureFetchPatch(); + setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); + const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); + const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); + // Tee RSC stream: one for SSR, one to capture rscData + const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); + // Capture rscData bytes in parallel with SSR + const __rscDataPromise = (async () => { + const __rscReader = __revalRscForCapture.getReader(); + const __rscChunks = []; + let __rscTotal = 0; + for (;;) { + const { done, value } = await __rscReader.read(); + if (done) break; + __rscChunks.push(value); + __rscTotal += value.byteLength; + } + const __rscBuf = new Uint8Array(__rscTotal); + let __rscOff = 0; + for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } + return __rscBuf.buffer; + })(); + const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; + const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); + const __freshRscData = await __rscDataPromise; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. + const __renderHeaders = consumeRenderResponseHeaders(); + setHeadersContext(null); + setNavigationContext(null); + // Collect the full HTML string from the stream + const __revalReader = __revalHtmlStream.getReader(); + const __revalDecoder = new TextDecoder(); + const __revalChunks = []; + for (;;) { + const { done, value } = await __revalReader.read(); + if (done) break; + __revalChunks.push(__revalDecoder.decode(value, { stream: true })); + } + __revalChunks.push(__revalDecoder.decode()); + const __freshHtml = __revalChunks.join(""); + const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; + }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), @@ -2628,7 +2804,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError }); // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext + // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. return new Response(interceptStream, { headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -2862,7 +3038,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // NOTE: Do NOT clear headers/navigation context here! // The RSC stream is consumed lazily - components render when chunks are read. // If we clear context now, headers()/cookies() will fail during rendering. - // Context will be cleared when the next request starts (via runWithHeadersContext). + // Context will be cleared when the next request starts (via runWithRequestContext). const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; // Include matched route params so the client can hydrate useParams() if (params && Object.keys(params).length > 0) { @@ -3243,7 +3419,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; +import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; @@ -3253,19 +3429,38 @@ 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 { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; -import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; +import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; +import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; +import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; -import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. -import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; +import "vinext/navigation-state"; +import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; function _getSSRFontStyles() { return [..._getSSRFontStylesGoogle(), ..._getSSRFontStylesLocal()]; } function _getSSRFontPreloads() { return [..._getSSRFontPreloadsGoogle(), ..._getSSRFontPreloadsLocal()]; } + +// Duplicated from the build-time constant above via JSON.stringify. +const ROUTE_HANDLER_HTTP_METHODS = ["GET","HEAD","POST","PUT","DELETE","PATCH","OPTIONS"]; + +function collectRouteHandlerMethods(handler) { + const methods = ROUTE_HANDLER_HTTP_METHODS.filter((method) => typeof handler[method] === "function"); + if (methods.includes("GET") && !methods.includes("HEAD")) { + methods.push("HEAD"); + } + return methods; +} + +function buildRouteHandlerAllowHeader(exportedMethods) { + const allow = new Set(exportedMethods); + allow.add("OPTIONS"); + return Array.from(allow).sort().join(", "); +} + + // ALS used to suppress the expected "Invalid hook call" dev warning when // layout/page components are probed outside React's render cycle. Patching // console.error once at module load (instead of per-request) avoids the @@ -3376,6 +3571,20 @@ function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) { function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) { __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override"); } +function __copyResponseHeaders(targetHeaders, sourceHeaders, excludedKeys) { + const __excluded = new Set(excludedKeys.map((key) => key.toLowerCase())); + const __setCookies = sourceHeaders.getSetCookie(); + for (const [key, value] of sourceHeaders) { + const __lowerKey = key.toLowerCase(); + if (__excluded.has(__lowerKey) || __lowerKey === "set-cookie") continue; + targetHeaders.append(key, value); + } + if (!__excluded.has("set-cookie")) { + for (const cookie of __setCookies) { + targetHeaders.append("Set-Cookie", cookie); + } + } +} function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) { const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status; if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response; @@ -3438,6 +3647,7 @@ function __isrCacheKey(pathname, suffix) { } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } +function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators // have a single knob for all cache tracing. @@ -3590,9 +3800,7 @@ function rscOnError(error, requestInfo, errorContext) { error instanceof Error ? error : new Error(String(error)), requestInfo, errorContext, - ).catch((reportErr) => { - console.error("[vinext] Failed to report render error:", reportErr); - }); + ); } // In production, generate a digest hash for non-navigation errors @@ -3829,7 +4037,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. + // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. return new Response(rscStream, { status: statusCode, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -3962,7 +4170,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. + // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. return new Response(rscStream, { status: 200, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -4634,62 +4842,64 @@ async function __readFormDataWithLimit(request, maxBytes) { return new Response(combined, { headers: { "Content-Type": contentType } }).formData(); } +// Map from route pattern to generateStaticParams function. +// Used by the prerender phase to enumerate dynamic route URLs without +// loading route modules via the dev server. +export const generateStaticParamsMap = { +// TODO: layout-level generateStaticParams — this map only includes routes that +// have a pagePath (leaf pages). Layout segments can also export generateStaticParams +// to provide parent params for nested dynamic routes, but they don't have a pagePath +// so they are excluded here. Supporting layout-level generateStaticParams requires +// scanning layout.tsx files separately and including them in this map. + "/blog/:slug": mod_3?.generateStaticParams ?? null, +}; + export default async function handler(request, ctx) { - // 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 - // streaming), preventing state leakage between concurrent requests on - // Cloudflare Workers and other concurrent runtimes. - // - // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so - // that KVCacheHandler._putInBackground can register background KV puts with - // ctx.waitUntil() without needing ctx passed at construction time. + // Wrap the entire request in a single unified ALS scope for per-request + // isolation. All state modules (headers, navigation, cache, fetch-cache, + // execution-context) read from this store via isInsideUnifiedScope(). const headersCtx = headersContextFromRequest(request); - const _run = () => runWithHeadersContext(headersCtx, () => - _runWithNavigationContext(() => - _runWithCacheState(() => - _runWithPrivateCache(() => - runWithFetchCache(async () => { - const __reqCtx = requestContextFromRequest(request); - // 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); - // 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); - let pathname; - try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } - if (pathname.startsWith("/base")) pathname = pathname.slice("/base".length) || "/"; - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); - for (const h of extraHeaders) { - // Use append() for headers where multiple values must coexist - // (Vary, Set-Cookie). Using set() on these would destroy - // existing values like "Vary: RSC, Accept" which are critical - // for correct CDN caching behavior. - const lk = h.key.toLowerCase(); - if (lk === "vary" || lk === "set-cookie") { - response.headers.append(h.key, h.value); - } else if (!response.headers.has(lk)) { - // Middleware headers take precedence: skip config keys already - // set by middleware so middleware headers always win. - response.headers.set(h.key, h.value); - } - } - } - } - return response; - }) - ) - ) - ) - ); - return ctx ? _runWithExecutionContext(ctx, _run) : _run(); + const __uCtx = _createUnifiedCtx({ + headersContext: headersCtx, + executionContext: ctx ?? _getRequestExecutionContext() ?? null, + }); + return _runWithUnifiedCtx(__uCtx, async () => { + _ensureFetchPatch(); + const __reqCtx = requestContextFromRequest(request); + // 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); + // 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); + let pathname; + try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } + if (pathname.startsWith("/base")) pathname = pathname.slice("/base".length) || "/"; + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + for (const h of extraHeaders) { + // Use append() for headers where multiple values must coexist + // (Vary, Set-Cookie). Using set() on these would destroy + // existing values like "Vary: RSC, Accept" which are critical + // for correct CDN caching behavior. + const lk = h.key.toLowerCase(); + if (lk === "vary" || lk === "set-cookie") { + response.headers.append(h.key, h.value); + } else if (!response.headers.has(lk)) { + // Middleware headers take precedence: skip config keys already + // set by middleware so middleware headers always win. + response.headers.set(h.key, h.value); + } + } + } + } + return response; + }); } async function _handleRequest(request, __reqCtx, _mwCtx) { @@ -4727,6 +4937,38 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { pathname = stripBasePath(pathname, __basePath); + // ── Prerender: static-params endpoint ──────────────────────────────── + // Internal endpoint used by prerenderApp() during build to fetch + // generateStaticParams results via wrangler unstable_startWorker. + // Gated on VINEXT_PRERENDER=1 to prevent exposure in normal deployments. + // For Node builds, process.env.VINEXT_PRERENDER is set directly by the + // prerender orchestrator. For CF Workers builds, wrangler unstable_startWorker + // injects VINEXT_PRERENDER as a binding which Miniflare exposes via process.env + // in bundled workers. The /__vinext/ prefix ensures no user route ever conflicts. + if (pathname === "/__vinext/prerender/static-params") { + if (process.env.VINEXT_PRERENDER !== "1") { + return new Response("Not Found", { status: 404 }); + } + const pattern = url.searchParams.get("pattern"); + if (!pattern) return new Response("missing pattern", { status: 400 }); + const fn = generateStaticParamsMap[pattern]; + if (typeof fn !== "function") return new Response("null", { status: 200, headers: { "content-type": "application/json" } }); + try { + const parentParams = url.searchParams.get("parentParams"); + const raw = parentParams ? JSON.parse(parentParams) : {}; + // Ensure params is a plain object — reject primitives, arrays, and null + // so user-authored generateStaticParams always receives { params: {} } + // rather than { params: 5 } or similar if input is malformed. + const params = (typeof raw === "object" && raw !== null && !Array.isArray(raw)) ? raw : {}; + const result = await fn({ params }); + return new Response(JSON.stringify(result), { status: 200, headers: { "content-type": "application/json" } }); + } catch (e) { + return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers: { "content-type": "application/json" } }); + } + } + + + // Trailing slash normalization (redirect to canonical form) const __tsRedirect = normalizeTrailingSlash(pathname, __basePath, __trailingSlash, url.search); if (__tsRedirect) return __tsRedirect; @@ -4821,45 +5063,53 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Skip — the base servedUrl is not served when generateSitemaps exists continue; } - if (cleanPathname === metaRoute.servedUrl) { - if (metaRoute.isDynamic) { - // Dynamic metadata route — call the default export and serialize - const metaFn = metaRoute.module.default; - if (typeof metaFn === "function") { - const result = await metaFn(); - let body; - // If it's already a Response (e.g., ImageResponse), return directly - if (result instanceof Response) return result; - // Serialize based on type - if (metaRoute.type === "sitemap") body = sitemapToXml(result); - else if (metaRoute.type === "robots") body = robotsToText(result); - else if (metaRoute.type === "manifest") body = manifestToJson(result); - else body = JSON.stringify(result); - return new Response(body, { - headers: { "Content-Type": metaRoute.contentType }, - }); - } - } else { - // Static metadata file — decode from embedded base64 data - try { - const binary = atob(metaRoute.fileDataBase64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new Response(bytes, { - headers: { - "Content-Type": metaRoute.contentType, - "Cache-Control": "public, max-age=0, must-revalidate", - }, - }); - } catch { - return new Response("Not Found", { status: 404 }); - } + // Match metadata route — use pattern matching for dynamic segments, + // strict equality for static paths. + var _metaParams = null; + if (metaRoute.patternParts) { + var _metaUrlParts = cleanPathname.split("/").filter(Boolean); + _metaParams = matchPattern(_metaUrlParts, metaRoute.patternParts); + if (!_metaParams) continue; + } else if (cleanPathname !== metaRoute.servedUrl) { + continue; + } + if (metaRoute.isDynamic) { + // Dynamic metadata route — call the default export and serialize + const metaFn = metaRoute.module.default; + if (typeof metaFn === "function") { + const result = await metaFn({ params: makeThenableParams(_metaParams || {}) }); + let body; + // If it's already a Response (e.g., ImageResponse), return directly + if (result instanceof Response) return result; + // Serialize based on type + if (metaRoute.type === "sitemap") body = sitemapToXml(result); + else if (metaRoute.type === "robots") body = robotsToText(result); + else if (metaRoute.type === "manifest") body = manifestToJson(result); + else body = JSON.stringify(result); + return new Response(body, { + headers: { "Content-Type": metaRoute.contentType }, + }); + } + } else { + // Static metadata file — decode from embedded base64 data + try { + const binary = atob(metaRoute.fileDataBase64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new Response(bytes, { + headers: { + "Content-Type": metaRoute.contentType, + "Cache-Control": "public, max-age=0, must-revalidate", + }, + }); + } catch { + return new Response("Not Found", { status: 404 }); } } } // Set navigation context for Server Components. - // Note: Headers context is already set by runWithHeadersContext in the handler wrapper. + // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, @@ -4994,7 +5244,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Collect cookies set during the action synchronously (before stream is consumed). // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext + // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. const actionRenderHeaders = consumeRenderResponseHeaders(); @@ -5010,9 +5260,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: cleanPathname, routeType: "action" }, - ).catch((reportErr) => { - console.error("[vinext] Failed to report server action error:", reportErr); - }); + ); setHeadersContext(null); setNavigationContext(null); return new Response( @@ -5054,6 +5302,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (!match) { + // Render custom not-found page if available, otherwise plain 404 const notFoundResponse = await renderNotFoundPage(null, isRscRequest, request); if (notFoundResponse) return notFoundResponse; @@ -5075,16 +5324,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (route.routeHandler) { const handler = route.routeHandler; const method = request.method.toUpperCase(); - const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 ? handler.revalidate : null; + const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 && handler.revalidate !== Infinity ? handler.revalidate : null; + if (typeof handler["default"] === "function" && process.env.NODE_ENV === "development") { + console.error( + "[vinext] Detected default export in route handler " + route.pattern + ". Export a named export for each HTTP method instead.", + ); + } // Collect exported HTTP methods for OPTIONS auto-response and Allow header - const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; - const exportedMethods = HTTP_METHODS.filter((m) => typeof handler[m] === "function"); - // If GET is exported, HEAD is implicitly supported - if (exportedMethods.includes("GET") && !exportedMethods.includes("HEAD")) { - exportedMethods.push("HEAD"); - } - const hasDefault = typeof handler["default"] === "function"; + const exportedMethods = collectRouteHandlerMethods(handler); + const allowHeaderForOptions = buildRouteHandlerAllowHeader(exportedMethods); // Route handlers need the same middleware header/status merge behavior as // page responses. This keeps middleware response headers visible on API @@ -5107,29 +5356,107 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // OPTIONS auto-implementation: respond with Allow header and 204 if (method === "OPTIONS" && typeof handler["OPTIONS"] !== "function") { - const allowMethods = hasDefault ? HTTP_METHODS : exportedMethods; - if (!allowMethods.includes("OPTIONS")) allowMethods.push("OPTIONS"); setHeadersContext(null); setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 204, - headers: { "Allow": allowMethods.join(", ") }, + headers: { "Allow": allowHeaderForOptions }, })); } // HEAD auto-implementation: run GET handler and strip body - let handlerFn = handler[method] || handler["default"]; + let handlerFn = handler[method]; let isAutoHead = false; if (method === "HEAD" && typeof handler["HEAD"] !== "function" && typeof handler["GET"] === "function") { handlerFn = handler["GET"]; isAutoHead = true; } + // ISR cache read for route handlers (production only). + // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. + // This runs before handler execution so a cache HIT skips the handler entirely. + if ( + process.env.NODE_ENV === "production" && + revalidateSeconds !== null && + handler.dynamic !== "force-dynamic" && + (method === "GET" || isAutoHead) && + typeof handlerFn === "function" + ) { + const __routeKey = __isrRouteKey(cleanPathname); + try { + const __cached = await __isrGet(__routeKey); + if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { + // HIT — return cached response immediately + const __cv = __cached.value.value; + __isrDebug?.("HIT (route)", cleanPathname); + setHeadersContext(null); + setNavigationContext(null); + const __hitHeaders = Object.assign({}, __cv.headers || {}); + __hitHeaders["X-Vinext-Cache"] = "HIT"; + __hitHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; + if (isAutoHead) { + return attachRouteHandlerMiddlewareContext(new Response(null, { status: __cv.status, headers: __hitHeaders })); + } + return attachRouteHandlerMiddlewareContext(new Response(__cv.body, { status: __cv.status, headers: __hitHeaders })); + } + if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { + // STALE — serve stale response, trigger background regeneration + const __sv = __cached.value.value; + const __revalSecs = revalidateSeconds; + const __revalHandlerFn = handlerFn; + const __revalParams = params; + const __revalUrl = request.url; + const __revalSearchParams = new URLSearchParams(url.searchParams); + __triggerBackgroundRegeneration(__routeKey, async function() { + const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; + const __revalUCtx = _createUnifiedCtx({ + headersContext: __revalHeadCtx, + executionContext: _getRequestExecutionContext(), + }); + await _runWithUnifiedCtx(__revalUCtx, async () => { + _ensureFetchPatch(); + setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); + const __syntheticReq = new Request(__revalUrl, { method: "GET" }); + const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); + const __regenDynamic = consumeDynamicUsage(); + setNavigationContext(null); + if (__regenDynamic) { + __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); + return; + } + const __freshBody = await __revalResponse.arrayBuffer(); + const __freshHeaders = {}; + __revalResponse.headers.forEach(function(v, k) { + if (k !== "x-vinext-cache" && k !== "cache-control") __freshHeaders[k] = v; + }); + const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __freshBody, status: __revalResponse.status, headers: __freshHeaders }, __revalSecs, __routeTags); + __isrDebug?.("route regen complete", __routeKey); + }); + }); + __isrDebug?.("STALE (route)", cleanPathname); + setHeadersContext(null); + setNavigationContext(null); + const __staleHeaders = Object.assign({}, __sv.headers || {}); + __staleHeaders["X-Vinext-Cache"] = "STALE"; + __staleHeaders["Cache-Control"] = "s-maxage=0, stale-while-revalidate"; + if (isAutoHead) { + return attachRouteHandlerMiddlewareContext(new Response(null, { status: __sv.status, headers: __staleHeaders })); + } + return attachRouteHandlerMiddlewareContext(new Response(__sv.body, { status: __sv.status, headers: __staleHeaders })); + } + } catch (__routeCacheErr) { + // Cache read failure — fall through to normal handler execution + console.error("[vinext] ISR route cache read error:", __routeCacheErr); + } + } + if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); + const handlerSetCacheControl = response.headers.has("cache-control"); // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, @@ -5138,11 +5465,42 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { revalidateSeconds !== null && !dynamicUsedInHandler && (method === "GET" || isAutoHead) && - !response.headers.has("cache-control") + !handlerSetCacheControl ) { response.headers.set("cache-control", "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"); } + // ISR cache write for route handlers (production, MISS). + // Store the raw handler response before cookie/middleware transforms + // (those are request-specific and shouldn't be cached). + if ( + process.env.NODE_ENV === "production" && + revalidateSeconds !== null && + handler.dynamic !== "force-dynamic" && + !dynamicUsedInHandler && + (method === "GET" || isAutoHead) && + !handlerSetCacheControl + ) { + response.headers.set("X-Vinext-Cache", "MISS"); + const __routeClone = response.clone(); + const __routeKey = __isrRouteKey(cleanPathname); + const __revalSecs = revalidateSeconds; + const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __routeWritePromise = (async () => { + try { + const __buf = await __routeClone.arrayBuffer(); + const __hdrs = {}; + __routeClone.headers.forEach(function(v, k) { + if (k !== "x-vinext-cache" && k !== "cache-control") __hdrs[k] = v; + }); + await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __buf, status: __routeClone.status, headers: __hdrs }, __revalSecs, __routeTags); + __isrDebug?.("route cache written", __routeKey); + } catch (__cacheErr) { + console.error("[vinext] ISR route cache write error:", __cacheErr); + } + })(); + _getRequestExecutionContext()?.waitUntil(__routeWritePromise); + } const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); @@ -5206,9 +5564,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: route.pattern, routeType: "route" }, - ).catch((reportErr) => { - console.error("[vinext] Failed to report route handler error:", reportErr); - }); + ); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 500 })); } finally { setHeadersAccessPhase(previousHeadersPhase); @@ -5218,7 +5574,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 405, - headers: { Allow: exportedMethods.join(", ") }, })); } @@ -5338,60 +5693,57 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // user request — to prevent user-specific cookies/auth headers from leaking // into content that is cached and served to all subsequent users. const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalResult = await runWithHeadersContext(__revalHeadCtx, () => - _runWithNavigationContext(() => - _runWithCacheState(() => - _runWithPrivateCache(() => - runWithFetchCache(async () => { - setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); - const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); - const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); - const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); - // Tee RSC stream: one for SSR, one to capture rscData - const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); - // Capture rscData bytes in parallel with SSR - const __rscDataPromise = (async () => { - const __rscReader = __revalRscForCapture.getReader(); - const __rscChunks = []; - let __rscTotal = 0; - for (;;) { - const { done, value } = await __rscReader.read(); - if (done) break; - __rscChunks.push(value); - __rscTotal += value.byteLength; - } - const __rscBuf = new Uint8Array(__rscTotal); - let __rscOff = 0; - for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } - return __rscBuf.buffer; - })(); - const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; - const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); - const __freshRscData = await __rscDataPromise; - // RSC data must be fully consumed before headers are finalized, - // since async server components may append headers while streaming. - const __renderHeaders = consumeRenderResponseHeaders(); - setHeadersContext(null); - setNavigationContext(null); - // Collect the full HTML string from the stream - const __revalReader = __revalHtmlStream.getReader(); - const __revalDecoder = new TextDecoder(); - const __revalChunks = []; - for (;;) { - const { done, value } = await __revalReader.read(); - if (done) break; - __revalChunks.push(__revalDecoder.decode(value, { stream: true })); - } - __revalChunks.push(__revalDecoder.decode()); - const __freshHtml = __revalChunks.join(""); - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; - }) - ) - ) - ) - ); + const __revalUCtx = _createUnifiedCtx({ + headersContext: __revalHeadCtx, + executionContext: _getRequestExecutionContext(), + }); + const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { + _ensureFetchPatch(); + setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); + const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); + const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); + // Tee RSC stream: one for SSR, one to capture rscData + const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); + // Capture rscData bytes in parallel with SSR + const __rscDataPromise = (async () => { + const __rscReader = __revalRscForCapture.getReader(); + const __rscChunks = []; + let __rscTotal = 0; + for (;;) { + const { done, value } = await __rscReader.read(); + if (done) break; + __rscChunks.push(value); + __rscTotal += value.byteLength; + } + const __rscBuf = new Uint8Array(__rscTotal); + let __rscOff = 0; + for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } + return __rscBuf.buffer; + })(); + const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; + const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); + const __freshRscData = await __rscDataPromise; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. + const __renderHeaders = consumeRenderResponseHeaders(); + setHeadersContext(null); + setNavigationContext(null); + // Collect the full HTML string from the stream + const __revalReader = __revalHtmlStream.getReader(); + const __revalDecoder = new TextDecoder(); + const __revalChunks = []; + for (;;) { + const { done, value } = await __revalReader.read(); + if (done) break; + __revalChunks.push(__revalDecoder.decode(value, { stream: true })); + } + __revalChunks.push(__revalDecoder.decode()); + const __freshHtml = __revalChunks.join(""); + const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; + }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), @@ -5500,7 +5852,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError }); // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext + // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. return new Response(interceptStream, { headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -5734,7 +6086,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // NOTE: Do NOT clear headers/navigation context here! // The RSC stream is consumed lazily - components render when chunks are read. // If we clear context now, headers()/cookies() will fail during rendering. - // Context will be cleared when the next request starts (via runWithHeadersContext). + // Context will be cleared when the next request starts (via runWithRequestContext). const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; // Include matched route params so the client can hydrate useParams() if (params && Object.keys(params).length > 0) { @@ -6115,7 +6467,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; +import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; @@ -6125,19 +6477,38 @@ 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 { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; -import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; +import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; +import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; +import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; -import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. -import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; +import "vinext/navigation-state"; +import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; function _getSSRFontStyles() { return [..._getSSRFontStylesGoogle(), ..._getSSRFontStylesLocal()]; } function _getSSRFontPreloads() { return [..._getSSRFontPreloadsGoogle(), ..._getSSRFontPreloadsLocal()]; } + +// Duplicated from the build-time constant above via JSON.stringify. +const ROUTE_HANDLER_HTTP_METHODS = ["GET","HEAD","POST","PUT","DELETE","PATCH","OPTIONS"]; + +function collectRouteHandlerMethods(handler) { + const methods = ROUTE_HANDLER_HTTP_METHODS.filter((method) => typeof handler[method] === "function"); + if (methods.includes("GET") && !methods.includes("HEAD")) { + methods.push("HEAD"); + } + return methods; +} + +function buildRouteHandlerAllowHeader(exportedMethods) { + const allow = new Set(exportedMethods); + allow.add("OPTIONS"); + return Array.from(allow).sort().join(", "); +} + + // ALS used to suppress the expected "Invalid hook call" dev warning when // layout/page components are probed outside React's render cycle. Patching // console.error once at module load (instead of per-request) avoids the @@ -6248,6 +6619,20 @@ function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) { function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) { __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override"); } +function __copyResponseHeaders(targetHeaders, sourceHeaders, excludedKeys) { + const __excluded = new Set(excludedKeys.map((key) => key.toLowerCase())); + const __setCookies = sourceHeaders.getSetCookie(); + for (const [key, value] of sourceHeaders) { + const __lowerKey = key.toLowerCase(); + if (__excluded.has(__lowerKey) || __lowerKey === "set-cookie") continue; + targetHeaders.append(key, value); + } + if (!__excluded.has("set-cookie")) { + for (const cookie of __setCookies) { + targetHeaders.append("Set-Cookie", cookie); + } + } +} function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) { const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status; if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response; @@ -6310,6 +6695,7 @@ function __isrCacheKey(pathname, suffix) { } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } +function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators // have a single knob for all cache tracing. @@ -6462,9 +6848,7 @@ function rscOnError(error, requestInfo, errorContext) { error instanceof Error ? error : new Error(String(error)), requestInfo, errorContext, - ).catch((reportErr) => { - console.error("[vinext] Failed to report render error:", reportErr); - }); + ); } // In production, generate a digest hash for non-navigation errors @@ -6710,7 +7094,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. + // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. return new Response(rscStream, { status: statusCode, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -6856,7 +7240,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. + // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. return new Response(rscStream, { status: 200, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -7536,68 +7920,70 @@ async function __readFormDataWithLimit(request, maxBytes) { return new Response(combined, { headers: { "Content-Type": contentType } }).formData(); } +// Map from route pattern to generateStaticParams function. +// Used by the prerender phase to enumerate dynamic route URLs without +// loading route modules via the dev server. +export const generateStaticParamsMap = { +// TODO: layout-level generateStaticParams — this map only includes routes that +// have a pagePath (leaf pages). Layout segments can also export generateStaticParams +// to provide parent params for nested dynamic routes, but they don't have a pagePath +// so they are excluded here. Supporting layout-level generateStaticParams requires +// scanning layout.tsx files separately and including them in this map. + "/blog/:slug": mod_3?.generateStaticParams ?? null, +}; + export default async function handler(request, ctx) { - // 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 - // streaming), preventing state leakage between concurrent requests on - // Cloudflare Workers and other concurrent runtimes. - // - // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so - // that KVCacheHandler._putInBackground can register background KV puts with - // ctx.waitUntil() without needing ctx passed at construction time. + // Wrap the entire request in a single unified ALS scope for per-request + // isolation. All state modules (headers, navigation, cache, fetch-cache, + // execution-context) read from this store via isInsideUnifiedScope(). const headersCtx = headersContextFromRequest(request); - const _run = () => runWithHeadersContext(headersCtx, () => - _runWithNavigationContext(() => - _runWithCacheState(() => - _runWithPrivateCache(() => - runWithFetchCache(async () => { - const __reqCtx = requestContextFromRequest(request); - // 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); - // 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); - let pathname; - try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } - - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); - for (const h of extraHeaders) { - // Use append() for headers where multiple values must coexist - // (Vary, Set-Cookie). Using set() on these would destroy - // existing values like "Vary: RSC, Accept" which are critical - // for correct CDN caching behavior. - const lk = h.key.toLowerCase(); - if (lk === "vary" || lk === "set-cookie") { - response.headers.append(h.key, h.value); - } else if (!response.headers.has(lk)) { - // Middleware headers take precedence: skip config keys already - // set by middleware so middleware headers always win. - response.headers.set(h.key, h.value); - } - } - } - } - return response; - }) - ) - ) - ) - ); - return ctx ? _runWithExecutionContext(ctx, _run) : _run(); -} - -async function _handleRequest(request, __reqCtx, _mwCtx) { - const __reqStart = process.env.NODE_ENV !== "production" ? performance.now() : 0; - let __compileEnd; - let __renderEnd; + const __uCtx = _createUnifiedCtx({ + headersContext: headersCtx, + executionContext: ctx ?? _getRequestExecutionContext() ?? null, + }); + return _runWithUnifiedCtx(__uCtx, async () => { + _ensureFetchPatch(); + const __reqCtx = requestContextFromRequest(request); + // 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); + // 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); + let pathname; + try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } + + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + for (const h of extraHeaders) { + // Use append() for headers where multiple values must coexist + // (Vary, Set-Cookie). Using set() on these would destroy + // existing values like "Vary: RSC, Accept" which are critical + // for correct CDN caching behavior. + const lk = h.key.toLowerCase(); + if (lk === "vary" || lk === "set-cookie") { + response.headers.append(h.key, h.value); + } else if (!response.headers.has(lk)) { + // Middleware headers take precedence: skip config keys already + // set by middleware so middleware headers always win. + response.headers.set(h.key, h.value); + } + } + } + } + return response; + }); +} + +async function _handleRequest(request, __reqCtx, _mwCtx) { + const __reqStart = process.env.NODE_ENV !== "production" ? performance.now() : 0; + let __compileEnd; + let __renderEnd; // __reqStart is included in the timing header so the Node logging middleware // can compute true compile time as: handlerStart - middlewareStart. // Format: "handlerStart,compileMs,renderMs" - all as integers (ms). Dev-only. @@ -7626,6 +8012,38 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { + // ── Prerender: static-params endpoint ──────────────────────────────── + // Internal endpoint used by prerenderApp() during build to fetch + // generateStaticParams results via wrangler unstable_startWorker. + // Gated on VINEXT_PRERENDER=1 to prevent exposure in normal deployments. + // For Node builds, process.env.VINEXT_PRERENDER is set directly by the + // prerender orchestrator. For CF Workers builds, wrangler unstable_startWorker + // injects VINEXT_PRERENDER as a binding which Miniflare exposes via process.env + // in bundled workers. The /__vinext/ prefix ensures no user route ever conflicts. + if (pathname === "/__vinext/prerender/static-params") { + if (process.env.VINEXT_PRERENDER !== "1") { + return new Response("Not Found", { status: 404 }); + } + const pattern = url.searchParams.get("pattern"); + if (!pattern) return new Response("missing pattern", { status: 400 }); + const fn = generateStaticParamsMap[pattern]; + if (typeof fn !== "function") return new Response("null", { status: 200, headers: { "content-type": "application/json" } }); + try { + const parentParams = url.searchParams.get("parentParams"); + const raw = parentParams ? JSON.parse(parentParams) : {}; + // Ensure params is a plain object — reject primitives, arrays, and null + // so user-authored generateStaticParams always receives { params: {} } + // rather than { params: 5 } or similar if input is malformed. + const params = (typeof raw === "object" && raw !== null && !Array.isArray(raw)) ? raw : {}; + const result = await fn({ params }); + return new Response(JSON.stringify(result), { status: 200, headers: { "content-type": "application/json" } }); + } catch (e) { + return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers: { "content-type": "application/json" } }); + } + } + + + // Trailing slash normalization (redirect to canonical form) const __tsRedirect = normalizeTrailingSlash(pathname, __basePath, __trailingSlash, url.search); if (__tsRedirect) return __tsRedirect; @@ -7720,45 +8138,53 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Skip — the base servedUrl is not served when generateSitemaps exists continue; } - if (cleanPathname === metaRoute.servedUrl) { - if (metaRoute.isDynamic) { - // Dynamic metadata route — call the default export and serialize - const metaFn = metaRoute.module.default; - if (typeof metaFn === "function") { - const result = await metaFn(); - let body; - // If it's already a Response (e.g., ImageResponse), return directly - if (result instanceof Response) return result; - // Serialize based on type - if (metaRoute.type === "sitemap") body = sitemapToXml(result); - else if (metaRoute.type === "robots") body = robotsToText(result); - else if (metaRoute.type === "manifest") body = manifestToJson(result); - else body = JSON.stringify(result); - return new Response(body, { - headers: { "Content-Type": metaRoute.contentType }, - }); - } - } else { - // Static metadata file — decode from embedded base64 data - try { - const binary = atob(metaRoute.fileDataBase64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new Response(bytes, { - headers: { - "Content-Type": metaRoute.contentType, - "Cache-Control": "public, max-age=0, must-revalidate", - }, - }); - } catch { - return new Response("Not Found", { status: 404 }); - } + // Match metadata route — use pattern matching for dynamic segments, + // strict equality for static paths. + var _metaParams = null; + if (metaRoute.patternParts) { + var _metaUrlParts = cleanPathname.split("/").filter(Boolean); + _metaParams = matchPattern(_metaUrlParts, metaRoute.patternParts); + if (!_metaParams) continue; + } else if (cleanPathname !== metaRoute.servedUrl) { + continue; + } + if (metaRoute.isDynamic) { + // Dynamic metadata route — call the default export and serialize + const metaFn = metaRoute.module.default; + if (typeof metaFn === "function") { + const result = await metaFn({ params: makeThenableParams(_metaParams || {}) }); + let body; + // If it's already a Response (e.g., ImageResponse), return directly + if (result instanceof Response) return result; + // Serialize based on type + if (metaRoute.type === "sitemap") body = sitemapToXml(result); + else if (metaRoute.type === "robots") body = robotsToText(result); + else if (metaRoute.type === "manifest") body = manifestToJson(result); + else body = JSON.stringify(result); + return new Response(body, { + headers: { "Content-Type": metaRoute.contentType }, + }); + } + } else { + // Static metadata file — decode from embedded base64 data + try { + const binary = atob(metaRoute.fileDataBase64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new Response(bytes, { + headers: { + "Content-Type": metaRoute.contentType, + "Cache-Control": "public, max-age=0, must-revalidate", + }, + }); + } catch { + return new Response("Not Found", { status: 404 }); } } } // Set navigation context for Server Components. - // Note: Headers context is already set by runWithHeadersContext in the handler wrapper. + // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, @@ -7893,7 +8319,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Collect cookies set during the action synchronously (before stream is consumed). // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext + // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. const actionRenderHeaders = consumeRenderResponseHeaders(); @@ -7909,9 +8335,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: cleanPathname, routeType: "action" }, - ).catch((reportErr) => { - console.error("[vinext] Failed to report server action error:", reportErr); - }); + ); setHeadersContext(null); setNavigationContext(null); return new Response( @@ -7953,6 +8377,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (!match) { + // Render custom not-found page if available, otherwise plain 404 const notFoundResponse = await renderNotFoundPage(null, isRscRequest, request); if (notFoundResponse) return notFoundResponse; @@ -7974,16 +8399,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (route.routeHandler) { const handler = route.routeHandler; const method = request.method.toUpperCase(); - const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 ? handler.revalidate : null; + const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 && handler.revalidate !== Infinity ? handler.revalidate : null; + if (typeof handler["default"] === "function" && process.env.NODE_ENV === "development") { + console.error( + "[vinext] Detected default export in route handler " + route.pattern + ". Export a named export for each HTTP method instead.", + ); + } // Collect exported HTTP methods for OPTIONS auto-response and Allow header - const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; - const exportedMethods = HTTP_METHODS.filter((m) => typeof handler[m] === "function"); - // If GET is exported, HEAD is implicitly supported - if (exportedMethods.includes("GET") && !exportedMethods.includes("HEAD")) { - exportedMethods.push("HEAD"); - } - const hasDefault = typeof handler["default"] === "function"; + const exportedMethods = collectRouteHandlerMethods(handler); + const allowHeaderForOptions = buildRouteHandlerAllowHeader(exportedMethods); // Route handlers need the same middleware header/status merge behavior as // page responses. This keeps middleware response headers visible on API @@ -8006,29 +8431,107 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // OPTIONS auto-implementation: respond with Allow header and 204 if (method === "OPTIONS" && typeof handler["OPTIONS"] !== "function") { - const allowMethods = hasDefault ? HTTP_METHODS : exportedMethods; - if (!allowMethods.includes("OPTIONS")) allowMethods.push("OPTIONS"); setHeadersContext(null); setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 204, - headers: { "Allow": allowMethods.join(", ") }, + headers: { "Allow": allowHeaderForOptions }, })); } // HEAD auto-implementation: run GET handler and strip body - let handlerFn = handler[method] || handler["default"]; + let handlerFn = handler[method]; let isAutoHead = false; if (method === "HEAD" && typeof handler["HEAD"] !== "function" && typeof handler["GET"] === "function") { handlerFn = handler["GET"]; isAutoHead = true; } + // ISR cache read for route handlers (production only). + // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. + // This runs before handler execution so a cache HIT skips the handler entirely. + if ( + process.env.NODE_ENV === "production" && + revalidateSeconds !== null && + handler.dynamic !== "force-dynamic" && + (method === "GET" || isAutoHead) && + typeof handlerFn === "function" + ) { + const __routeKey = __isrRouteKey(cleanPathname); + try { + const __cached = await __isrGet(__routeKey); + if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { + // HIT — return cached response immediately + const __cv = __cached.value.value; + __isrDebug?.("HIT (route)", cleanPathname); + setHeadersContext(null); + setNavigationContext(null); + const __hitHeaders = Object.assign({}, __cv.headers || {}); + __hitHeaders["X-Vinext-Cache"] = "HIT"; + __hitHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; + if (isAutoHead) { + return attachRouteHandlerMiddlewareContext(new Response(null, { status: __cv.status, headers: __hitHeaders })); + } + return attachRouteHandlerMiddlewareContext(new Response(__cv.body, { status: __cv.status, headers: __hitHeaders })); + } + if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { + // STALE — serve stale response, trigger background regeneration + const __sv = __cached.value.value; + const __revalSecs = revalidateSeconds; + const __revalHandlerFn = handlerFn; + const __revalParams = params; + const __revalUrl = request.url; + const __revalSearchParams = new URLSearchParams(url.searchParams); + __triggerBackgroundRegeneration(__routeKey, async function() { + const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; + const __revalUCtx = _createUnifiedCtx({ + headersContext: __revalHeadCtx, + executionContext: _getRequestExecutionContext(), + }); + await _runWithUnifiedCtx(__revalUCtx, async () => { + _ensureFetchPatch(); + setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); + const __syntheticReq = new Request(__revalUrl, { method: "GET" }); + const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); + const __regenDynamic = consumeDynamicUsage(); + setNavigationContext(null); + if (__regenDynamic) { + __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); + return; + } + const __freshBody = await __revalResponse.arrayBuffer(); + const __freshHeaders = {}; + __revalResponse.headers.forEach(function(v, k) { + if (k !== "x-vinext-cache" && k !== "cache-control") __freshHeaders[k] = v; + }); + const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __freshBody, status: __revalResponse.status, headers: __freshHeaders }, __revalSecs, __routeTags); + __isrDebug?.("route regen complete", __routeKey); + }); + }); + __isrDebug?.("STALE (route)", cleanPathname); + setHeadersContext(null); + setNavigationContext(null); + const __staleHeaders = Object.assign({}, __sv.headers || {}); + __staleHeaders["X-Vinext-Cache"] = "STALE"; + __staleHeaders["Cache-Control"] = "s-maxage=0, stale-while-revalidate"; + if (isAutoHead) { + return attachRouteHandlerMiddlewareContext(new Response(null, { status: __sv.status, headers: __staleHeaders })); + } + return attachRouteHandlerMiddlewareContext(new Response(__sv.body, { status: __sv.status, headers: __staleHeaders })); + } + } catch (__routeCacheErr) { + // Cache read failure — fall through to normal handler execution + console.error("[vinext] ISR route cache read error:", __routeCacheErr); + } + } + if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); + const handlerSetCacheControl = response.headers.has("cache-control"); // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, @@ -8037,11 +8540,42 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { revalidateSeconds !== null && !dynamicUsedInHandler && (method === "GET" || isAutoHead) && - !response.headers.has("cache-control") + !handlerSetCacheControl ) { response.headers.set("cache-control", "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"); } + // ISR cache write for route handlers (production, MISS). + // Store the raw handler response before cookie/middleware transforms + // (those are request-specific and shouldn't be cached). + if ( + process.env.NODE_ENV === "production" && + revalidateSeconds !== null && + handler.dynamic !== "force-dynamic" && + !dynamicUsedInHandler && + (method === "GET" || isAutoHead) && + !handlerSetCacheControl + ) { + response.headers.set("X-Vinext-Cache", "MISS"); + const __routeClone = response.clone(); + const __routeKey = __isrRouteKey(cleanPathname); + const __revalSecs = revalidateSeconds; + const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __routeWritePromise = (async () => { + try { + const __buf = await __routeClone.arrayBuffer(); + const __hdrs = {}; + __routeClone.headers.forEach(function(v, k) { + if (k !== "x-vinext-cache" && k !== "cache-control") __hdrs[k] = v; + }); + await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __buf, status: __routeClone.status, headers: __hdrs }, __revalSecs, __routeTags); + __isrDebug?.("route cache written", __routeKey); + } catch (__cacheErr) { + console.error("[vinext] ISR route cache write error:", __cacheErr); + } + })(); + _getRequestExecutionContext()?.waitUntil(__routeWritePromise); + } const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); @@ -8105,9 +8639,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: route.pattern, routeType: "route" }, - ).catch((reportErr) => { - console.error("[vinext] Failed to report route handler error:", reportErr); - }); + ); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 500 })); } finally { setHeadersAccessPhase(previousHeadersPhase); @@ -8117,7 +8649,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 405, - headers: { Allow: exportedMethods.join(", ") }, })); } @@ -8237,60 +8768,57 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // user request — to prevent user-specific cookies/auth headers from leaking // into content that is cached and served to all subsequent users. const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalResult = await runWithHeadersContext(__revalHeadCtx, () => - _runWithNavigationContext(() => - _runWithCacheState(() => - _runWithPrivateCache(() => - runWithFetchCache(async () => { - setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); - const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); - const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); - const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); - // Tee RSC stream: one for SSR, one to capture rscData - const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); - // Capture rscData bytes in parallel with SSR - const __rscDataPromise = (async () => { - const __rscReader = __revalRscForCapture.getReader(); - const __rscChunks = []; - let __rscTotal = 0; - for (;;) { - const { done, value } = await __rscReader.read(); - if (done) break; - __rscChunks.push(value); - __rscTotal += value.byteLength; - } - const __rscBuf = new Uint8Array(__rscTotal); - let __rscOff = 0; - for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } - return __rscBuf.buffer; - })(); - const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; - const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); - const __freshRscData = await __rscDataPromise; - // RSC data must be fully consumed before headers are finalized, - // since async server components may append headers while streaming. - const __renderHeaders = consumeRenderResponseHeaders(); - setHeadersContext(null); - setNavigationContext(null); - // Collect the full HTML string from the stream - const __revalReader = __revalHtmlStream.getReader(); - const __revalDecoder = new TextDecoder(); - const __revalChunks = []; - for (;;) { - const { done, value } = await __revalReader.read(); - if (done) break; - __revalChunks.push(__revalDecoder.decode(value, { stream: true })); - } - __revalChunks.push(__revalDecoder.decode()); - const __freshHtml = __revalChunks.join(""); - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; - }) - ) - ) - ) - ); + const __revalUCtx = _createUnifiedCtx({ + headersContext: __revalHeadCtx, + executionContext: _getRequestExecutionContext(), + }); + const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { + _ensureFetchPatch(); + setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); + const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); + const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); + // Tee RSC stream: one for SSR, one to capture rscData + const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); + // Capture rscData bytes in parallel with SSR + const __rscDataPromise = (async () => { + const __rscReader = __revalRscForCapture.getReader(); + const __rscChunks = []; + let __rscTotal = 0; + for (;;) { + const { done, value } = await __rscReader.read(); + if (done) break; + __rscChunks.push(value); + __rscTotal += value.byteLength; + } + const __rscBuf = new Uint8Array(__rscTotal); + let __rscOff = 0; + for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } + return __rscBuf.buffer; + })(); + const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; + const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); + const __freshRscData = await __rscDataPromise; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. + const __renderHeaders = consumeRenderResponseHeaders(); + setHeadersContext(null); + setNavigationContext(null); + // Collect the full HTML string from the stream + const __revalReader = __revalHtmlStream.getReader(); + const __revalDecoder = new TextDecoder(); + const __revalChunks = []; + for (;;) { + const { done, value } = await __revalReader.read(); + if (done) break; + __revalChunks.push(__revalDecoder.decode(value, { stream: true })); + } + __revalChunks.push(__revalDecoder.decode()); + const __freshHtml = __revalChunks.join(""); + const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; + }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), @@ -8399,7 +8927,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError }); // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext + // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. return new Response(interceptStream, { headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -8633,7 +9161,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // NOTE: Do NOT clear headers/navigation context here! // The RSC stream is consumed lazily - components render when chunks are read. // If we clear context now, headers()/cookies() will fail during rendering. - // Context will be cleared when the next request starts (via runWithHeadersContext). + // Context will be cleared when the next request starts (via runWithRequestContext). const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; // Include matched route params so the client can hydrate useParams() if (params && Object.keys(params).length > 0) { @@ -9029,7 +9557,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; +import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; @@ -9039,19 +9567,38 @@ 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 { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; -import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; +import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; +import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; +import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; -import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. -import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; +import "vinext/navigation-state"; +import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; function _getSSRFontStyles() { return [..._getSSRFontStylesGoogle(), ..._getSSRFontStylesLocal()]; } function _getSSRFontPreloads() { return [..._getSSRFontPreloadsGoogle(), ..._getSSRFontPreloadsLocal()]; } + +// Duplicated from the build-time constant above via JSON.stringify. +const ROUTE_HANDLER_HTTP_METHODS = ["GET","HEAD","POST","PUT","DELETE","PATCH","OPTIONS"]; + +function collectRouteHandlerMethods(handler) { + const methods = ROUTE_HANDLER_HTTP_METHODS.filter((method) => typeof handler[method] === "function"); + if (methods.includes("GET") && !methods.includes("HEAD")) { + methods.push("HEAD"); + } + return methods; +} + +function buildRouteHandlerAllowHeader(exportedMethods) { + const allow = new Set(exportedMethods); + allow.add("OPTIONS"); + return Array.from(allow).sort().join(", "); +} + + // ALS used to suppress the expected "Invalid hook call" dev warning when // layout/page components are probed outside React's render cycle. Patching // console.error once at module load (instead of per-request) avoids the @@ -9162,6 +9709,20 @@ function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) { function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) { __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override"); } +function __copyResponseHeaders(targetHeaders, sourceHeaders, excludedKeys) { + const __excluded = new Set(excludedKeys.map((key) => key.toLowerCase())); + const __setCookies = sourceHeaders.getSetCookie(); + for (const [key, value] of sourceHeaders) { + const __lowerKey = key.toLowerCase(); + if (__excluded.has(__lowerKey) || __lowerKey === "set-cookie") continue; + targetHeaders.append(key, value); + } + if (!__excluded.has("set-cookie")) { + for (const cookie of __setCookies) { + targetHeaders.append("Set-Cookie", cookie); + } + } +} function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) { const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status; if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response; @@ -9224,6 +9785,7 @@ function __isrCacheKey(pathname, suffix) { } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } +function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators // have a single knob for all cache tracing. @@ -9376,9 +9938,7 @@ function rscOnError(error, requestInfo, errorContext) { error instanceof Error ? error : new Error(String(error)), requestInfo, errorContext, - ).catch((reportErr) => { - console.error("[vinext] Failed to report render error:", reportErr); - }); + ); } // In production, generate a digest hash for non-navigation errors @@ -9429,6 +9989,7 @@ import * as mod_10 from "/tmp/test/app/dashboard/not-found.tsx"; let __instrumentationInitialized = false; let __instrumentationInitPromise = null; async function __ensureInstrumentation() { + if (process.env.VINEXT_PRERENDER === "1") return; if (__instrumentationInitialized) return; if (__instrumentationInitPromise) return __instrumentationInitPromise; __instrumentationInitPromise = (async () => { @@ -9644,7 +10205,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. + // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. return new Response(rscStream, { status: statusCode, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -9777,7 +10338,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. + // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. return new Response(rscStream, { status: 200, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -10449,65 +11010,67 @@ async function __readFormDataWithLimit(request, maxBytes) { return new Response(combined, { headers: { "Content-Type": contentType } }).formData(); } +// Map from route pattern to generateStaticParams function. +// Used by the prerender phase to enumerate dynamic route URLs without +// loading route modules via the dev server. +export const generateStaticParamsMap = { +// TODO: layout-level generateStaticParams — this map only includes routes that +// have a pagePath (leaf pages). Layout segments can also export generateStaticParams +// to provide parent params for nested dynamic routes, but they don't have a pagePath +// so they are excluded here. Supporting layout-level generateStaticParams requires +// scanning layout.tsx files separately and including them in this map. + "/blog/:slug": mod_3?.generateStaticParams ?? null, +}; + export default async function handler(request, ctx) { // Ensure instrumentation.register() has run before handling the first request. // This is a no-op after the first call (guarded by __instrumentationInitialized). await __ensureInstrumentation(); - // 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 - // streaming), preventing state leakage between concurrent requests on - // Cloudflare Workers and other concurrent runtimes. - // - // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so - // that KVCacheHandler._putInBackground can register background KV puts with - // ctx.waitUntil() without needing ctx passed at construction time. + // Wrap the entire request in a single unified ALS scope for per-request + // isolation. All state modules (headers, navigation, cache, fetch-cache, + // execution-context) read from this store via isInsideUnifiedScope(). const headersCtx = headersContextFromRequest(request); - const _run = () => runWithHeadersContext(headersCtx, () => - _runWithNavigationContext(() => - _runWithCacheState(() => - _runWithPrivateCache(() => - runWithFetchCache(async () => { - const __reqCtx = requestContextFromRequest(request); - // 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); - // 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); - let pathname; - try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } - - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); - for (const h of extraHeaders) { - // Use append() for headers where multiple values must coexist - // (Vary, Set-Cookie). Using set() on these would destroy - // existing values like "Vary: RSC, Accept" which are critical - // for correct CDN caching behavior. - const lk = h.key.toLowerCase(); - if (lk === "vary" || lk === "set-cookie") { - response.headers.append(h.key, h.value); - } else if (!response.headers.has(lk)) { - // Middleware headers take precedence: skip config keys already - // set by middleware so middleware headers always win. - response.headers.set(h.key, h.value); - } - } - } - } - return response; - }) - ) - ) - ) - ); - return ctx ? _runWithExecutionContext(ctx, _run) : _run(); + const __uCtx = _createUnifiedCtx({ + headersContext: headersCtx, + executionContext: ctx ?? _getRequestExecutionContext() ?? null, + }); + return _runWithUnifiedCtx(__uCtx, async () => { + _ensureFetchPatch(); + const __reqCtx = requestContextFromRequest(request); + // 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); + // 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); + let pathname; + try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } + + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + for (const h of extraHeaders) { + // Use append() for headers where multiple values must coexist + // (Vary, Set-Cookie). Using set() on these would destroy + // existing values like "Vary: RSC, Accept" which are critical + // for correct CDN caching behavior. + const lk = h.key.toLowerCase(); + if (lk === "vary" || lk === "set-cookie") { + response.headers.append(h.key, h.value); + } else if (!response.headers.has(lk)) { + // Middleware headers take precedence: skip config keys already + // set by middleware so middleware headers always win. + response.headers.set(h.key, h.value); + } + } + } + } + return response; + }); } async function _handleRequest(request, __reqCtx, _mwCtx) { @@ -10542,6 +11105,38 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { + // ── Prerender: static-params endpoint ──────────────────────────────── + // Internal endpoint used by prerenderApp() during build to fetch + // generateStaticParams results via wrangler unstable_startWorker. + // Gated on VINEXT_PRERENDER=1 to prevent exposure in normal deployments. + // For Node builds, process.env.VINEXT_PRERENDER is set directly by the + // prerender orchestrator. For CF Workers builds, wrangler unstable_startWorker + // injects VINEXT_PRERENDER as a binding which Miniflare exposes via process.env + // in bundled workers. The /__vinext/ prefix ensures no user route ever conflicts. + if (pathname === "/__vinext/prerender/static-params") { + if (process.env.VINEXT_PRERENDER !== "1") { + return new Response("Not Found", { status: 404 }); + } + const pattern = url.searchParams.get("pattern"); + if (!pattern) return new Response("missing pattern", { status: 400 }); + const fn = generateStaticParamsMap[pattern]; + if (typeof fn !== "function") return new Response("null", { status: 200, headers: { "content-type": "application/json" } }); + try { + const parentParams = url.searchParams.get("parentParams"); + const raw = parentParams ? JSON.parse(parentParams) : {}; + // Ensure params is a plain object — reject primitives, arrays, and null + // so user-authored generateStaticParams always receives { params: {} } + // rather than { params: 5 } or similar if input is malformed. + const params = (typeof raw === "object" && raw !== null && !Array.isArray(raw)) ? raw : {}; + const result = await fn({ params }); + return new Response(JSON.stringify(result), { status: 200, headers: { "content-type": "application/json" } }); + } catch (e) { + return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers: { "content-type": "application/json" } }); + } + } + + + // Trailing slash normalization (redirect to canonical form) const __tsRedirect = normalizeTrailingSlash(pathname, __basePath, __trailingSlash, url.search); if (__tsRedirect) return __tsRedirect; @@ -10636,45 +11231,53 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Skip — the base servedUrl is not served when generateSitemaps exists continue; } - if (cleanPathname === metaRoute.servedUrl) { - if (metaRoute.isDynamic) { - // Dynamic metadata route — call the default export and serialize - const metaFn = metaRoute.module.default; - if (typeof metaFn === "function") { - const result = await metaFn(); - let body; - // If it's already a Response (e.g., ImageResponse), return directly - if (result instanceof Response) return result; - // Serialize based on type - if (metaRoute.type === "sitemap") body = sitemapToXml(result); - else if (metaRoute.type === "robots") body = robotsToText(result); - else if (metaRoute.type === "manifest") body = manifestToJson(result); - else body = JSON.stringify(result); - return new Response(body, { - headers: { "Content-Type": metaRoute.contentType }, - }); - } - } else { - // Static metadata file — decode from embedded base64 data - try { - const binary = atob(metaRoute.fileDataBase64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new Response(bytes, { - headers: { - "Content-Type": metaRoute.contentType, - "Cache-Control": "public, max-age=0, must-revalidate", - }, - }); - } catch { - return new Response("Not Found", { status: 404 }); - } + // Match metadata route — use pattern matching for dynamic segments, + // strict equality for static paths. + var _metaParams = null; + if (metaRoute.patternParts) { + var _metaUrlParts = cleanPathname.split("/").filter(Boolean); + _metaParams = matchPattern(_metaUrlParts, metaRoute.patternParts); + if (!_metaParams) continue; + } else if (cleanPathname !== metaRoute.servedUrl) { + continue; + } + if (metaRoute.isDynamic) { + // Dynamic metadata route — call the default export and serialize + const metaFn = metaRoute.module.default; + if (typeof metaFn === "function") { + const result = await metaFn({ params: makeThenableParams(_metaParams || {}) }); + let body; + // If it's already a Response (e.g., ImageResponse), return directly + if (result instanceof Response) return result; + // Serialize based on type + if (metaRoute.type === "sitemap") body = sitemapToXml(result); + else if (metaRoute.type === "robots") body = robotsToText(result); + else if (metaRoute.type === "manifest") body = manifestToJson(result); + else body = JSON.stringify(result); + return new Response(body, { + headers: { "Content-Type": metaRoute.contentType }, + }); + } + } else { + // Static metadata file — decode from embedded base64 data + try { + const binary = atob(metaRoute.fileDataBase64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new Response(bytes, { + headers: { + "Content-Type": metaRoute.contentType, + "Cache-Control": "public, max-age=0, must-revalidate", + }, + }); + } catch { + return new Response("Not Found", { status: 404 }); } } } // Set navigation context for Server Components. - // Note: Headers context is already set by runWithHeadersContext in the handler wrapper. + // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, @@ -10809,7 +11412,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Collect cookies set during the action synchronously (before stream is consumed). // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext + // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. const actionRenderHeaders = consumeRenderResponseHeaders(); @@ -10825,9 +11428,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: cleanPathname, routeType: "action" }, - ).catch((reportErr) => { - console.error("[vinext] Failed to report server action error:", reportErr); - }); + ); setHeadersContext(null); setNavigationContext(null); return new Response( @@ -10869,6 +11470,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (!match) { + // Render custom not-found page if available, otherwise plain 404 const notFoundResponse = await renderNotFoundPage(null, isRscRequest, request); if (notFoundResponse) return notFoundResponse; @@ -10890,16 +11492,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (route.routeHandler) { const handler = route.routeHandler; const method = request.method.toUpperCase(); - const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 ? handler.revalidate : null; + const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 && handler.revalidate !== Infinity ? handler.revalidate : null; + if (typeof handler["default"] === "function" && process.env.NODE_ENV === "development") { + console.error( + "[vinext] Detected default export in route handler " + route.pattern + ". Export a named export for each HTTP method instead.", + ); + } // Collect exported HTTP methods for OPTIONS auto-response and Allow header - const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; - const exportedMethods = HTTP_METHODS.filter((m) => typeof handler[m] === "function"); - // If GET is exported, HEAD is implicitly supported - if (exportedMethods.includes("GET") && !exportedMethods.includes("HEAD")) { - exportedMethods.push("HEAD"); - } - const hasDefault = typeof handler["default"] === "function"; + const exportedMethods = collectRouteHandlerMethods(handler); + const allowHeaderForOptions = buildRouteHandlerAllowHeader(exportedMethods); // Route handlers need the same middleware header/status merge behavior as // page responses. This keeps middleware response headers visible on API @@ -10922,29 +11524,107 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // OPTIONS auto-implementation: respond with Allow header and 204 if (method === "OPTIONS" && typeof handler["OPTIONS"] !== "function") { - const allowMethods = hasDefault ? HTTP_METHODS : exportedMethods; - if (!allowMethods.includes("OPTIONS")) allowMethods.push("OPTIONS"); setHeadersContext(null); setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 204, - headers: { "Allow": allowMethods.join(", ") }, + headers: { "Allow": allowHeaderForOptions }, })); } // HEAD auto-implementation: run GET handler and strip body - let handlerFn = handler[method] || handler["default"]; + let handlerFn = handler[method]; let isAutoHead = false; if (method === "HEAD" && typeof handler["HEAD"] !== "function" && typeof handler["GET"] === "function") { handlerFn = handler["GET"]; isAutoHead = true; } + // ISR cache read for route handlers (production only). + // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. + // This runs before handler execution so a cache HIT skips the handler entirely. + if ( + process.env.NODE_ENV === "production" && + revalidateSeconds !== null && + handler.dynamic !== "force-dynamic" && + (method === "GET" || isAutoHead) && + typeof handlerFn === "function" + ) { + const __routeKey = __isrRouteKey(cleanPathname); + try { + const __cached = await __isrGet(__routeKey); + if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { + // HIT — return cached response immediately + const __cv = __cached.value.value; + __isrDebug?.("HIT (route)", cleanPathname); + setHeadersContext(null); + setNavigationContext(null); + const __hitHeaders = Object.assign({}, __cv.headers || {}); + __hitHeaders["X-Vinext-Cache"] = "HIT"; + __hitHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; + if (isAutoHead) { + return attachRouteHandlerMiddlewareContext(new Response(null, { status: __cv.status, headers: __hitHeaders })); + } + return attachRouteHandlerMiddlewareContext(new Response(__cv.body, { status: __cv.status, headers: __hitHeaders })); + } + if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { + // STALE — serve stale response, trigger background regeneration + const __sv = __cached.value.value; + const __revalSecs = revalidateSeconds; + const __revalHandlerFn = handlerFn; + const __revalParams = params; + const __revalUrl = request.url; + const __revalSearchParams = new URLSearchParams(url.searchParams); + __triggerBackgroundRegeneration(__routeKey, async function() { + const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; + const __revalUCtx = _createUnifiedCtx({ + headersContext: __revalHeadCtx, + executionContext: _getRequestExecutionContext(), + }); + await _runWithUnifiedCtx(__revalUCtx, async () => { + _ensureFetchPatch(); + setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); + const __syntheticReq = new Request(__revalUrl, { method: "GET" }); + const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); + const __regenDynamic = consumeDynamicUsage(); + setNavigationContext(null); + if (__regenDynamic) { + __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); + return; + } + const __freshBody = await __revalResponse.arrayBuffer(); + const __freshHeaders = {}; + __revalResponse.headers.forEach(function(v, k) { + if (k !== "x-vinext-cache" && k !== "cache-control") __freshHeaders[k] = v; + }); + const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __freshBody, status: __revalResponse.status, headers: __freshHeaders }, __revalSecs, __routeTags); + __isrDebug?.("route regen complete", __routeKey); + }); + }); + __isrDebug?.("STALE (route)", cleanPathname); + setHeadersContext(null); + setNavigationContext(null); + const __staleHeaders = Object.assign({}, __sv.headers || {}); + __staleHeaders["X-Vinext-Cache"] = "STALE"; + __staleHeaders["Cache-Control"] = "s-maxage=0, stale-while-revalidate"; + if (isAutoHead) { + return attachRouteHandlerMiddlewareContext(new Response(null, { status: __sv.status, headers: __staleHeaders })); + } + return attachRouteHandlerMiddlewareContext(new Response(__sv.body, { status: __sv.status, headers: __staleHeaders })); + } + } catch (__routeCacheErr) { + // Cache read failure — fall through to normal handler execution + console.error("[vinext] ISR route cache read error:", __routeCacheErr); + } + } + if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); + const handlerSetCacheControl = response.headers.has("cache-control"); // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, @@ -10953,11 +11633,42 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { revalidateSeconds !== null && !dynamicUsedInHandler && (method === "GET" || isAutoHead) && - !response.headers.has("cache-control") + !handlerSetCacheControl ) { response.headers.set("cache-control", "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"); } + // ISR cache write for route handlers (production, MISS). + // Store the raw handler response before cookie/middleware transforms + // (those are request-specific and shouldn't be cached). + if ( + process.env.NODE_ENV === "production" && + revalidateSeconds !== null && + handler.dynamic !== "force-dynamic" && + !dynamicUsedInHandler && + (method === "GET" || isAutoHead) && + !handlerSetCacheControl + ) { + response.headers.set("X-Vinext-Cache", "MISS"); + const __routeClone = response.clone(); + const __routeKey = __isrRouteKey(cleanPathname); + const __revalSecs = revalidateSeconds; + const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __routeWritePromise = (async () => { + try { + const __buf = await __routeClone.arrayBuffer(); + const __hdrs = {}; + __routeClone.headers.forEach(function(v, k) { + if (k !== "x-vinext-cache" && k !== "cache-control") __hdrs[k] = v; + }); + await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __buf, status: __routeClone.status, headers: __hdrs }, __revalSecs, __routeTags); + __isrDebug?.("route cache written", __routeKey); + } catch (__cacheErr) { + console.error("[vinext] ISR route cache write error:", __cacheErr); + } + })(); + _getRequestExecutionContext()?.waitUntil(__routeWritePromise); + } const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); @@ -11021,9 +11732,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: route.pattern, routeType: "route" }, - ).catch((reportErr) => { - console.error("[vinext] Failed to report route handler error:", reportErr); - }); + ); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 500 })); } finally { setHeadersAccessPhase(previousHeadersPhase); @@ -11033,7 +11742,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 405, - headers: { Allow: exportedMethods.join(", ") }, })); } @@ -11153,60 +11861,57 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // user request — to prevent user-specific cookies/auth headers from leaking // into content that is cached and served to all subsequent users. const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalResult = await runWithHeadersContext(__revalHeadCtx, () => - _runWithNavigationContext(() => - _runWithCacheState(() => - _runWithPrivateCache(() => - runWithFetchCache(async () => { - setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); - const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); - const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); - const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); - // Tee RSC stream: one for SSR, one to capture rscData - const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); - // Capture rscData bytes in parallel with SSR - const __rscDataPromise = (async () => { - const __rscReader = __revalRscForCapture.getReader(); - const __rscChunks = []; - let __rscTotal = 0; - for (;;) { - const { done, value } = await __rscReader.read(); - if (done) break; - __rscChunks.push(value); - __rscTotal += value.byteLength; - } - const __rscBuf = new Uint8Array(__rscTotal); - let __rscOff = 0; - for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } - return __rscBuf.buffer; - })(); - const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; - const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); - const __freshRscData = await __rscDataPromise; - // RSC data must be fully consumed before headers are finalized, - // since async server components may append headers while streaming. - const __renderHeaders = consumeRenderResponseHeaders(); - setHeadersContext(null); - setNavigationContext(null); - // Collect the full HTML string from the stream - const __revalReader = __revalHtmlStream.getReader(); - const __revalDecoder = new TextDecoder(); - const __revalChunks = []; - for (;;) { - const { done, value } = await __revalReader.read(); - if (done) break; - __revalChunks.push(__revalDecoder.decode(value, { stream: true })); - } - __revalChunks.push(__revalDecoder.decode()); - const __freshHtml = __revalChunks.join(""); - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; - }) - ) - ) - ) - ); + const __revalUCtx = _createUnifiedCtx({ + headersContext: __revalHeadCtx, + executionContext: _getRequestExecutionContext(), + }); + const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { + _ensureFetchPatch(); + setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); + const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); + const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); + // Tee RSC stream: one for SSR, one to capture rscData + const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); + // Capture rscData bytes in parallel with SSR + const __rscDataPromise = (async () => { + const __rscReader = __revalRscForCapture.getReader(); + const __rscChunks = []; + let __rscTotal = 0; + for (;;) { + const { done, value } = await __rscReader.read(); + if (done) break; + __rscChunks.push(value); + __rscTotal += value.byteLength; + } + const __rscBuf = new Uint8Array(__rscTotal); + let __rscOff = 0; + for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } + return __rscBuf.buffer; + })(); + const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; + const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); + const __freshRscData = await __rscDataPromise; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. + const __renderHeaders = consumeRenderResponseHeaders(); + setHeadersContext(null); + setNavigationContext(null); + // Collect the full HTML string from the stream + const __revalReader = __revalHtmlStream.getReader(); + const __revalDecoder = new TextDecoder(); + const __revalChunks = []; + for (;;) { + const { done, value } = await __revalReader.read(); + if (done) break; + __revalChunks.push(__revalDecoder.decode(value, { stream: true })); + } + __revalChunks.push(__revalDecoder.decode()); + const __freshHtml = __revalChunks.join(""); + const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; + }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), @@ -11315,7 +12020,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError }); // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext + // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. return new Response(interceptStream, { headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -11549,7 +12254,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // NOTE: Do NOT clear headers/navigation context here! // The RSC stream is consumed lazily - components render when chunks are read. // If we clear context now, headers()/cookies() will fail during rendering. - // Context will be cleared when the next request starts (via runWithHeadersContext). + // Context will be cleared when the next request starts (via runWithRequestContext). const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; // Include matched route params so the client can hydrate useParams() if (params && Object.keys(params).length > 0) { @@ -11930,7 +12635,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; +import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; @@ -11940,19 +12645,38 @@ 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 { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; -import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; +import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; +import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; +import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; -import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. -import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; +import "vinext/navigation-state"; +import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; function _getSSRFontStyles() { return [..._getSSRFontStylesGoogle(), ..._getSSRFontStylesLocal()]; } function _getSSRFontPreloads() { return [..._getSSRFontPreloadsGoogle(), ..._getSSRFontPreloadsLocal()]; } + +// Duplicated from the build-time constant above via JSON.stringify. +const ROUTE_HANDLER_HTTP_METHODS = ["GET","HEAD","POST","PUT","DELETE","PATCH","OPTIONS"]; + +function collectRouteHandlerMethods(handler) { + const methods = ROUTE_HANDLER_HTTP_METHODS.filter((method) => typeof handler[method] === "function"); + if (methods.includes("GET") && !methods.includes("HEAD")) { + methods.push("HEAD"); + } + return methods; +} + +function buildRouteHandlerAllowHeader(exportedMethods) { + const allow = new Set(exportedMethods); + allow.add("OPTIONS"); + return Array.from(allow).sort().join(", "); +} + + // ALS used to suppress the expected "Invalid hook call" dev warning when // layout/page components are probed outside React's render cycle. Patching // console.error once at module load (instead of per-request) avoids the @@ -12063,6 +12787,20 @@ function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) { function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) { __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override"); } +function __copyResponseHeaders(targetHeaders, sourceHeaders, excludedKeys) { + const __excluded = new Set(excludedKeys.map((key) => key.toLowerCase())); + const __setCookies = sourceHeaders.getSetCookie(); + for (const [key, value] of sourceHeaders) { + const __lowerKey = key.toLowerCase(); + if (__excluded.has(__lowerKey) || __lowerKey === "set-cookie") continue; + targetHeaders.append(key, value); + } + if (!__excluded.has("set-cookie")) { + for (const cookie of __setCookies) { + targetHeaders.append("Set-Cookie", cookie); + } + } +} function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) { const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status; if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response; @@ -12125,6 +12863,7 @@ function __isrCacheKey(pathname, suffix) { } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } +function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators // have a single knob for all cache tracing. @@ -12277,9 +13016,7 @@ function rscOnError(error, requestInfo, errorContext) { error instanceof Error ? error : new Error(String(error)), requestInfo, errorContext, - ).catch((reportErr) => { - console.error("[vinext] Failed to report render error:", reportErr); - }); + ); } // In production, generate a digest hash for non-navigation errors @@ -12523,7 +13260,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. + // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. return new Response(rscStream, { status: statusCode, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -12656,7 +13393,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. + // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. return new Response(rscStream, { status: 200, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -13328,62 +14065,64 @@ async function __readFormDataWithLimit(request, maxBytes) { return new Response(combined, { headers: { "Content-Type": contentType } }).formData(); } +// Map from route pattern to generateStaticParams function. +// Used by the prerender phase to enumerate dynamic route URLs without +// loading route modules via the dev server. +export const generateStaticParamsMap = { +// TODO: layout-level generateStaticParams — this map only includes routes that +// have a pagePath (leaf pages). Layout segments can also export generateStaticParams +// to provide parent params for nested dynamic routes, but they don't have a pagePath +// so they are excluded here. Supporting layout-level generateStaticParams requires +// scanning layout.tsx files separately and including them in this map. + "/blog/:slug": mod_3?.generateStaticParams ?? null, +}; + export default async function handler(request, ctx) { - // 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 - // streaming), preventing state leakage between concurrent requests on - // Cloudflare Workers and other concurrent runtimes. - // - // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so - // that KVCacheHandler._putInBackground can register background KV puts with - // ctx.waitUntil() without needing ctx passed at construction time. + // Wrap the entire request in a single unified ALS scope for per-request + // isolation. All state modules (headers, navigation, cache, fetch-cache, + // execution-context) read from this store via isInsideUnifiedScope(). const headersCtx = headersContextFromRequest(request); - const _run = () => runWithHeadersContext(headersCtx, () => - _runWithNavigationContext(() => - _runWithCacheState(() => - _runWithPrivateCache(() => - runWithFetchCache(async () => { - const __reqCtx = requestContextFromRequest(request); - // 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); - // 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); - let pathname; - try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } - - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); - for (const h of extraHeaders) { - // Use append() for headers where multiple values must coexist - // (Vary, Set-Cookie). Using set() on these would destroy - // existing values like "Vary: RSC, Accept" which are critical - // for correct CDN caching behavior. - const lk = h.key.toLowerCase(); - if (lk === "vary" || lk === "set-cookie") { - response.headers.append(h.key, h.value); - } else if (!response.headers.has(lk)) { - // Middleware headers take precedence: skip config keys already - // set by middleware so middleware headers always win. - response.headers.set(h.key, h.value); - } - } - } - } - return response; - }) - ) - ) - ) - ); - return ctx ? _runWithExecutionContext(ctx, _run) : _run(); + const __uCtx = _createUnifiedCtx({ + headersContext: headersCtx, + executionContext: ctx ?? _getRequestExecutionContext() ?? null, + }); + return _runWithUnifiedCtx(__uCtx, async () => { + _ensureFetchPatch(); + const __reqCtx = requestContextFromRequest(request); + // 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); + // 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); + let pathname; + try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } + + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + for (const h of extraHeaders) { + // Use append() for headers where multiple values must coexist + // (Vary, Set-Cookie). Using set() on these would destroy + // existing values like "Vary: RSC, Accept" which are critical + // for correct CDN caching behavior. + const lk = h.key.toLowerCase(); + if (lk === "vary" || lk === "set-cookie") { + response.headers.append(h.key, h.value); + } else if (!response.headers.has(lk)) { + // Middleware headers take precedence: skip config keys already + // set by middleware so middleware headers always win. + response.headers.set(h.key, h.value); + } + } + } + } + return response; + }); } async function _handleRequest(request, __reqCtx, _mwCtx) { @@ -13418,6 +14157,38 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { + // ── Prerender: static-params endpoint ──────────────────────────────── + // Internal endpoint used by prerenderApp() during build to fetch + // generateStaticParams results via wrangler unstable_startWorker. + // Gated on VINEXT_PRERENDER=1 to prevent exposure in normal deployments. + // For Node builds, process.env.VINEXT_PRERENDER is set directly by the + // prerender orchestrator. For CF Workers builds, wrangler unstable_startWorker + // injects VINEXT_PRERENDER as a binding which Miniflare exposes via process.env + // in bundled workers. The /__vinext/ prefix ensures no user route ever conflicts. + if (pathname === "/__vinext/prerender/static-params") { + if (process.env.VINEXT_PRERENDER !== "1") { + return new Response("Not Found", { status: 404 }); + } + const pattern = url.searchParams.get("pattern"); + if (!pattern) return new Response("missing pattern", { status: 400 }); + const fn = generateStaticParamsMap[pattern]; + if (typeof fn !== "function") return new Response("null", { status: 200, headers: { "content-type": "application/json" } }); + try { + const parentParams = url.searchParams.get("parentParams"); + const raw = parentParams ? JSON.parse(parentParams) : {}; + // Ensure params is a plain object — reject primitives, arrays, and null + // so user-authored generateStaticParams always receives { params: {} } + // rather than { params: 5 } or similar if input is malformed. + const params = (typeof raw === "object" && raw !== null && !Array.isArray(raw)) ? raw : {}; + const result = await fn({ params }); + return new Response(JSON.stringify(result), { status: 200, headers: { "content-type": "application/json" } }); + } catch (e) { + return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers: { "content-type": "application/json" } }); + } + } + + + // Trailing slash normalization (redirect to canonical form) const __tsRedirect = normalizeTrailingSlash(pathname, __basePath, __trailingSlash, url.search); if (__tsRedirect) return __tsRedirect; @@ -13512,45 +14283,53 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Skip — the base servedUrl is not served when generateSitemaps exists continue; } - if (cleanPathname === metaRoute.servedUrl) { - if (metaRoute.isDynamic) { - // Dynamic metadata route — call the default export and serialize - const metaFn = metaRoute.module.default; - if (typeof metaFn === "function") { - const result = await metaFn(); - let body; - // If it's already a Response (e.g., ImageResponse), return directly - if (result instanceof Response) return result; - // Serialize based on type - if (metaRoute.type === "sitemap") body = sitemapToXml(result); - else if (metaRoute.type === "robots") body = robotsToText(result); - else if (metaRoute.type === "manifest") body = manifestToJson(result); - else body = JSON.stringify(result); - return new Response(body, { - headers: { "Content-Type": metaRoute.contentType }, - }); - } - } else { - // Static metadata file — decode from embedded base64 data - try { - const binary = atob(metaRoute.fileDataBase64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new Response(bytes, { - headers: { - "Content-Type": metaRoute.contentType, - "Cache-Control": "public, max-age=0, must-revalidate", - }, - }); - } catch { - return new Response("Not Found", { status: 404 }); - } + // Match metadata route — use pattern matching for dynamic segments, + // strict equality for static paths. + var _metaParams = null; + if (metaRoute.patternParts) { + var _metaUrlParts = cleanPathname.split("/").filter(Boolean); + _metaParams = matchPattern(_metaUrlParts, metaRoute.patternParts); + if (!_metaParams) continue; + } else if (cleanPathname !== metaRoute.servedUrl) { + continue; + } + if (metaRoute.isDynamic) { + // Dynamic metadata route — call the default export and serialize + const metaFn = metaRoute.module.default; + if (typeof metaFn === "function") { + const result = await metaFn({ params: makeThenableParams(_metaParams || {}) }); + let body; + // If it's already a Response (e.g., ImageResponse), return directly + if (result instanceof Response) return result; + // Serialize based on type + if (metaRoute.type === "sitemap") body = sitemapToXml(result); + else if (metaRoute.type === "robots") body = robotsToText(result); + else if (metaRoute.type === "manifest") body = manifestToJson(result); + else body = JSON.stringify(result); + return new Response(body, { + headers: { "Content-Type": metaRoute.contentType }, + }); + } + } else { + // Static metadata file — decode from embedded base64 data + try { + const binary = atob(metaRoute.fileDataBase64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new Response(bytes, { + headers: { + "Content-Type": metaRoute.contentType, + "Cache-Control": "public, max-age=0, must-revalidate", + }, + }); + } catch { + return new Response("Not Found", { status: 404 }); } } } // Set navigation context for Server Components. - // Note: Headers context is already set by runWithHeadersContext in the handler wrapper. + // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, @@ -13685,7 +14464,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Collect cookies set during the action synchronously (before stream is consumed). // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext + // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. const actionRenderHeaders = consumeRenderResponseHeaders(); @@ -13701,9 +14480,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: cleanPathname, routeType: "action" }, - ).catch((reportErr) => { - console.error("[vinext] Failed to report server action error:", reportErr); - }); + ); setHeadersContext(null); setNavigationContext(null); return new Response( @@ -13745,6 +14522,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (!match) { + // Render custom not-found page if available, otherwise plain 404 const notFoundResponse = await renderNotFoundPage(null, isRscRequest, request); if (notFoundResponse) return notFoundResponse; @@ -13766,16 +14544,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (route.routeHandler) { const handler = route.routeHandler; const method = request.method.toUpperCase(); - const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 ? handler.revalidate : null; + const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 && handler.revalidate !== Infinity ? handler.revalidate : null; + if (typeof handler["default"] === "function" && process.env.NODE_ENV === "development") { + console.error( + "[vinext] Detected default export in route handler " + route.pattern + ". Export a named export for each HTTP method instead.", + ); + } // Collect exported HTTP methods for OPTIONS auto-response and Allow header - const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; - const exportedMethods = HTTP_METHODS.filter((m) => typeof handler[m] === "function"); - // If GET is exported, HEAD is implicitly supported - if (exportedMethods.includes("GET") && !exportedMethods.includes("HEAD")) { - exportedMethods.push("HEAD"); - } - const hasDefault = typeof handler["default"] === "function"; + const exportedMethods = collectRouteHandlerMethods(handler); + const allowHeaderForOptions = buildRouteHandlerAllowHeader(exportedMethods); // Route handlers need the same middleware header/status merge behavior as // page responses. This keeps middleware response headers visible on API @@ -13798,29 +14576,107 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // OPTIONS auto-implementation: respond with Allow header and 204 if (method === "OPTIONS" && typeof handler["OPTIONS"] !== "function") { - const allowMethods = hasDefault ? HTTP_METHODS : exportedMethods; - if (!allowMethods.includes("OPTIONS")) allowMethods.push("OPTIONS"); setHeadersContext(null); setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 204, - headers: { "Allow": allowMethods.join(", ") }, + headers: { "Allow": allowHeaderForOptions }, })); } // HEAD auto-implementation: run GET handler and strip body - let handlerFn = handler[method] || handler["default"]; + let handlerFn = handler[method]; let isAutoHead = false; if (method === "HEAD" && typeof handler["HEAD"] !== "function" && typeof handler["GET"] === "function") { handlerFn = handler["GET"]; isAutoHead = true; } + // ISR cache read for route handlers (production only). + // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. + // This runs before handler execution so a cache HIT skips the handler entirely. + if ( + process.env.NODE_ENV === "production" && + revalidateSeconds !== null && + handler.dynamic !== "force-dynamic" && + (method === "GET" || isAutoHead) && + typeof handlerFn === "function" + ) { + const __routeKey = __isrRouteKey(cleanPathname); + try { + const __cached = await __isrGet(__routeKey); + if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { + // HIT — return cached response immediately + const __cv = __cached.value.value; + __isrDebug?.("HIT (route)", cleanPathname); + setHeadersContext(null); + setNavigationContext(null); + const __hitHeaders = Object.assign({}, __cv.headers || {}); + __hitHeaders["X-Vinext-Cache"] = "HIT"; + __hitHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; + if (isAutoHead) { + return attachRouteHandlerMiddlewareContext(new Response(null, { status: __cv.status, headers: __hitHeaders })); + } + return attachRouteHandlerMiddlewareContext(new Response(__cv.body, { status: __cv.status, headers: __hitHeaders })); + } + if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { + // STALE — serve stale response, trigger background regeneration + const __sv = __cached.value.value; + const __revalSecs = revalidateSeconds; + const __revalHandlerFn = handlerFn; + const __revalParams = params; + const __revalUrl = request.url; + const __revalSearchParams = new URLSearchParams(url.searchParams); + __triggerBackgroundRegeneration(__routeKey, async function() { + const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; + const __revalUCtx = _createUnifiedCtx({ + headersContext: __revalHeadCtx, + executionContext: _getRequestExecutionContext(), + }); + await _runWithUnifiedCtx(__revalUCtx, async () => { + _ensureFetchPatch(); + setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); + const __syntheticReq = new Request(__revalUrl, { method: "GET" }); + const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); + const __regenDynamic = consumeDynamicUsage(); + setNavigationContext(null); + if (__regenDynamic) { + __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); + return; + } + const __freshBody = await __revalResponse.arrayBuffer(); + const __freshHeaders = {}; + __revalResponse.headers.forEach(function(v, k) { + if (k !== "x-vinext-cache" && k !== "cache-control") __freshHeaders[k] = v; + }); + const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __freshBody, status: __revalResponse.status, headers: __freshHeaders }, __revalSecs, __routeTags); + __isrDebug?.("route regen complete", __routeKey); + }); + }); + __isrDebug?.("STALE (route)", cleanPathname); + setHeadersContext(null); + setNavigationContext(null); + const __staleHeaders = Object.assign({}, __sv.headers || {}); + __staleHeaders["X-Vinext-Cache"] = "STALE"; + __staleHeaders["Cache-Control"] = "s-maxage=0, stale-while-revalidate"; + if (isAutoHead) { + return attachRouteHandlerMiddlewareContext(new Response(null, { status: __sv.status, headers: __staleHeaders })); + } + return attachRouteHandlerMiddlewareContext(new Response(__sv.body, { status: __sv.status, headers: __staleHeaders })); + } + } catch (__routeCacheErr) { + // Cache read failure — fall through to normal handler execution + console.error("[vinext] ISR route cache read error:", __routeCacheErr); + } + } + if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); + const handlerSetCacheControl = response.headers.has("cache-control"); // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, @@ -13829,11 +14685,42 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { revalidateSeconds !== null && !dynamicUsedInHandler && (method === "GET" || isAutoHead) && - !response.headers.has("cache-control") + !handlerSetCacheControl ) { response.headers.set("cache-control", "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"); } + // ISR cache write for route handlers (production, MISS). + // Store the raw handler response before cookie/middleware transforms + // (those are request-specific and shouldn't be cached). + if ( + process.env.NODE_ENV === "production" && + revalidateSeconds !== null && + handler.dynamic !== "force-dynamic" && + !dynamicUsedInHandler && + (method === "GET" || isAutoHead) && + !handlerSetCacheControl + ) { + response.headers.set("X-Vinext-Cache", "MISS"); + const __routeClone = response.clone(); + const __routeKey = __isrRouteKey(cleanPathname); + const __revalSecs = revalidateSeconds; + const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __routeWritePromise = (async () => { + try { + const __buf = await __routeClone.arrayBuffer(); + const __hdrs = {}; + __routeClone.headers.forEach(function(v, k) { + if (k !== "x-vinext-cache" && k !== "cache-control") __hdrs[k] = v; + }); + await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __buf, status: __routeClone.status, headers: __hdrs }, __revalSecs, __routeTags); + __isrDebug?.("route cache written", __routeKey); + } catch (__cacheErr) { + console.error("[vinext] ISR route cache write error:", __cacheErr); + } + })(); + _getRequestExecutionContext()?.waitUntil(__routeWritePromise); + } const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); @@ -13897,9 +14784,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: route.pattern, routeType: "route" }, - ).catch((reportErr) => { - console.error("[vinext] Failed to report route handler error:", reportErr); - }); + ); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 500 })); } finally { setHeadersAccessPhase(previousHeadersPhase); @@ -13909,7 +14794,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 405, - headers: { Allow: exportedMethods.join(", ") }, })); } @@ -14029,60 +14913,57 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // user request — to prevent user-specific cookies/auth headers from leaking // into content that is cached and served to all subsequent users. const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalResult = await runWithHeadersContext(__revalHeadCtx, () => - _runWithNavigationContext(() => - _runWithCacheState(() => - _runWithPrivateCache(() => - runWithFetchCache(async () => { - setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); - const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); - const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); - const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); - // Tee RSC stream: one for SSR, one to capture rscData - const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); - // Capture rscData bytes in parallel with SSR - const __rscDataPromise = (async () => { - const __rscReader = __revalRscForCapture.getReader(); - const __rscChunks = []; - let __rscTotal = 0; - for (;;) { - const { done, value } = await __rscReader.read(); - if (done) break; - __rscChunks.push(value); - __rscTotal += value.byteLength; - } - const __rscBuf = new Uint8Array(__rscTotal); - let __rscOff = 0; - for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } - return __rscBuf.buffer; - })(); - const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; - const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); - const __freshRscData = await __rscDataPromise; - // RSC data must be fully consumed before headers are finalized, - // since async server components may append headers while streaming. - const __renderHeaders = consumeRenderResponseHeaders(); - setHeadersContext(null); - setNavigationContext(null); - // Collect the full HTML string from the stream - const __revalReader = __revalHtmlStream.getReader(); - const __revalDecoder = new TextDecoder(); - const __revalChunks = []; - for (;;) { - const { done, value } = await __revalReader.read(); - if (done) break; - __revalChunks.push(__revalDecoder.decode(value, { stream: true })); - } - __revalChunks.push(__revalDecoder.decode()); - const __freshHtml = __revalChunks.join(""); - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; - }) - ) - ) - ) - ); + const __revalUCtx = _createUnifiedCtx({ + headersContext: __revalHeadCtx, + executionContext: _getRequestExecutionContext(), + }); + const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { + _ensureFetchPatch(); + setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); + const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); + const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); + // Tee RSC stream: one for SSR, one to capture rscData + const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); + // Capture rscData bytes in parallel with SSR + const __rscDataPromise = (async () => { + const __rscReader = __revalRscForCapture.getReader(); + const __rscChunks = []; + let __rscTotal = 0; + for (;;) { + const { done, value } = await __rscReader.read(); + if (done) break; + __rscChunks.push(value); + __rscTotal += value.byteLength; + } + const __rscBuf = new Uint8Array(__rscTotal); + let __rscOff = 0; + for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } + return __rscBuf.buffer; + })(); + const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; + const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); + const __freshRscData = await __rscDataPromise; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. + const __renderHeaders = consumeRenderResponseHeaders(); + setHeadersContext(null); + setNavigationContext(null); + // Collect the full HTML string from the stream + const __revalReader = __revalHtmlStream.getReader(); + const __revalDecoder = new TextDecoder(); + const __revalChunks = []; + for (;;) { + const { done, value } = await __revalReader.read(); + if (done) break; + __revalChunks.push(__revalDecoder.decode(value, { stream: true })); + } + __revalChunks.push(__revalDecoder.decode()); + const __freshHtml = __revalChunks.join(""); + const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; + }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), @@ -14191,7 +15072,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError }); // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext + // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. return new Response(interceptStream, { headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -14425,7 +15306,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // NOTE: Do NOT clear headers/navigation context here! // The RSC stream is consumed lazily - components render when chunks are read. // If we clear context now, headers()/cookies() will fail during rendering. - // Context will be cleared when the next request starts (via runWithHeadersContext). + // Context will be cleared when the next request starts (via runWithRequestContext). const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; // Include matched route params so the client can hydrate useParams() if (params && Object.keys(params).length > 0) { @@ -14806,7 +15687,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; +import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; @@ -14816,19 +15697,38 @@ 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 { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; -import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; +import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; +import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; +import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; -import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. -import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; +import "vinext/navigation-state"; +import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; function _getSSRFontStyles() { return [..._getSSRFontStylesGoogle(), ..._getSSRFontStylesLocal()]; } function _getSSRFontPreloads() { return [..._getSSRFontPreloadsGoogle(), ..._getSSRFontPreloadsLocal()]; } + +// Duplicated from the build-time constant above via JSON.stringify. +const ROUTE_HANDLER_HTTP_METHODS = ["GET","HEAD","POST","PUT","DELETE","PATCH","OPTIONS"]; + +function collectRouteHandlerMethods(handler) { + const methods = ROUTE_HANDLER_HTTP_METHODS.filter((method) => typeof handler[method] === "function"); + if (methods.includes("GET") && !methods.includes("HEAD")) { + methods.push("HEAD"); + } + return methods; +} + +function buildRouteHandlerAllowHeader(exportedMethods) { + const allow = new Set(exportedMethods); + allow.add("OPTIONS"); + return Array.from(allow).sort().join(", "); +} + + // ALS used to suppress the expected "Invalid hook call" dev warning when // layout/page components are probed outside React's render cycle. Patching // console.error once at module load (instead of per-request) avoids the @@ -14939,6 +15839,20 @@ function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) { function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) { __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override"); } +function __copyResponseHeaders(targetHeaders, sourceHeaders, excludedKeys) { + const __excluded = new Set(excludedKeys.map((key) => key.toLowerCase())); + const __setCookies = sourceHeaders.getSetCookie(); + for (const [key, value] of sourceHeaders) { + const __lowerKey = key.toLowerCase(); + if (__excluded.has(__lowerKey) || __lowerKey === "set-cookie") continue; + targetHeaders.append(key, value); + } + if (!__excluded.has("set-cookie")) { + for (const cookie of __setCookies) { + targetHeaders.append("Set-Cookie", cookie); + } + } +} function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) { const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status; if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response; @@ -15001,6 +15915,7 @@ function __isrCacheKey(pathname, suffix) { } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } +function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators // have a single knob for all cache tracing. @@ -15153,9 +16068,7 @@ function rscOnError(error, requestInfo, errorContext) { error instanceof Error ? error : new Error(String(error)), requestInfo, errorContext, - ).catch((reportErr) => { - console.error("[vinext] Failed to report render error:", reportErr); - }); + ); } // In production, generate a digest hash for non-navigation errors @@ -15392,7 +16305,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. + // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. return new Response(rscStream, { status: statusCode, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -15525,7 +16438,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc // that run during stream consumption to see null headers/navigation context and throw, // resulting in missing provider context on the client (e.g. next-intl useTranslations fails // with "context from NextIntlClientProvider was not found"). - // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds. + // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds. return new Response(rscStream, { status: 200, headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -16426,62 +17339,64 @@ async function __readFormDataWithLimit(request, maxBytes) { return new Response(combined, { headers: { "Content-Type": contentType } }).formData(); } +// Map from route pattern to generateStaticParams function. +// Used by the prerender phase to enumerate dynamic route URLs without +// loading route modules via the dev server. +export const generateStaticParamsMap = { +// TODO: layout-level generateStaticParams — this map only includes routes that +// have a pagePath (leaf pages). Layout segments can also export generateStaticParams +// to provide parent params for nested dynamic routes, but they don't have a pagePath +// so they are excluded here. Supporting layout-level generateStaticParams requires +// scanning layout.tsx files separately and including them in this map. + "/blog/:slug": mod_3?.generateStaticParams ?? null, +}; + export default async function handler(request, ctx) { - // 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 - // streaming), preventing state leakage between concurrent requests on - // Cloudflare Workers and other concurrent runtimes. - // - // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so - // that KVCacheHandler._putInBackground can register background KV puts with - // ctx.waitUntil() without needing ctx passed at construction time. + // Wrap the entire request in a single unified ALS scope for per-request + // isolation. All state modules (headers, navigation, cache, fetch-cache, + // execution-context) read from this store via isInsideUnifiedScope(). const headersCtx = headersContextFromRequest(request); - const _run = () => runWithHeadersContext(headersCtx, () => - _runWithNavigationContext(() => - _runWithCacheState(() => - _runWithPrivateCache(() => - runWithFetchCache(async () => { - const __reqCtx = requestContextFromRequest(request); - // 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); - // 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); - let pathname; - try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } - - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); - for (const h of extraHeaders) { - // Use append() for headers where multiple values must coexist - // (Vary, Set-Cookie). Using set() on these would destroy - // existing values like "Vary: RSC, Accept" which are critical - // for correct CDN caching behavior. - const lk = h.key.toLowerCase(); - if (lk === "vary" || lk === "set-cookie") { - response.headers.append(h.key, h.value); - } else if (!response.headers.has(lk)) { - // Middleware headers take precedence: skip config keys already - // set by middleware so middleware headers always win. - response.headers.set(h.key, h.value); - } - } - } - } - return response; - }) - ) - ) - ) - ); - return ctx ? _runWithExecutionContext(ctx, _run) : _run(); + const __uCtx = _createUnifiedCtx({ + headersContext: headersCtx, + executionContext: ctx ?? _getRequestExecutionContext() ?? null, + }); + return _runWithUnifiedCtx(__uCtx, async () => { + _ensureFetchPatch(); + const __reqCtx = requestContextFromRequest(request); + // 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); + // 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); + let pathname; + try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } + + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + for (const h of extraHeaders) { + // Use append() for headers where multiple values must coexist + // (Vary, Set-Cookie). Using set() on these would destroy + // existing values like "Vary: RSC, Accept" which are critical + // for correct CDN caching behavior. + const lk = h.key.toLowerCase(); + if (lk === "vary" || lk === "set-cookie") { + response.headers.append(h.key, h.value); + } else if (!response.headers.has(lk)) { + // Middleware headers take precedence: skip config keys already + // set by middleware so middleware headers always win. + response.headers.set(h.key, h.value); + } + } + } + } + return response; + }); } async function _handleRequest(request, __reqCtx, _mwCtx) { @@ -16516,6 +17431,38 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { + // ── Prerender: static-params endpoint ──────────────────────────────── + // Internal endpoint used by prerenderApp() during build to fetch + // generateStaticParams results via wrangler unstable_startWorker. + // Gated on VINEXT_PRERENDER=1 to prevent exposure in normal deployments. + // For Node builds, process.env.VINEXT_PRERENDER is set directly by the + // prerender orchestrator. For CF Workers builds, wrangler unstable_startWorker + // injects VINEXT_PRERENDER as a binding which Miniflare exposes via process.env + // in bundled workers. The /__vinext/ prefix ensures no user route ever conflicts. + if (pathname === "/__vinext/prerender/static-params") { + if (process.env.VINEXT_PRERENDER !== "1") { + return new Response("Not Found", { status: 404 }); + } + const pattern = url.searchParams.get("pattern"); + if (!pattern) return new Response("missing pattern", { status: 400 }); + const fn = generateStaticParamsMap[pattern]; + if (typeof fn !== "function") return new Response("null", { status: 200, headers: { "content-type": "application/json" } }); + try { + const parentParams = url.searchParams.get("parentParams"); + const raw = parentParams ? JSON.parse(parentParams) : {}; + // Ensure params is a plain object — reject primitives, arrays, and null + // so user-authored generateStaticParams always receives { params: {} } + // rather than { params: 5 } or similar if input is malformed. + const params = (typeof raw === "object" && raw !== null && !Array.isArray(raw)) ? raw : {}; + const result = await fn({ params }); + return new Response(JSON.stringify(result), { status: 200, headers: { "content-type": "application/json" } }); + } catch (e) { + return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers: { "content-type": "application/json" } }); + } + } + + + // Trailing slash normalization (redirect to canonical form) const __tsRedirect = normalizeTrailingSlash(pathname, __basePath, __trailingSlash, url.search); if (__tsRedirect) return __tsRedirect; @@ -16550,6 +17497,47 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // every response path without module-level state that races on Workers. + // In hybrid app+pages dev mode the connect handler already ran middleware + // and forwarded the results via x-vinext-mw-ctx. Reconstruct _mwCtx from + // the forwarded data instead of re-running the middleware function. + // Guarded by NODE_ENV because this header only exists in dev (the connect + // handler sets it). In production there is no connect handler, so an + // attacker-supplied header must not be trusted. + let __mwCtxApplied = false; + if (process.env.NODE_ENV !== "production") { + const __mwCtxHeader = request.headers.get("x-vinext-mw-ctx"); + if (__mwCtxHeader) { + try { + const __mwCtxData = JSON.parse(__mwCtxHeader); + if (__mwCtxData.h && __mwCtxData.h.length > 0) { + // Note: h may include x-middleware-request-* internal headers so + // applyMiddlewareRequestHeaders() can unpack them below. + // processMiddlewareHeaders() strips them before any response. + _mwCtx.headers = new Headers(); + for (const [key, value] of __mwCtxData.h) { + _mwCtx.headers.append(key, value); + } + } + if (__mwCtxData.s != null) { + _mwCtx.status = __mwCtxData.s; + } + // Apply forwarded middleware rewrite so routing uses the rewritten path. + // The RSC plugin constructs its Request from the original HTTP request, + // not from req.url, so the connect handler's req.url rewrite is invisible. + if (__mwCtxData.r) { + const __rewriteParsed = new URL(__mwCtxData.r, request.url); + cleanPathname = __rewriteParsed.pathname; + url.search = __rewriteParsed.search; + } + // Flag set after full context application — if any step fails (e.g. malformed + // rewrite URL), we fall back to re-running middleware as a safety net. + __mwCtxApplied = true; + } catch (e) { + console.error("[vinext] Failed to parse forwarded middleware context:", e); + } + } + } + if (!__mwCtxApplied) { // 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 @@ -16574,7 +17562,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest); const mwFetchEvent = new NextFetchEvent({ page: cleanPathname }); const mwResponse = await middlewareFn(nextRequest, mwFetchEvent); - mwFetchEvent.drainWaitUntil(); + const _mwWaitUntil = mwFetchEvent.drainWaitUntil(); + const _mwExecCtx = _getRequestExecutionContext(); + if (_mwExecCtx && typeof _mwExecCtx.waitUntil === "function") { _mwExecCtx.waitUntil(_mwWaitUntil); } if (mwResponse) { // Check for x-middleware-next (continue) if (mwResponse.headers.get("x-middleware-next") === "1") { @@ -16584,11 +17574,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // the blanket strip loop after that call removes every remaining // x-middleware-* header before the set is merged into the response. _mwCtx.headers = new Headers(); - for (const [key, value] of mwResponse.headers) { - if (key !== "x-middleware-next" && key !== "x-middleware-rewrite") { - _mwCtx.headers.append(key, value); - } - } + __copyResponseHeaders(_mwCtx.headers, mwResponse.headers, [ + "x-middleware-next", + "x-middleware-rewrite", + ]); } else { // Check for redirect if (mwResponse.status >= 300 && mwResponse.status < 400) { @@ -16599,17 +17588,20 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (rewriteUrl) { const rewriteParsed = new URL(rewriteUrl, request.url); cleanPathname = rewriteParsed.pathname; + // Carry over query params from the rewrite URL so that + // searchParams props, useSearchParams(), and navigation context + // reflect the rewrite destination, not the original request. + url.search = rewriteParsed.search; // Capture custom status code from rewrite (e.g. NextResponse.rewrite(url, { status: 403 })) if (mwResponse.status !== 200) { _mwCtx.status = mwResponse.status; } // Also save any other headers from the rewrite response _mwCtx.headers = new Headers(); - for (const [key, value] of mwResponse.headers) { - if (key !== "x-middleware-next" && key !== "x-middleware-rewrite") { - _mwCtx.headers.append(key, value); - } - } + __copyResponseHeaders(_mwCtx.headers, mwResponse.headers, [ + "x-middleware-next", + "x-middleware-rewrite", + ]); } else { // Middleware returned a custom response return mwResponse; @@ -16621,6 +17613,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return new Response("Internal Server Error", { status: 500 }); } } + } // end of if (!__mwCtxApplied) // Unpack x-middleware-request-* headers into the request context so that // headers() returns the middleware-modified headers instead of the original @@ -16692,45 +17685,53 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Skip — the base servedUrl is not served when generateSitemaps exists continue; } - if (cleanPathname === metaRoute.servedUrl) { - if (metaRoute.isDynamic) { - // Dynamic metadata route — call the default export and serialize - const metaFn = metaRoute.module.default; - if (typeof metaFn === "function") { - const result = await metaFn(); - let body; - // If it's already a Response (e.g., ImageResponse), return directly - if (result instanceof Response) return result; - // Serialize based on type - if (metaRoute.type === "sitemap") body = sitemapToXml(result); - else if (metaRoute.type === "robots") body = robotsToText(result); - else if (metaRoute.type === "manifest") body = manifestToJson(result); - else body = JSON.stringify(result); - return new Response(body, { - headers: { "Content-Type": metaRoute.contentType }, - }); - } - } else { - // Static metadata file — decode from embedded base64 data - try { - const binary = atob(metaRoute.fileDataBase64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new Response(bytes, { - headers: { - "Content-Type": metaRoute.contentType, - "Cache-Control": "public, max-age=0, must-revalidate", - }, - }); - } catch { - return new Response("Not Found", { status: 404 }); - } + // Match metadata route — use pattern matching for dynamic segments, + // strict equality for static paths. + var _metaParams = null; + if (metaRoute.patternParts) { + var _metaUrlParts = cleanPathname.split("/").filter(Boolean); + _metaParams = matchPattern(_metaUrlParts, metaRoute.patternParts); + if (!_metaParams) continue; + } else if (cleanPathname !== metaRoute.servedUrl) { + continue; + } + if (metaRoute.isDynamic) { + // Dynamic metadata route — call the default export and serialize + const metaFn = metaRoute.module.default; + if (typeof metaFn === "function") { + const result = await metaFn({ params: makeThenableParams(_metaParams || {}) }); + let body; + // If it's already a Response (e.g., ImageResponse), return directly + if (result instanceof Response) return result; + // Serialize based on type + if (metaRoute.type === "sitemap") body = sitemapToXml(result); + else if (metaRoute.type === "robots") body = robotsToText(result); + else if (metaRoute.type === "manifest") body = manifestToJson(result); + else body = JSON.stringify(result); + return new Response(body, { + headers: { "Content-Type": metaRoute.contentType }, + }); + } + } else { + // Static metadata file — decode from embedded base64 data + try { + const binary = atob(metaRoute.fileDataBase64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new Response(bytes, { + headers: { + "Content-Type": metaRoute.contentType, + "Cache-Control": "public, max-age=0, must-revalidate", + }, + }); + } catch { + return new Response("Not Found", { status: 404 }); } } } // Set navigation context for Server Components. - // Note: Headers context is already set by runWithHeadersContext in the handler wrapper. + // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, @@ -16865,7 +17866,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Collect cookies set during the action synchronously (before stream is consumed). // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext + // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. const actionRenderHeaders = consumeRenderResponseHeaders(); @@ -16881,9 +17882,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: cleanPathname, routeType: "action" }, - ).catch((reportErr) => { - console.error("[vinext] Failed to report server action error:", reportErr); - }); + ); setHeadersContext(null); setNavigationContext(null); return new Response( @@ -16925,6 +17924,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (!match) { + // Render custom not-found page if available, otherwise plain 404 const notFoundResponse = await renderNotFoundPage(null, isRscRequest, request); if (notFoundResponse) return notFoundResponse; @@ -16946,16 +17946,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (route.routeHandler) { const handler = route.routeHandler; const method = request.method.toUpperCase(); - const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 ? handler.revalidate : null; + const revalidateSeconds = typeof handler.revalidate === "number" && handler.revalidate > 0 && handler.revalidate !== Infinity ? handler.revalidate : null; + if (typeof handler["default"] === "function" && process.env.NODE_ENV === "development") { + console.error( + "[vinext] Detected default export in route handler " + route.pattern + ". Export a named export for each HTTP method instead.", + ); + } // Collect exported HTTP methods for OPTIONS auto-response and Allow header - const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; - const exportedMethods = HTTP_METHODS.filter((m) => typeof handler[m] === "function"); - // If GET is exported, HEAD is implicitly supported - if (exportedMethods.includes("GET") && !exportedMethods.includes("HEAD")) { - exportedMethods.push("HEAD"); - } - const hasDefault = typeof handler["default"] === "function"; + const exportedMethods = collectRouteHandlerMethods(handler); + const allowHeaderForOptions = buildRouteHandlerAllowHeader(exportedMethods); // Route handlers need the same middleware header/status merge behavior as // page responses. This keeps middleware response headers visible on API @@ -16978,29 +17978,107 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // OPTIONS auto-implementation: respond with Allow header and 204 if (method === "OPTIONS" && typeof handler["OPTIONS"] !== "function") { - const allowMethods = hasDefault ? HTTP_METHODS : exportedMethods; - if (!allowMethods.includes("OPTIONS")) allowMethods.push("OPTIONS"); setHeadersContext(null); setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 204, - headers: { "Allow": allowMethods.join(", ") }, + headers: { "Allow": allowHeaderForOptions }, })); } // HEAD auto-implementation: run GET handler and strip body - let handlerFn = handler[method] || handler["default"]; + let handlerFn = handler[method]; let isAutoHead = false; if (method === "HEAD" && typeof handler["HEAD"] !== "function" && typeof handler["GET"] === "function") { handlerFn = handler["GET"]; isAutoHead = true; } + // ISR cache read for route handlers (production only). + // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. + // This runs before handler execution so a cache HIT skips the handler entirely. + if ( + process.env.NODE_ENV === "production" && + revalidateSeconds !== null && + handler.dynamic !== "force-dynamic" && + (method === "GET" || isAutoHead) && + typeof handlerFn === "function" + ) { + const __routeKey = __isrRouteKey(cleanPathname); + try { + const __cached = await __isrGet(__routeKey); + if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { + // HIT — return cached response immediately + const __cv = __cached.value.value; + __isrDebug?.("HIT (route)", cleanPathname); + setHeadersContext(null); + setNavigationContext(null); + const __hitHeaders = Object.assign({}, __cv.headers || {}); + __hitHeaders["X-Vinext-Cache"] = "HIT"; + __hitHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; + if (isAutoHead) { + return attachRouteHandlerMiddlewareContext(new Response(null, { status: __cv.status, headers: __hitHeaders })); + } + return attachRouteHandlerMiddlewareContext(new Response(__cv.body, { status: __cv.status, headers: __hitHeaders })); + } + if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_ROUTE") { + // STALE — serve stale response, trigger background regeneration + const __sv = __cached.value.value; + const __revalSecs = revalidateSeconds; + const __revalHandlerFn = handlerFn; + const __revalParams = params; + const __revalUrl = request.url; + const __revalSearchParams = new URLSearchParams(url.searchParams); + __triggerBackgroundRegeneration(__routeKey, async function() { + const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; + const __revalUCtx = _createUnifiedCtx({ + headersContext: __revalHeadCtx, + executionContext: _getRequestExecutionContext(), + }); + await _runWithUnifiedCtx(__revalUCtx, async () => { + _ensureFetchPatch(); + setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); + const __syntheticReq = new Request(__revalUrl, { method: "GET" }); + const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); + const __regenDynamic = consumeDynamicUsage(); + setNavigationContext(null); + if (__regenDynamic) { + __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); + return; + } + const __freshBody = await __revalResponse.arrayBuffer(); + const __freshHeaders = {}; + __revalResponse.headers.forEach(function(v, k) { + if (k !== "x-vinext-cache" && k !== "cache-control") __freshHeaders[k] = v; + }); + const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __freshBody, status: __revalResponse.status, headers: __freshHeaders }, __revalSecs, __routeTags); + __isrDebug?.("route regen complete", __routeKey); + }); + }); + __isrDebug?.("STALE (route)", cleanPathname); + setHeadersContext(null); + setNavigationContext(null); + const __staleHeaders = Object.assign({}, __sv.headers || {}); + __staleHeaders["X-Vinext-Cache"] = "STALE"; + __staleHeaders["Cache-Control"] = "s-maxage=0, stale-while-revalidate"; + if (isAutoHead) { + return attachRouteHandlerMiddlewareContext(new Response(null, { status: __sv.status, headers: __staleHeaders })); + } + return attachRouteHandlerMiddlewareContext(new Response(__sv.body, { status: __sv.status, headers: __staleHeaders })); + } + } catch (__routeCacheErr) { + // Cache read failure — fall through to normal handler execution + console.error("[vinext] ISR route cache read error:", __routeCacheErr); + } + } + if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); + const handlerSetCacheControl = response.headers.has("cache-control"); // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, @@ -17009,11 +18087,42 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { revalidateSeconds !== null && !dynamicUsedInHandler && (method === "GET" || isAutoHead) && - !response.headers.has("cache-control") + !handlerSetCacheControl ) { response.headers.set("cache-control", "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"); } + // ISR cache write for route handlers (production, MISS). + // Store the raw handler response before cookie/middleware transforms + // (those are request-specific and shouldn't be cached). + if ( + process.env.NODE_ENV === "production" && + revalidateSeconds !== null && + handler.dynamic !== "force-dynamic" && + !dynamicUsedInHandler && + (method === "GET" || isAutoHead) && + !handlerSetCacheControl + ) { + response.headers.set("X-Vinext-Cache", "MISS"); + const __routeClone = response.clone(); + const __routeKey = __isrRouteKey(cleanPathname); + const __revalSecs = revalidateSeconds; + const __routeTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __routeWritePromise = (async () => { + try { + const __buf = await __routeClone.arrayBuffer(); + const __hdrs = {}; + __routeClone.headers.forEach(function(v, k) { + if (k !== "x-vinext-cache" && k !== "cache-control") __hdrs[k] = v; + }); + await __isrSet(__routeKey, { kind: "APP_ROUTE", body: __buf, status: __routeClone.status, headers: __hdrs }, __revalSecs, __routeTags); + __isrDebug?.("route cache written", __routeKey); + } catch (__cacheErr) { + console.error("[vinext] ISR route cache write error:", __cacheErr); + } + })(); + _getRequestExecutionContext()?.waitUntil(__routeWritePromise); + } const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); @@ -17077,9 +18186,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { err instanceof Error ? err : new Error(String(err)), { path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, { routerKind: "App Router", routePath: route.pattern, routeType: "route" }, - ).catch((reportErr) => { - console.error("[vinext] Failed to report route handler error:", reportErr); - }); + ); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 500 })); } finally { setHeadersAccessPhase(previousHeadersPhase); @@ -17089,7 +18196,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); return attachRouteHandlerMiddlewareContext(new Response(null, { status: 405, - headers: { Allow: exportedMethods.join(", ") }, })); } @@ -17209,60 +18315,57 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // user request — to prevent user-specific cookies/auth headers from leaking // into content that is cached and served to all subsequent users. const __revalHeadCtx = { headers: new Headers(), cookies: new Map() }; - const __revalResult = await runWithHeadersContext(__revalHeadCtx, () => - _runWithNavigationContext(() => - _runWithCacheState(() => - _runWithPrivateCache(() => - runWithFetchCache(async () => { - setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); - const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); - const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); - const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); - // Tee RSC stream: one for SSR, one to capture rscData - const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); - // Capture rscData bytes in parallel with SSR - const __rscDataPromise = (async () => { - const __rscReader = __revalRscForCapture.getReader(); - const __rscChunks = []; - let __rscTotal = 0; - for (;;) { - const { done, value } = await __rscReader.read(); - if (done) break; - __rscChunks.push(value); - __rscTotal += value.byteLength; - } - const __rscBuf = new Uint8Array(__rscTotal); - let __rscOff = 0; - for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } - return __rscBuf.buffer; - })(); - const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; - const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); - const __freshRscData = await __rscDataPromise; - // RSC data must be fully consumed before headers are finalized, - // since async server components may append headers while streaming. - const __renderHeaders = consumeRenderResponseHeaders(); - setHeadersContext(null); - setNavigationContext(null); - // Collect the full HTML string from the stream - const __revalReader = __revalHtmlStream.getReader(); - const __revalDecoder = new TextDecoder(); - const __revalChunks = []; - for (;;) { - const { done, value } = await __revalReader.read(); - if (done) break; - __revalChunks.push(__revalDecoder.decode(value, { stream: true })); - } - __revalChunks.push(__revalDecoder.decode()); - const __freshHtml = __revalChunks.join(""); - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; - }) - ) - ) - ) - ); + const __revalUCtx = _createUnifiedCtx({ + headersContext: __revalHeadCtx, + executionContext: _getRequestExecutionContext(), + }); + const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { + _ensureFetchPatch(); + setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); + const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); + const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); + // Tee RSC stream: one for SSR, one to capture rscData + const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee(); + // Capture rscData bytes in parallel with SSR + const __rscDataPromise = (async () => { + const __rscReader = __revalRscForCapture.getReader(); + const __rscChunks = []; + let __rscTotal = 0; + for (;;) { + const { done, value } = await __rscReader.read(); + if (done) break; + __rscChunks.push(value); + __rscTotal += value.byteLength; + } + const __rscBuf = new Uint8Array(__rscTotal); + let __rscOff = 0; + for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; } + return __rscBuf.buffer; + })(); + const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() }; + const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData); + const __freshRscData = await __rscDataPromise; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. + const __renderHeaders = consumeRenderResponseHeaders(); + setHeadersContext(null); + setNavigationContext(null); + // Collect the full HTML string from the stream + const __revalReader = __revalHtmlStream.getReader(); + const __revalDecoder = new TextDecoder(); + const __revalChunks = []; + for (;;) { + const { done, value } = await __revalReader.read(); + if (done) break; + __revalChunks.push(__revalDecoder.decode(value, { stream: true })); + } + __revalChunks.push(__revalDecoder.decode()); + const __freshHtml = __revalChunks.join(""); + const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; + }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), @@ -17371,7 +18474,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError }); // Do NOT clear headers/navigation context here — the RSC stream is consumed lazily // by the client, and async server components that run during consumption need the - // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext + // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. return new Response(interceptStream, { headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, @@ -17605,7 +18708,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // NOTE: Do NOT clear headers/navigation context here! // The RSC stream is consumed lazily - components render when chunks are read. // If we clear context now, headers()/cookies() will fail during rendering. - // Context will be cleared when the next request starts (via runWithHeadersContext). + // Context will be cleared when the next request starts (via runWithRequestContext). const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; // Include matched route params so the client can hydrate useParams() if (params && Object.keys(params).length > 0) { diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 21bb82a9d..42066c113 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -1829,9 +1829,7 @@ describe("App Router Production server (startProdServer)", () => { const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); const streamServer = await startProdServer({ port: 0, outDir, noCompression: true }); try { - const addr = streamServer.address(); - const port = typeof addr === "object" && addr ? addr.port : 4210; - const streamBaseUrl = `http://localhost:${port}`; + const streamBaseUrl = `http://localhost:${streamServer.port}`; const start = performance.now(); const res = await fetch(`${streamBaseUrl}/nextjs-compat/cached-render-headers-stream`); @@ -1853,7 +1851,7 @@ describe("App Router Production server (startProdServer)", () => { expect(fetchResolvedAt - start).toBeLessThan(doneAt - start); expect(doneAt - firstAt).toBeGreaterThan(15); } finally { - await new Promise((resolve) => streamServer.close(() => resolve())); + await new Promise((resolve) => streamServer.server.close(() => resolve())); } }); @@ -1865,9 +1863,7 @@ describe("App Router Production server (startProdServer)", () => { const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); const streamServer = await startProdServer({ port: 0, outDir, noCompression: true }); try { - const addr = streamServer.address(); - const port = typeof addr === "object" && addr ? addr.port : 4210; - const streamBaseUrl = `http://localhost:${port}`; + const streamBaseUrl = `http://localhost:${streamServer.port}`; const start = performance.now(); const res = await fetch( @@ -1898,7 +1894,7 @@ describe("App Router Production server (startProdServer)", () => { expect(fetchResolvedAt - start).toBeLessThan(doneAt - start); expect(doneAt - firstAt).toBeGreaterThan(15); } finally { - await new Promise((resolve) => streamServer.close(() => resolve())); + await new Promise((resolve) => streamServer.server.close(() => resolve())); } }); @@ -1948,9 +1944,7 @@ describe("App Router Production server (startProdServer)", () => { }; try { - const addr = rscServer.address(); - const port = typeof addr === "object" && addr ? addr.port : 4210; - const rscBaseUrl = `http://localhost:${port}`; + const rscBaseUrl = `http://localhost:${rscServer.port}`; const rscMiss = await fetch( `${rscBaseUrl}/nextjs-compat/cached-render-headers-rsc-parity.rsc`, @@ -1995,7 +1989,7 @@ describe("App Router Production server (startProdServer)", () => { expect(rscHit.headers.get("www-authenticate")).toBe(expectedWwwAuthenticate); expect(rscHit.headers.getSetCookie()).toEqual(expectedHitSetCookies); } finally { - await new Promise((resolve) => rscServer.close(() => resolve())); + await new Promise((resolve) => rscServer.server.close(() => resolve())); } }); diff --git a/tests/image-optimization-parity.test.ts b/tests/image-optimization-parity.test.ts index 505ce8e1d..10a947fae 100644 --- a/tests/image-optimization-parity.test.ts +++ b/tests/image-optimization-parity.test.ts @@ -30,6 +30,20 @@ async function createImageFixture(router: "app" | "pages"): Promise { if (router === "app") { await fs.rm(path.join(rootDir, "app", "alias-test"), { recursive: true, force: true }); await fs.rm(path.join(rootDir, "app", "baseurl-test"), { recursive: true, force: true }); + const renderResponseHeaderFile = path.join(rootDir, "app", "lib", "render-response-header.ts"); + const renderResponseHeaderImport = path + .relative( + path.dirname(renderResponseHeaderFile), + path.resolve(import.meta.dirname, "../packages/vinext/src/shims/headers.js"), + ) + .replace(/\\/g, "/"); + const normalizedImport = renderResponseHeaderImport.startsWith(".") + ? renderResponseHeaderImport + : `./${renderResponseHeaderImport}`; + await fs.writeFile( + renderResponseHeaderFile, + `export { appendRenderResponseHeader } from ${JSON.stringify(normalizedImport)};\n`, + ); await fs.mkdir(path.join(rootDir, "app", "image-parity"), { recursive: true }); await fs.writeFile( path.join(rootDir, "app", "image-parity", "page.tsx"), From 3979d4436e0a441d3b40ac26141be0bc74ffc134 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Tue, 17 Mar 2026 12:46:50 -0500 Subject: [PATCH 11/11] Align ISR streaming test with main --- tests/app-router.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 42066c113..2567b1729 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -1838,7 +1838,6 @@ describe("App Router Production server (startProdServer)", () => { expect(reader).toBeDefined(); const first = await reader!.read(); - const firstAt = performance.now(); expect(first.done).toBe(false); for (;;) { const chunk = await reader!.read(); @@ -1847,9 +1846,7 @@ describe("App Router Production server (startProdServer)", () => { const doneAt = performance.now(); expect(res.headers.get("x-vinext-cache")).toBe("MISS"); - expect(res.headers.get("x-rendered-late")).toBeNull(); expect(fetchResolvedAt - start).toBeLessThan(doneAt - start); - expect(doneAt - firstAt).toBeGreaterThan(15); } finally { await new Promise((resolve) => streamServer.server.close(() => resolve())); }