diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 23e1c3ec..a71d2441 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -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", @@ -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, @@ -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); + 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); } @@ -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); @@ -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, diff --git a/packages/vinext/src/server/app-page-response.ts b/packages/vinext/src/server/app-page-response.ts index c8784764..4c4da7de 100644 --- a/packages/vinext/src/server/app-page-response.ts +++ b/packages/vinext/src/server/app-page-response.ts @@ -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) { + 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, @@ -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); diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 7c8a503e..fa8450d9 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -82,6 +82,9 @@ import { import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; +import { + mergeMiddlewareResponseHeaders as __mergeMiddlewareResponseHeaders, +} from "/packages/vinext/src/server/app-page-response.js"; import { buildAppPageElement as __buildAppPageElement, resolveAppPageIntercept as __resolveAppPageIntercept, @@ -1603,10 +1606,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); + 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); } @@ -1649,15 +1656,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); @@ -2026,8 +2033,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, @@ -2276,6 +2286,9 @@ import { import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; +import { + mergeMiddlewareResponseHeaders as __mergeMiddlewareResponseHeaders, +} from "/packages/vinext/src/server/app-page-response.js"; import { buildAppPageElement as __buildAppPageElement, resolveAppPageIntercept as __resolveAppPageIntercept, @@ -3800,10 +3813,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); + 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); } @@ -3846,15 +3863,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); @@ -4223,8 +4240,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, @@ -4473,6 +4493,9 @@ import { import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; +import { + mergeMiddlewareResponseHeaders as __mergeMiddlewareResponseHeaders, +} from "/packages/vinext/src/server/app-page-response.js"; import { buildAppPageElement as __buildAppPageElement, resolveAppPageIntercept as __resolveAppPageIntercept, @@ -6003,10 +6026,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); + 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); } @@ -6049,15 +6076,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); @@ -6426,8 +6453,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, @@ -6676,6 +6706,9 @@ import { import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; +import { + mergeMiddlewareResponseHeaders as __mergeMiddlewareResponseHeaders, +} from "/packages/vinext/src/server/app-page-response.js"; import { buildAppPageElement as __buildAppPageElement, resolveAppPageIntercept as __resolveAppPageIntercept, @@ -8230,10 +8263,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); + 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); } @@ -8276,15 +8313,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); @@ -8653,8 +8690,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, @@ -8903,6 +8943,9 @@ import { import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; +import { + mergeMiddlewareResponseHeaders as __mergeMiddlewareResponseHeaders, +} from "/packages/vinext/src/server/app-page-response.js"; import { buildAppPageElement as __buildAppPageElement, resolveAppPageIntercept as __resolveAppPageIntercept, @@ -10431,10 +10474,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); + 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); } @@ -10477,15 +10524,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); @@ -10854,8 +10901,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, @@ -11104,6 +11154,9 @@ import { import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; +import { + mergeMiddlewareResponseHeaders as __mergeMiddlewareResponseHeaders, +} from "/packages/vinext/src/server/app-page-response.js"; import { buildAppPageElement as __buildAppPageElement, resolveAppPageIntercept as __resolveAppPageIntercept, @@ -12989,10 +13042,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); + 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); } @@ -13035,15 +13092,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); @@ -13412,8 +13469,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, diff --git a/tests/app-page-response.test.ts b/tests/app-page-response.test.ts index 230f8bbf..d142ac2e 100644 --- a/tests/app-page-response.test.ts +++ b/tests/app-page-response.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildAppPageHtmlResponse, buildAppPageRscResponse, + mergeMiddlewareResponseHeaders, resolveAppPageHtmlResponsePolicy, resolveAppPageRscResponsePolicy, } from "../packages/vinext/src/server/app-page-response.js"; @@ -288,3 +289,47 @@ describe("app page response helpers", () => { await expect(response.text()).resolves.toBe("

page

