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 071514f13..56c7e480b 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -342,7 +342,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, 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"; @@ -430,16 +430,82 @@ 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") { + // entries() flattens Set-Cookie into a single comma-joined value. + 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; @@ -1791,11 +1857,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // the blanket strip loop after that call removes every remaining // x-middleware-* header before the set is merged into the response. _mwCtx.headers = new Headers(); - for (const [key, value] of mwResponse.headers) { - if (key !== "x-middleware-next" && key !== "x-middleware-rewrite") { - _mwCtx.headers.append(key, value); - } - } + __copyResponseHeaders(_mwCtx.headers, mwResponse.headers, [ + "x-middleware-next", + "x-middleware-rewrite", + ]); } else { // Check for redirect if (mwResponse.status >= 300 && mwResponse.status < 400) { @@ -1816,11 +1881,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; @@ -2044,21 +2108,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 }); } @@ -2094,20 +2153,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)), @@ -2223,12 +2277,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // return is skipped, but the copy loop below is a no-op, so no incorrect // headers are added. The allocation cost in that case is acceptable. if (!_mwCtx.headers && _mwCtx.status == null) return response; - const responseHeaders = new Headers(response.headers); - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - responseHeaders.append(key, value); - } - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers); + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); return new Response(response.body, { status: _mwCtx.status ?? response.status, statusText: response.statusText, @@ -2383,20 +2433,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { })(); _getRequestExecutionContext()?.waitUntil(__routeWritePromise); } - - // Collect any Set-Cookie headers from cookies().set()/delete() calls - const pendingCookies = getAndClearPendingCookies(); - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // If we have pending cookies, create a new response with them attached - if (pendingCookies.length > 0 || draftCookie) { - const newHeaders = new Headers(response.headers); - for (const cookie of pendingCookies) { - newHeaders.append("Set-Cookie", cookie); - } - if (draftCookie) newHeaders.append("Set-Cookie", draftCookie); + if (renderResponseHeaders) { + const newHeaders = __headersWithRenderResponseHeaders( + response.headers, + renderResponseHeaders, + ); if (isAutoHead) { return attachRouteHandlerMiddlewareContext(new Response(null, { @@ -2422,7 +2467,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return attachRouteHandlerMiddlewareContext(response); } catch (err) { - getAndClearPendingCookies(); // Clear any pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error // Catch redirect() / notFound() thrown from route handlers if (err && typeof err === "object" && "digest" in err) { const digest = String(err.digest); @@ -2542,29 +2587,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HIT (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.rscData, { + return __responseWithMiddlewareContext(new Response(__cachedValue.rscData, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } if (!isRscRequest && __hasHtml) { __isrDebug?.("HIT (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.html, { + return __responseWithMiddlewareContext(new Response(__cachedValue.html, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } __isrDebug?.("MISS (empty cached entry)", cleanPathname); } @@ -2611,6 +2656,10 @@ 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; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. + const __renderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); // Collect the full HTML string from the stream @@ -2624,14 +2673,13 @@ 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 }; }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); __isrDebug?.("regen complete", cleanPathname); }); @@ -2639,29 +2687,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("STALE (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.rscData, { + return __responseWithMiddlewareContext(new Response(__staleValue.rscData, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { __isrDebug?.("STALE (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.html, { + return __responseWithMiddlewareContext(new Response(__staleValue.html, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } // Stale entry exists but is empty for this request type — fall through to render __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); @@ -2751,65 +2799,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let element; - try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); - } catch (buildErr) { - // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components - if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { - const digest = String(buildErr.digest); + // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim + async function handleRenderError(err, fallbackOpts) { + if (err && typeof err === "object" && "digest" in err) { + const digest = String(err.digest); if (digest.startsWith("NEXT_REDIRECT;")) { const parts = digest.split(";"); const redirectUrl = decodeURIComponent(parts[2]); const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); + return __responseWithMiddlewareContext(new Response(null, { + status: statusCode, + headers: { Location: new URL(redirectUrl, request.url).toString() }, + }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false }); } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + ...fallbackOpts, + }); + const renderResponseHeaders = consumeRenderResponseHeaders(); + if (fallbackResp) { + return __responseWithMiddlewareContext( + fallbackResp, + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); + } setHeadersContext(null); setNavigationContext(null); const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); + return __responseWithMiddlewareContext( + new Response(statusText, { status: statusCode }), + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); } } + return null; + } + + let element; + try { + element = await buildPageElement(route, params, interceptOpts, url.searchParams); + } catch (buildErr) { + const specialResponse = await handleRenderError(buildErr); + if (specialResponse) return specialResponse; // Non-special error (e.g. generateMetadata() threw) — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw buildErr; } + const __buildRenderResponseHeaders = peekRenderResponseHeaders(); + const __buildDynamicUsage = peekDynamicUsage(); // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - if (err && typeof err === "object" && "digest" in err) { - const digest = String(err.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); - } - } - return null; - } - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws // notFound(), the parent layout's boundary catches it and renders the parent's @@ -2835,44 +2893,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const lr = LayoutComp({ params: asyncParams, children: null }); if (lr && typeof lr === "object" && typeof lr.then === "function") await lr; } catch (layoutErr) { - if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) { - const digest = String(layoutErr.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; } - if (!parentNotFound) parentNotFound = ${rootNotFoundVar ? `${rootNotFoundVar}?.default` : "null"}; - // Wrap in only the layouts above the throwing one - const parentLayouts = route.layouts.slice(0, li); - const fallbackResp = await renderHTTPAccessFallbackPage( - route, statusCode, isRscRequest, request, - { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params } - ); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); } } + if (!parentNotFound) parentNotFound = ${rootNotFoundVar ? `${rootNotFoundVar}?.default` : "null"}; + // Wrap in only the layouts above the throwing one + const parentLayouts = route.layouts.slice(0, li); + const specialResponse = await handleRenderError(layoutErr, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, + }); + if (specialResponse) return specialResponse; // Not a special error — let it propagate through normal RSC rendering } } @@ -2921,6 +2961,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(); @@ -2988,37 +3035,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } else if (revalidateSeconds) { responseHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; } - // Merge middleware response headers into the RSC response. - // set-cookie and vary are accumulated to preserve existing values - // (e.g. "Vary: RSC, Accept" set above); all other keys use plain - // assignment so middleware headers win over config headers, which - // the outer handler applies afterward and skips keys already present. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - const lk = key.toLowerCase(); - if (lk === "set-cookie") { - const existing = responseHeaders[lk]; - if (Array.isArray(existing)) { - existing.push(value); - } else if (existing) { - responseHeaders[lk] = [existing, value]; - } else { - responseHeaders[lk] = [value]; - } - } else if (lk === "vary") { - // Accumulate Vary values to preserve the existing "RSC, Accept" entry. - const existing = responseHeaders["Vary"] ?? responseHeaders["vary"]; - if (existing) { - responseHeaders["Vary"] = existing + ", " + value; - if (responseHeaders["vary"] !== undefined) delete responseHeaders["vary"]; - } else { - responseHeaders[key] = value; - } - } else { - responseHeaders[key] = value; - } - } - } // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -3034,26 +3050,49 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } + const __responseRenderHeaders = peekRenderResponseHeaders(); // For ISR-eligible RSC requests in production: write rscData to its own key. // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { - responseHeaders["X-Vinext-Cache"] = "MISS"; const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; + if (peekDynamicUsage()) { + responseHeaders["Cache-Control"] = "no-store, must-revalidate"; + } else { + responseHeaders["X-Vinext-Cache"] = "MISS"; + } const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? __responseRenderHeaders; + // consume picks up headers added during late async RSC streaming work. + // Falls back to the snapshot taken before the live MISS response was returned. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip RSC cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); + await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); + } finally { + setHeadersContext(null); + setNavigationContext(null); } })(); _getRequestExecutionContext()?.waitUntil(__rscWritePromise); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } - return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } // Collect font data from RSC environment before passing to SSR @@ -3091,7 +3130,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw ssrErr; } @@ -3106,38 +3152,36 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const _hasLocalBoundary = !!(route?.error?.default) || !!(route?.errors && route.errors.some(function(e) { return e?.default; })); if (!_hasLocalBoundary) { const cleanResp = await renderErrorBoundaryPage(route, _rscErrorForRerender, false, request, params); - if (cleanResp) return cleanResp; + if (cleanResp) { + return __responseWithMiddlewareContext( + cleanResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } } } ` : "" } - // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response + // Helper to attach render-time response headers, middleware headers, font + // Link header, and rewrite status to a response. function attachMiddlewareContext(response) { - if (draftCookie) { - response.headers.append("Set-Cookie", draftCookie); - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderResponseHeaders); // Set HTTP Link header for font preloading if (fontLinkHeader) { - response.headers.set("Link", fontLinkHeader); - } - // Merge middleware response headers into the final response. - // The response is freshly constructed above (new Response(htmlStream, {...})), - // so set() and append() are equivalent — there are no same-key conflicts yet. - // Precedence over config headers is handled by the outer handler, which - // skips config keys that middleware already placed on the response. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - response.headers.append(key, value); - } + responseHeaders.set("Link", fontLinkHeader); } + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -3152,21 +3196,30 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const renderMs = __renderEnd !== undefined && __compileEnd !== undefined ? Math.round(__renderEnd - __compileEnd) : -1; - response.headers.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); + responseHeaders.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); } // Apply custom status code from middleware rewrite if (_mwCtx.status) { return new Response(response.body, { status: _mwCtx.status, - headers: response.headers, + headers: responseHeaders, }); } - return response; + const responseInit = { + status: response.status, + headers: responseHeaders, + }; + if (response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); } // Check if any component called connection(), cookies(), headers(), or noStore() // during rendering. If so, treat as dynamic (skip ISR, set no-store). - const dynamicUsedDuringRender = consumeDynamicUsage(); + const dynamicUsedDuringRender = __isrRscDataPromise + ? peekDynamicUsage() + : consumeDynamicUsage(); // Check if cacheLife() was called during rendering (e.g., page with file-level "use cache"). // If so, use its revalidation period for the Cache-Control header. @@ -3250,6 +3303,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? renderResponseHeaders; + // consume picks up any headers added during stream consumption by late + // async render work (for example, suspended branches). Falls back to + // the snapshot taken before streaming began when nothing new was added. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip HTML cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) @@ -3257,12 +3319,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ensuring the first client-side navigation after a direct visit is a // cache hit rather than a miss. const __writes = [ - __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags), + __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags), ]; if (__capturedRscDataPromise) { __writes.push( __capturedRscDataPromise.then((__rscBuf) => - __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags) + __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags) ) ); } @@ -3270,6 +3332,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HTML cache written", __isrKey); } catch (__cacheErr) { console.error("[vinext] ISR cache write error:", __cacheErr); + } finally { + consumeRenderResponseHeaders(); } })(); // Register with ExecutionContext (from ALS) so the Workers runtime keeps diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 2c4fe1235..92eae72a4 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1669,6 +1669,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { "vinext/i18n-context": path.join(shimsDir, "i18n-context"), "vinext/instrumentation": path.resolve(__dirname, "server", "instrumentation"), "vinext/html": path.resolve(__dirname, "server", "html"), + "vinext/server/app-router-entry": path.resolve(__dirname, "server", "app-router-entry"), }; // Detect if Cloudflare's vite plugin is present — if so, skip @@ -2923,13 +2924,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (hasAppDir) { const mwCtxEntries: [string, string][] = []; if (result.responseHeaders) { + const setCookies = result.responseHeaders.getSetCookie(); for (const [key, value] of result.responseHeaders) { // Exclude control headers that runMiddleware already // consumed — matches the RSC entry's inline filtering. + if (key === "set-cookie") continue; if (key !== "x-middleware-next" && key !== "x-middleware-rewrite") { mwCtxEntries.push([key, value]); } } + for (const cookie of setCookies) { + mwCtxEntries.push(["set-cookie", cookie]); + } } req.headers["x-vinext-mw-ctx"] = JSON.stringify({ h: mwCtxEntries, 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 813388650..ab544b78f 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -374,7 +374,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, 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"; @@ -479,16 +479,82 @@ 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") { + // entries() flattens Set-Cookie into a single comma-joined value. + 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; @@ -2087,21 +2153,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 }); } @@ -2137,20 +2198,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)), @@ -2241,12 +2297,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // return is skipped, but the copy loop below is a no-op, so no incorrect // headers are added. The allocation cost in that case is acceptable. if (!_mwCtx.headers && _mwCtx.status == null) return response; - const responseHeaders = new Headers(response.headers); - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - responseHeaders.append(key, value); - } - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers); + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); return new Response(response.body, { status: _mwCtx.status ?? response.status, statusText: response.statusText, @@ -2401,20 +2453,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { })(); _getRequestExecutionContext()?.waitUntil(__routeWritePromise); } - - // Collect any Set-Cookie headers from cookies().set()/delete() calls - const pendingCookies = getAndClearPendingCookies(); - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // If we have pending cookies, create a new response with them attached - if (pendingCookies.length > 0 || draftCookie) { - const newHeaders = new Headers(response.headers); - for (const cookie of pendingCookies) { - newHeaders.append("Set-Cookie", cookie); - } - if (draftCookie) newHeaders.append("Set-Cookie", draftCookie); + if (renderResponseHeaders) { + const newHeaders = __headersWithRenderResponseHeaders( + response.headers, + renderResponseHeaders, + ); if (isAutoHead) { return attachRouteHandlerMiddlewareContext(new Response(null, { @@ -2440,7 +2487,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return attachRouteHandlerMiddlewareContext(response); } catch (err) { - getAndClearPendingCookies(); // Clear any pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error // Catch redirect() / notFound() thrown from route handlers if (err && typeof err === "object" && "digest" in err) { const digest = String(err.digest); @@ -2560,29 +2607,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HIT (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.rscData, { + return __responseWithMiddlewareContext(new Response(__cachedValue.rscData, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } if (!isRscRequest && __hasHtml) { __isrDebug?.("HIT (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.html, { + return __responseWithMiddlewareContext(new Response(__cachedValue.html, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } __isrDebug?.("MISS (empty cached entry)", cleanPathname); } @@ -2629,6 +2676,10 @@ 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; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. + const __renderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); // Collect the full HTML string from the stream @@ -2642,14 +2693,13 @@ 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 }; }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); __isrDebug?.("regen complete", cleanPathname); }); @@ -2657,29 +2707,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("STALE (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.rscData, { + return __responseWithMiddlewareContext(new Response(__staleValue.rscData, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { __isrDebug?.("STALE (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.html, { + return __responseWithMiddlewareContext(new Response(__staleValue.html, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } // Stale entry exists but is empty for this request type — fall through to render __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); @@ -2769,65 +2819,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let element; - try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); - } catch (buildErr) { - // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components - if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { - const digest = String(buildErr.digest); + // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim + async function handleRenderError(err, fallbackOpts) { + if (err && typeof err === "object" && "digest" in err) { + const digest = String(err.digest); if (digest.startsWith("NEXT_REDIRECT;")) { const parts = digest.split(";"); const redirectUrl = decodeURIComponent(parts[2]); const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); + return __responseWithMiddlewareContext(new Response(null, { + status: statusCode, + headers: { Location: new URL(redirectUrl, request.url).toString() }, + }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false }); } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + ...fallbackOpts, + }); + const renderResponseHeaders = consumeRenderResponseHeaders(); + if (fallbackResp) { + return __responseWithMiddlewareContext( + fallbackResp, + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); + } setHeadersContext(null); setNavigationContext(null); const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); + return __responseWithMiddlewareContext( + new Response(statusText, { status: statusCode }), + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); } } + return null; + } + + let element; + try { + element = await buildPageElement(route, params, interceptOpts, url.searchParams); + } catch (buildErr) { + const specialResponse = await handleRenderError(buildErr); + if (specialResponse) return specialResponse; // Non-special error (e.g. generateMetadata() threw) — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw buildErr; } + const __buildRenderResponseHeaders = peekRenderResponseHeaders(); + const __buildDynamicUsage = peekDynamicUsage(); // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - if (err && typeof err === "object" && "digest" in err) { - const digest = String(err.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); - } - } - return null; - } - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws // notFound(), the parent layout's boundary catches it and renders the parent's @@ -2853,44 +2913,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const lr = LayoutComp({ params: asyncParams, children: null }); if (lr && typeof lr === "object" && typeof lr.then === "function") await lr; } catch (layoutErr) { - if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) { - const digest = String(layoutErr.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; } - if (!parentNotFound) parentNotFound = null; - // Wrap in only the layouts above the throwing one - const parentLayouts = route.layouts.slice(0, li); - const fallbackResp = await renderHTTPAccessFallbackPage( - route, statusCode, isRscRequest, request, - { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params } - ); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); } } + if (!parentNotFound) parentNotFound = null; + // Wrap in only the layouts above the throwing one + const parentLayouts = route.layouts.slice(0, li); + const specialResponse = await handleRenderError(layoutErr, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, + }); + if (specialResponse) return specialResponse; // Not a special error — let it propagate through normal RSC rendering } } @@ -2939,6 +2981,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(); @@ -3006,37 +3055,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } else if (revalidateSeconds) { responseHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; } - // Merge middleware response headers into the RSC response. - // set-cookie and vary are accumulated to preserve existing values - // (e.g. "Vary: RSC, Accept" set above); all other keys use plain - // assignment so middleware headers win over config headers, which - // the outer handler applies afterward and skips keys already present. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - const lk = key.toLowerCase(); - if (lk === "set-cookie") { - const existing = responseHeaders[lk]; - if (Array.isArray(existing)) { - existing.push(value); - } else if (existing) { - responseHeaders[lk] = [existing, value]; - } else { - responseHeaders[lk] = [value]; - } - } else if (lk === "vary") { - // Accumulate Vary values to preserve the existing "RSC, Accept" entry. - const existing = responseHeaders["Vary"] ?? responseHeaders["vary"]; - if (existing) { - responseHeaders["Vary"] = existing + ", " + value; - if (responseHeaders["vary"] !== undefined) delete responseHeaders["vary"]; - } else { - responseHeaders[key] = value; - } - } else { - responseHeaders[key] = value; - } - } - } // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -3052,26 +3070,49 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } + const __responseRenderHeaders = peekRenderResponseHeaders(); // For ISR-eligible RSC requests in production: write rscData to its own key. // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { - responseHeaders["X-Vinext-Cache"] = "MISS"; const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; + if (peekDynamicUsage()) { + responseHeaders["Cache-Control"] = "no-store, must-revalidate"; + } else { + responseHeaders["X-Vinext-Cache"] = "MISS"; + } const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? __responseRenderHeaders; + // consume picks up headers added during late async RSC streaming work. + // Falls back to the snapshot taken before the live MISS response was returned. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip RSC cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); + await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); + } finally { + setHeadersContext(null); + setNavigationContext(null); } })(); _getRequestExecutionContext()?.waitUntil(__rscWritePromise); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } - return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } // Collect font data from RSC environment before passing to SSR @@ -3109,7 +3150,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw ssrErr; } @@ -3119,31 +3167,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // when the error falls through to global-error.tsx. - // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response + // Helper to attach render-time response headers, middleware headers, font + // Link header, and rewrite status to a response. function attachMiddlewareContext(response) { - if (draftCookie) { - response.headers.append("Set-Cookie", draftCookie); - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderResponseHeaders); // Set HTTP Link header for font preloading if (fontLinkHeader) { - response.headers.set("Link", fontLinkHeader); - } - // Merge middleware response headers into the final response. - // The response is freshly constructed above (new Response(htmlStream, {...})), - // so set() and append() are equivalent — there are no same-key conflicts yet. - // Precedence over config headers is handled by the outer handler, which - // skips config keys that middleware already placed on the response. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - response.headers.append(key, value); - } + responseHeaders.set("Link", fontLinkHeader); } + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -3158,21 +3197,30 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const renderMs = __renderEnd !== undefined && __compileEnd !== undefined ? Math.round(__renderEnd - __compileEnd) : -1; - response.headers.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); + responseHeaders.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); } // Apply custom status code from middleware rewrite if (_mwCtx.status) { return new Response(response.body, { status: _mwCtx.status, - headers: response.headers, + headers: responseHeaders, }); } - return response; + const responseInit = { + status: response.status, + headers: responseHeaders, + }; + if (response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); } // Check if any component called connection(), cookies(), headers(), or noStore() // during rendering. If so, treat as dynamic (skip ISR, set no-store). - const dynamicUsedDuringRender = consumeDynamicUsage(); + const dynamicUsedDuringRender = __isrRscDataPromise + ? peekDynamicUsage() + : consumeDynamicUsage(); // Check if cacheLife() was called during rendering (e.g., page with file-level "use cache"). // If so, use its revalidation period for the Cache-Control header. @@ -3256,6 +3304,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? renderResponseHeaders; + // consume picks up any headers added during stream consumption by late + // async render work (for example, suspended branches). Falls back to + // the snapshot taken before streaming began when nothing new was added. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip HTML cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) @@ -3263,12 +3320,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ensuring the first client-side navigation after a direct visit is a // cache hit rather than a miss. const __writes = [ - __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags), + __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags), ]; if (__capturedRscDataPromise) { __writes.push( __capturedRscDataPromise.then((__rscBuf) => - __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags) + __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags) ) ); } @@ -3276,6 +3333,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HTML cache written", __isrKey); } catch (__cacheErr) { console.error("[vinext] ISR cache write error:", __cacheErr); + } finally { + consumeRenderResponseHeaders(); } })(); // Register with ExecutionContext (from ALS) so the Workers runtime keeps @@ -3360,7 +3419,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, 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"; @@ -3465,16 +3524,82 @@ 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") { + // entries() flattens Set-Cookie into a single comma-joined value. + 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; @@ -5076,21 +5201,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 }); } @@ -5126,20 +5246,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)), @@ -5230,12 +5345,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // return is skipped, but the copy loop below is a no-op, so no incorrect // headers are added. The allocation cost in that case is acceptable. if (!_mwCtx.headers && _mwCtx.status == null) return response; - const responseHeaders = new Headers(response.headers); - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - responseHeaders.append(key, value); - } - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers); + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); return new Response(response.body, { status: _mwCtx.status ?? response.status, statusText: response.statusText, @@ -5390,20 +5501,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { })(); _getRequestExecutionContext()?.waitUntil(__routeWritePromise); } - - // Collect any Set-Cookie headers from cookies().set()/delete() calls - const pendingCookies = getAndClearPendingCookies(); - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // If we have pending cookies, create a new response with them attached - if (pendingCookies.length > 0 || draftCookie) { - const newHeaders = new Headers(response.headers); - for (const cookie of pendingCookies) { - newHeaders.append("Set-Cookie", cookie); - } - if (draftCookie) newHeaders.append("Set-Cookie", draftCookie); + if (renderResponseHeaders) { + const newHeaders = __headersWithRenderResponseHeaders( + response.headers, + renderResponseHeaders, + ); if (isAutoHead) { return attachRouteHandlerMiddlewareContext(new Response(null, { @@ -5429,7 +5535,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return attachRouteHandlerMiddlewareContext(response); } catch (err) { - getAndClearPendingCookies(); // Clear any pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error // Catch redirect() / notFound() thrown from route handlers if (err && typeof err === "object" && "digest" in err) { const digest = String(err.digest); @@ -5549,29 +5655,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HIT (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.rscData, { + return __responseWithMiddlewareContext(new Response(__cachedValue.rscData, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } if (!isRscRequest && __hasHtml) { __isrDebug?.("HIT (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.html, { + return __responseWithMiddlewareContext(new Response(__cachedValue.html, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } __isrDebug?.("MISS (empty cached entry)", cleanPathname); } @@ -5618,6 +5724,10 @@ 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; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. + const __renderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); // Collect the full HTML string from the stream @@ -5631,14 +5741,13 @@ 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 }; }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); __isrDebug?.("regen complete", cleanPathname); }); @@ -5646,29 +5755,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("STALE (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.rscData, { + return __responseWithMiddlewareContext(new Response(__staleValue.rscData, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { __isrDebug?.("STALE (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.html, { + return __responseWithMiddlewareContext(new Response(__staleValue.html, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } // Stale entry exists but is empty for this request type — fall through to render __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); @@ -5758,65 +5867,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let element; - try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); - } catch (buildErr) { - // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components - if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { - const digest = String(buildErr.digest); + // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim + async function handleRenderError(err, fallbackOpts) { + if (err && typeof err === "object" && "digest" in err) { + const digest = String(err.digest); if (digest.startsWith("NEXT_REDIRECT;")) { const parts = digest.split(";"); const redirectUrl = decodeURIComponent(parts[2]); const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); + return __responseWithMiddlewareContext(new Response(null, { + status: statusCode, + headers: { Location: new URL(redirectUrl, request.url).toString() }, + }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false }); } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + ...fallbackOpts, + }); + const renderResponseHeaders = consumeRenderResponseHeaders(); + if (fallbackResp) { + return __responseWithMiddlewareContext( + fallbackResp, + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); + } setHeadersContext(null); setNavigationContext(null); const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); + return __responseWithMiddlewareContext( + new Response(statusText, { status: statusCode }), + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); } } + return null; + } + + let element; + try { + element = await buildPageElement(route, params, interceptOpts, url.searchParams); + } catch (buildErr) { + const specialResponse = await handleRenderError(buildErr); + if (specialResponse) return specialResponse; // Non-special error (e.g. generateMetadata() threw) — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw buildErr; } + const __buildRenderResponseHeaders = peekRenderResponseHeaders(); + const __buildDynamicUsage = peekDynamicUsage(); // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - if (err && typeof err === "object" && "digest" in err) { - const digest = String(err.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); - } - } - return null; - } - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws // notFound(), the parent layout's boundary catches it and renders the parent's @@ -5842,44 +5961,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const lr = LayoutComp({ params: asyncParams, children: null }); if (lr && typeof lr === "object" && typeof lr.then === "function") await lr; } catch (layoutErr) { - if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) { - const digest = String(layoutErr.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; } - if (!parentNotFound) parentNotFound = null; - // Wrap in only the layouts above the throwing one - const parentLayouts = route.layouts.slice(0, li); - const fallbackResp = await renderHTTPAccessFallbackPage( - route, statusCode, isRscRequest, request, - { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params } - ); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); } } + if (!parentNotFound) parentNotFound = null; + // Wrap in only the layouts above the throwing one + const parentLayouts = route.layouts.slice(0, li); + const specialResponse = await handleRenderError(layoutErr, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, + }); + if (specialResponse) return specialResponse; // Not a special error — let it propagate through normal RSC rendering } } @@ -5928,6 +6029,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(); @@ -5995,37 +6103,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } else if (revalidateSeconds) { responseHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; } - // Merge middleware response headers into the RSC response. - // set-cookie and vary are accumulated to preserve existing values - // (e.g. "Vary: RSC, Accept" set above); all other keys use plain - // assignment so middleware headers win over config headers, which - // the outer handler applies afterward and skips keys already present. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - const lk = key.toLowerCase(); - if (lk === "set-cookie") { - const existing = responseHeaders[lk]; - if (Array.isArray(existing)) { - existing.push(value); - } else if (existing) { - responseHeaders[lk] = [existing, value]; - } else { - responseHeaders[lk] = [value]; - } - } else if (lk === "vary") { - // Accumulate Vary values to preserve the existing "RSC, Accept" entry. - const existing = responseHeaders["Vary"] ?? responseHeaders["vary"]; - if (existing) { - responseHeaders["Vary"] = existing + ", " + value; - if (responseHeaders["vary"] !== undefined) delete responseHeaders["vary"]; - } else { - responseHeaders[key] = value; - } - } else { - responseHeaders[key] = value; - } - } - } // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -6041,26 +6118,49 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } + const __responseRenderHeaders = peekRenderResponseHeaders(); // For ISR-eligible RSC requests in production: write rscData to its own key. // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { - responseHeaders["X-Vinext-Cache"] = "MISS"; const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; + if (peekDynamicUsage()) { + responseHeaders["Cache-Control"] = "no-store, must-revalidate"; + } else { + responseHeaders["X-Vinext-Cache"] = "MISS"; + } const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? __responseRenderHeaders; + // consume picks up headers added during late async RSC streaming work. + // Falls back to the snapshot taken before the live MISS response was returned. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip RSC cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); + await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); + } finally { + setHeadersContext(null); + setNavigationContext(null); } })(); _getRequestExecutionContext()?.waitUntil(__rscWritePromise); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } - return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } // Collect font data from RSC environment before passing to SSR @@ -6098,7 +6198,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw ssrErr; } @@ -6108,31 +6215,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // when the error falls through to global-error.tsx. - // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response + // Helper to attach render-time response headers, middleware headers, font + // Link header, and rewrite status to a response. function attachMiddlewareContext(response) { - if (draftCookie) { - response.headers.append("Set-Cookie", draftCookie); - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderResponseHeaders); // Set HTTP Link header for font preloading if (fontLinkHeader) { - response.headers.set("Link", fontLinkHeader); - } - // Merge middleware response headers into the final response. - // The response is freshly constructed above (new Response(htmlStream, {...})), - // so set() and append() are equivalent — there are no same-key conflicts yet. - // Precedence over config headers is handled by the outer handler, which - // skips config keys that middleware already placed on the response. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - response.headers.append(key, value); - } + responseHeaders.set("Link", fontLinkHeader); } + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -6147,21 +6245,30 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const renderMs = __renderEnd !== undefined && __compileEnd !== undefined ? Math.round(__renderEnd - __compileEnd) : -1; - response.headers.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); + responseHeaders.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); } // Apply custom status code from middleware rewrite if (_mwCtx.status) { return new Response(response.body, { status: _mwCtx.status, - headers: response.headers, + headers: responseHeaders, }); } - return response; + const responseInit = { + status: response.status, + headers: responseHeaders, + }; + if (response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); } // Check if any component called connection(), cookies(), headers(), or noStore() // during rendering. If so, treat as dynamic (skip ISR, set no-store). - const dynamicUsedDuringRender = consumeDynamicUsage(); + const dynamicUsedDuringRender = __isrRscDataPromise + ? peekDynamicUsage() + : consumeDynamicUsage(); // Check if cacheLife() was called during rendering (e.g., page with file-level "use cache"). // If so, use its revalidation period for the Cache-Control header. @@ -6245,6 +6352,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? renderResponseHeaders; + // consume picks up any headers added during stream consumption by late + // async render work (for example, suspended branches). Falls back to + // the snapshot taken before streaming began when nothing new was added. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip HTML cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) @@ -6252,12 +6368,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ensuring the first client-side navigation after a direct visit is a // cache hit rather than a miss. const __writes = [ - __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags), + __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags), ]; if (__capturedRscDataPromise) { __writes.push( __capturedRscDataPromise.then((__rscBuf) => - __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags) + __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags) ) ); } @@ -6265,6 +6381,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HTML cache written", __isrKey); } catch (__cacheErr) { console.error("[vinext] ISR cache write error:", __cacheErr); + } finally { + consumeRenderResponseHeaders(); } })(); // Register with ExecutionContext (from ALS) so the Workers runtime keeps @@ -6349,7 +6467,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, 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"; @@ -6454,16 +6572,82 @@ 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") { + // entries() flattens Set-Cookie into a single comma-joined value. + 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; @@ -8092,21 +8276,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 }); } @@ -8142,20 +8321,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)), @@ -8246,12 +8420,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // return is skipped, but the copy loop below is a no-op, so no incorrect // headers are added. The allocation cost in that case is acceptable. if (!_mwCtx.headers && _mwCtx.status == null) return response; - const responseHeaders = new Headers(response.headers); - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - responseHeaders.append(key, value); - } - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers); + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); return new Response(response.body, { status: _mwCtx.status ?? response.status, statusText: response.statusText, @@ -8406,20 +8576,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { })(); _getRequestExecutionContext()?.waitUntil(__routeWritePromise); } - - // Collect any Set-Cookie headers from cookies().set()/delete() calls - const pendingCookies = getAndClearPendingCookies(); - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // If we have pending cookies, create a new response with them attached - if (pendingCookies.length > 0 || draftCookie) { - const newHeaders = new Headers(response.headers); - for (const cookie of pendingCookies) { - newHeaders.append("Set-Cookie", cookie); - } - if (draftCookie) newHeaders.append("Set-Cookie", draftCookie); + if (renderResponseHeaders) { + const newHeaders = __headersWithRenderResponseHeaders( + response.headers, + renderResponseHeaders, + ); if (isAutoHead) { return attachRouteHandlerMiddlewareContext(new Response(null, { @@ -8445,7 +8610,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return attachRouteHandlerMiddlewareContext(response); } catch (err) { - getAndClearPendingCookies(); // Clear any pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error // Catch redirect() / notFound() thrown from route handlers if (err && typeof err === "object" && "digest" in err) { const digest = String(err.digest); @@ -8565,29 +8730,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HIT (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.rscData, { + return __responseWithMiddlewareContext(new Response(__cachedValue.rscData, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } if (!isRscRequest && __hasHtml) { __isrDebug?.("HIT (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.html, { + return __responseWithMiddlewareContext(new Response(__cachedValue.html, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } __isrDebug?.("MISS (empty cached entry)", cleanPathname); } @@ -8634,6 +8799,10 @@ 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; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. + const __renderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); // Collect the full HTML string from the stream @@ -8647,14 +8816,13 @@ 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 }; }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); __isrDebug?.("regen complete", cleanPathname); }); @@ -8662,29 +8830,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("STALE (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.rscData, { + return __responseWithMiddlewareContext(new Response(__staleValue.rscData, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { __isrDebug?.("STALE (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.html, { + return __responseWithMiddlewareContext(new Response(__staleValue.html, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } // Stale entry exists but is empty for this request type — fall through to render __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); @@ -8774,65 +8942,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let element; - try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); - } catch (buildErr) { - // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components - if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { - const digest = String(buildErr.digest); + // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim + async function handleRenderError(err, fallbackOpts) { + if (err && typeof err === "object" && "digest" in err) { + const digest = String(err.digest); if (digest.startsWith("NEXT_REDIRECT;")) { const parts = digest.split(";"); const redirectUrl = decodeURIComponent(parts[2]); const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); + return __responseWithMiddlewareContext(new Response(null, { + status: statusCode, + headers: { Location: new URL(redirectUrl, request.url).toString() }, + }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false }); } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + ...fallbackOpts, + }); + const renderResponseHeaders = consumeRenderResponseHeaders(); + if (fallbackResp) { + return __responseWithMiddlewareContext( + fallbackResp, + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); + } setHeadersContext(null); setNavigationContext(null); const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); + return __responseWithMiddlewareContext( + new Response(statusText, { status: statusCode }), + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); } } + return null; + } + + let element; + try { + element = await buildPageElement(route, params, interceptOpts, url.searchParams); + } catch (buildErr) { + const specialResponse = await handleRenderError(buildErr); + if (specialResponse) return specialResponse; // Non-special error (e.g. generateMetadata() threw) — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw buildErr; } + const __buildRenderResponseHeaders = peekRenderResponseHeaders(); + const __buildDynamicUsage = peekDynamicUsage(); // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - if (err && typeof err === "object" && "digest" in err) { - const digest = String(err.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); - } - } - return null; - } - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws // notFound(), the parent layout's boundary catches it and renders the parent's @@ -8858,44 +9036,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const lr = LayoutComp({ params: asyncParams, children: null }); if (lr && typeof lr === "object" && typeof lr.then === "function") await lr; } catch (layoutErr) { - if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) { - const digest = String(layoutErr.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; } - if (!parentNotFound) parentNotFound = null; - // Wrap in only the layouts above the throwing one - const parentLayouts = route.layouts.slice(0, li); - const fallbackResp = await renderHTTPAccessFallbackPage( - route, statusCode, isRscRequest, request, - { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params } - ); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); } } + if (!parentNotFound) parentNotFound = null; + // Wrap in only the layouts above the throwing one + const parentLayouts = route.layouts.slice(0, li); + const specialResponse = await handleRenderError(layoutErr, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, + }); + if (specialResponse) return specialResponse; // Not a special error — let it propagate through normal RSC rendering } } @@ -8944,6 +9104,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(); @@ -9011,37 +9178,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } else if (revalidateSeconds) { responseHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; } - // Merge middleware response headers into the RSC response. - // set-cookie and vary are accumulated to preserve existing values - // (e.g. "Vary: RSC, Accept" set above); all other keys use plain - // assignment so middleware headers win over config headers, which - // the outer handler applies afterward and skips keys already present. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - const lk = key.toLowerCase(); - if (lk === "set-cookie") { - const existing = responseHeaders[lk]; - if (Array.isArray(existing)) { - existing.push(value); - } else if (existing) { - responseHeaders[lk] = [existing, value]; - } else { - responseHeaders[lk] = [value]; - } - } else if (lk === "vary") { - // Accumulate Vary values to preserve the existing "RSC, Accept" entry. - const existing = responseHeaders["Vary"] ?? responseHeaders["vary"]; - if (existing) { - responseHeaders["Vary"] = existing + ", " + value; - if (responseHeaders["vary"] !== undefined) delete responseHeaders["vary"]; - } else { - responseHeaders[key] = value; - } - } else { - responseHeaders[key] = value; - } - } - } // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -9057,26 +9193,49 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } + const __responseRenderHeaders = peekRenderResponseHeaders(); // For ISR-eligible RSC requests in production: write rscData to its own key. // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { - responseHeaders["X-Vinext-Cache"] = "MISS"; const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; + if (peekDynamicUsage()) { + responseHeaders["Cache-Control"] = "no-store, must-revalidate"; + } else { + responseHeaders["X-Vinext-Cache"] = "MISS"; + } const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? __responseRenderHeaders; + // consume picks up headers added during late async RSC streaming work. + // Falls back to the snapshot taken before the live MISS response was returned. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip RSC cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); + await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); + } finally { + setHeadersContext(null); + setNavigationContext(null); } })(); _getRequestExecutionContext()?.waitUntil(__rscWritePromise); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } - return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } // Collect font data from RSC environment before passing to SSR @@ -9114,7 +9273,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw ssrErr; } @@ -9127,36 +9293,34 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const _hasLocalBoundary = !!(route?.error?.default) || !!(route?.errors && route.errors.some(function(e) { return e?.default; })); if (!_hasLocalBoundary) { const cleanResp = await renderErrorBoundaryPage(route, _rscErrorForRerender, false, request, params); - if (cleanResp) return cleanResp; + if (cleanResp) { + return __responseWithMiddlewareContext( + cleanResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } } } - // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response + // Helper to attach render-time response headers, middleware headers, font + // Link header, and rewrite status to a response. function attachMiddlewareContext(response) { - if (draftCookie) { - response.headers.append("Set-Cookie", draftCookie); - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderResponseHeaders); // Set HTTP Link header for font preloading if (fontLinkHeader) { - response.headers.set("Link", fontLinkHeader); - } - // Merge middleware response headers into the final response. - // The response is freshly constructed above (new Response(htmlStream, {...})), - // so set() and append() are equivalent — there are no same-key conflicts yet. - // Precedence over config headers is handled by the outer handler, which - // skips config keys that middleware already placed on the response. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - response.headers.append(key, value); - } + responseHeaders.set("Link", fontLinkHeader); } + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -9171,21 +9335,30 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const renderMs = __renderEnd !== undefined && __compileEnd !== undefined ? Math.round(__renderEnd - __compileEnd) : -1; - response.headers.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); + responseHeaders.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); } // Apply custom status code from middleware rewrite if (_mwCtx.status) { return new Response(response.body, { status: _mwCtx.status, - headers: response.headers, + headers: responseHeaders, }); } - return response; + const responseInit = { + status: response.status, + headers: responseHeaders, + }; + if (response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); } // Check if any component called connection(), cookies(), headers(), or noStore() // during rendering. If so, treat as dynamic (skip ISR, set no-store). - const dynamicUsedDuringRender = consumeDynamicUsage(); + const dynamicUsedDuringRender = __isrRscDataPromise + ? peekDynamicUsage() + : consumeDynamicUsage(); // Check if cacheLife() was called during rendering (e.g., page with file-level "use cache"). // If so, use its revalidation period for the Cache-Control header. @@ -9269,6 +9442,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? renderResponseHeaders; + // consume picks up any headers added during stream consumption by late + // async render work (for example, suspended branches). Falls back to + // the snapshot taken before streaming began when nothing new was added. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip HTML cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) @@ -9276,12 +9458,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ensuring the first client-side navigation after a direct visit is a // cache hit rather than a miss. const __writes = [ - __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags), + __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags), ]; if (__capturedRscDataPromise) { __writes.push( __capturedRscDataPromise.then((__rscBuf) => - __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags) + __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags) ) ); } @@ -9289,6 +9471,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HTML cache written", __isrKey); } catch (__cacheErr) { console.error("[vinext] ISR cache write error:", __cacheErr); + } finally { + consumeRenderResponseHeaders(); } })(); // Register with ExecutionContext (from ALS) so the Workers runtime keeps @@ -9373,7 +9557,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, 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"; @@ -9478,16 +9662,82 @@ 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") { + // entries() flattens Set-Cookie into a single comma-joined value. + 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; @@ -11119,21 +11369,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 }); } @@ -11169,20 +11414,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)), @@ -11273,12 +11513,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // return is skipped, but the copy loop below is a no-op, so no incorrect // headers are added. The allocation cost in that case is acceptable. if (!_mwCtx.headers && _mwCtx.status == null) return response; - const responseHeaders = new Headers(response.headers); - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - responseHeaders.append(key, value); - } - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers); + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); return new Response(response.body, { status: _mwCtx.status ?? response.status, statusText: response.statusText, @@ -11433,20 +11669,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { })(); _getRequestExecutionContext()?.waitUntil(__routeWritePromise); } - - // Collect any Set-Cookie headers from cookies().set()/delete() calls - const pendingCookies = getAndClearPendingCookies(); - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // If we have pending cookies, create a new response with them attached - if (pendingCookies.length > 0 || draftCookie) { - const newHeaders = new Headers(response.headers); - for (const cookie of pendingCookies) { - newHeaders.append("Set-Cookie", cookie); - } - if (draftCookie) newHeaders.append("Set-Cookie", draftCookie); + if (renderResponseHeaders) { + const newHeaders = __headersWithRenderResponseHeaders( + response.headers, + renderResponseHeaders, + ); if (isAutoHead) { return attachRouteHandlerMiddlewareContext(new Response(null, { @@ -11472,7 +11703,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return attachRouteHandlerMiddlewareContext(response); } catch (err) { - getAndClearPendingCookies(); // Clear any pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error // Catch redirect() / notFound() thrown from route handlers if (err && typeof err === "object" && "digest" in err) { const digest = String(err.digest); @@ -11592,29 +11823,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HIT (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.rscData, { + return __responseWithMiddlewareContext(new Response(__cachedValue.rscData, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } if (!isRscRequest && __hasHtml) { __isrDebug?.("HIT (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.html, { + return __responseWithMiddlewareContext(new Response(__cachedValue.html, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } __isrDebug?.("MISS (empty cached entry)", cleanPathname); } @@ -11661,6 +11892,10 @@ 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; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. + const __renderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); // Collect the full HTML string from the stream @@ -11674,14 +11909,13 @@ 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 }; }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); __isrDebug?.("regen complete", cleanPathname); }); @@ -11689,29 +11923,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("STALE (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.rscData, { + return __responseWithMiddlewareContext(new Response(__staleValue.rscData, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { __isrDebug?.("STALE (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.html, { + return __responseWithMiddlewareContext(new Response(__staleValue.html, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } // Stale entry exists but is empty for this request type — fall through to render __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); @@ -11801,65 +12035,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let element; - try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); - } catch (buildErr) { - // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components - if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { - const digest = String(buildErr.digest); + // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim + async function handleRenderError(err, fallbackOpts) { + if (err && typeof err === "object" && "digest" in err) { + const digest = String(err.digest); if (digest.startsWith("NEXT_REDIRECT;")) { const parts = digest.split(";"); const redirectUrl = decodeURIComponent(parts[2]); const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); + return __responseWithMiddlewareContext(new Response(null, { + status: statusCode, + headers: { Location: new URL(redirectUrl, request.url).toString() }, + }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false }); } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + ...fallbackOpts, + }); + const renderResponseHeaders = consumeRenderResponseHeaders(); + if (fallbackResp) { + return __responseWithMiddlewareContext( + fallbackResp, + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); + } setHeadersContext(null); setNavigationContext(null); const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); + return __responseWithMiddlewareContext( + new Response(statusText, { status: statusCode }), + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); } } + return null; + } + + let element; + try { + element = await buildPageElement(route, params, interceptOpts, url.searchParams); + } catch (buildErr) { + const specialResponse = await handleRenderError(buildErr); + if (specialResponse) return specialResponse; // Non-special error (e.g. generateMetadata() threw) — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw buildErr; } + const __buildRenderResponseHeaders = peekRenderResponseHeaders(); + const __buildDynamicUsage = peekDynamicUsage(); // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - if (err && typeof err === "object" && "digest" in err) { - const digest = String(err.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); - } - } - return null; - } - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws // notFound(), the parent layout's boundary catches it and renders the parent's @@ -11885,44 +12129,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const lr = LayoutComp({ params: asyncParams, children: null }); if (lr && typeof lr === "object" && typeof lr.then === "function") await lr; } catch (layoutErr) { - if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) { - const digest = String(layoutErr.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; } - if (!parentNotFound) parentNotFound = null; - // Wrap in only the layouts above the throwing one - const parentLayouts = route.layouts.slice(0, li); - const fallbackResp = await renderHTTPAccessFallbackPage( - route, statusCode, isRscRequest, request, - { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params } - ); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); } } + if (!parentNotFound) parentNotFound = null; + // Wrap in only the layouts above the throwing one + const parentLayouts = route.layouts.slice(0, li); + const specialResponse = await handleRenderError(layoutErr, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, + }); + if (specialResponse) return specialResponse; // Not a special error — let it propagate through normal RSC rendering } } @@ -11971,6 +12197,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(); @@ -12038,37 +12271,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } else if (revalidateSeconds) { responseHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; } - // Merge middleware response headers into the RSC response. - // set-cookie and vary are accumulated to preserve existing values - // (e.g. "Vary: RSC, Accept" set above); all other keys use plain - // assignment so middleware headers win over config headers, which - // the outer handler applies afterward and skips keys already present. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - const lk = key.toLowerCase(); - if (lk === "set-cookie") { - const existing = responseHeaders[lk]; - if (Array.isArray(existing)) { - existing.push(value); - } else if (existing) { - responseHeaders[lk] = [existing, value]; - } else { - responseHeaders[lk] = [value]; - } - } else if (lk === "vary") { - // Accumulate Vary values to preserve the existing "RSC, Accept" entry. - const existing = responseHeaders["Vary"] ?? responseHeaders["vary"]; - if (existing) { - responseHeaders["Vary"] = existing + ", " + value; - if (responseHeaders["vary"] !== undefined) delete responseHeaders["vary"]; - } else { - responseHeaders[key] = value; - } - } else { - responseHeaders[key] = value; - } - } - } // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -12084,26 +12286,49 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } + const __responseRenderHeaders = peekRenderResponseHeaders(); // For ISR-eligible RSC requests in production: write rscData to its own key. // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { - responseHeaders["X-Vinext-Cache"] = "MISS"; const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; + if (peekDynamicUsage()) { + responseHeaders["Cache-Control"] = "no-store, must-revalidate"; + } else { + responseHeaders["X-Vinext-Cache"] = "MISS"; + } const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? __responseRenderHeaders; + // consume picks up headers added during late async RSC streaming work. + // Falls back to the snapshot taken before the live MISS response was returned. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip RSC cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); + await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); + } finally { + setHeadersContext(null); + setNavigationContext(null); } })(); _getRequestExecutionContext()?.waitUntil(__rscWritePromise); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } - return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } // Collect font data from RSC environment before passing to SSR @@ -12141,7 +12366,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw ssrErr; } @@ -12151,31 +12383,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // when the error falls through to global-error.tsx. - // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response + // Helper to attach render-time response headers, middleware headers, font + // Link header, and rewrite status to a response. function attachMiddlewareContext(response) { - if (draftCookie) { - response.headers.append("Set-Cookie", draftCookie); - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderResponseHeaders); // Set HTTP Link header for font preloading if (fontLinkHeader) { - response.headers.set("Link", fontLinkHeader); - } - // Merge middleware response headers into the final response. - // The response is freshly constructed above (new Response(htmlStream, {...})), - // so set() and append() are equivalent — there are no same-key conflicts yet. - // Precedence over config headers is handled by the outer handler, which - // skips config keys that middleware already placed on the response. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - response.headers.append(key, value); - } + responseHeaders.set("Link", fontLinkHeader); } + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -12190,21 +12413,30 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const renderMs = __renderEnd !== undefined && __compileEnd !== undefined ? Math.round(__renderEnd - __compileEnd) : -1; - response.headers.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); + responseHeaders.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); } // Apply custom status code from middleware rewrite if (_mwCtx.status) { return new Response(response.body, { status: _mwCtx.status, - headers: response.headers, + headers: responseHeaders, }); } - return response; + const responseInit = { + status: response.status, + headers: responseHeaders, + }; + if (response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); } // Check if any component called connection(), cookies(), headers(), or noStore() // during rendering. If so, treat as dynamic (skip ISR, set no-store). - const dynamicUsedDuringRender = consumeDynamicUsage(); + const dynamicUsedDuringRender = __isrRscDataPromise + ? peekDynamicUsage() + : consumeDynamicUsage(); // Check if cacheLife() was called during rendering (e.g., page with file-level "use cache"). // If so, use its revalidation period for the Cache-Control header. @@ -12288,6 +12520,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? renderResponseHeaders; + // consume picks up any headers added during stream consumption by late + // async render work (for example, suspended branches). Falls back to + // the snapshot taken before streaming began when nothing new was added. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip HTML cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) @@ -12295,12 +12536,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ensuring the first client-side navigation after a direct visit is a // cache hit rather than a miss. const __writes = [ - __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags), + __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags), ]; if (__capturedRscDataPromise) { __writes.push( __capturedRscDataPromise.then((__rscBuf) => - __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags) + __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags) ) ); } @@ -12308,6 +12549,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HTML cache written", __isrKey); } catch (__cacheErr) { console.error("[vinext] ISR cache write error:", __cacheErr); + } finally { + consumeRenderResponseHeaders(); } })(); // Register with ExecutionContext (from ALS) so the Workers runtime keeps @@ -12392,7 +12635,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, 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"; @@ -12497,16 +12740,82 @@ 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") { + // entries() flattens Set-Cookie into a single comma-joined value. + 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; @@ -14112,21 +14421,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 }); } @@ -14162,20 +14466,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)), @@ -14266,12 +14565,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // return is skipped, but the copy loop below is a no-op, so no incorrect // headers are added. The allocation cost in that case is acceptable. if (!_mwCtx.headers && _mwCtx.status == null) return response; - const responseHeaders = new Headers(response.headers); - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - responseHeaders.append(key, value); - } - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers); + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); return new Response(response.body, { status: _mwCtx.status ?? response.status, statusText: response.statusText, @@ -14426,20 +14721,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { })(); _getRequestExecutionContext()?.waitUntil(__routeWritePromise); } - - // Collect any Set-Cookie headers from cookies().set()/delete() calls - const pendingCookies = getAndClearPendingCookies(); - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // If we have pending cookies, create a new response with them attached - if (pendingCookies.length > 0 || draftCookie) { - const newHeaders = new Headers(response.headers); - for (const cookie of pendingCookies) { - newHeaders.append("Set-Cookie", cookie); - } - if (draftCookie) newHeaders.append("Set-Cookie", draftCookie); + if (renderResponseHeaders) { + const newHeaders = __headersWithRenderResponseHeaders( + response.headers, + renderResponseHeaders, + ); if (isAutoHead) { return attachRouteHandlerMiddlewareContext(new Response(null, { @@ -14465,7 +14755,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return attachRouteHandlerMiddlewareContext(response); } catch (err) { - getAndClearPendingCookies(); // Clear any pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error // Catch redirect() / notFound() thrown from route handlers if (err && typeof err === "object" && "digest" in err) { const digest = String(err.digest); @@ -14585,29 +14875,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HIT (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.rscData, { + return __responseWithMiddlewareContext(new Response(__cachedValue.rscData, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } if (!isRscRequest && __hasHtml) { __isrDebug?.("HIT (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.html, { + return __responseWithMiddlewareContext(new Response(__cachedValue.html, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } __isrDebug?.("MISS (empty cached entry)", cleanPathname); } @@ -14654,6 +14944,10 @@ 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; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. + const __renderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); // Collect the full HTML string from the stream @@ -14667,14 +14961,13 @@ 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 }; }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); __isrDebug?.("regen complete", cleanPathname); }); @@ -14682,29 +14975,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("STALE (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.rscData, { + return __responseWithMiddlewareContext(new Response(__staleValue.rscData, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { __isrDebug?.("STALE (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.html, { + return __responseWithMiddlewareContext(new Response(__staleValue.html, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } // Stale entry exists but is empty for this request type — fall through to render __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); @@ -14794,65 +15087,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let element; - try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); - } catch (buildErr) { - // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components - if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { - const digest = String(buildErr.digest); + // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim + async function handleRenderError(err, fallbackOpts) { + if (err && typeof err === "object" && "digest" in err) { + const digest = String(err.digest); if (digest.startsWith("NEXT_REDIRECT;")) { const parts = digest.split(";"); const redirectUrl = decodeURIComponent(parts[2]); const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); + return __responseWithMiddlewareContext(new Response(null, { + status: statusCode, + headers: { Location: new URL(redirectUrl, request.url).toString() }, + }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false }); } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + ...fallbackOpts, + }); + const renderResponseHeaders = consumeRenderResponseHeaders(); + if (fallbackResp) { + return __responseWithMiddlewareContext( + fallbackResp, + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); + } setHeadersContext(null); setNavigationContext(null); const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); + return __responseWithMiddlewareContext( + new Response(statusText, { status: statusCode }), + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); } } + return null; + } + + let element; + try { + element = await buildPageElement(route, params, interceptOpts, url.searchParams); + } catch (buildErr) { + const specialResponse = await handleRenderError(buildErr); + if (specialResponse) return specialResponse; // Non-special error (e.g. generateMetadata() threw) — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw buildErr; } + const __buildRenderResponseHeaders = peekRenderResponseHeaders(); + const __buildDynamicUsage = peekDynamicUsage(); // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - if (err && typeof err === "object" && "digest" in err) { - const digest = String(err.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); - } - } - return null; - } - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws // notFound(), the parent layout's boundary catches it and renders the parent's @@ -14878,44 +15181,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const lr = LayoutComp({ params: asyncParams, children: null }); if (lr && typeof lr === "object" && typeof lr.then === "function") await lr; } catch (layoutErr) { - if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) { - const digest = String(layoutErr.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; } - if (!parentNotFound) parentNotFound = null; - // Wrap in only the layouts above the throwing one - const parentLayouts = route.layouts.slice(0, li); - const fallbackResp = await renderHTTPAccessFallbackPage( - route, statusCode, isRscRequest, request, - { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params } - ); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); } } + if (!parentNotFound) parentNotFound = null; + // Wrap in only the layouts above the throwing one + const parentLayouts = route.layouts.slice(0, li); + const specialResponse = await handleRenderError(layoutErr, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, + }); + if (specialResponse) return specialResponse; // Not a special error — let it propagate through normal RSC rendering } } @@ -14964,6 +15249,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(); @@ -15031,37 +15323,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } else if (revalidateSeconds) { responseHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; } - // Merge middleware response headers into the RSC response. - // set-cookie and vary are accumulated to preserve existing values - // (e.g. "Vary: RSC, Accept" set above); all other keys use plain - // assignment so middleware headers win over config headers, which - // the outer handler applies afterward and skips keys already present. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - const lk = key.toLowerCase(); - if (lk === "set-cookie") { - const existing = responseHeaders[lk]; - if (Array.isArray(existing)) { - existing.push(value); - } else if (existing) { - responseHeaders[lk] = [existing, value]; - } else { - responseHeaders[lk] = [value]; - } - } else if (lk === "vary") { - // Accumulate Vary values to preserve the existing "RSC, Accept" entry. - const existing = responseHeaders["Vary"] ?? responseHeaders["vary"]; - if (existing) { - responseHeaders["Vary"] = existing + ", " + value; - if (responseHeaders["vary"] !== undefined) delete responseHeaders["vary"]; - } else { - responseHeaders[key] = value; - } - } else { - responseHeaders[key] = value; - } - } - } // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -15077,26 +15338,49 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } + const __responseRenderHeaders = peekRenderResponseHeaders(); // For ISR-eligible RSC requests in production: write rscData to its own key. // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { - responseHeaders["X-Vinext-Cache"] = "MISS"; const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; + if (peekDynamicUsage()) { + responseHeaders["Cache-Control"] = "no-store, must-revalidate"; + } else { + responseHeaders["X-Vinext-Cache"] = "MISS"; + } const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? __responseRenderHeaders; + // consume picks up headers added during late async RSC streaming work. + // Falls back to the snapshot taken before the live MISS response was returned. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip RSC cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); + await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); + } finally { + setHeadersContext(null); + setNavigationContext(null); } })(); _getRequestExecutionContext()?.waitUntil(__rscWritePromise); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } - return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } // Collect font data from RSC environment before passing to SSR @@ -15134,7 +15418,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw ssrErr; } @@ -15144,31 +15435,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // when the error falls through to global-error.tsx. - // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response + // Helper to attach render-time response headers, middleware headers, font + // Link header, and rewrite status to a response. function attachMiddlewareContext(response) { - if (draftCookie) { - response.headers.append("Set-Cookie", draftCookie); - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderResponseHeaders); // Set HTTP Link header for font preloading if (fontLinkHeader) { - response.headers.set("Link", fontLinkHeader); - } - // Merge middleware response headers into the final response. - // The response is freshly constructed above (new Response(htmlStream, {...})), - // so set() and append() are equivalent — there are no same-key conflicts yet. - // Precedence over config headers is handled by the outer handler, which - // skips config keys that middleware already placed on the response. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - response.headers.append(key, value); - } + responseHeaders.set("Link", fontLinkHeader); } + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -15183,21 +15465,30 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const renderMs = __renderEnd !== undefined && __compileEnd !== undefined ? Math.round(__renderEnd - __compileEnd) : -1; - response.headers.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); + responseHeaders.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); } // Apply custom status code from middleware rewrite if (_mwCtx.status) { return new Response(response.body, { status: _mwCtx.status, - headers: response.headers, + headers: responseHeaders, }); } - return response; + const responseInit = { + status: response.status, + headers: responseHeaders, + }; + if (response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); } // Check if any component called connection(), cookies(), headers(), or noStore() // during rendering. If so, treat as dynamic (skip ISR, set no-store). - const dynamicUsedDuringRender = consumeDynamicUsage(); + const dynamicUsedDuringRender = __isrRscDataPromise + ? peekDynamicUsage() + : consumeDynamicUsage(); // Check if cacheLife() was called during rendering (e.g., page with file-level "use cache"). // If so, use its revalidation period for the Cache-Control header. @@ -15281,6 +15572,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? renderResponseHeaders; + // consume picks up any headers added during stream consumption by late + // async render work (for example, suspended branches). Falls back to + // the snapshot taken before streaming began when nothing new was added. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip HTML cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) @@ -15288,12 +15588,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ensuring the first client-side navigation after a direct visit is a // cache hit rather than a miss. const __writes = [ - __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags), + __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags), ]; if (__capturedRscDataPromise) { __writes.push( __capturedRscDataPromise.then((__rscBuf) => - __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags) + __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags) ) ); } @@ -15301,6 +15601,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HTML cache written", __isrKey); } catch (__cacheErr) { console.error("[vinext] ISR cache write error:", __cacheErr); + } finally { + consumeRenderResponseHeaders(); } })(); // Register with ExecutionContext (from ALS) so the Workers runtime keeps @@ -15385,7 +15687,7 @@ function renderToReadableStream(model, options) { } import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, 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"; @@ -15490,16 +15792,82 @@ 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") { + // entries() flattens Set-Cookie into a single comma-joined value. + 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; @@ -17206,11 +17574,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // the blanket strip loop after that call removes every remaining // x-middleware-* header before the set is merged into the response. _mwCtx.headers = new Headers(); - for (const [key, value] of mwResponse.headers) { - if (key !== "x-middleware-next" && key !== "x-middleware-rewrite") { - _mwCtx.headers.append(key, value); - } - } + __copyResponseHeaders(_mwCtx.headers, mwResponse.headers, [ + "x-middleware-next", + "x-middleware-rewrite", + ]); } else { // Check for redirect if (mwResponse.status >= 300 && mwResponse.status < 400) { @@ -17231,11 +17598,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; @@ -17457,21 +17823,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 }); } @@ -17507,20 +17868,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)), @@ -17611,12 +17967,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // return is skipped, but the copy loop below is a no-op, so no incorrect // headers are added. The allocation cost in that case is acceptable. if (!_mwCtx.headers && _mwCtx.status == null) return response; - const responseHeaders = new Headers(response.headers); - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - responseHeaders.append(key, value); - } - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers); + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); return new Response(response.body, { status: _mwCtx.status ?? response.status, statusText: response.statusText, @@ -17771,20 +18123,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { })(); _getRequestExecutionContext()?.waitUntil(__routeWritePromise); } - - // Collect any Set-Cookie headers from cookies().set()/delete() calls - const pendingCookies = getAndClearPendingCookies(); - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // If we have pending cookies, create a new response with them attached - if (pendingCookies.length > 0 || draftCookie) { - const newHeaders = new Headers(response.headers); - for (const cookie of pendingCookies) { - newHeaders.append("Set-Cookie", cookie); - } - if (draftCookie) newHeaders.append("Set-Cookie", draftCookie); + if (renderResponseHeaders) { + const newHeaders = __headersWithRenderResponseHeaders( + response.headers, + renderResponseHeaders, + ); if (isAutoHead) { return attachRouteHandlerMiddlewareContext(new Response(null, { @@ -17810,7 +18157,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } return attachRouteHandlerMiddlewareContext(response); } catch (err) { - getAndClearPendingCookies(); // Clear any pending cookies on error + consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error // Catch redirect() / notFound() thrown from route handlers if (err && typeof err === "object" && "digest" in err) { const digest = String(err.digest); @@ -17930,29 +18277,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HIT (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.rscData, { + return __responseWithMiddlewareContext(new Response(__cachedValue.rscData, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } if (!isRscRequest && __hasHtml) { __isrDebug?.("HIT (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__cachedValue.html, { + return __responseWithMiddlewareContext(new Response(__cachedValue.html, { status: __cachedValue.status || 200, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "HIT", - }, - }); + }, __cachedValue.headers), + }), _mwCtx); } __isrDebug?.("MISS (empty cached entry)", cleanPathname); } @@ -17999,6 +18346,10 @@ 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; + // RSC data must be fully consumed before headers are finalized, + // since async server components may append headers while streaming. + const __renderHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); // Collect the full HTML string from the stream @@ -18012,14 +18363,13 @@ 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 }; }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: __revalResult.headers, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); __isrDebug?.("regen complete", cleanPathname); }); @@ -18027,29 +18377,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("STALE (RSC)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.rscData, { + return __responseWithMiddlewareContext(new Response(__staleValue.rscData, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/x-component; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { __isrDebug?.("STALE (HTML)", cleanPathname); setHeadersContext(null); setNavigationContext(null); - return new Response(__staleValue.html, { + return __responseWithMiddlewareContext(new Response(__staleValue.html, { status: __staleStatus, - headers: { + headers: __headersWithRenderResponseHeaders({ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "s-maxage=0, stale-while-revalidate", "Vary": "RSC, Accept", "X-Vinext-Cache": "STALE", - }, - }); + }, __staleValue.headers), + }), _mwCtx); } // Stale entry exists but is empty for this request type — fall through to render __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); @@ -18139,65 +18489,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let element; - try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); - } catch (buildErr) { - // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components - if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { - const digest = String(buildErr.digest); + // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim + async function handleRenderError(err, fallbackOpts) { + if (err && typeof err === "object" && "digest" in err) { + const digest = String(err.digest); if (digest.startsWith("NEXT_REDIRECT;")) { const parts = digest.split(";"); const redirectUrl = decodeURIComponent(parts[2]); const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; + const renderResponseHeaders = consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); + return __responseWithMiddlewareContext(new Response(null, { + status: statusCode, + headers: { Location: new URL(redirectUrl, request.url).toString() }, + }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false }); } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + ...fallbackOpts, + }); + const renderResponseHeaders = consumeRenderResponseHeaders(); + if (fallbackResp) { + return __responseWithMiddlewareContext( + fallbackResp, + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); + } setHeadersContext(null); setNavigationContext(null); const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); + return __responseWithMiddlewareContext( + new Response(statusText, { status: statusCode }), + _mwCtx, + renderResponseHeaders, + { applyRewriteStatus: false }, + ); } } + return null; + } + + let element; + try { + element = await buildPageElement(route, params, interceptOpts, url.searchParams); + } catch (buildErr) { + const specialResponse = await handleRenderError(buildErr); + if (specialResponse) return specialResponse; // Non-special error (e.g. generateMetadata() threw) — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw buildErr; } + const __buildRenderResponseHeaders = peekRenderResponseHeaders(); + const __buildDynamicUsage = peekDynamicUsage(); // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - if (err && typeof err === "object" && "digest" in err) { - const digest = String(err.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); - } - } - return null; - } - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws // notFound(), the parent layout's boundary catches it and renders the parent's @@ -18223,44 +18583,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const lr = LayoutComp({ params: asyncParams, children: null }); if (lr && typeof lr === "object" && typeof lr.then === "function") await lr; } catch (layoutErr) { - if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) { - const digest = String(layoutErr.digest); - if (digest.startsWith("NEXT_REDIRECT;")) { - const parts = digest.split(";"); - const redirectUrl = decodeURIComponent(parts[2]); - const statusCode = parts[3] ? parseInt(parts[3], 10) : 307; - setHeadersContext(null); - setNavigationContext(null); - return Response.redirect(new URL(redirectUrl, request.url), statusCode); - } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { - const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; } - if (!parentNotFound) parentNotFound = null; - // Wrap in only the layouts above the throwing one - const parentLayouts = route.layouts.slice(0, li); - const fallbackResp = await renderHTTPAccessFallbackPage( - route, statusCode, isRscRequest, request, - { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params } - ); - if (fallbackResp) return fallbackResp; - setHeadersContext(null); - setNavigationContext(null); - const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; - return new Response(statusText, { status: statusCode }); } } + if (!parentNotFound) parentNotFound = null; + // Wrap in only the layouts above the throwing one + const parentLayouts = route.layouts.slice(0, li); + const specialResponse = await handleRenderError(layoutErr, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, + }); + if (specialResponse) return specialResponse; // Not a special error — let it propagate through normal RSC rendering } } @@ -18309,6 +18651,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(); @@ -18376,37 +18725,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } else if (revalidateSeconds) { responseHeaders["Cache-Control"] = "s-maxage=" + revalidateSeconds + ", stale-while-revalidate"; } - // Merge middleware response headers into the RSC response. - // set-cookie and vary are accumulated to preserve existing values - // (e.g. "Vary: RSC, Accept" set above); all other keys use plain - // assignment so middleware headers win over config headers, which - // the outer handler applies afterward and skips keys already present. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - const lk = key.toLowerCase(); - if (lk === "set-cookie") { - const existing = responseHeaders[lk]; - if (Array.isArray(existing)) { - existing.push(value); - } else if (existing) { - responseHeaders[lk] = [existing, value]; - } else { - responseHeaders[lk] = [value]; - } - } else if (lk === "vary") { - // Accumulate Vary values to preserve the existing "RSC, Accept" entry. - const existing = responseHeaders["Vary"] ?? responseHeaders["vary"]; - if (existing) { - responseHeaders["Vary"] = existing + ", " + value; - if (responseHeaders["vary"] !== undefined) delete responseHeaders["vary"]; - } else { - responseHeaders[key] = value; - } - } else { - responseHeaders[key] = value; - } - } - } // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -18422,26 +18740,49 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } + const __responseRenderHeaders = peekRenderResponseHeaders(); // For ISR-eligible RSC requests in production: write rscData to its own key. // HTML is stored under a separate key (written by the HTML path below) so // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { - responseHeaders["X-Vinext-Cache"] = "MISS"; const __isrKeyRsc = __isrRscKey(cleanPathname); const __revalSecsRsc = revalidateSeconds; + if (peekDynamicUsage()) { + responseHeaders["Cache-Control"] = "no-store, must-revalidate"; + } else { + responseHeaders["X-Vinext-Cache"] = "MISS"; + } const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? __responseRenderHeaders; + // consume picks up headers added during late async RSC streaming work. + // Falls back to the snapshot taken before the live MISS response was returned. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip RSC cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); - await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); + await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { console.error("[vinext] ISR RSC cache write error:", __rscWriteErr); + } finally { + setHeadersContext(null); + setNavigationContext(null); } })(); _getRequestExecutionContext()?.waitUntil(__rscWritePromise); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } - return new Response(__rscForResponse, { status: _mwCtx.status || 200, headers: responseHeaders }); + return __responseWithMiddlewareContext(new Response(__rscForResponse, { + status: 200, + headers: __headersWithRenderResponseHeaders(responseHeaders, __responseRenderHeaders), + }), _mwCtx); } // Collect font data from RSC environment before passing to SSR @@ -18479,7 +18820,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); - if (errorBoundaryResp) return errorBoundaryResp; + if (errorBoundaryResp) { + return __responseWithMiddlewareContext( + errorBoundaryResp, + _mwCtx, + consumeRenderResponseHeaders(), + { applyRewriteStatus: false }, + ); + } throw ssrErr; } @@ -18489,31 +18837,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // when the error falls through to global-error.tsx. - // Check for draftMode Set-Cookie header (from draftMode().enable()/disable()) - const draftCookie = getDraftModeCookieHeader(); + const renderResponseHeaders = __isrRscDataPromise + ? peekRenderResponseHeaders() + : consumeRenderResponseHeaders(); setHeadersContext(null); setNavigationContext(null); - // Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response + // Helper to attach render-time response headers, middleware headers, font + // Link header, and rewrite status to a response. function attachMiddlewareContext(response) { - if (draftCookie) { - response.headers.append("Set-Cookie", draftCookie); - } + const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderResponseHeaders); // Set HTTP Link header for font preloading if (fontLinkHeader) { - response.headers.set("Link", fontLinkHeader); - } - // Merge middleware response headers into the final response. - // The response is freshly constructed above (new Response(htmlStream, {...})), - // so set() and append() are equivalent — there are no same-key conflicts yet. - // Precedence over config headers is handled by the outer handler, which - // skips config keys that middleware already placed on the response. - if (_mwCtx.headers) { - for (const [key, value] of _mwCtx.headers) { - response.headers.append(key, value); - } + responseHeaders.set("Link", fontLinkHeader); } + __applyMiddlewareResponseHeaders(responseHeaders, _mwCtx.headers); // Attach internal timing header so the dev server middleware can log it. // Format: "handlerStart,compileMs,renderMs" // handlerStart - absolute performance.now() when _handleRequest began, @@ -18528,21 +18867,30 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const renderMs = __renderEnd !== undefined && __compileEnd !== undefined ? Math.round(__renderEnd - __compileEnd) : -1; - response.headers.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); + responseHeaders.set("x-vinext-timing", handlerStart + "," + compileMs + "," + renderMs); } // Apply custom status code from middleware rewrite if (_mwCtx.status) { return new Response(response.body, { status: _mwCtx.status, - headers: response.headers, + headers: responseHeaders, }); } - return response; + const responseInit = { + status: response.status, + headers: responseHeaders, + }; + if (response.statusText) { + responseInit.statusText = response.statusText; + } + return new Response(response.body, responseInit); } // Check if any component called connection(), cookies(), headers(), or noStore() // during rendering. If so, treat as dynamic (skip ISR, set no-store). - const dynamicUsedDuringRender = consumeDynamicUsage(); + const dynamicUsedDuringRender = __isrRscDataPromise + ? peekDynamicUsage() + : consumeDynamicUsage(); // Check if cacheLife() was called during rendering (e.g., page with file-level "use cache"). // If so, use its revalidation period for the Cache-Control header. @@ -18626,6 +18974,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); + const __renderHeadersForCache = consumeRenderResponseHeaders() ?? renderResponseHeaders; + // consume picks up any headers added during stream consumption by late + // async render work (for example, suspended branches). Falls back to + // the snapshot taken before streaming began when nothing new was added. + const __dynamicUsedForCache = consumeDynamicUsage(); + if (__dynamicUsedForCache) { + __isrDebug?.("skip HTML cache write after late dynamic usage", cleanPathname); + return; + } const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) @@ -18633,12 +18990,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ensuring the first client-side navigation after a direct visit is a // cache hit rather than a miss. const __writes = [ - __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags), + __isrSet(__isrKey, { kind: "APP_PAGE", html: __fullHtml, rscData: undefined, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags), ]; if (__capturedRscDataPromise) { __writes.push( __capturedRscDataPromise.then((__rscBuf) => - __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __pageTags) + __isrSet(__isrKeyRscFromHtml, { kind: "APP_PAGE", html: "", rscData: __rscBuf, headers: __renderHeadersForCache, postponed: undefined, status: 200 }, __revalSecs, __pageTags) ) ); } @@ -18646,6 +19003,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __isrDebug?.("HTML cache written", __isrKey); } catch (__cacheErr) { console.error("[vinext] ISR cache write error:", __cacheErr); + } finally { + consumeRenderResponseHeaders(); } })(); // Register with ExecutionContext (from ALS) so the Workers runtime keeps diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 931779a23..2567b1729 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,60 @@ 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("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 +1551,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 +1742,254 @@ 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 () => { + // Next.js preserves duplicate response headers instead of overwriting them. + // Ref: packages/next/src/server/send-response.ts + // Ref: test/e2e/app-dir/no-duplicate-headers-middleware/no-duplicate-headers-middleware.test.ts + const expectedVaryPrefix = [ + "x-render-one", + "x-render-two", + "RSC", + "Accept", + "x-middleware-test", + ]; + const expectedWwwAuthenticate = + 'Basic realm="render", Bearer realm="render", Digest realm="middleware"'; + const expectMergedVary = (value: string | null) => { + expect(value).not.toBeNull(); + const tokens = value!.split(",").map((part) => part.trim()); + expect(tokens.slice(0, expectedVaryPrefix.length)).toEqual(expectedVaryPrefix); + }; + const expectedMissSetCookies = [ + "rendered=1; Path=/; HttpOnly", + "rendered-second=1; Path=/; HttpOnly", + "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("returns snapshot render-time headers on direct RSC MISSes and replays final headers on HITs", async () => { + const expectedVary = "x-render-one, x-render-two, RSC, Accept, x-middleware-test"; + const expectedWwwAuthenticate = + 'Basic realm="render", Bearer realm="render", Digest realm="middleware"'; + const expectedMiddlewareSetCookies = [ + "middleware-render=1; Path=/; HttpOnly", + "middleware-render-second=1; Path=/; HttpOnly", + ]; + const expectedHitSetCookies = [ + "rendered=1; Path=/; HttpOnly", + "rendered-second=1; Path=/; HttpOnly", + "rendered-late=1; Path=/; HttpOnly", + "middleware-render=1; Path=/; HttpOnly", + "middleware-render-second=1; Path=/; HttpOnly", + ]; + const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); + const rscServer = await startProdServer({ port: 0, outDir, noCompression: true }); + + const fetchUntilLocalCacheState = async ( + localBaseUrl: string, + pathname: string, + expectedState: string, + init?: RequestInit, + timeoutMs: number = 3000, + ): Promise => { + const deadline = Date.now() + timeoutMs; + let lastState: string | null = null; + + while (Date.now() < deadline) { + const res = await fetch(`${localBaseUrl}${pathname}`, init); + const cacheState = res.headers.get("x-vinext-cache"); + if (cacheState === expectedState) { + return res; + } + + 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 rscBaseUrl = `http://localhost:${rscServer.port}`; + + const rscMiss = await fetch( + `${rscBaseUrl}/nextjs-compat/cached-render-headers-rsc-parity.rsc`, + { + headers: { Accept: "text/x-component" }, + }, + ); + expect(rscMiss.status).toBe(200); + expect(rscMiss.headers.get("x-vinext-cache")).toBe("MISS"); + expect(rscMiss.headers.get("x-rendered-late")).toBeNull(); + expect(rscMiss.headers.get("x-mw-conflict")).toBe("middleware"); + expect(rscMiss.headers.get("x-mw-ran")).toBe("true"); + expect(rscMiss.headers.get("x-mw-pathname")).toBe( + "/nextjs-compat/cached-render-headers-rsc-parity", + ); + expect(rscMiss.headers.get("vary")).toContain("RSC"); + expect(rscMiss.headers.get("vary")).toContain("Accept"); + expect(rscMiss.headers.get("vary")).toContain("x-middleware-test"); + expect(rscMiss.headers.get("www-authenticate")).toContain('Digest realm="middleware"'); + expect(rscMiss.headers.getSetCookie()).toEqual( + expect.arrayContaining(expectedMiddlewareSetCookies), + ); + expect(rscMiss.headers.getSetCookie()).not.toContain("rendered-late=1; Path=/; HttpOnly"); + expect((await rscMiss.text()).length).toBeGreaterThan(0); + + const rscHit = await fetchUntilLocalCacheState( + rscBaseUrl, + "/nextjs-compat/cached-render-headers-rsc-parity.rsc", + "HIT", + { headers: { Accept: "text/x-component" } }, + ); + expect(rscHit.status).toBe(200); + expect(rscHit.headers.get("x-vinext-cache")).toBe("HIT"); + expect(rscHit.headers.get("x-rendered-in-page")).toBe("yes"); + expect(rscHit.headers.get("x-rendered-late")).toBe("yes"); + expect(rscHit.headers.get("x-mw-conflict")).toBe("middleware"); + expect(rscHit.headers.get("x-mw-ran")).toBe("true"); + expect(rscHit.headers.get("x-mw-pathname")).toBe( + "/nextjs-compat/cached-render-headers-rsc-parity", + ); + expect(rscHit.headers.get("vary")).toBe(expectedVary); + expect(rscHit.headers.get("www-authenticate")).toBe(expectedWwwAuthenticate); + expect(rscHit.headers.getSetCookie()).toEqual(expectedHitSetCookies); + } finally { + await new Promise((resolve) => rscServer.server.close(() => resolve())); + } + }); + it("revalidateTag invalidates App Router ISR page entries by fetch tag", async () => { const res1 = await fetch(`${baseUrl}/revalidate-tag-test`); expect(res1.status).toBe(200); @@ -3056,6 +3410,21 @@ describe("App Router middleware with NextRequest", () => { expect(html).toContain("Welcome to App Router"); }); + it("middleware rewrite status does not override redirect() or notFound() in production", async () => { + const redirectRes = await fetch(`${baseUrl}/middleware-rewrite-status-redirect`, { + redirect: "manual", + }); + expect(redirectRes.status).toBe(307); + expect(redirectRes.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 can return custom response", async () => { const res = await fetch(`${baseUrl}/middleware-blocked`); expect(res.status).toBe(403); @@ -3725,15 +4094,67 @@ describe("generateRscEntry ISR code generation", () => { expect(code).toContain("rscData: __freshRscData"); }); + it("generated code persists render-time response headers in App Router ISR entries", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + expect(code).toContain("peekRenderResponseHeaders"); + expect(code).toContain("consumeRenderResponseHeaders"); + expect(code).toContain("const __responseRenderHeaders = peekRenderResponseHeaders()"); + expect(code).toContain( + "const __renderHeadersForCache = consumeRenderResponseHeaders() ?? __responseRenderHeaders;", + ); + expect(code).toContain("const renderResponseHeaders = __isrRscDataPromise"); + expect(code).toContain("? peekRenderResponseHeaders()"); + expect(code).toContain(": consumeRenderResponseHeaders();"); + expect(code).toContain("headers: __renderHeadersForCache"); + expect(code).toContain("headers: __revalResult.headers"); + const rscResponseBlock = code.slice( + code.indexOf('if (process.env.NODE_ENV === "production" && __isrRscDataPromise)'), + code.indexOf("// Collect font data from RSC environment before passing to SSR"), + ); + expect( + rscResponseBlock.match( + /headers: __headersWithRenderResponseHeaders\(responseHeaders, __responseRenderHeaders\)/g, + ), + ).toHaveLength(2); + }); + + it("generated code replays cached render-time response headers on HIT and STALE", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + expect(code).toContain("function __mergeResponseHeaders("); + expect(code).toContain("function __headersWithRenderResponseHeaders("); + expect(code).toContain("sourceHeaders.getSetCookie()"); + expect(code).not.toContain("if (__setCookies.length === 0)"); + expect(code).toContain('lowerKey === "vary"'); + expect(code).toContain('lowerKey === "www-authenticate"'); + expect(code).toContain('lowerKey === "proxy-authenticate"'); + expect(code).toContain("__headersWithRenderResponseHeaders({"); + expect(code).toContain("}, __cachedValue.headers)"); + expect(code).toContain("}, __staleValue.headers)"); + }); + + it("generated code avoids reusing stale statusText when rewrite status overrides", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + const helperBlock = code.slice( + code.indexOf("function __responseWithMiddlewareContext"), + code.indexOf("const __pendingRegenerations"), + ); + expect(helperBlock).toContain("const status = rewriteStatus ?? response.status;"); + expect(helperBlock).toContain("if (status === response.status && response.statusText)"); + expect(helperBlock).not.toContain("statusText: response.statusText"); + }); + it("generated code writes RSC-first partial cache entry on RSC MISS", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes); // The RSC-path cache write must store rscData with html:"" as a partial entry. // This lets subsequent RSC requests hit cache immediately without waiting // for an HTML request to come in and populate a complete entry. expect(code).toContain('html: ""'); - // The RSC write must use __isrKeyRsc / __rscDataForCache variable names - expect(code).toContain("__rscDataForCache"); + // The live RSC MISS response should keep streaming while the cache write + // runs in the background and consumes the final render-time headers. + expect(code).toContain("const __responseRenderHeaders = peekRenderResponseHeaders()"); + expect(code).toContain("const __rscWritePromise = (async () => {"); expect(code).toContain("__isrKeyRsc"); + expect(code).toContain("new Response(__rscForResponse"); }); it("generated code treats html:'' partial entries as MISS for HTML requests", () => { @@ -3798,9 +4219,10 @@ describe("generateRscEntry ISR code generation", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes); // __rscForResponse must be assigned before the RSC response is returned so // the tee branch (__rscB) is also consumed (populating the ISR cache). - // The RSC response is: new Response(__rscForResponse, ...) + // The RSC response is wrapped in middleware handling but still originates + // from new Response(__rscForResponse, ...). const teeAssignIdx = code.indexOf("let __rscForResponse"); - const rscResponseIdx = code.indexOf("return new Response(__rscForResponse"); + const rscResponseIdx = code.indexOf("new Response(__rscForResponse"); expect(teeAssignIdx).toBeGreaterThan(-1); expect(rscResponseIdx).toBeGreaterThan(-1); expect(teeAssignIdx).toBeLessThan(rscResponseIdx); diff --git a/tests/fixtures/app-basic/app/lib/render-response-header.ts b/tests/fixtures/app-basic/app/lib/render-response-header.ts new file mode 100644 index 000000000..f259cd399 --- /dev/null +++ b/tests/fixtures/app-basic/app/lib/render-response-header.ts @@ -0,0 +1 @@ +export { appendRenderResponseHeader } from "../../../../../packages/vinext/src/shims/headers.js"; diff --git a/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-rsc-first/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-rsc-first/page.tsx new file mode 100644 index 000000000..7c2f62c9b --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-rsc-first/page.tsx @@ -0,0 +1 @@ +export { revalidate, default } from "../cached-render-headers/page"; diff --git a/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-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..a729ddbf1 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,18 @@ export function middleware(request: NextRequest) { }); } + if (pathname === "/middleware-rewrite-status-redirect") { + return NextResponse.rewrite(new URL("/redirect-test", request.url), { + status: 403, + }); + } + + if (pathname === "/middleware-rewrite-status-not-found") { + return NextResponse.rewrite(new URL("/nextjs-compat/not-found-no-boundary/404", request.url), { + status: 403, + }); + } + // Block /middleware-blocked with custom response if (pathname === "/middleware-blocked") { return new Response("Blocked by middleware", { status: 403 }); @@ -149,16 +184,38 @@ 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-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/shims.test.ts b/tests/shims.test.ts index 9c54b32f3..50593a9d2 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");