Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 20 additions & 9 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const appPageBoundaryRenderPath = resolveEntryPath(
import.meta.url,
);
const appPageRenderPath = resolveEntryPath("../server/app-page-render.js", import.meta.url);
const appPageResponsePath = resolveEntryPath("../server/app-page-response.js", import.meta.url);
const appPageRequestPath = resolveEntryPath("../server/app-page-request.js", import.meta.url);
const appRouteHandlerResponsePath = resolveEntryPath(
"../server/app-route-handler-response.js",
Expand Down Expand Up @@ -378,6 +379,9 @@ import {
import {
renderAppPageLifecycle as __renderAppPageLifecycle,
} from ${JSON.stringify(appPageRenderPath)};
import {
mergeMiddlewareResponseHeaders as __mergeMiddlewareResponseHeaders,
} from ${JSON.stringify(appPageResponsePath)};
import {
buildAppPageElement as __buildAppPageElement,
resolveAppPageIntercept as __resolveAppPageIntercept,
Expand Down Expand Up @@ -1877,10 +1881,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const redirectHeaders = new Headers({
"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),
});
// Merge middleware headers first so the framework's own redirect control
// headers below are always authoritative and cannot be clobbered by
// middleware that happens to set x-action-redirect* keys.
__mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve server-action redirect control headers

In the action-redirect branch, the code builds x-action-redirect* headers and then merges middleware headers afterward; because mergeMiddlewareResponseHeaders() uses set() for most keys, middleware can overwrite these control headers. If a middleware sets x-action-redirect, x-action-redirect-type, or x-action-redirect-status (intentionally or via header passthrough), the client can navigate to the wrong URL or fail to process the redirect correctly. Merge middleware headers before writing the action redirect control headers, or explicitly protect these keys from override.

Useful? React with 👍 / 👎.

redirectHeaders.set("x-action-redirect", actionRedirect.url);
redirectHeaders.set("x-action-redirect-type", actionRedirect.type);
redirectHeaders.set("x-action-redirect-status", String(actionRedirect.status));
for (const cookie of actionPendingCookies) {
redirectHeaders.append("Set-Cookie", cookie);
}
Expand Down Expand Up @@ -1923,15 +1931,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
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 });
const actionHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" });
__mergeMiddlewareResponseHeaders(actionHeaders, _mwCtx.headers);
if (actionPendingCookies.length > 0 || actionDraftCookie) {
for (const cookie of actionPendingCookies) {
actionResponse.headers.append("Set-Cookie", cookie);
actionHeaders.append("Set-Cookie", cookie);
}
if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie);
if (actionDraftCookie) actionHeaders.append("Set-Cookie", actionDraftCookie);
}
return actionResponse;
return new Response(rscStream, { status: _mwCtx.status ?? 200, headers: actionHeaders });
} catch (err) {
getAndClearPendingCookies(); // Clear pending cookies on error
console.error("[vinext] Server action error:", err);
Expand Down Expand Up @@ -2330,8 +2338,11 @@ 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 interceptHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" });
__mergeMiddlewareResponseHeaders(interceptHeaders, _mwCtx.headers);
return new Response(interceptStream, {
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
status: _mwCtx.status ?? 200,
headers: interceptHeaders,
});
},
searchParams: url.searchParams,
Expand Down
43 changes: 29 additions & 14 deletions packages/vinext/src/server/app-page-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,34 @@ export function resolveAppPageHtmlResponsePolicy(
return { shouldWriteToCache: false };
}

/**
* Merge middleware response headers into a target Headers object.
*
* Set-Cookie and Vary are accumulated (append) since multiple sources can
* contribute values. All other headers use set() so middleware owns singular
* response headers like Cache-Control.
*
* Used by buildAppPageRscResponse and the generated entry for intercepting
* route and server action responses that bypass the normal page render path.
*/
export function mergeMiddlewareResponseHeaders(
target: Headers,
middlewareHeaders: Headers | null,
): void {
if (!middlewareHeaders) {
return;
}

for (const [key, value] of middlewareHeaders) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Headers iterator always yields lowercase keys per the Fetch spec, so key.toLowerCase() here is a no-op. Harmless and defensive — just noting for awareness.

const lowerKey = key.toLowerCase();
if (lowerKey === "set-cookie" || lowerKey === "vary") {
target.append(key, value);
} else {
target.set(key, value);
}
}
}

export function buildAppPageRscResponse(
body: ReadableStream,
options: BuildAppPageRscResponseOptions,
Expand All @@ -178,20 +206,7 @@ export function buildAppPageRscResponse(
headers.set("X-Vinext-Cache", options.policy.cacheState);
}

if (options.middlewareContext.headers) {
for (const [key, value] of options.middlewareContext.headers) {
const lowerKey = key.toLowerCase();
if (lowerKey === "set-cookie" || lowerKey === "vary") {
headers.append(key, value);
} else {
// Keep parity with the old inline RSC path: middleware owns singular
// response headers like Cache-Control here, while Set-Cookie and Vary
// are accumulated. The HTML helper intentionally keeps its legacy
// append-for-everything behavior below.
headers.set(key, value);
}
}
}
mergeMiddlewareResponseHeaders(headers, options.middlewareContext.headers);

applyTimingHeader(headers, options.timing);

Expand Down
Loading
Loading