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 71ccdcf60..d01c65e49 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -339,7 +339,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, 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"; @@ -453,16 +453,81 @@ 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 = sourceHeaders.getSetCookie(); + for (const [key, value] of sourceHeaders) { + if (key.toLowerCase() === "set-cookie") { + 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 __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; + 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; @@ -1815,11 +1880,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) { @@ -1840,11 +1904,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } // 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; @@ -2068,21 +2131,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 }); } @@ -2118,20 +2176,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // 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)), @@ -2321,10 +2374,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); }, consumeDynamicUsage, + consumeRenderResponseHeaders, executionContext: _getRequestExecutionContext(), - getAndClearPendingCookies, getCollectedFetchTags, - getDraftModeCookieHeader, handler, handlerFn, i18n: __i18nConfig, @@ -2471,6 +2523,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { 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 @@ -2484,15 +2538,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __revalChunks.push(__revalDecoder.decode()); const __freshHtml = __revalChunks.join(""); - const __freshRscData = await __rscDataPromise; const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; }); }, scheduleBackgroundRegeneration: __triggerBackgroundRegeneration, }); if (__cachedPageResponse) { - return __cachedPageResponse; + return __responseWithMiddlewareContext(__cachedPageResponse, _mwCtx); } } @@ -2572,65 +2625,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 @@ -2656,44 +2719,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 } } @@ -2742,6 +2787,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(); @@ -2794,8 +2846,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If we clear context now, headers()/cookies() will fail during rendering. // Context will be cleared when the next request starts (via runWithRequestContext). const __dynamicUsedInRsc = consumeDynamicUsage(); + const __rscResponseRenderHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); + const __rscLateDynamicUsage = __isrRscDataPromise ? peekDynamicUsage() : false; const __rscResponsePolicy = __resolveAppPageRscResponsePolicy({ - dynamicUsedDuringBuild: __dynamicUsedInRsc, + dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage, isDynamicError, isForceDynamic, isForceStatic, @@ -2806,6 +2862,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { middlewareContext: _mwCtx, params, policy: __rscResponsePolicy, + renderHeaders: __rscResponseRenderHeaders, timing: process.env.NODE_ENV !== "production" ? { compileEnd: __compileEnd, @@ -2821,10 +2878,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { capturedRscDataPromise: process.env.NODE_ENV === "production" ? __isrRscDataPromise : null, cleanPathname, consumeDynamicUsage, - dynamicUsedDuringBuild: __dynamicUsedInRsc, + consumeRenderResponseHeaders, + dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage, getPageTags() { return __pageCacheTags(cleanPathname, getCollectedFetchTags()); }, + initialRenderHeaders: __rscResponseRenderHeaders, isrDebug: __isrDebug, isrRscKey: __isrRscKey, isrSet: __isrSet, @@ -2871,7 +2930,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; } @@ -2886,22 +2952,32 @@ 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); // 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. @@ -2931,18 +3007,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // static here so we emit s-maxage=31536000 but skip ISR cache management. if (__htmlResponsePolicy.shouldWriteToCache) { const __isrResponseProd = __buildAppPageHtmlResponse(htmlStream, { - draftCookie, fontLinkHeader, middlewareContext: _mwCtx, policy: __htmlResponsePolicy, + renderHeaders: renderResponseHeaders, timing: __htmlResponseTiming, }); return __finalizeAppPageHtmlCacheResponse(__isrResponseProd, { capturedRscDataPromise: __isrRscDataPromise, cleanPathname, + consumeDynamicUsage, + consumeRenderResponseHeaders, getPageTags: function() { return __pageCacheTags(cleanPathname, getCollectedFetchTags()); }, + initialRenderHeaders: renderResponseHeaders, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, @@ -2957,10 +3036,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return __buildAppPageHtmlResponse(htmlStream, { - draftCookie, fontLinkHeader, middlewareContext: _mwCtx, policy: __htmlResponsePolicy, + renderHeaders: renderResponseHeaders, timing: __htmlResponseTiming, }); } diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index db8073349..606785b52 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1663,6 +1663,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"), }).flatMap(([k, v]) => k.startsWith("next/") ? [ @@ -2926,13 +2927,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, diff --git a/packages/vinext/src/server/app-page-cache.ts b/packages/vinext/src/server/app-page-cache.ts index c2ae91e44..f7e0435dc 100644 --- a/packages/vinext/src/server/app-page-cache.ts +++ b/packages/vinext/src/server/app-page-cache.ts @@ -12,6 +12,7 @@ type AppPageCacheSetter = ( type AppPageBackgroundRegenerator = (key: string, renderFn: () => Promise) => void; export interface AppPageCacheRenderResult { + headers?: Record; html: string; rscData: ArrayBuffer; tags: string[]; @@ -40,7 +41,10 @@ export interface ReadAppPageCacheResponseOptions { export interface FinalizeAppPageHtmlCacheResponseOptions { capturedRscDataPromise: Promise | null; cleanPathname: string; + consumeDynamicUsage: () => boolean; + consumeRenderResponseHeaders: () => Record | undefined; getPageTags: () => string[]; + initialRenderHeaders?: Record; isrDebug?: AppPageDebugLogger; isrHtmlKey: (pathname: string) => string; isrRscKey: (pathname: string) => string; @@ -53,8 +57,10 @@ export interface ScheduleAppPageRscCacheWriteOptions { capturedRscDataPromise: Promise | null; cleanPathname: string; consumeDynamicUsage: () => boolean; + consumeRenderResponseHeaders: () => Record | undefined; dynamicUsedDuringBuild: boolean; getPageTags: () => string[]; + initialRenderHeaders?: Record; isrDebug?: AppPageDebugLogger; isrRscKey: (pathname: string) => string; isrSet: AppPageCacheSetter; @@ -77,6 +83,70 @@ function getCachedAppPageValue(entry: ISRCacheEntry | null): CachedAppPageValue return entry?.value.value && entry.value.value.kind === "APP_PAGE" ? entry.value.value : null; } +function isAppendOnlyResponseHeader(lowerKey: string): boolean { + return ( + lowerKey === "set-cookie" || + lowerKey === "vary" || + lowerKey === "www-authenticate" || + lowerKey === "proxy-authenticate" + ); +} + +function mergeResponseHeaderValues( + targetHeaders: Headers, + key: string, + value: string | string[], + mode: "fallback" | "override", +): void { + 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: Headers, + sourceHeaders: Record | null | undefined, + mode: "fallback" | "override", +): void { + if (!sourceHeaders) { + return; + } + + for (const [key, value] of Object.entries(sourceHeaders)) { + mergeResponseHeaderValues(targetHeaders, key, value, mode); + } +} + +function headersWithCachedHeaders( + baseHeaders: HeadersInit, + cachedHeaders: Record | undefined, +): Headers { + const headers = new Headers(); + mergeResponseHeaders(headers, cachedHeaders, "fallback"); + mergeResponseHeaders(headers, Object.fromEntries(new Headers(baseHeaders)), "override"); + return headers; +} + export function buildAppPageCachedResponse( cachedValue: CachedAppPageValue, options: BuildAppPageCachedResponseOptions, @@ -97,10 +167,13 @@ export function buildAppPageCachedResponse( return new Response(cachedValue.rscData, { status, - headers: { - "Content-Type": "text/x-component; charset=utf-8", - ...headers, - }, + headers: headersWithCachedHeaders( + { + "Content-Type": "text/x-component; charset=utf-8", + ...headers, + }, + cachedValue.headers, + ), }); } @@ -110,10 +183,13 @@ export function buildAppPageCachedResponse( return new Response(cachedValue.html, { status, - headers: { - "Content-Type": "text/html; charset=utf-8", - ...headers, - }, + headers: headersWithCachedHeaders( + { + "Content-Type": "text/html; charset=utf-8", + ...headers, + }, + cachedValue.headers, + ), }); } @@ -157,13 +233,13 @@ export async function readAppPageCacheResponse( await Promise.all([ options.isrSet( options.isrHtmlKey(options.cleanPathname), - buildAppPageCacheValue(revalidatedPage.html, undefined, 200), + buildAppPageCacheValue(revalidatedPage.html, undefined, revalidatedPage.headers, 200), options.revalidateSeconds, revalidatedPage.tags, ), options.isrSet( options.isrRscKey(options.cleanPathname), - buildAppPageCacheValue("", revalidatedPage.rscData, 200), + buildAppPageCacheValue("", revalidatedPage.rscData, revalidatedPage.headers, 200), options.revalidateSeconds, revalidatedPage.tags, ), @@ -225,11 +301,19 @@ export function finalizeAppPageHtmlCacheResponse( } chunks.push(decoder.decode()); + const renderHeadersForCache = + options.consumeRenderResponseHeaders() ?? options.initialRenderHeaders; + + if (options.consumeDynamicUsage()) { + options.isrDebug?.("skip HTML cache write after late dynamic usage", options.cleanPathname); + return; + } + const pageTags = options.getPageTags(); const writes = [ options.isrSet( htmlKey, - buildAppPageCacheValue(chunks.join(""), undefined, 200), + buildAppPageCacheValue(chunks.join(""), undefined, renderHeadersForCache, 200), options.revalidateSeconds, pageTags, ), @@ -240,7 +324,7 @@ export function finalizeAppPageHtmlCacheResponse( options.capturedRscDataPromise.then((rscData) => options.isrSet( rscKey, - buildAppPageCacheValue("", rscData, 200), + buildAppPageCacheValue("", rscData, renderHeadersForCache, 200), options.revalidateSeconds, pageTags, ), @@ -252,6 +336,8 @@ export function finalizeAppPageHtmlCacheResponse( options.isrDebug?.("HTML cache written", htmlKey); } catch (cacheError) { console.error("[vinext] ISR cache write error:", cacheError); + } finally { + options.consumeRenderResponseHeaders(); } })(); @@ -276,6 +362,8 @@ export function scheduleAppPageRscCacheWrite( const cachePromise = (async () => { try { const rscData = await capturedRscDataPromise; + const renderHeadersForCache = + options.consumeRenderResponseHeaders() ?? options.initialRenderHeaders; // Two-phase dynamic detection: // 1. dynamicUsedDuringBuild catches searchParams-driven opt-in before the @@ -289,7 +377,7 @@ export function scheduleAppPageRscCacheWrite( await options.isrSet( rscKey, - buildAppPageCacheValue("", rscData, 200), + buildAppPageCacheValue("", rscData, renderHeadersForCache, 200), options.revalidateSeconds, options.getPageTags(), ); diff --git a/packages/vinext/src/server/app-page-response.ts b/packages/vinext/src/server/app-page-response.ts index 0193a922e..c67eb708d 100644 --- a/packages/vinext/src/server/app-page-response.ts +++ b/packages/vinext/src/server/app-page-response.ts @@ -39,6 +39,7 @@ export interface BuildAppPageRscResponseOptions { middlewareContext: AppPageMiddlewareContext; params?: Record; policy: AppPageResponsePolicy; + renderHeaders?: Record; timing?: AppPageResponseTiming; } @@ -47,16 +48,98 @@ export interface BuildAppPageHtmlResponseOptions { fontLinkHeader?: string; middlewareContext: AppPageMiddlewareContext; policy: AppPageResponsePolicy; + renderHeaders?: Record; timing?: AppPageResponseTiming; } const STATIC_CACHE_CONTROL = "s-maxage=31536000, stale-while-revalidate"; const NO_STORE_CACHE_CONTROL = "no-store, must-revalidate"; +type ResponseHeaderSource = Headers | Record; +type ResponseHeaderMergeMode = "fallback" | "override"; + function buildRevalidateCacheControl(revalidateSeconds: number): string { return `s-maxage=${revalidateSeconds}, stale-while-revalidate`; } +function isAppendOnlyResponseHeader(lowerKey: string): boolean { + return ( + lowerKey === "set-cookie" || + lowerKey === "vary" || + lowerKey === "www-authenticate" || + lowerKey === "proxy-authenticate" + ); +} + +function mergeResponseHeaderValues( + targetHeaders: Headers, + key: string, + value: string | string[], + mode: ResponseHeaderMergeMode, +): void { + 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: Headers, + sourceHeaders: ResponseHeaderSource | null | undefined, + mode: ResponseHeaderMergeMode, +): void { + if (!sourceHeaders) { + return; + } + + if (sourceHeaders instanceof Headers) { + const setCookies = sourceHeaders.getSetCookie(); + for (const [key, value] of sourceHeaders) { + if (key.toLowerCase() === "set-cookie") { + 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: ResponseHeaderSource, + renderHeaders: Record | undefined, +): Headers { + const headers = new Headers(); + mergeResponseHeaders(headers, renderHeaders, "fallback"); + mergeResponseHeaders(headers, baseHeaders, "override"); + return headers; +} + function applyTimingHeader(headers: Headers, timing?: AppPageResponseTiming): void { if (!timing) { return; @@ -161,36 +244,24 @@ export function buildAppPageRscResponse( body: ReadableStream, options: BuildAppPageRscResponseOptions, ): Response { - const headers = new Headers({ + const baseHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", Vary: "RSC, Accept", }); if (options.params && Object.keys(options.params).length > 0) { - headers.set("X-Vinext-Params", JSON.stringify(options.params)); + baseHeaders.set("X-Vinext-Params", JSON.stringify(options.params)); } if (options.policy.cacheControl) { - headers.set("Cache-Control", options.policy.cacheControl); + baseHeaders.set("Cache-Control", options.policy.cacheControl); } if (options.policy.cacheState) { - headers.set("X-Vinext-Cache", options.policy.cacheState); - } - - if (options.middlewareContext.headers) { - for (const [key, value] of options.middlewareContext.headers) { - const lowerKey = key.toLowerCase(); - if (lowerKey === "set-cookie" || lowerKey === "vary") { - headers.append(key, value); - } else { - // Keep parity with the old inline RSC path: middleware owns singular - // response headers like Cache-Control here, while Set-Cookie and Vary - // are accumulated. The HTML helper intentionally keeps its legacy - // append-for-everything behavior below. - headers.set(key, value); - } - } + baseHeaders.set("X-Vinext-Cache", options.policy.cacheState); } + const headers = headersWithRenderResponseHeaders(baseHeaders, options.renderHeaders); + mergeResponseHeaders(headers, options.middlewareContext.headers, "override"); + applyTimingHeader(headers, options.timing); return new Response(body, { @@ -203,29 +274,26 @@ export function buildAppPageHtmlResponse( body: ReadableStream, options: BuildAppPageHtmlResponseOptions, ): Response { - const headers = new Headers({ + const baseHeaders = new Headers({ "Content-Type": "text/html; charset=utf-8", Vary: "RSC, Accept", }); if (options.policy.cacheControl) { - headers.set("Cache-Control", options.policy.cacheControl); + baseHeaders.set("Cache-Control", options.policy.cacheControl); } if (options.policy.cacheState) { - headers.set("X-Vinext-Cache", options.policy.cacheState); + baseHeaders.set("X-Vinext-Cache", options.policy.cacheState); } if (options.draftCookie) { - headers.append("Set-Cookie", options.draftCookie); + baseHeaders.append("Set-Cookie", options.draftCookie); } if (options.fontLinkHeader) { - headers.set("Link", options.fontLinkHeader); + baseHeaders.set("Link", options.fontLinkHeader); } - if (options.middlewareContext.headers) { - for (const [key, value] of options.middlewareContext.headers) { - headers.append(key, value); - } - } + const headers = headersWithRenderResponseHeaders(baseHeaders, options.renderHeaders); + mergeResponseHeaders(headers, options.middlewareContext.headers, "override"); applyTimingHeader(headers, options.timing); diff --git a/packages/vinext/src/server/app-route-handler-execution.ts b/packages/vinext/src/server/app-route-handler-execution.ts index e61e6d6b8..82d49d77b 100644 --- a/packages/vinext/src/server/app-route-handler-execution.ts +++ b/packages/vinext/src/server/app-route-handler-execution.ts @@ -60,10 +60,9 @@ export interface ExecuteAppRouteHandlerOptions extends RunAppRouteHandlerOptions buildPageCacheTags: (pathname: string, extraTags: string[]) => string[]; clearRequestContext: () => void; cleanPathname: string; + consumeRenderResponseHeaders: () => Record | undefined; executionContext: ExecutionContextLike | null; - getAndClearPendingCookies: () => string[]; getCollectedFetchTags: () => string[]; - getDraftModeCookieHeader: () => string | null | undefined; handler: AppRouteHandlerModule; isAutoHead: boolean; isProduction: boolean; @@ -162,37 +161,50 @@ export async function executeAppRouteHandler( options.executionContext?.waitUntil(routeWritePromise); } - const pendingCookies = options.getAndClearPendingCookies(); - const draftCookie = options.getDraftModeCookieHeader(); + const renderResponseHeaders = options.consumeRenderResponseHeaders(); options.clearRequestContext(); return applyRouteHandlerMiddlewareContext( finalizeRouteHandlerResponse(response, { - pendingCookies, - draftCookie, isHead: options.isAutoHead, + renderHeaders: renderResponseHeaders, }), options.middlewareContext, ); } catch (error) { - options.getAndClearPendingCookies(); + const renderResponseHeaders = options.consumeRenderResponseHeaders(); const specialError = resolveAppRouteHandlerSpecialError(error, options.request.url); options.clearRequestContext(); if (specialError) { if (specialError.kind === "redirect") { return applyRouteHandlerMiddlewareContext( - new Response(null, { - status: specialError.statusCode, - headers: { Location: specialError.location }, - }), + finalizeRouteHandlerResponse( + new Response(null, { + status: specialError.statusCode, + headers: { Location: specialError.location }, + }), + { + isHead: false, + renderHeaders: renderResponseHeaders, + }, + ), options.middlewareContext, + { + applyRewriteStatus: false, + }, ); } return applyRouteHandlerMiddlewareContext( - new Response(null, { status: specialError.statusCode }), + finalizeRouteHandlerResponse(new Response(null, { status: specialError.statusCode }), { + isHead: false, + renderHeaders: renderResponseHeaders, + }), options.middlewareContext, + { + applyRewriteStatus: false, + }, ); } diff --git a/packages/vinext/src/server/app-route-handler-response.ts b/packages/vinext/src/server/app-route-handler-response.ts index 51c30046e..68d028345 100644 --- a/packages/vinext/src/server/app-route-handler-response.ts +++ b/packages/vinext/src/server/app-route-handler-response.ts @@ -11,12 +11,18 @@ export interface BuildRouteHandlerCachedResponseOptions { revalidateSeconds: number; } +export interface ApplyRouteHandlerMiddlewareContextOptions { + applyRewriteStatus?: boolean; +} + export interface FinalizeRouteHandlerResponseOptions { - pendingCookies: string[]; - draftCookie?: string | null; isHead: boolean; + renderHeaders?: Record; } +type ResponseHeaderSource = Headers | Record; +type ResponseHeaderMergeMode = "fallback" | "override"; + function buildRouteHandlerCacheControl( cacheState: BuildRouteHandlerCachedResponseOptions["cacheState"], revalidateSeconds: number, @@ -28,26 +34,109 @@ function buildRouteHandlerCacheControl( return `s-maxage=${revalidateSeconds}, stale-while-revalidate`; } +function isAppendOnlyResponseHeader(lowerKey: string): boolean { + return ( + lowerKey === "set-cookie" || + lowerKey === "vary" || + lowerKey === "www-authenticate" || + lowerKey === "proxy-authenticate" + ); +} + +function mergeResponseHeaderValues( + targetHeaders: Headers, + key: string, + value: string | string[], + mode: ResponseHeaderMergeMode, +): void { + 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: Headers, + sourceHeaders: ResponseHeaderSource | null | undefined, + mode: ResponseHeaderMergeMode, +): void { + if (!sourceHeaders) { + return; + } + + if (sourceHeaders instanceof Headers) { + const setCookies = sourceHeaders.getSetCookie(); + for (const [key, value] of sourceHeaders) { + if (key.toLowerCase() === "set-cookie") { + 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: ResponseHeaderSource, + renderHeaders: Record | undefined, +): Headers { + const headers = new Headers(); + mergeResponseHeaders(headers, renderHeaders, "fallback"); + mergeResponseHeaders(headers, baseHeaders, "override"); + return headers; +} + export function applyRouteHandlerMiddlewareContext( response: Response, middlewareContext: RouteHandlerMiddlewareContext, + options?: ApplyRouteHandlerMiddlewareContextOptions, ): Response { - if (!middlewareContext.headers && middlewareContext.status == null) { + const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareContext.status; + + if (!middlewareContext.headers && rewriteStatus == null) { return response; } const responseHeaders = new Headers(response.headers); - if (middlewareContext.headers) { - for (const [key, value] of middlewareContext.headers) { - responseHeaders.append(key, value); - } - } + mergeResponseHeaders(responseHeaders, middlewareContext.headers, "override"); - return new Response(response.body, { - status: middlewareContext.status ?? response.status, - statusText: response.statusText, + const status = rewriteStatus ?? response.status; + const responseInit: ResponseInit = { + status, headers: responseHeaders, - }); + }; + + if (status === response.status && response.statusText) { + responseInit.statusText = response.statusText; + } + + return new Response(response.body, responseInit); } export function buildRouteHandlerCachedResponse( @@ -109,22 +198,20 @@ export function finalizeRouteHandlerResponse( response: Response, options: FinalizeRouteHandlerResponseOptions, ): Response { - const { pendingCookies, draftCookie, isHead } = options; - if (pendingCookies.length === 0 && !draftCookie && !isHead) { + const { isHead, renderHeaders } = options; + if (!renderHeaders && !isHead) { return response; } - const headers = new Headers(response.headers); - for (const cookie of pendingCookies) { - headers.append("Set-Cookie", cookie); - } - if (draftCookie) { - headers.append("Set-Cookie", draftCookie); - } - - return new Response(isHead ? null : response.body, { + const headers = headersWithRenderResponseHeaders(response.headers, renderHeaders); + const responseInit: ResponseInit = { status: response.status, - statusText: response.statusText, headers, - }); + }; + + if (response.statusText) { + responseInit.statusText = response.statusText; + } + + return new Response(isHead ? null : response.body, responseInit); } 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..af49eeaae 100644 --- a/packages/vinext/src/shims/headers.ts +++ b/packages/vinext/src/shims/headers.ts @@ -32,14 +32,91 @@ export interface HeadersContext { export type HeadersAccessPhase = "render" | "action" | "route-handler"; +type RenderSetCookieSource = "cookie" | "draft" | "header"; + +interface RenderSetCookieEntry { + source: RenderSetCookieSource; + value: string; +} + +interface RenderResponseHeaderEntry { + name: string; + values: string[]; +} + +interface RenderResponseHeaders { + headers: Map; + setCookies: RenderSetCookieEntry[]; +} + export 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 { + if (renderResponseHeaders.headers.size === 0 && renderResponseHeaders.setCookies.length === 0) { + return 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,8 +133,7 @@ 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(); @@ -66,7 +142,31 @@ 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 +242,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, @@ -183,8 +291,13 @@ export function setHeadersContext(ctx: HeadersContext | null): void { if (ctx !== null) { state.headersContext = ctx; state.dynamicUsageDetected = false; - state.pendingSetCookies = []; - state.draftModeCookieHeader = null; + state.renderResponseHeaders = createRenderResponseHeaders(); + const legacyState = state as VinextHeadersShimState & { + pendingSetCookies?: string[]; + draftModeCookieHeader?: string | null; + }; + legacyState.pendingSetCookies = []; + legacyState.draftModeCookieHeader = null; state.phase = "render"; } else { state.headersContext = null; @@ -212,6 +325,7 @@ export function runWithHeadersContext( uCtx.dynamicUsageDetected = false; uCtx.pendingSetCookies = []; uCtx.draftModeCookieHeader = null; + uCtx.renderResponseHeaders = createRenderResponseHeaders(); uCtx.phase = "render"; }, fn); } @@ -219,8 +333,7 @@ export function runWithHeadersContext( const state: VinextHeadersShimState = { headersContext: ctx, dynamicUsageDetected: false, - pendingSetCookies: [], - draftModeCookieHeader: null, + renderResponseHeaders: createRenderResponseHeaders(), phase: "render", }; @@ -566,11 +679,19 @@ 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(); - 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; } @@ -597,12 +718,60 @@ 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(); - 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 +811,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 +826,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", + ); }, }; } @@ -783,12 +960,12 @@ 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; @@ -805,7 +982,7 @@ class RequestCookies { 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", 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 1c12f3069..34dea0d33 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -43,7 +43,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, 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"; @@ -157,16 +157,81 @@ 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 = sourceHeaders.getSetCookie(); + for (const [key, value] of sourceHeaders) { + if (key.toLowerCase() === "set-cookie") { + 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 __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; + 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; @@ -1765,21 +1830,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 }); } @@ -1815,20 +1875,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // 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)), @@ -1993,10 +2048,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); }, consumeDynamicUsage, + consumeRenderResponseHeaders, executionContext: _getRequestExecutionContext(), - getAndClearPendingCookies, getCollectedFetchTags, - getDraftModeCookieHeader, handler, handlerFn, i18n: __i18nConfig, @@ -2143,6 +2197,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { 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 @@ -2156,15 +2212,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __revalChunks.push(__revalDecoder.decode()); const __freshHtml = __revalChunks.join(""); - const __freshRscData = await __rscDataPromise; const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; }); }, scheduleBackgroundRegeneration: __triggerBackgroundRegeneration, }); if (__cachedPageResponse) { - return __cachedPageResponse; + return __responseWithMiddlewareContext(__cachedPageResponse, _mwCtx); } } @@ -2244,65 +2299,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 @@ -2328,44 +2393,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 } } @@ -2414,6 +2461,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(); @@ -2466,8 +2520,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If we clear context now, headers()/cookies() will fail during rendering. // Context will be cleared when the next request starts (via runWithRequestContext). const __dynamicUsedInRsc = consumeDynamicUsage(); + const __rscResponseRenderHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); + const __rscLateDynamicUsage = __isrRscDataPromise ? peekDynamicUsage() : false; const __rscResponsePolicy = __resolveAppPageRscResponsePolicy({ - dynamicUsedDuringBuild: __dynamicUsedInRsc, + dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage, isDynamicError, isForceDynamic, isForceStatic, @@ -2478,6 +2536,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { middlewareContext: _mwCtx, params, policy: __rscResponsePolicy, + renderHeaders: __rscResponseRenderHeaders, timing: process.env.NODE_ENV !== "production" ? { compileEnd: __compileEnd, @@ -2493,10 +2552,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { capturedRscDataPromise: process.env.NODE_ENV === "production" ? __isrRscDataPromise : null, cleanPathname, consumeDynamicUsage, - dynamicUsedDuringBuild: __dynamicUsedInRsc, + consumeRenderResponseHeaders, + dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage, getPageTags() { return __pageCacheTags(cleanPathname, getCollectedFetchTags()); }, + initialRenderHeaders: __rscResponseRenderHeaders, isrDebug: __isrDebug, isrRscKey: __isrRscKey, isrSet: __isrSet, @@ -2543,7 +2604,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; } @@ -2553,15 +2621,18 @@ 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); // 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. @@ -2591,18 +2662,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // static here so we emit s-maxage=31536000 but skip ISR cache management. if (__htmlResponsePolicy.shouldWriteToCache) { const __isrResponseProd = __buildAppPageHtmlResponse(htmlStream, { - draftCookie, fontLinkHeader, middlewareContext: _mwCtx, policy: __htmlResponsePolicy, + renderHeaders: renderResponseHeaders, timing: __htmlResponseTiming, }); return __finalizeAppPageHtmlCacheResponse(__isrResponseProd, { capturedRscDataPromise: __isrRscDataPromise, cleanPathname, + consumeDynamicUsage, + consumeRenderResponseHeaders, getPageTags: function() { return __pageCacheTags(cleanPathname, getCollectedFetchTags()); }, + initialRenderHeaders: renderResponseHeaders, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, @@ -2617,10 +2691,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return __buildAppPageHtmlResponse(htmlStream, { - draftCookie, fontLinkHeader, middlewareContext: _mwCtx, policy: __htmlResponsePolicy, + renderHeaders: renderResponseHeaders, timing: __htmlResponseTiming, }); } @@ -2672,7 +2746,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, 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"; @@ -2786,16 +2860,81 @@ 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 = sourceHeaders.getSetCookie(); + for (const [key, value] of sourceHeaders) { + if (key.toLowerCase() === "set-cookie") { + 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 __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; + 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; @@ -4397,21 +4536,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 }); } @@ -4447,20 +4581,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // 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)), @@ -4625,10 +4754,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); }, consumeDynamicUsage, + consumeRenderResponseHeaders, executionContext: _getRequestExecutionContext(), - getAndClearPendingCookies, getCollectedFetchTags, - getDraftModeCookieHeader, handler, handlerFn, i18n: __i18nConfig, @@ -4775,6 +4903,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { 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 @@ -4788,15 +4918,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __revalChunks.push(__revalDecoder.decode()); const __freshHtml = __revalChunks.join(""); - const __freshRscData = await __rscDataPromise; const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; }); }, scheduleBackgroundRegeneration: __triggerBackgroundRegeneration, }); if (__cachedPageResponse) { - return __cachedPageResponse; + return __responseWithMiddlewareContext(__cachedPageResponse, _mwCtx); } } @@ -4876,65 +5005,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 @@ -4960,44 +5099,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 } } @@ -5046,6 +5167,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(); @@ -5098,8 +5226,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If we clear context now, headers()/cookies() will fail during rendering. // Context will be cleared when the next request starts (via runWithRequestContext). const __dynamicUsedInRsc = consumeDynamicUsage(); + const __rscResponseRenderHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); + const __rscLateDynamicUsage = __isrRscDataPromise ? peekDynamicUsage() : false; const __rscResponsePolicy = __resolveAppPageRscResponsePolicy({ - dynamicUsedDuringBuild: __dynamicUsedInRsc, + dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage, isDynamicError, isForceDynamic, isForceStatic, @@ -5110,6 +5242,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { middlewareContext: _mwCtx, params, policy: __rscResponsePolicy, + renderHeaders: __rscResponseRenderHeaders, timing: process.env.NODE_ENV !== "production" ? { compileEnd: __compileEnd, @@ -5125,10 +5258,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { capturedRscDataPromise: process.env.NODE_ENV === "production" ? __isrRscDataPromise : null, cleanPathname, consumeDynamicUsage, - dynamicUsedDuringBuild: __dynamicUsedInRsc, + consumeRenderResponseHeaders, + dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage, getPageTags() { return __pageCacheTags(cleanPathname, getCollectedFetchTags()); }, + initialRenderHeaders: __rscResponseRenderHeaders, isrDebug: __isrDebug, isrRscKey: __isrRscKey, isrSet: __isrSet, @@ -5175,7 +5310,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; } @@ -5185,15 +5327,18 @@ 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); // 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. @@ -5223,18 +5368,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // static here so we emit s-maxage=31536000 but skip ISR cache management. if (__htmlResponsePolicy.shouldWriteToCache) { const __isrResponseProd = __buildAppPageHtmlResponse(htmlStream, { - draftCookie, fontLinkHeader, middlewareContext: _mwCtx, policy: __htmlResponsePolicy, + renderHeaders: renderResponseHeaders, timing: __htmlResponseTiming, }); return __finalizeAppPageHtmlCacheResponse(__isrResponseProd, { capturedRscDataPromise: __isrRscDataPromise, cleanPathname, + consumeDynamicUsage, + consumeRenderResponseHeaders, getPageTags: function() { return __pageCacheTags(cleanPathname, getCollectedFetchTags()); }, + initialRenderHeaders: renderResponseHeaders, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, @@ -5249,10 +5397,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return __buildAppPageHtmlResponse(htmlStream, { - draftCookie, fontLinkHeader, middlewareContext: _mwCtx, policy: __htmlResponsePolicy, + renderHeaders: renderResponseHeaders, timing: __htmlResponseTiming, }); } @@ -5304,7 +5452,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, 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"; @@ -5418,16 +5566,81 @@ 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 = sourceHeaders.getSetCookie(); + for (const [key, value] of sourceHeaders) { + if (key.toLowerCase() === "set-cookie") { + 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 __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; + 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; @@ -7056,21 +7269,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 }); } @@ -7106,20 +7314,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // 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)), @@ -7284,10 +7487,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); }, consumeDynamicUsage, + consumeRenderResponseHeaders, executionContext: _getRequestExecutionContext(), - getAndClearPendingCookies, getCollectedFetchTags, - getDraftModeCookieHeader, handler, handlerFn, i18n: __i18nConfig, @@ -7434,6 +7636,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { 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 @@ -7447,15 +7651,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __revalChunks.push(__revalDecoder.decode()); const __freshHtml = __revalChunks.join(""); - const __freshRscData = await __rscDataPromise; const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; }); }, scheduleBackgroundRegeneration: __triggerBackgroundRegeneration, }); if (__cachedPageResponse) { - return __cachedPageResponse; + return __responseWithMiddlewareContext(__cachedPageResponse, _mwCtx); } } @@ -7535,65 +7738,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 @@ -7619,44 +7832,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 } } @@ -7705,6 +7900,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(); @@ -7757,8 +7959,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If we clear context now, headers()/cookies() will fail during rendering. // Context will be cleared when the next request starts (via runWithRequestContext). const __dynamicUsedInRsc = consumeDynamicUsage(); + const __rscResponseRenderHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); + const __rscLateDynamicUsage = __isrRscDataPromise ? peekDynamicUsage() : false; const __rscResponsePolicy = __resolveAppPageRscResponsePolicy({ - dynamicUsedDuringBuild: __dynamicUsedInRsc, + dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage, isDynamicError, isForceDynamic, isForceStatic, @@ -7769,6 +7975,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { middlewareContext: _mwCtx, params, policy: __rscResponsePolicy, + renderHeaders: __rscResponseRenderHeaders, timing: process.env.NODE_ENV !== "production" ? { compileEnd: __compileEnd, @@ -7784,10 +7991,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { capturedRscDataPromise: process.env.NODE_ENV === "production" ? __isrRscDataPromise : null, cleanPathname, consumeDynamicUsage, - dynamicUsedDuringBuild: __dynamicUsedInRsc, + consumeRenderResponseHeaders, + dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage, getPageTags() { return __pageCacheTags(cleanPathname, getCollectedFetchTags()); }, + initialRenderHeaders: __rscResponseRenderHeaders, isrDebug: __isrDebug, isrRscKey: __isrRscKey, isrSet: __isrSet, @@ -7834,7 +8043,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; } @@ -7847,20 +8063,30 @@ 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); // 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. @@ -7890,18 +8116,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // static here so we emit s-maxage=31536000 but skip ISR cache management. if (__htmlResponsePolicy.shouldWriteToCache) { const __isrResponseProd = __buildAppPageHtmlResponse(htmlStream, { - draftCookie, fontLinkHeader, middlewareContext: _mwCtx, policy: __htmlResponsePolicy, + renderHeaders: renderResponseHeaders, timing: __htmlResponseTiming, }); return __finalizeAppPageHtmlCacheResponse(__isrResponseProd, { capturedRscDataPromise: __isrRscDataPromise, cleanPathname, + consumeDynamicUsage, + consumeRenderResponseHeaders, getPageTags: function() { return __pageCacheTags(cleanPathname, getCollectedFetchTags()); }, + initialRenderHeaders: renderResponseHeaders, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, @@ -7916,10 +8145,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return __buildAppPageHtmlResponse(htmlStream, { - draftCookie, fontLinkHeader, middlewareContext: _mwCtx, policy: __htmlResponsePolicy, + renderHeaders: renderResponseHeaders, timing: __htmlResponseTiming, }); } @@ -7971,7 +8200,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, 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"; @@ -8085,16 +8314,81 @@ 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 = sourceHeaders.getSetCookie(); + for (const [key, value] of sourceHeaders) { + if (key.toLowerCase() === "set-cookie") { + 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 __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; + 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; @@ -9726,21 +10020,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 }); } @@ -9776,20 +10065,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // 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)), @@ -9954,10 +10238,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); }, consumeDynamicUsage, + consumeRenderResponseHeaders, executionContext: _getRequestExecutionContext(), - getAndClearPendingCookies, getCollectedFetchTags, - getDraftModeCookieHeader, handler, handlerFn, i18n: __i18nConfig, @@ -10104,6 +10387,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { 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 @@ -10117,15 +10402,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __revalChunks.push(__revalDecoder.decode()); const __freshHtml = __revalChunks.join(""); - const __freshRscData = await __rscDataPromise; const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; }); }, scheduleBackgroundRegeneration: __triggerBackgroundRegeneration, }); if (__cachedPageResponse) { - return __cachedPageResponse; + return __responseWithMiddlewareContext(__cachedPageResponse, _mwCtx); } } @@ -10205,65 +10489,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 @@ -10289,44 +10583,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 } } @@ -10375,6 +10651,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(); @@ -10427,8 +10710,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If we clear context now, headers()/cookies() will fail during rendering. // Context will be cleared when the next request starts (via runWithRequestContext). const __dynamicUsedInRsc = consumeDynamicUsage(); + const __rscResponseRenderHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); + const __rscLateDynamicUsage = __isrRscDataPromise ? peekDynamicUsage() : false; const __rscResponsePolicy = __resolveAppPageRscResponsePolicy({ - dynamicUsedDuringBuild: __dynamicUsedInRsc, + dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage, isDynamicError, isForceDynamic, isForceStatic, @@ -10439,6 +10726,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { middlewareContext: _mwCtx, params, policy: __rscResponsePolicy, + renderHeaders: __rscResponseRenderHeaders, timing: process.env.NODE_ENV !== "production" ? { compileEnd: __compileEnd, @@ -10454,10 +10742,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { capturedRscDataPromise: process.env.NODE_ENV === "production" ? __isrRscDataPromise : null, cleanPathname, consumeDynamicUsage, - dynamicUsedDuringBuild: __dynamicUsedInRsc, + consumeRenderResponseHeaders, + dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage, getPageTags() { return __pageCacheTags(cleanPathname, getCollectedFetchTags()); }, + initialRenderHeaders: __rscResponseRenderHeaders, isrDebug: __isrDebug, isrRscKey: __isrRscKey, isrSet: __isrSet, @@ -10504,7 +10794,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; } @@ -10514,15 +10811,18 @@ 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); // 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. @@ -10552,18 +10852,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // static here so we emit s-maxage=31536000 but skip ISR cache management. if (__htmlResponsePolicy.shouldWriteToCache) { const __isrResponseProd = __buildAppPageHtmlResponse(htmlStream, { - draftCookie, fontLinkHeader, middlewareContext: _mwCtx, policy: __htmlResponsePolicy, + renderHeaders: renderResponseHeaders, timing: __htmlResponseTiming, }); return __finalizeAppPageHtmlCacheResponse(__isrResponseProd, { capturedRscDataPromise: __isrRscDataPromise, cleanPathname, + consumeDynamicUsage, + consumeRenderResponseHeaders, getPageTags: function() { return __pageCacheTags(cleanPathname, getCollectedFetchTags()); }, + initialRenderHeaders: renderResponseHeaders, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, @@ -10578,10 +10881,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return __buildAppPageHtmlResponse(htmlStream, { - draftCookie, fontLinkHeader, middlewareContext: _mwCtx, policy: __htmlResponsePolicy, + renderHeaders: renderResponseHeaders, timing: __htmlResponseTiming, }); } @@ -10633,7 +10936,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, 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"; @@ -10747,16 +11050,81 @@ 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 = sourceHeaders.getSetCookie(); + for (const [key, value] of sourceHeaders) { + if (key.toLowerCase() === "set-cookie") { + 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 __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; + 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; @@ -12362,21 +12730,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 }); } @@ -12412,20 +12775,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // 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)), @@ -12590,10 +12948,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); }, consumeDynamicUsage, + consumeRenderResponseHeaders, executionContext: _getRequestExecutionContext(), - getAndClearPendingCookies, getCollectedFetchTags, - getDraftModeCookieHeader, handler, handlerFn, i18n: __i18nConfig, @@ -12740,6 +13097,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { 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 @@ -12753,15 +13112,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __revalChunks.push(__revalDecoder.decode()); const __freshHtml = __revalChunks.join(""); - const __freshRscData = await __rscDataPromise; const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; }); }, scheduleBackgroundRegeneration: __triggerBackgroundRegeneration, }); if (__cachedPageResponse) { - return __cachedPageResponse; + return __responseWithMiddlewareContext(__cachedPageResponse, _mwCtx); } } @@ -12841,65 +13199,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 @@ -12925,44 +13293,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 } } @@ -13011,6 +13361,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(); @@ -13063,8 +13420,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If we clear context now, headers()/cookies() will fail during rendering. // Context will be cleared when the next request starts (via runWithRequestContext). const __dynamicUsedInRsc = consumeDynamicUsage(); + const __rscResponseRenderHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); + const __rscLateDynamicUsage = __isrRscDataPromise ? peekDynamicUsage() : false; const __rscResponsePolicy = __resolveAppPageRscResponsePolicy({ - dynamicUsedDuringBuild: __dynamicUsedInRsc, + dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage, isDynamicError, isForceDynamic, isForceStatic, @@ -13075,6 +13436,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { middlewareContext: _mwCtx, params, policy: __rscResponsePolicy, + renderHeaders: __rscResponseRenderHeaders, timing: process.env.NODE_ENV !== "production" ? { compileEnd: __compileEnd, @@ -13090,10 +13452,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { capturedRscDataPromise: process.env.NODE_ENV === "production" ? __isrRscDataPromise : null, cleanPathname, consumeDynamicUsage, - dynamicUsedDuringBuild: __dynamicUsedInRsc, + consumeRenderResponseHeaders, + dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage, getPageTags() { return __pageCacheTags(cleanPathname, getCollectedFetchTags()); }, + initialRenderHeaders: __rscResponseRenderHeaders, isrDebug: __isrDebug, isrRscKey: __isrRscKey, isrSet: __isrSet, @@ -13140,7 +13504,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; } @@ -13150,15 +13521,18 @@ 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); // 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. @@ -13188,18 +13562,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // static here so we emit s-maxage=31536000 but skip ISR cache management. if (__htmlResponsePolicy.shouldWriteToCache) { const __isrResponseProd = __buildAppPageHtmlResponse(htmlStream, { - draftCookie, fontLinkHeader, middlewareContext: _mwCtx, policy: __htmlResponsePolicy, + renderHeaders: renderResponseHeaders, timing: __htmlResponseTiming, }); return __finalizeAppPageHtmlCacheResponse(__isrResponseProd, { capturedRscDataPromise: __isrRscDataPromise, cleanPathname, + consumeDynamicUsage, + consumeRenderResponseHeaders, getPageTags: function() { return __pageCacheTags(cleanPathname, getCollectedFetchTags()); }, + initialRenderHeaders: renderResponseHeaders, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, @@ -13214,10 +13591,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return __buildAppPageHtmlResponse(htmlStream, { - draftCookie, fontLinkHeader, middlewareContext: _mwCtx, policy: __htmlResponsePolicy, + renderHeaders: renderResponseHeaders, timing: __htmlResponseTiming, }); } @@ -13269,7 +13646,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, 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"; @@ -13383,16 +13760,81 @@ 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 = sourceHeaders.getSetCookie(); + for (const [key, value] of sourceHeaders) { + if (key.toLowerCase() === "set-cookie") { + 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 __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; + 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; @@ -15100,11 +15542,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) { @@ -15125,11 +15566,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } // 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; @@ -15351,21 +15791,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 }); } @@ -15401,20 +15836,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // 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)), @@ -15579,10 +16009,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); }, consumeDynamicUsage, + consumeRenderResponseHeaders, executionContext: _getRequestExecutionContext(), - getAndClearPendingCookies, getCollectedFetchTags, - getDraftModeCookieHeader, handler, handlerFn, i18n: __i18nConfig, @@ -15729,6 +16158,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { 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 @@ -15742,15 +16173,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __revalChunks.push(__revalDecoder.decode()); const __freshHtml = __revalChunks.join(""); - const __freshRscData = await __rscDataPromise; const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; + return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags }; }); }, scheduleBackgroundRegeneration: __triggerBackgroundRegeneration, }); if (__cachedPageResponse) { - return __cachedPageResponse; + return __responseWithMiddlewareContext(__cachedPageResponse, _mwCtx); } } @@ -15830,65 +16260,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 @@ -15914,44 +16354,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 } } @@ -16000,6 +16422,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(); @@ -16052,8 +16481,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If we clear context now, headers()/cookies() will fail during rendering. // Context will be cleared when the next request starts (via runWithRequestContext). const __dynamicUsedInRsc = consumeDynamicUsage(); + const __rscResponseRenderHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); + const __rscLateDynamicUsage = __isrRscDataPromise ? peekDynamicUsage() : false; const __rscResponsePolicy = __resolveAppPageRscResponsePolicy({ - dynamicUsedDuringBuild: __dynamicUsedInRsc, + dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage, isDynamicError, isForceDynamic, isForceStatic, @@ -16064,6 +16497,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { middlewareContext: _mwCtx, params, policy: __rscResponsePolicy, + renderHeaders: __rscResponseRenderHeaders, timing: process.env.NODE_ENV !== "production" ? { compileEnd: __compileEnd, @@ -16079,10 +16513,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { capturedRscDataPromise: process.env.NODE_ENV === "production" ? __isrRscDataPromise : null, cleanPathname, consumeDynamicUsage, - dynamicUsedDuringBuild: __dynamicUsedInRsc, + consumeRenderResponseHeaders, + dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage, getPageTags() { return __pageCacheTags(cleanPathname, getCollectedFetchTags()); }, + initialRenderHeaders: __rscResponseRenderHeaders, isrDebug: __isrDebug, isrRscKey: __isrRscKey, isrSet: __isrSet, @@ -16129,7 +16565,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; } @@ -16139,15 +16582,18 @@ 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); // 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. @@ -16177,18 +16623,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // static here so we emit s-maxage=31536000 but skip ISR cache management. if (__htmlResponsePolicy.shouldWriteToCache) { const __isrResponseProd = __buildAppPageHtmlResponse(htmlStream, { - draftCookie, fontLinkHeader, middlewareContext: _mwCtx, policy: __htmlResponsePolicy, + renderHeaders: renderResponseHeaders, timing: __htmlResponseTiming, }); return __finalizeAppPageHtmlCacheResponse(__isrResponseProd, { capturedRscDataPromise: __isrRscDataPromise, cleanPathname, + consumeDynamicUsage, + consumeRenderResponseHeaders, getPageTags: function() { return __pageCacheTags(cleanPathname, getCollectedFetchTags()); }, + initialRenderHeaders: renderResponseHeaders, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, @@ -16203,10 +16652,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return __buildAppPageHtmlResponse(htmlStream, { - draftCookie, fontLinkHeader, middlewareContext: _mwCtx, policy: __htmlResponsePolicy, + renderHeaders: renderResponseHeaders, timing: __htmlResponseTiming, }); } diff --git a/tests/app-page-cache.test.ts b/tests/app-page-cache.test.ts index 7746465b9..81b9647eb 100644 --- a/tests/app-page-cache.test.ts +++ b/tests/app-page-cache.test.ts @@ -22,12 +22,13 @@ function buildCachedAppPageValue( html: string, rscData?: ArrayBuffer, status?: number, + headers?: Record, ): CachedAppPageValue { return { kind: "APP_PAGE", html, rscData, - headers: undefined, + headers, postponed: undefined, status, }; @@ -36,7 +37,11 @@ function buildCachedAppPageValue( describe("app page cache helpers", () => { it("builds cached HTML and RSC responses", async () => { const rscData = new TextEncoder().encode("flight").buffer; - const cachedValue = buildCachedAppPageValue("

cached

", rscData, 201); + const cachedValue = buildCachedAppPageValue("

cached

", rscData, 201, { + "set-cookie": "rendered=1; Path=/", + vary: "x-render", + "x-rendered": "yes", + }); const htmlResponse = buildAppPageCachedResponse(cachedValue, { cacheState: "HIT", @@ -46,6 +51,9 @@ describe("app page cache helpers", () => { expect(htmlResponse?.status).toBe(201); expect(htmlResponse?.headers.get("content-type")).toBe("text/html; charset=utf-8"); expect(htmlResponse?.headers.get("x-vinext-cache")).toBe("HIT"); + expect(htmlResponse?.headers.get("vary")).toBe("x-render, RSC, Accept"); + expect(htmlResponse?.headers.get("x-rendered")).toBe("yes"); + expect(htmlResponse?.headers.getSetCookie?.()).toEqual(["rendered=1; Path=/"]); await expect(htmlResponse?.text()).resolves.toBe("

cached

"); const rscResponse = buildAppPageCachedResponse(cachedValue, { @@ -127,6 +135,7 @@ describe("app page cache helpers", () => { it("serves stale entries and regenerates HTML and RSC cache keys", async () => { const scheduledRegenerations: Array<() => Promise> = []; const isrSetCalls: Array<{ + headers: Record | undefined; key: string; html: string; hasRscData: boolean; @@ -150,6 +159,7 @@ describe("app page cache helpers", () => { }, async isrSet(key, data, revalidateSeconds, tags) { isrSetCalls.push({ + headers: data.headers, key, html: data.html, hasRscData: Boolean(data.rscData), @@ -160,6 +170,10 @@ describe("app page cache helpers", () => { revalidateSeconds: 60, async renderFreshPageForCache() { return { + headers: { + "set-cookie": "rendered=1; Path=/", + "x-rendered": "yes", + }, html: "

fresh

", rscData, tags: ["/stale", "_N_T_/stale"], @@ -177,6 +191,10 @@ describe("app page cache helpers", () => { expect(isrSetCalls).toEqual([ { + headers: { + "set-cookie": "rendered=1; Path=/", + "x-rendered": "yes", + }, key: "html:/stale", html: "

fresh

", hasRscData: false, @@ -184,6 +202,10 @@ describe("app page cache helpers", () => { tags: ["/stale", "_N_T_/stale"], }, { + headers: { + "set-cookie": "rendered=1; Path=/", + "x-rendered": "yes", + }, key: "rsc:/stale", html: "", hasRscData: true, @@ -222,6 +244,7 @@ describe("app page cache helpers", () => { revalidateSeconds: 60, async renderFreshPageForCache() { return { + headers: undefined, html: "

fresh

", rscData: new TextEncoder().encode("fresh-flight").buffer, tags: ["/stale-html-miss", "_N_T_/stale-html-miss"], @@ -271,6 +294,7 @@ describe("app page cache helpers", () => { it("finalizes HTML responses by teeing the stream and writing HTML and RSC cache keys", async () => { const pendingCacheWrites: Promise[] = []; const isrSetCalls: Array<{ + headers: Record | undefined; key: string; html: string; hasRscData: boolean; @@ -292,9 +316,21 @@ describe("app page cache helpers", () => { { capturedRscDataPromise: Promise.resolve(rscData), cleanPathname: "/fresh", + consumeDynamicUsage() { + return false; + }, + consumeRenderResponseHeaders() { + return { + "set-cookie": "rendered=1; Path=/", + "x-rendered": "yes", + }; + }, getPageTags() { return ["/fresh", "_N_T_/fresh"]; }, + initialRenderHeaders: { + "set-cookie": "fallback=1; Path=/", + }, isrDebug(event, detail) { debugCalls.push([event, detail]); }, @@ -306,6 +342,7 @@ describe("app page cache helpers", () => { }, async isrSet(key, data, revalidateSeconds, tags) { isrSetCalls.push({ + headers: data.headers, key, html: data.html, hasRscData: Boolean(data.rscData), @@ -328,6 +365,10 @@ describe("app page cache helpers", () => { expect(isrSetCalls).toEqual([ { + headers: { + "set-cookie": "rendered=1; Path=/", + "x-rendered": "yes", + }, key: "html:/fresh", html: "

fresh

", hasRscData: false, @@ -335,6 +376,10 @@ describe("app page cache helpers", () => { tags: ["/fresh", "_N_T_/fresh"], }, { + headers: { + "set-cookie": "rendered=1; Path=/", + "x-rendered": "yes", + }, key: "rsc:/fresh", html: "", hasRscData: true, @@ -349,6 +394,7 @@ describe("app page cache helpers", () => { const pendingCacheWrites: Promise[] = []; const debugCalls: Array<[string, string]> = []; const isrSetCalls: Array<{ + headers: Record | undefined; key: string; html: string; hasRscData: boolean; @@ -362,10 +408,18 @@ describe("app page cache helpers", () => { consumeDynamicUsage() { return false; }, + consumeRenderResponseHeaders() { + return { + "set-cookie": "rendered=1; Path=/", + }; + }, dynamicUsedDuringBuild: false, getPageTags() { return ["/fresh-rsc", "_N_T_/fresh-rsc"]; }, + initialRenderHeaders: { + "set-cookie": "fallback=1; Path=/", + }, isrDebug(event, detail) { debugCalls.push([event, detail]); }, @@ -374,6 +428,7 @@ describe("app page cache helpers", () => { }, async isrSet(key, data, revalidateSeconds, tags) { isrSetCalls.push({ + headers: data.headers, key, html: data.html, hasRscData: Boolean(data.rscData), @@ -394,6 +449,9 @@ describe("app page cache helpers", () => { expect(isrSetCalls).toEqual([ { + headers: { + "set-cookie": "rendered=1; Path=/", + }, key: "rsc:/fresh-rsc", html: "", hasRscData: true, @@ -415,10 +473,18 @@ describe("app page cache helpers", () => { consumeDynamicUsage() { return true; }, + consumeRenderResponseHeaders() { + return { + "set-cookie": "rendered=1; Path=/", + }; + }, dynamicUsedDuringBuild: false, getPageTags() { return ["/dynamic-rsc", "_N_T_/dynamic-rsc"]; }, + initialRenderHeaders: { + "set-cookie": "fallback=1; Path=/", + }, isrDebug(event, detail) { debugCalls.push([event, detail]); }, diff --git a/tests/app-page-response.test.ts b/tests/app-page-response.test.ts index 1a679968f..22dd0bc41 100644 --- a/tests/app-page-response.test.ts +++ b/tests/app-page-response.test.ts @@ -211,6 +211,11 @@ describe("app page response helpers", () => { cacheControl: "s-maxage=60, stale-while-revalidate", cacheState: "MISS", }, + renderHeaders: { + "set-cookie": "rendered=1; Path=/", + vary: "x-render", + "x-rendered": "yes", + }, timing: { compileEnd: 15, handlerStart: 10, @@ -223,18 +228,22 @@ describe("app page response helpers", () => { expect(response.headers.get("x-vinext-params")).toBe('{"slug":"test"}'); expect(response.headers.get("cache-control")).toBe("private, max-age=5"); expect(response.headers.get("x-vinext-cache")).toBe("MISS"); - expect(response.headers.get("vary")).toBe("RSC, Accept, Next-Router-State-Tree"); + expect(response.headers.get("vary")).toBe("x-render, RSC, Accept, Next-Router-State-Tree"); + expect(response.headers.get("x-rendered")).toBe("yes"); + expect(response.headers.getSetCookie?.()).toEqual([ + "rendered=1; Path=/", + "session=abc; Path=/", + ]); expect(response.headers.get("x-vinext-timing")).toBe("10,5,-1"); await expect(response.text()).resolves.toBe("flight"); }); - it("builds HTML responses with draft cookies, preload links, middleware, and timing", async () => { + it("builds HTML responses with render headers, preload links, middleware, and timing", async () => { const middlewareHeaders = new Headers(); middlewareHeaders.append("set-cookie", "mw=1; Path=/"); middlewareHeaders.append("x-extra", "present"); const response = buildAppPageHtmlResponse(createBody("

page

"), { - draftCookie: "__prerender_bypass=token; Path=/", fontLinkHeader: "; rel=preload; as=font; type=font/woff2; crossorigin", middlewareContext: { headers: middlewareHeaders, @@ -244,6 +253,11 @@ describe("app page response helpers", () => { cacheControl: "s-maxage=31536000, stale-while-revalidate", cacheState: "STATIC", }, + renderHeaders: { + "set-cookie": "rendered=1; Path=/", + vary: "x-render", + "x-rendered": "yes", + }, timing: { compileEnd: 12, handlerStart: 10, @@ -259,11 +273,13 @@ describe("app page response helpers", () => { expect(response.headers.get("link")).toBe( "; rel=preload; as=font; type=font/woff2; crossorigin", ); + expect(response.headers.get("vary")).toBe("x-render, RSC, Accept"); + expect(response.headers.get("x-rendered")).toBe("yes"); expect(response.headers.get("x-extra")).toBe("present"); expect(response.headers.get("x-vinext-timing")).toBe("10,2,8"); const setCookies = response.headers.getSetCookie(); - expect(setCookies).toContain("__prerender_bypass=token; Path=/"); + expect(setCookies).toContain("rendered=1; Path=/"); expect(setCookies).toContain("mw=1; Path=/"); await expect(response.text()).resolves.toBe("

page

"); }); diff --git a/tests/app-route-handler-execution.test.ts b/tests/app-route-handler-execution.test.ts index 0b832aa37..70a0843dc 100644 --- a/tests/app-route-handler-execution.test.ts +++ b/tests/app-route-handler-execution.test.ts @@ -69,20 +69,19 @@ describe("app route handler execution helpers", () => { didClearRequestContext = true; }, consumeDynamicUsage: dynamicUsage.consumeDynamicUsage, + consumeRenderResponseHeaders() { + return { + "set-cookie": ["session=1; Path=/", "draft=1; Path=/"], + }; + }, executionContext: { waitUntil(promise) { waitUntilPromises.push(promise); }, }, - getAndClearPendingCookies() { - return ["session=1; Path=/"]; - }, getCollectedFetchTags() { return ["tag:demo"]; }, - getDraftModeCookieHeader() { - return "draft=1; Path=/"; - }, handler: { dynamic: "auto" }, handlerFn() { return new Response("ok", { @@ -153,16 +152,13 @@ describe("app route handler execution helpers", () => { cleanPathname: "/api/dynamic", clearRequestContext() {}, consumeDynamicUsage: dynamicUsage.consumeDynamicUsage, - executionContext: null, - getAndClearPendingCookies() { - return []; + consumeRenderResponseHeaders() { + return undefined; }, + executionContext: null, getCollectedFetchTags() { return []; }, - getDraftModeCookieHeader() { - return null; - }, handler: { dynamic: "auto" }, handlerFn(request) { return Response.json({ @@ -211,16 +207,15 @@ describe("app route handler execution helpers", () => { cleanPathname: "/api/redirect", clearRequestContext() {}, consumeDynamicUsage: dynamicUsage.consumeDynamicUsage, - executionContext: null, - getAndClearPendingCookies() { - return []; + consumeRenderResponseHeaders() { + return { + "set-cookie": "route-special=redirect; Path=/; HttpOnly", + }; }, + executionContext: null, getCollectedFetchTags() { return []; }, - getDraftModeCookieHeader() { - return null; - }, handler: { dynamic: "auto" }, handlerFn() { throw { digest: "NEXT_REDIRECT;replace;%2Ftarget;308" }; @@ -233,7 +228,10 @@ describe("app route handler execution helpers", () => { async isrSet() {}, markDynamicUsage: dynamicUsage.markDynamicUsage, method: "GET", - middlewareContext: { headers: null, status: null }, + middlewareContext: { + headers: new Headers([["x-middleware", "present"]]), + status: 403, + }, params: {}, reportRequestError(error) { reportedErrors.push(error); @@ -248,6 +246,10 @@ describe("app route handler execution helpers", () => { expect(redirectResponse.status).toBe(308); expect(redirectResponse.headers.get("location")).toBe("https://example.com/target"); + expect(redirectResponse.headers.getSetCookie?.()).toEqual([ + "route-special=redirect; Path=/; HttpOnly", + ]); + expect(redirectResponse.headers.get("x-middleware")).toBe("present"); expect(reportedErrors).toEqual([]); const errorResponse = await executeAppRouteHandler({ @@ -257,16 +259,13 @@ describe("app route handler execution helpers", () => { cleanPathname: "/api/error", clearRequestContext() {}, consumeDynamicUsage: dynamicUsage.consumeDynamicUsage, - executionContext: null, - getAndClearPendingCookies() { - return []; + consumeRenderResponseHeaders() { + return undefined; }, + executionContext: null, getCollectedFetchTags() { return []; }, - getDraftModeCookieHeader() { - return null; - }, handler: { dynamic: "auto" }, handlerFn() { throw new Error("boom"); diff --git a/tests/app-route-handler-response.test.ts b/tests/app-route-handler-response.test.ts index 9b7423957..d63f23c17 100644 --- a/tests/app-route-handler-response.test.ts +++ b/tests/app-route-handler-response.test.ts @@ -52,11 +52,46 @@ describe("app route handler response helpers", () => { expect(result.status).toBe(202); expect(result.headers.get("content-type")).toBe("text/plain"); - expect(result.headers.get("x-response")).toBe("app, middleware-copy"); + expect(result.headers.get("x-response")).toBe("middleware-copy"); expect(result.headers.get("x-middleware")).toBe("mw"); await expect(result.text()).resolves.toBe("hello"); }); + it("drops stale statusText when middleware overrides the route-handler status", () => { + const response = new Response("hello", { + status: 201, + statusText: "Created", + }); + + const result = applyRouteHandlerMiddlewareContext(response, { + headers: null, + status: 403, + }); + + expect(result.status).toBe(403); + expect(result.statusText).not.toBe("Created"); + }); + + it("can preserve special-response status codes when rewrite status must not apply", () => { + const response = new Response(null, { + status: 307, + headers: { Location: "https://example.com/about" }, + }); + + const result = applyRouteHandlerMiddlewareContext( + response, + { + headers: new Headers([["x-middleware", "present"]]), + status: 403, + }, + { applyRewriteStatus: false }, + ); + + expect(result.status).toBe(307); + expect(result.headers.get("location")).toBe("https://example.com/about"); + expect(result.headers.get("x-middleware")).toBe("present"); + }); + it("builds cached HIT and STALE route handler responses", async () => { const cachedValue = buildCachedRouteValue("from-cache", { "content-type": "text/plain", @@ -103,7 +138,7 @@ describe("app route handler response helpers", () => { expect(new TextDecoder().decode(value.body)).toBe("cache me"); }); - it("finalizes route handler responses with cookies and auto-head semantics", async () => { + it("finalizes route handler responses with render headers and auto-head semantics", async () => { const response = new Response("body", { status: 202, statusText: "Accepted", @@ -113,14 +148,17 @@ describe("app route handler response helpers", () => { }); const result = finalizeRouteHandlerResponse(response, { - pendingCookies: ["a=1; Path=/"], - draftCookie: "draft=1; Path=/", isHead: true, + renderHeaders: { + "set-cookie": ["a=1; Path=/", "draft=1; Path=/"], + vary: "x-test", + }, }); expect(result.status).toBe(202); expect(result.statusText).toBe("Accepted"); expect(result.headers.getSetCookie?.()).toEqual(["a=1; Path=/", "draft=1; Path=/"]); + expect(result.headers.get("vary")).toBe("x-test"); await expect(result.text()).resolves.toBe(""); }); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 014247929..871fd3ef4 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,96 @@ 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.headers.get("location")).toContain("/about"); + + const notFoundRes = await fetch(`${baseUrl}/middleware-rewrite-status-not-found`); + expect(notFoundRes.status).toBe(404); + + const html = await notFoundRes.text(); + expect(html).toContain("404 - Page Not Found"); + expect(html).toContain("does not exist"); + }); + + it("middleware rewrite status does not reuse a route handler's stale statusText", async () => { + const directRes = await fetch(`${baseUrl}/api/custom-status-text`); + expect(directRes.status).toBe(201); + expect(directRes.statusText).toBe("Created"); + expect(await directRes.text()).toBe("custom status text route"); + + const res = await fetch(`${baseUrl}/middleware-rewrite-status-route-status-text`); + expect(res.status).toBe(403); + expect(res.statusText).not.toBe("Created"); + expect(await res.text()).toBe("custom status text route"); + }); + + it("middleware rewrite status does not override route-handler redirects with queued cookies", async () => { + const res = await fetch(`${baseUrl}/middleware-rewrite-status-route-redirect-with-cookie`, { + redirect: "manual", + }); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain("/about"); + expect(res.headers.getSetCookie()).toContain("route-special=redirect; Path=/; HttpOnly"); + expect(await res.text()).toBe(""); + }); + + it("middleware rewrite status does not override route-handler notFound() with queued cookies", async () => { + const res = await fetch(`${baseUrl}/middleware-rewrite-status-route-not-found-with-cookie`); + expect(res.status).toBe(404); + expect(res.headers.getSetCookie()).toContain("route-special=not-found; Path=/; HttpOnly"); + expect(await res.text()).toBe(""); + }); + + it("middleware rewrite status does not override route-handler unauthorized() with queued cookies", async () => { + const res = await fetch(`${baseUrl}/middleware-rewrite-status-route-unauthorized-with-cookie`); + expect(res.status).toBe(401); + expect(res.headers.getSetCookie()).toContain("route-special=unauthorized; Path=/; HttpOnly"); + expect(await res.text()).toBe(""); + }); + + 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("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("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); @@ -1475,6 +1587,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({ @@ -1636,6 +1778,287 @@ 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 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", + "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`); + 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")).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"); + 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"); + 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"); + expectMergedVary(res2.headers.get("vary")); + expect(res2.headers.get("www-authenticate")).toBe(expectedWwwAuthenticate); + expect(res2.headers.getSetCookie()).toEqual(expectedCachedSetCookies); + + await sleep(1100); + + 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"); + 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"); + 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"); + 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"); + expectMergedVary(regenHitRes.headers.get("vary")); + expect(regenHitRes.headers.get("www-authenticate")).toBe(expectedWwwAuthenticate); + 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 streamBaseUrl = `http://localhost:${streamServer.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(); + 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(fetchResolvedAt - start).toBeLessThan(doneAt - start); + } finally { + await new Promise((resolve) => streamServer.server.close(() => resolve())); + } + }); + + 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 streamBaseUrl = `http://localhost:${streamServer.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.server.close(() => resolve())); + } + }); + + it("replays final render-time headers on direct RSC HITs after streamed MISSes", async () => { + const expectedMissVary = "RSC, Accept, x-middleware-test"; + const expectedHitVary = "x-render-one, x-render-two, RSC, Accept, x-middleware-test"; + const expectedMissWwwAuthenticate = 'Digest realm="middleware"'; + const expectedHitWwwAuthenticate = + '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; + } + + 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 localBaseUrl = `http://localhost:${rscServer.port}`; + + const missRes = await fetch( + `${localBaseUrl}/nextjs-compat/cached-render-headers-rsc-parity.rsc`, + { + headers: { Accept: "text/x-component" }, + }, + ); + expect(missRes.status).toBe(200); + expect(missRes.headers.get("x-vinext-cache")).toBe("MISS"); + expect(missRes.headers.get("x-rendered-in-page")).toBeNull(); + expect(missRes.headers.get("x-rendered-late")).toBeNull(); + expect(missRes.headers.get("x-mw-conflict")).toBe("middleware"); + expect(missRes.headers.get("x-mw-ran")).toBe("true"); + expect(missRes.headers.get("vary")).toBe(expectedMissVary); + expect(missRes.headers.get("www-authenticate")).toBe(expectedMissWwwAuthenticate); + expect(missRes.headers.getSetCookie()).toEqual( + expect.arrayContaining(expectedMiddlewareSetCookies), + ); + expect(missRes.headers.getSetCookie()).not.toContain("rendered-late=1; Path=/; HttpOnly"); + const missBody = await missRes.text(); + expect(missBody.length).toBeGreaterThan(0); + + const hitRes = await fetchUntilLocalCacheState( + localBaseUrl, + "/nextjs-compat/cached-render-headers-rsc-parity.rsc", + "HIT", + { + headers: { Accept: "text/x-component" }, + }, + ); + expect(hitRes.status).toBe(200); + expect(hitRes.headers.get("x-vinext-cache")).toBe("HIT"); + expect(hitRes.headers.get("x-rendered-in-page")).toBe("yes"); + expect(hitRes.headers.get("x-rendered-late")).toBe("yes"); + expect(hitRes.headers.get("x-mw-conflict")).toBe("middleware"); + expect(hitRes.headers.get("x-mw-ran")).toBe("true"); + expect(hitRes.headers.get("vary")).toBe(expectedHitVary); + expect(hitRes.headers.get("www-authenticate")).toBe(expectedHitWwwAuthenticate); + expect(hitRes.headers.getSetCookie()).toEqual(expectedHitSetCookies); + const hitBody = await hitRes.text(); + expect(hitBody.length).toBeGreaterThan(0); + } finally { + await new Promise((resolve) => rscServer.server.close(() => resolve())); + } + }); + + it("middleware rewrite status does not reuse a route handler's stale statusText in production", async () => { + const directRes = await fetch(`${baseUrl}/api/custom-status-text`); + expect(directRes.status).toBe(201); + expect(directRes.statusText).toBe("Created"); + expect(await directRes.text()).toBe("custom status text route"); + + const res = await fetch(`${baseUrl}/middleware-rewrite-status-route-status-text`); + expect(res.status).toBe(403); + expect(res.statusText).not.toBe("Created"); + expect(await res.text()).toBe("custom status text route"); + }); + + it("middleware rewrite status does not override route-handler redirects with queued cookies in production", async () => { + const res = await fetch(`${baseUrl}/middleware-rewrite-status-route-redirect-with-cookie`, { + redirect: "manual", + }); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain("/about"); + expect(res.headers.getSetCookie()).toContain("route-special=redirect; Path=/; HttpOnly"); + expect(await res.text()).toBe(""); + }); + + it("middleware rewrite status does not override route-handler notFound() with queued cookies in production", async () => { + const res = await fetch(`${baseUrl}/middleware-rewrite-status-route-not-found-with-cookie`); + expect(res.status).toBe(404); + expect(res.headers.getSetCookie()).toContain("route-special=not-found; Path=/; HttpOnly"); + expect(await res.text()).toBe(""); + }); + + it("middleware rewrite status does not override route-handler unauthorized() with queued cookies in production", async () => { + const res = await fetch(`${baseUrl}/middleware-rewrite-status-route-unauthorized-with-cookie`); + expect(res.status).toBe(401); + expect(res.headers.getSetCookie()).toContain("route-special=unauthorized; Path=/; HttpOnly"); + expect(await res.text()).toBe(""); + }); + 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); @@ -3782,6 +4205,22 @@ describe("generateRscEntry ISR code generation", () => { expect(code).toContain("consumeDynamicUsage,"); }); + it("generated code imports render-header helpers from next/headers", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + expect(code).toContain("peekRenderResponseHeaders"); + expect(code).toContain("restoreRenderResponseHeaders"); + expect(code).toContain("consumeRenderResponseHeaders"); + }); + + it("generated code forwards render-header snapshots through page cache helpers", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + expect(code).toContain("renderHeaders: __rscResponseRenderHeaders"); + expect(code).toContain("consumeRenderResponseHeaders,"); + expect(code).toContain("initialRenderHeaders: __rscResponseRenderHeaders"); + expect(code).toContain("renderHeaders: renderResponseHeaders"); + expect(code).toContain("initialRenderHeaders: renderResponseHeaders"); + }); + // Route handler ISR code generation tests it("generated code contains __isrRouteKey helper", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes); @@ -3804,7 +4243,10 @@ describe("generateRscEntry ISR code generation", () => { // Route handler ISR writes now flow through the typed execution helper. expect(code).toContain("executeAppRouteHandler as __executeAppRouteHandler"); expect(code).toContain("return __executeAppRouteHandler({"); + expect(code).toContain("consumeRenderResponseHeaders,"); expect(code).toContain("isrRouteKey: __isrRouteKey"); expect(code).toContain("isrSet: __isrSet"); + expect(code).not.toContain("getAndClearPendingCookies,"); + expect(code).not.toContain("getDraftModeCookieHeader,"); }); }); diff --git a/tests/fixtures/app-basic/app/api/custom-status-text/route.ts b/tests/fixtures/app-basic/app/api/custom-status-text/route.ts new file mode 100644 index 000000000..3eea40b11 --- /dev/null +++ b/tests/fixtures/app-basic/app/api/custom-status-text/route.ts @@ -0,0 +1,6 @@ +export async function GET() { + return new Response("custom status text route", { + status: 201, + statusText: "Created", + }); +} diff --git a/tests/fixtures/app-basic/app/api/not-found-with-cookie/route.ts b/tests/fixtures/app-basic/app/api/not-found-with-cookie/route.ts new file mode 100644 index 000000000..fdc2d6d57 --- /dev/null +++ b/tests/fixtures/app-basic/app/api/not-found-with-cookie/route.ts @@ -0,0 +1,8 @@ +import { cookies } from "next/headers"; +import { notFound } from "next/navigation"; + +export async function GET() { + const cookieStore = await cookies(); + cookieStore.set("route-special", "not-found", { path: "/", httpOnly: true }); + notFound(); +} diff --git a/tests/fixtures/app-basic/app/api/redirect-with-cookie/route.ts b/tests/fixtures/app-basic/app/api/redirect-with-cookie/route.ts new file mode 100644 index 000000000..d095972e1 --- /dev/null +++ b/tests/fixtures/app-basic/app/api/redirect-with-cookie/route.ts @@ -0,0 +1,8 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +export async function GET() { + const cookieStore = await cookies(); + cookieStore.set("route-special", "redirect", { path: "/", httpOnly: true }); + redirect("/about"); +} diff --git a/tests/fixtures/app-basic/app/api/unauthorized-with-cookie/route.ts b/tests/fixtures/app-basic/app/api/unauthorized-with-cookie/route.ts new file mode 100644 index 000000000..966786de1 --- /dev/null +++ b/tests/fixtures/app-basic/app/api/unauthorized-with-cookie/route.ts @@ -0,0 +1,8 @@ +import { cookies } from "next/headers"; +import { unauthorized } from "next/navigation"; + +export async function GET() { + const cookieStore = await cookies(); + cookieStore.set("route-special", "unauthorized", { path: "/", httpOnly: true }); + unauthorized(); +} 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-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/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/cached-render-headers/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers/page.tsx new file mode 100644 index 000000000..5271ac5e4 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers/page.tsx @@ -0,0 +1,34 @@ +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("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"); + + 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-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/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..5b54366cb 100644 --- a/tests/fixtures/app-basic/middleware.ts +++ b/tests/fixtures/app-basic/middleware.ts @@ -27,6 +27,14 @@ 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.headers.append("WWW-Authenticate", 'Digest realm="middleware"'); + 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 +43,21 @@ 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-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/") + ) { + 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 +88,42 @@ 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, + }); + } + + if (pathname === "/middleware-rewrite-status-route-status-text") { + return NextResponse.rewrite(new URL("/api/custom-status-text", request.url), { + status: 403, + }); + } + + if (pathname === "/middleware-rewrite-status-route-redirect-with-cookie") { + return NextResponse.rewrite(new URL("/api/redirect-with-cookie", request.url), { + status: 403, + }); + } + + if (pathname === "/middleware-rewrite-status-route-not-found-with-cookie") { + return NextResponse.rewrite(new URL("/api/not-found-with-cookie", request.url), { + status: 403, + }); + } + + if (pathname === "/middleware-rewrite-status-route-unauthorized-with-cookie") { + return NextResponse.rewrite(new URL("/api/unauthorized-with-cookie", request.url), { + status: 403, + }); + } + // Block /middleware-blocked with custom response if (pathname === "/middleware-blocked") { return new Response("Blocked by middleware", { status: 403 }); @@ -149,16 +208,42 @@ 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-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/") + ) { + applyRenderHeaderParityHeaders(r); + } return r; } 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", "/middleware-rewrite", "/middleware-rewrite-query", "/middleware-rewrite-status", + "/middleware-rewrite-status-redirect", + "/middleware-rewrite-status-not-found", + "/middleware-rewrite-status-route-status-text", + "/middleware-rewrite-status-route-redirect-with-cookie", + "/middleware-rewrite-status-route-not-found-with-cookie", + "/middleware-rewrite-status-route-unauthorized-with-cookie", "/middleware-blocked", "/middleware-throw", "/search-query", 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"), 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/nextjs-compat/app-routes.test.ts b/tests/nextjs-compat/app-routes.test.ts index 492586187..eb2c94f40 100644 --- a/tests/nextjs-compat/app-routes.test.ts +++ b/tests/nextjs-compat/app-routes.test.ts @@ -243,6 +243,30 @@ describe("Next.js compat: app-routes", () => { expect(res.status).toBe(404); }); + it("redirect() preserves queued Set-Cookie headers", async () => { + const res = await fetch(`${baseUrl}/api/redirect-with-cookie`, { + redirect: "manual", + }); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain("/about"); + expect(res.headers.getSetCookie()).toContain("route-special=redirect; Path=/; HttpOnly"); + expect(await res.text()).toBe(""); + }); + + it("notFound() preserves queued Set-Cookie headers", async () => { + const res = await fetch(`${baseUrl}/api/not-found-with-cookie`); + expect(res.status).toBe(404); + expect(res.headers.getSetCookie()).toContain("route-special=not-found; Path=/; HttpOnly"); + expect(await res.text()).toBe(""); + }); + + it("unauthorized() preserves queued Set-Cookie headers", async () => { + const res = await fetch(`${baseUrl}/api/unauthorized-with-cookie`); + expect(res.status).toBe(401); + expect(res.headers.getSetCookie()).toContain("route-special=unauthorized; Path=/; HttpOnly"); + expect(await res.text()).toBe(""); + }); + // ── cookies().set() ────────────────────────────────────────── // Next.js: tests cookie setting via cookies() in route handlers // Using pre-existing /api/set-cookie diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 3b714fc20..634a2708a 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -1081,6 +1081,153 @@ describe("next/headers phase-aware cookies", () => { }); }); +describe("next/headers render response headers", () => { + it("preserves repeated non-cookie headers and multi-value Set-Cookie deterministically", async () => { + const { + setHeadersContext, + appendRenderResponseHeader, + restoreRenderResponseHeaders, + peekRenderResponseHeaders, + setRenderResponseHeader, + deleteRenderResponseHeader, + consumeRenderResponseHeaders, + markDynamicUsage, + peekDynamicUsage, + restoreDynamicUsage, + consumeDynamicUsage, + } = 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("Vary", "x-render-one"); + appendRenderResponseHeader("Vary", "x-render-two"); + 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=/"], + 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", + }); + + 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); + } + }); + + 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", + expect.stringContaining("session=; Path=/; Expires="), + 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");