From a15778a6239dbf0f60a10a40a5ecd7f851f48877 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 6 Mar 2026 21:14:00 +0000 Subject: [PATCH 1/6] fix: rebuild reqCtx for afterFiles/fallback rewrites after middleware afterFiles and fallback rewrites run after middleware in the App Router execution order. Their has/missing conditions should evaluate against middleware-modified headers, not the original pre-middleware request. Introduces postMwReqCtx (prod-server, deploy.ts) and __postMwReqCtx (app-dev-server) built after x-middleware-request-* headers are unpacked. headers, redirects, and beforeFiles rewrites keep using the original pre-middleware reqCtx, matching Next.js execution order. Adds getHeadersContext() to the headers shim so app-dev-server can read the ALS-mutated headers context for the post-middleware snapshot. Test: afterFiles rewrite with has:[cookie:mw-user] is skipped without middleware injection and matches after middleware injects the cookie. --- packages/vinext/src/deploy.ts | 15 ++++++++-- packages/vinext/src/server/app-dev-server.ts | 30 ++++++++++++++++++-- packages/vinext/src/server/prod-server.ts | 16 +++++++++-- packages/vinext/src/shims/headers.ts | 9 ++++++ tests/fixtures/pages-basic/middleware.ts | 11 +++++++ tests/fixtures/pages-basic/next.config.mjs | 9 ++++++ tests/pages-router.test.ts | 20 ++++++++++++- 7 files changed, 100 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index 611e247d..f48d4e25 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -543,7 +543,11 @@ export default { } // Build request context for has/missing condition matching. - // Created before middleware runs, matching prod-server ordering. + // headers, redirects, and beforeFiles rewrites run before middleware, + // so they use this pre-middleware snapshot. afterFiles and fallback + // rewrites run after middleware (App Router order), so they use a + // rebuilt context (postMwReqCtx) created after x-middleware-request-* + // headers are unpacked into request. const reqCtx = requestContextFromRequest(request); // ── 3. Run middleware ────────────────────────────────────────── @@ -609,6 +613,11 @@ export default { }); } + // Rebuild context after middleware has unpacked x-middleware-request-* + // headers into the cloned request. Used only for afterFiles and fallback + // rewrites, which run after middleware in the App Router execution order. + const postMwReqCtx = requestContextFromRequest(request); + let resolvedPathname = resolvedUrl.split("?")[0]; // ── 4. Apply custom headers from next.config.js ─────────────── @@ -667,7 +676,7 @@ export default { // ── 8. Apply afterFiles rewrites from next.config.js ────────── if (configRewrites.afterFiles?.length) { - const rewritten = matchRewrite(resolvedPathname, configRewrites.afterFiles, reqCtx); + const rewritten = matchRewrite(resolvedPathname, configRewrites.afterFiles, postMwReqCtx); if (rewritten) { if (isExternalUrl(rewritten)) { return proxyExternalRequest(request, rewritten); @@ -684,7 +693,7 @@ export default { // ── 10. Fallback rewrites (if SSR returned 404) ───────────── if (response && response.status === 404 && configRewrites.fallback?.length) { - const fallbackRewrite = matchRewrite(resolvedPathname, configRewrites.fallback, reqCtx); + const fallbackRewrite = matchRewrite(resolvedPathname, configRewrites.fallback, postMwReqCtx); if (fallbackRewrite) { if (isExternalUrl(fallbackRewrite)) { return proxyExternalRequest(request, fallbackRewrite); diff --git a/packages/vinext/src/server/app-dev-server.ts b/packages/vinext/src/server/app-dev-server.ts index 67093af0..85d3e2a9 100644 --- a/packages/vinext/src/server/app-dev-server.ts +++ b/packages/vinext/src/server/app-dev-server.ts @@ -213,7 +213,7 @@ import { } from "@vitejs/plugin-rsc/rsc"; import { createElement, Suspense, Fragment } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; -import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders } from "next/headers"; +import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext } from "next/headers"; import { NextRequest } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; @@ -1068,6 +1068,24 @@ function __buildRequestContext(request) { }; } +/** + * Build a request context from the live ALS HeadersContext, which reflects + * any x-middleware-request-* header mutations applied by middleware. + * Used for afterFiles and fallback rewrite has/missing evaluation — these + * run after middleware in the App Router execution order. + */ +function __buildPostMwRequestContext(request) { + const url = new URL(request.url); + const ctx = getHeadersContext(); + if (!ctx) return __buildRequestContext(request); + return { + headers: ctx.headers, + cookies: ctx.cookies, + query: url.searchParams, + host: ctx.headers.get("host") || url.host, + }; +} + function __sanitizeDestination(dest) { if (dest.startsWith("http://") || dest.startsWith("https://")) return dest; dest = dest.replace(/^[\\\\/]+/, "/"); @@ -1469,6 +1487,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } ` : ""} + // Build post-middleware request context for afterFiles/fallback rewrites. + // These run after middleware in the App Router execution order and should + // evaluate has/missing conditions against middleware-modified headers. + // When no middleware is present, this falls back to __buildRequestContext. + const __postMwReqCtx = __buildPostMwRequestContext(request); + // ── Image optimization passthrough (dev mode — no transformation) ─────── if (cleanPathname === "/_vinext/image") { const __rawImgUrl = url.searchParams.get("url"); @@ -1694,7 +1718,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ── Apply afterFiles rewrites from next.config.js ────────────────────── if (__configRewrites.afterFiles && __configRewrites.afterFiles.length) { - const __afterRewritten = __applyConfigRewrites(cleanPathname, __configRewrites.afterFiles, __reqCtx); + const __afterRewritten = __applyConfigRewrites(cleanPathname, __configRewrites.afterFiles, __postMwReqCtx); if (__afterRewritten) { if (__isExternalUrl(__afterRewritten)) { setHeadersContext(null); @@ -1709,7 +1733,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ── Fallback rewrites from next.config.js (if no route matched) ─────── if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { - const __fallbackRewritten = __applyConfigRewrites(cleanPathname, __configRewrites.fallback, __reqCtx); + const __fallbackRewritten = __applyConfigRewrites(cleanPathname, __configRewrites.fallback, __postMwReqCtx); if (__fallbackRewritten) { if (__isExternalUrl(__fallbackRewritten)) { setHeadersContext(null); diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index c0df3d13..a752a899 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -765,7 +765,12 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { duplex: hasBody ? "half" : undefined, }); - // Build request context for has/missing condition matching + // Build request context for has/missing condition matching. + // headers, redirects, and beforeFiles rewrites run before middleware, + // so they use this pre-middleware snapshot. afterFiles and fallback + // rewrites run after middleware (App Router order), so they use a + // rebuilt context (postMwReqCtx) created after x-middleware-request-* + // headers are unpacked into webRequest. const reqCtx: RequestContext = requestContextFromRequest(webRequest); // ── 4. Run middleware ───────────────────────────────────────── @@ -846,6 +851,11 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { } } + // Rebuild context after middleware has unpacked x-middleware-request-* + // headers into webRequest. Used only for afterFiles and fallback rewrites, + // which run after middleware in the App Router execution order. + const postMwReqCtx: RequestContext = requestContextFromRequest(webRequest); + let resolvedPathname = resolvedUrl.split("?")[0]; // ── 5. Apply custom headers from next.config.js ─────────────── @@ -929,7 +939,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { // ── 9. Apply afterFiles rewrites from next.config.js ────────── if (configRewrites.afterFiles?.length) { - const rewritten = matchRewrite(resolvedPathname, configRewrites.afterFiles, reqCtx); + const rewritten = matchRewrite(resolvedPathname, configRewrites.afterFiles, postMwReqCtx); if (rewritten) { if (isExternalUrl(rewritten)) { const proxyResponse = await proxyExternalRequest(webRequest, rewritten); @@ -948,7 +958,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { // ── 11. Fallback rewrites (if SSR returned 404) ───────────── if (response && response.status === 404 && configRewrites.fallback?.length) { - const fallbackRewrite = matchRewrite(resolvedPathname, configRewrites.fallback, reqCtx); + const fallbackRewrite = matchRewrite(resolvedPathname, configRewrites.fallback, postMwReqCtx); if (fallbackRewrite) { if (isExternalUrl(fallbackRewrite)) { const proxyResponse = await proxyExternalRequest(webRequest, fallbackRewrite); diff --git a/packages/vinext/src/shims/headers.ts b/packages/vinext/src/shims/headers.ts index f707bd26..593cbeba 100644 --- a/packages/vinext/src/shims/headers.ts +++ b/packages/vinext/src/shims/headers.ts @@ -132,6 +132,15 @@ export function consumeDynamicUsage(): boolean { * in-place and is only safe for cleanup (ctx=null) within an existing * als.run() scope. */ +/** + * Returns the current live HeadersContext from ALS (or the fallback). + * Used after applyMiddlewareRequestHeaders() to build a post-middleware + * request context for afterFiles/fallback rewrite has/missing evaluation. + */ +export function getHeadersContext(): HeadersContext | null { + return _getState().headersContext; +} + export function setHeadersContext(ctx: HeadersContext | null): void { if (ctx !== null) { // For backward compatibility, set context on the current ALS store diff --git a/tests/fixtures/pages-basic/middleware.ts b/tests/fixtures/pages-basic/middleware.ts index d08b3194..209189de 100644 --- a/tests/fixtures/pages-basic/middleware.ts +++ b/tests/fixtures/pages-basic/middleware.ts @@ -46,6 +46,17 @@ export function middleware(request: NextRequest) { return NextResponse.next({ request: { headers } }); } + // Inject mw-user=1 cookie for afterFiles rewrite gating test. + // afterFiles rewrites run after middleware, so they should see this cookie. + // The /mw-gated-rewrite rule in next.config.mjs has: [cookie:mw-user], + // which should match when ?mw-auth is present and middleware injects it. + if (url.pathname === "/mw-gated-rewrite" && url.searchParams.has("mw-auth")) { + const headers = new Headers(request.headers); + const existing = headers.get("cookie") ?? ""; + headers.set("cookie", existing ? existing + "; mw-user=1" : "mw-user=1"); + return NextResponse.next({ request: { headers } }); + } + return response; } diff --git a/tests/fixtures/pages-basic/next.config.mjs b/tests/fixtures/pages-basic/next.config.mjs index 1fff4d6e..cb00ba18 100644 --- a/tests/fixtures/pages-basic/next.config.mjs +++ b/tests/fixtures/pages-basic/next.config.mjs @@ -25,6 +25,15 @@ const nextConfig = { source: "/after-rewrite", destination: "/about", }, + // Used by Vitest: pages-router.test.ts — afterFiles rewrite gated on + // a cookie injected by middleware. Middleware injects mw-user=1 via + // x-middleware-request-cookie when ?mw-auth is present. The afterFiles + // rewrite should see this cookie because afterFiles runs after middleware. + { + source: "/mw-gated-rewrite", + has: [{ type: "cookie", key: "mw-user" }], + destination: "/about", + }, ], fallback: [ { diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index b9c9be69..39340bd2 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -1504,6 +1504,7 @@ describe("Production server next.config.js features (Pages Router)", () => { expect(res.headers.get("x-guest-only-header")).toBe("1"); }); +<<<<<<< Updated upstream it("config Vary header appends instead of replacing existing values", async () => { // The /ssr page has config headers: [{ key: "Vary", value: "Accept-Language" }]. // If the response already has a Vary header (e.g. from compression), @@ -1512,6 +1513,23 @@ describe("Production server next.config.js features (Pages Router)", () => { expect(res.status).toBe(200); const vary = res.headers.get("vary") ?? ""; expect(vary).toContain("Accept-Language"); +======= + // afterFiles rewrites run after middleware in the App Router execution order. + // has/missing conditions on afterFiles rules should evaluate against + // middleware-modified headers, not the original pre-middleware request. + it("afterFiles rewrite has/missing conditions see middleware-injected cookies", async () => { + // Without ?mw-auth, middleware does NOT inject mw-user=1. + // The has:[cookie:mw-user] afterFiles rule should NOT match → no rewrite. + const noAuthRes = await fetch(`${prodUrl}/mw-gated-rewrite`); + expect(noAuthRes.status).toBe(404); + + // With ?mw-auth, middleware injects mw-user=1 into request cookies. + // The has:[cookie:mw-user] afterFiles rule SHOULD match → rewrite to /about. + const authRes = await fetch(`${prodUrl}/mw-gated-rewrite?mw-auth`); + expect(authRes.status).toBe(200); + const html = await authRes.text(); + expect(html).toContain("About"); +>>>>>>> Stashed changes }); it("serves normal pages unaffected by config rules", async () => { @@ -1639,7 +1657,7 @@ describe("Static export (Pages Router)", () => { "utf-8", ); expect(html404).toContain("404"); - }); + }); it("escapes meta refresh URL to prevent HTML injection", async () => { expect(fs.existsSync(path.join(exportDir, "redirect-xss.html"))).toBe(true); From e922cadfa8e6522e9254e1ab94dd22f50c97e82f Mon Sep 17 00:00:00 2001 From: James Date: Fri, 6 Mar 2026 21:16:35 +0000 Subject: [PATCH 2/6] fix: also use postMwReqCtx for beforeFiles rewrites in deploy.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In App Router execution order, beforeFiles rewrites run after middleware (step 4 vs step 3). deploy.ts processes them after middleware runs, so they should use postMwReqCtx like afterFiles and fallback. prod-server.ts (Pages Router) correctly keeps reqCtx for beforeFiles — the Pages Router order has no middleware step before beforeFiles. --- packages/vinext/src/deploy.ts | 12 ++++++------ packages/vinext/src/server/prod-server.ts | 8 +++----- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index f48d4e25..a8f43274 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -543,11 +543,11 @@ export default { } // Build request context for has/missing condition matching. - // headers, redirects, and beforeFiles rewrites run before middleware, - // so they use this pre-middleware snapshot. afterFiles and fallback - // rewrites run after middleware (App Router order), so they use a - // rebuilt context (postMwReqCtx) created after x-middleware-request-* - // headers are unpacked into request. + // headers and redirects run before middleware, so they use this + // pre-middleware snapshot. beforeFiles, afterFiles, and fallback + // rewrites run after middleware (App Router order), so they use + // postMwReqCtx created after x-middleware-request-* headers are + // unpacked into request. const reqCtx = requestContextFromRequest(request); // ── 3. Run middleware ────────────────────────────────────────── @@ -656,7 +656,7 @@ export default { // ��─ 6. Apply beforeFiles rewrites from next.config.js ───────── if (configRewrites.beforeFiles?.length) { - const rewritten = matchRewrite(resolvedPathname, configRewrites.beforeFiles, reqCtx); + const rewritten = matchRewrite(resolvedPathname, configRewrites.beforeFiles, postMwReqCtx); if (rewritten) { if (isExternalUrl(rewritten)) { return proxyExternalRequest(request, rewritten); diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index a752a899..f7d6ed59 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -766,11 +766,9 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { }); // Build request context for has/missing condition matching. - // headers, redirects, and beforeFiles rewrites run before middleware, - // so they use this pre-middleware snapshot. afterFiles and fallback - // rewrites run after middleware (App Router order), so they use a - // rebuilt context (postMwReqCtx) created after x-middleware-request-* - // headers are unpacked into webRequest. + // Pages Router: headers, redirects, and beforeFiles all run before + // middleware, so they use this pre-middleware snapshot. afterFiles + // and fallback rewrites use postMwReqCtx (built after middleware). const reqCtx: RequestContext = requestContextFromRequest(webRequest); // ── 4. Run middleware ───────────────────────────────────────── From 28a0e46bee0c37a40d7de0c803b316a4c5e6be71 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 6 Mar 2026 21:19:20 +0000 Subject: [PATCH 3/6] fix: resolve merge conflict - keep Vary header test and afterFiles rewrite test --- tests/pages-router.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 39340bd2..f309e04f 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -1504,7 +1504,6 @@ describe("Production server next.config.js features (Pages Router)", () => { expect(res.headers.get("x-guest-only-header")).toBe("1"); }); -<<<<<<< Updated upstream it("config Vary header appends instead of replacing existing values", async () => { // The /ssr page has config headers: [{ key: "Vary", value: "Accept-Language" }]. // If the response already has a Vary header (e.g. from compression), @@ -1513,7 +1512,8 @@ describe("Production server next.config.js features (Pages Router)", () => { expect(res.status).toBe(200); const vary = res.headers.get("vary") ?? ""; expect(vary).toContain("Accept-Language"); -======= + }); + // afterFiles rewrites run after middleware in the App Router execution order. // has/missing conditions on afterFiles rules should evaluate against // middleware-modified headers, not the original pre-middleware request. @@ -1529,7 +1529,6 @@ describe("Production server next.config.js features (Pages Router)", () => { expect(authRes.status).toBe(200); const html = await authRes.text(); expect(html).toContain("About"); ->>>>>>> Stashed changes }); it("serves normal pages unaffected by config rules", async () => { From 40b8455d25f14551560d2e663c6cdd7d4154f768 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 6 Mar 2026 21:44:30 +0000 Subject: [PATCH 4/6] fix: move beforeFiles rewrites after middleware in app-dev-server; add has/missing regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move beforeFiles rewrite block after middleware execution so it sees middleware-injected cookies/headers (matches Next.js App Router order: middleware → beforeFiles → filesystem → afterFiles → fallback) - Fix Map→Record bug in __buildPostMwRequestContext: ctx.cookies is a Map but RequestContext.cookies must be Record; convert with Object.fromEntries() so cookie lookups work correctly - Update .rsc-suffix stripping test to reflect new code path (cleanPathname is now used directly by beforeFiles rather than a separate __rewritePathname) - Add regression tests: beforeFiles and fallback rewrite has/missing conditions see middleware-injected cookies (app-router); fixtures wire up mw-gated-before and mw-gated-fallback routes + middleware cookie injection --- packages/vinext/src/server/app-dev-server.ts | 38 +++++++------- tests/app-router.test.ts | 53 ++++++++++++++++---- tests/fixtures/app-basic/middleware.ts | 23 +++++++++ tests/fixtures/app-basic/next.config.ts | 20 +++++++- 4 files changed, 107 insertions(+), 27 deletions(-) diff --git a/packages/vinext/src/server/app-dev-server.ts b/packages/vinext/src/server/app-dev-server.ts index 85d3e2a9..95c0db89 100644 --- a/packages/vinext/src/server/app-dev-server.ts +++ b/packages/vinext/src/server/app-dev-server.ts @@ -1078,9 +1078,13 @@ function __buildPostMwRequestContext(request) { const url = new URL(request.url); const ctx = getHeadersContext(); if (!ctx) return __buildRequestContext(request); + // ctx.cookies is a Map (HeadersContext), but RequestContext + // requires a plain Record for has/missing cookie evaluation + // (config-matchers.ts uses obj[key] not Map.get()). Convert here. + const cookiesRecord = Object.fromEntries(ctx.cookies); return { headers: ctx.headers, - cookies: ctx.cookies, + cookies: cookiesRecord, query: url.searchParams, host: ctx.headers.get("host") || url.host, }; @@ -1379,23 +1383,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - // ── Apply beforeFiles rewrites from next.config.js ──────────────────── - if (__configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { - // Strip .rsc suffix before matching rewrite rules — same reason as redirects above. - const __rewritePathname = pathname.endsWith(".rsc") ? pathname.slice(0, -4) : pathname; - const __rewritten = __applyConfigRewrites(__rewritePathname, __configRewrites.beforeFiles, __reqCtx); - if (__rewritten) { - if (__isExternalUrl(__rewritten)) { - setHeadersContext(null); - setNavigationContext(null); - return __proxyExternalRequest(request, __rewritten); - } - pathname = __rewritten; - } - } - const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); - let cleanPathname = pathname.replace(/\\.rsc$/, ""); + let cleanPathname = pathname.replace(/.rsc$/, ""); // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -1493,6 +1482,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // When no middleware is present, this falls back to __buildRequestContext. const __postMwReqCtx = __buildPostMwRequestContext(request); + // ── Apply beforeFiles rewrites from next.config.js ──────────────────── + // In App Router execution order, beforeFiles runs after middleware so that + // has/missing conditions can evaluate against middleware-modified headers. + if (__configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { + const __rewritten = __applyConfigRewrites(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx); + if (__rewritten) { + if (__isExternalUrl(__rewritten)) { + setHeadersContext(null); + setNavigationContext(null); + return __proxyExternalRequest(request, __rewritten); + } + cleanPathname = __rewritten; + } + } + // ── Image optimization passthrough (dev mode — no transformation) ─────── if (cleanPathname === "/_vinext/image") { const __rawImgUrl = url.searchParams.get("url"); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index c120fd4c..bc9513ca 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -1960,6 +1960,40 @@ describe("App Router next.config.js features (dev server integration)", () => { expect(html).toContain("About"); }); + // In App Router execution order, beforeFiles rewrites run after middleware. + // has/missing conditions on beforeFiles rules should therefore evaluate against + // middleware-modified headers/cookies, not the original pre-middleware request. + it("beforeFiles rewrite has/missing conditions see middleware-injected cookies", async () => { + // Without ?mw-auth, middleware does NOT inject mw-before-user=1. + // The has:[cookie:mw-before-user] beforeFiles rule should NOT match → no rewrite. + const noAuthRes = await fetch(`${baseUrl}/mw-gated-before`); + expect(noAuthRes.status).toBe(404); + + // With ?mw-auth, middleware injects mw-before-user=1 into request cookies. + // The has:[cookie:mw-before-user] beforeFiles rule SHOULD match → rewrite to /about. + const authRes = await fetch(`${baseUrl}/mw-gated-before?mw-auth`); + expect(authRes.status).toBe(200); + const html = await authRes.text(); + expect(html).toContain("About"); + }); + + // Fallback rewrites run after middleware and after a 404 from route matching. + // has/missing conditions on fallback rules should evaluate against + // middleware-modified headers/cookies, not the original pre-middleware request. + it("fallback rewrite has/missing conditions see middleware-injected cookies", async () => { + // Without ?mw-auth, middleware does NOT inject mw-fallback-user=1. + // The has:[cookie:mw-fallback-user] fallback rule should NOT match → 404. + const noAuthRes = await fetch(`${baseUrl}/mw-gated-fallback`); + expect(noAuthRes.status).toBe(404); + + // With ?mw-auth, middleware injects mw-fallback-user=1 into request cookies. + // The has:[cookie:mw-fallback-user] fallback rule SHOULD match → rewrite to /about. + const authRes = await fetch(`${baseUrl}/mw-gated-fallback?mw-auth`); + expect(authRes.status).toBe(200); + const html = await authRes.text(); + expect(html).toContain("About"); + }); + it("applies custom headers from next.config.js on API routes", async () => { const res = await fetch(`${baseUrl}/api/hello`); expect(res.headers.get("x-custom-header")).toBe("vinext-app"); @@ -2163,8 +2197,9 @@ describe("App Router next.config.js features (generateRscEntry)", () => { it("strips .rsc suffix before matching beforeFiles rewrite rules", () => { // RSC (soft-nav) requests arrive as /some/path.rsc but rewrite patterns // are defined without the extension. The generated code must strip .rsc - // before calling __applyConfigRewrites for beforeFiles, just like it does - // for redirects. + // before calling __applyConfigRewrites for beforeFiles. + // beforeFiles now runs after middleware (using __postMwReqCtx), and + // cleanPathname has already had .rsc stripped at that point. const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, { rewrites: { beforeFiles: [{ source: "/old", destination: "/new" }], @@ -2172,14 +2207,14 @@ describe("App Router next.config.js features (generateRscEntry)", () => { fallback: [], }, }); - // The generated code should use a .rsc-stripped pathname variable when - // calling __applyConfigRewrites for beforeFiles, not the raw `pathname`. - const rewritePathIdx = code.indexOf("__rewritePathname"); - expect(rewritePathIdx).toBeGreaterThan(-1); - // The .rsc stripping assignment must appear before the beforeFiles rewrite call - const beforeFilesCallIdx = code.indexOf("__applyConfigRewrites(__rewritePathname"); + // The generated code uses cleanPathname (already .rsc-stripped) when + // calling __applyConfigRewrites for beforeFiles. + const beforeFilesCallIdx = code.indexOf("__applyConfigRewrites(cleanPathname, __configRewrites.beforeFiles"); expect(beforeFilesCallIdx).toBeGreaterThan(-1); - expect(rewritePathIdx).toBeLessThan(beforeFilesCallIdx); + // The cleanPathname assignment (stripping .rsc) must appear before the beforeFiles call + const cleanPathnameIdx = code.indexOf("cleanPathname = pathname.replace"); + expect(cleanPathnameIdx).toBeGreaterThan(-1); + expect(cleanPathnameIdx).toBeLessThan(beforeFilesCallIdx); }); it("applies afterFiles rewrites in the handler code", () => { diff --git a/tests/fixtures/app-basic/middleware.ts b/tests/fixtures/app-basic/middleware.ts index 4022d5be..d55c11d2 100644 --- a/tests/fixtures/app-basic/middleware.ts +++ b/tests/fixtures/app-basic/middleware.ts @@ -60,6 +60,27 @@ export function middleware(request: NextRequest) { throw new Error("middleware crash"); } + // Inject mw-before-user=1 cookie for beforeFiles rewrite gating test. + // In App Router order, beforeFiles rewrites run after middleware, so they + // should see this cookie. The /mw-gated-before rule in next.config.ts has: + // [cookie:mw-before-user], which matches when ?mw-auth is present. + if (pathname === "/mw-gated-before" && request.nextUrl.searchParams.has("mw-auth")) { + const headers = new Headers(request.headers); + const existing = headers.get("cookie") ?? ""; + headers.set("cookie", existing ? existing + "; mw-before-user=1" : "mw-before-user=1"); + return NextResponse.next({ request: { headers } }); + } + + // Inject mw-fallback-user=1 cookie for fallback rewrite gating test. + // Fallback rewrites run after middleware and after a 404 from route matching. + // The /mw-gated-fallback rule has: [cookie:mw-fallback-user]. + if (pathname === "/mw-gated-fallback" && request.nextUrl.searchParams.has("mw-auth")) { + const headers = new Headers(request.headers); + const existing = headers.get("cookie") ?? ""; + headers.set("cookie", existing ? existing + "; mw-fallback-user=1" : "mw-fallback-user=1"); + return NextResponse.next({ request: { headers } }); + } + // Forward search params as a header for RSC testing // Ref: opennextjs-cloudflare middleware.ts — search-params header const requestHeaders = new Headers(request.headers); @@ -88,5 +109,7 @@ export const config = { "/middleware-throw", "/search-query", "/", + "/mw-gated-before", + "/mw-gated-fallback", ], }; diff --git a/tests/fixtures/app-basic/next.config.ts b/tests/fixtures/app-basic/next.config.ts index d52ca925..cd881bb1 100644 --- a/tests/fixtures/app-basic/next.config.ts +++ b/tests/fixtures/app-basic/next.config.ts @@ -73,6 +73,14 @@ const nextConfig: NextConfig = { ...(process.env.TEST_EXTERNAL_PROXY_TARGET ? [{ source: "/proxy-external-test/:path*", destination: `${process.env.TEST_EXTERNAL_PROXY_TARGET}/:path*` }] : []), + // Used by Vitest: app-router.test.ts — beforeFiles rewrite gated on a + // cookie injected by middleware. In App Router order, beforeFiles runs + // after middleware, so it should see middleware-injected cookies. + { + source: "/mw-gated-before", + has: [{ type: "cookie", key: "mw-before-user" }], + destination: "/about", + }, ], afterFiles: [ // Used by Vitest: app-router.test.ts @@ -80,7 +88,17 @@ const nextConfig: NextConfig = { // Used by E2E: config-redirect.spec.ts { source: "/config-rewrite", destination: "/" }, ], - fallback: [], + fallback: [ + // Used by Vitest: app-router.test.ts — fallback rewrite gated on a + // cookie injected by middleware. Fallback runs after middleware and + // after a 404 from route matching, so it should see middleware-injected + // cookies. /mw-gated-fallback has no real page, so it falls through. + { + source: "/mw-gated-fallback", + has: [{ type: "cookie", key: "mw-fallback-user" }], + destination: "/about", + }, + ], }; }, From 6b153b1d1364a17386f02bfea6a05a5028fb86b2 Mon Sep 17 00:00:00 2001 From: James Anderson Date: Fri, 6 Mar 2026 21:46:22 +0000 Subject: [PATCH 5/6] Apply suggestion from @james-elicx --- packages/vinext/src/server/app-dev-server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vinext/src/server/app-dev-server.ts b/packages/vinext/src/server/app-dev-server.ts index 95c0db89..d0465ebb 100644 --- a/packages/vinext/src/server/app-dev-server.ts +++ b/packages/vinext/src/server/app-dev-server.ts @@ -1384,7 +1384,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); - let cleanPathname = pathname.replace(/.rsc$/, ""); + let cleanPathname = pathname.replace(/\\.rsc$/, ""); // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into From 86561fc530e7d64c3069e2c1124c35c57d8444f1 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 6 Mar 2026 21:52:38 +0000 Subject: [PATCH 6/6] fix: use postMwReqCtx for beforeFiles in prod-server (Pages Router) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Next.js docs, the execution order is: headers → redirects → Middleware → beforeFiles → filesystem → afterFiles → fallback. This applies to both App Router and Pages Router. prod-server.ts was incorrectly using the pre-middleware reqCtx for beforeFiles matching; switch to postMwReqCtx. Add regression test: beforeFiles has/missing conditions on the production Pages Router server now correctly see middleware-injected cookies. --- packages/vinext/src/server/prod-server.ts | 8 ++++---- tests/fixtures/pages-basic/middleware.ts | 10 ++++++++++ tests/fixtures/pages-basic/next.config.mjs | 9 +++++++++ tests/pages-router.test.ts | 18 ++++++++++++++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index f7d6ed59..13dde49e 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -766,9 +766,9 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { }); // Build request context for has/missing condition matching. - // Pages Router: headers, redirects, and beforeFiles all run before - // middleware, so they use this pre-middleware snapshot. afterFiles - // and fallback rewrites use postMwReqCtx (built after middleware). + // headers and redirects run before middleware and use this pre-middleware + // snapshot. beforeFiles, afterFiles, and fallback all run after middleware + // per the Next.js execution order, so they use postMwReqCtx below. const reqCtx: RequestContext = requestContextFromRequest(webRequest); // ── 4. Run middleware ───────────────────────────────────────── @@ -901,7 +901,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { // ── 7. Apply beforeFiles rewrites from next.config.js ───────── if (configRewrites.beforeFiles?.length) { - const rewritten = matchRewrite(resolvedPathname, configRewrites.beforeFiles, reqCtx); + const rewritten = matchRewrite(resolvedPathname, configRewrites.beforeFiles, postMwReqCtx); if (rewritten) { if (isExternalUrl(rewritten)) { const proxyResponse = await proxyExternalRequest(webRequest, rewritten); diff --git a/tests/fixtures/pages-basic/middleware.ts b/tests/fixtures/pages-basic/middleware.ts index 209189de..a7cea7e7 100644 --- a/tests/fixtures/pages-basic/middleware.ts +++ b/tests/fixtures/pages-basic/middleware.ts @@ -57,6 +57,16 @@ export function middleware(request: NextRequest) { return NextResponse.next({ request: { headers } }); } + // Inject mw-before-user=1 cookie for beforeFiles rewrite gating test. + // beforeFiles rewrites run after middleware per Next.js docs, so they + // should see this cookie. The /mw-gated-before rule has: [cookie:mw-before-user]. + if (url.pathname === "/mw-gated-before" && url.searchParams.has("mw-auth")) { + const headers = new Headers(request.headers); + const existing = headers.get("cookie") ?? ""; + headers.set("cookie", existing ? existing + "; mw-before-user=1" : "mw-before-user=1"); + return NextResponse.next({ request: { headers } }); + } + return response; } diff --git a/tests/fixtures/pages-basic/next.config.mjs b/tests/fixtures/pages-basic/next.config.mjs index cb00ba18..2cbd727a 100644 --- a/tests/fixtures/pages-basic/next.config.mjs +++ b/tests/fixtures/pages-basic/next.config.mjs @@ -19,6 +19,15 @@ const nextConfig = { source: "/before-rewrite", destination: "/about", }, + // Used by Vitest: pages-router.test.ts — beforeFiles rewrite gated on + // a cookie injected by middleware. Middleware injects mw-before-user=1 + // when ?mw-auth is present. The beforeFiles rewrite should see this + // cookie because beforeFiles runs after middleware per Next.js docs. + { + source: "/mw-gated-before", + has: [{ type: "cookie", key: "mw-before-user" }], + destination: "/about", + }, ], afterFiles: [ { diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index f309e04f..8223b891 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -1531,6 +1531,24 @@ describe("Production server next.config.js features (Pages Router)", () => { expect(html).toContain("About"); }); + // beforeFiles rewrites run after middleware per the Next.js execution order: + // headers → redirects → Middleware → beforeFiles → filesystem → afterFiles → fallback. + // has/missing conditions on beforeFiles rules should evaluate against + // middleware-modified headers, not the original pre-middleware request. + it("beforeFiles rewrite has/missing conditions see middleware-injected cookies", async () => { + // Without ?mw-auth, middleware does NOT inject mw-before-user=1. + // The has:[cookie:mw-before-user] beforeFiles rule should NOT match → 404. + const noAuthRes = await fetch(`${prodUrl}/mw-gated-before`); + expect(noAuthRes.status).toBe(404); + + // With ?mw-auth, middleware injects mw-before-user=1 into request cookies. + // The has:[cookie:mw-before-user] beforeFiles rule SHOULD match → rewrite to /about. + const authRes = await fetch(`${prodUrl}/mw-gated-before?mw-auth`); + expect(authRes.status).toBe(200); + const html = await authRes.text(); + expect(html).toContain("About"); + }); + it("serves normal pages unaffected by config rules", async () => { const res = await fetch(`${prodUrl}/`); expect(res.status).toBe(200);