"); }); }); + +describe("mergeMiddlewareResponseHeaders", () => { + it("is a no-op when middleware headers are null", () => { + const target = new Headers({ "Content-Type": "text/plain" }); + mergeMiddlewareResponseHeaders(target, null); + expect(target.get("Content-Type")).toBe("text/plain"); + expect([...target].length).toBe(1); + }); + + it("sets singular headers via set(), overriding existing values", () => { + const target = new Headers({ "Cache-Control": "no-store", "X-Custom": "original" }); + const mwHeaders = new Headers(); + mwHeaders.set("Cache-Control", "private, max-age=5"); + mwHeaders.set("X-Custom", "from-middleware"); + + mergeMiddlewareResponseHeaders(target, mwHeaders); + + expect(target.get("Cache-Control")).toBe("private, max-age=5"); + expect(target.get("X-Custom")).toBe("from-middleware"); + }); + + it("appends Set-Cookie headers instead of overriding", () => { + const target = new Headers(); + target.append("Set-Cookie", "existing=1; Path=/"); + const mwHeaders = new Headers(); + mwHeaders.append("Set-Cookie", "mw-session=abc; Path=/"); + + mergeMiddlewareResponseHeaders(target, mwHeaders); + + const cookies = target.getSetCookie(); + expect(cookies).toContain("existing=1; Path=/"); + expect(cookies).toContain("mw-session=abc; Path=/"); + }); + + it("appends Vary headers instead of overriding", () => { + const target = new Headers({ Vary: "RSC, Accept" }); + const mwHeaders = new Headers(); + mwHeaders.set("Vary", "Next-Router-State-Tree"); + + mergeMiddlewareResponseHeaders(target, mwHeaders); + + expect(target.get("Vary")).toBe("RSC, Accept, Next-Router-State-Tree"); + }); +}); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 29871a2a..4ba6e3f1 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -3245,6 +3245,20 @@ describe("App Router middleware with NextRequest", () => { const text = await res.text(); expect(text).toBe("Event OK"); }); + + it("middleware response headers appear on intercepting route RSC responses", async () => { + // Intercepting route responses are constructed via renderInterceptResponse(), + // which must merge _mwCtx.headers into the Response — same as the normal + // page path through buildAppPageRscResponse(). + const res = await fetch(`${baseUrl}/photos/42.rsc`, { + headers: { Accept: "text/x-component" }, + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/x-component"); + // Middleware sets x-mw-ran and x-mw-pathname on all matched paths + expect(res.headers.get("x-mw-ran")).toBe("true"); + expect(res.headers.get("x-mw-pathname")).toBe("/photos/42"); + }); }); describe("RSC Flight hint fix", () => { @@ -3909,4 +3923,40 @@ describe("generateRscEntry ISR code generation", () => { expect(code).toContain("isrRouteKey: __isrRouteKey"); expect(code).toContain("isrSet: __isrSet"); }); + + it("generated code merges middleware headers into intercept route responses", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + // The renderInterceptResponse callback must call mergeMiddlewareResponseHeaders + // to apply middleware headers (auth cookies, CORS, security headers) to + // intercepting route RSC responses. + expect(code).toContain("mergeMiddlewareResponseHeaders"); + // The call must appear between renderInterceptResponse and searchParams + // (the next callback in the options object), ensuring it's inside the + // intercept response construction path. + const interceptStart = code.indexOf("renderInterceptResponse(sourceRoute, interceptElement)"); + const interceptEnd = code.indexOf("searchParams:", interceptStart); + const interceptBody = code.slice(interceptStart, interceptEnd); + expect(interceptBody).toContain("mergeMiddlewareResponseHeaders"); + }); + + it("generated code merges middleware headers into server action re-render responses", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + // The server action re-render path must call mergeMiddlewareResponseHeaders + // to apply middleware headers to the RSC response containing the re-rendered page. + // Find the action response construction area (after actionHeaders, before catch) + const actionHeadersIdx = code.indexOf("const actionHeaders ="); + const actionCatchIdx = code.indexOf("} catch (err)", actionHeadersIdx); + const actionResponseBody = code.slice(actionHeadersIdx, actionCatchIdx); + expect(actionResponseBody).toContain("mergeMiddlewareResponseHeaders"); + }); + + it("generated code merges middleware headers into server action redirect responses", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + // The server action redirect path must call mergeMiddlewareResponseHeaders + // to apply middleware headers to the redirect response. + const redirectStart = code.indexOf("if (actionRedirect)"); + const redirectEnd = code.indexOf('return new Response(""', redirectStart); + const redirectBody = code.slice(redirectStart, redirectEnd); + expect(redirectBody).toContain("mergeMiddlewareResponseHeaders"); + }); }); diff --git a/tests/fixtures/app-basic/middleware.ts b/tests/fixtures/app-basic/middleware.ts index ce8c5bea..24fefdd7 100644 --- a/tests/fixtures/app-basic/middleware.ts +++ b/tests/fixtures/app-basic/middleware.ts @@ -184,5 +184,7 @@ export const config = { missing: [{ type: "cookie", key: "mw-blocked" }], }, "/mw-gated-fallback-pages", + "/photos/:path*", + "/actions", ], };