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 ;
+}
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");