From 90271239d05bf349bd0b239e88cab87093b05233 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:56:10 +1100 Subject: [PATCH 1/3] fix: apply middleware headers to intercept route and server action responses Middleware response headers (_mwCtx.headers) were silently dropped on three code paths: intercepting route responses, server action re-render responses, and server action redirect responses. All three constructed Response objects directly without merging middleware headers, unlike the normal page path which goes through buildAppPageRscResponse(). Extract mergeMiddlewareResponseHeaders() from the inline logic in buildAppPageRscResponse() and call it in all three paths. This ensures auth cookies, CORS headers, and security headers set in middleware are applied consistently to all response types. --- packages/vinext/src/entries/app-rsc-entry.ts | 20 ++- .../vinext/src/server/app-page-response.ts | 43 ++++--- .../entry-templates.test.ts.snap | 114 ++++++++++++------ tests/app-page-response.test.ts | 45 +++++++ tests/app-router.test.ts | 50 ++++++++ tests/fixtures/app-basic/middleware.ts | 2 + 6 files changed, 218 insertions(+), 56 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 23e1c3ecc..5d1cb7eb0 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, @@ -1881,6 +1885,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), }); + __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } @@ -1923,15 +1928,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 +2335,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 c8784764b..4c4da7dee 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 7c8a503e2..ef0443e5e 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, @@ -1607,6 +1610,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), }); + __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } @@ -1649,15 +1653,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 +2030,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 +2283,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, @@ -3804,6 +3814,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), }); + __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } @@ -3846,15 +3857,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 +4234,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 +4487,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, @@ -6007,6 +6024,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), }); + __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } @@ -6049,15 +6067,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 +6444,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 +6697,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, @@ -8234,6 +8258,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), }); + __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } @@ -8276,15 +8301,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 +8678,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 +8931,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, @@ -10435,6 +10466,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), }); + __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } @@ -10477,15 +10509,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 +10886,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 +11139,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, @@ -12993,6 +13031,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), }); + __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } @@ -13035,15 +13074,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 +13451,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 230f8bbfd..d142ac2ec 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 29871a2a0..4ba6e3f13 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 ce8c5bea0..24fefdd72 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", ], }; From d0bf61a0d85b5c2dd6f59170e1578aa74ff091ab Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:12:43 +1100 Subject: [PATCH 2/3] fix: merge middleware headers before x-action-redirect control headers Codex review correctly identified that mergeMiddlewareResponseHeaders() uses set() for most keys, meaning middleware headers could overwrite the x-action-redirect* control headers if a middleware happened to set them. Fix by merging middleware headers first, then writing the x-action-redirect* headers afterward so they are always authoritative. The framework's own redirect control signals must survive regardless of what middleware sets. --- packages/vinext/src/entries/app-rsc-entry.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 5d1cb7eb0..a71d2441f 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -1881,11 +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); } From c89252ab79303f360d41f6735561e7c26aeb6c8d Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:26:22 +1100 Subject: [PATCH 3/3] fix: update RSC entry snapshots for middleware headers --- .../entry-templates.test.ts.snap | 54 ++++++++++++------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index ef0443e5e..fa8450d92 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -1606,11 +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); } @@ -3810,11 +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); } @@ -6020,11 +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); } @@ -8254,11 +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); } @@ -10462,11 +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); } @@ -13027,11 +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); }