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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions packages/vinext/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,7 +543,11 @@ export default {
}

// Build request context for has/missing condition matching.
// Created before middleware runs, matching prod-server ordering.
// 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 ──────────────────────────────────────────
Expand Down Expand Up @@ -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 ───────────────
Expand Down Expand Up @@ -647,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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
64 changes: 46 additions & 18 deletions packages/vinext/src/server/app-dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1068,6 +1068,28 @@ 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);
// ctx.cookies is a Map<string, string> (HeadersContext), but RequestContext
// requires a plain Record<string, string> 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: cookiesRecord,
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(/^[\\\\/]+/, "/");
Expand Down Expand Up @@ -1361,21 +1383,6 @@ 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$/, "");

Expand Down Expand Up @@ -1469,6 +1476,27 @@ 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);

// ── 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");
Expand Down Expand Up @@ -1694,7 +1722,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);
Expand All @@ -1709,7 +1737,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);
Expand Down
16 changes: 12 additions & 4 deletions packages/vinext/src/server/prod-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -765,7 +765,10 @@ 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 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 ─────────────────────────────────────────
Expand Down Expand Up @@ -846,6 +849,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 ───────────────
Expand Down Expand Up @@ -893,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);
Expand Down Expand Up @@ -929,7 +937,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);
Expand All @@ -948,7 +956,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);
Expand Down
9 changes: 9 additions & 0 deletions packages/vinext/src/shims/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 44 additions & 9 deletions tests/app-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -2163,23 +2197,24 @@ 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" }],
afterFiles: [],
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", () => {
Expand Down
23 changes: 23 additions & 0 deletions tests/fixtures/app-basic/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -88,5 +109,7 @@ export const config = {
"/middleware-throw",
"/search-query",
"/",
"/mw-gated-before",
"/mw-gated-fallback",
],
};
20 changes: 19 additions & 1 deletion tests/fixtures/app-basic/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,32 @@ 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
{ source: "/after-rewrite-about", destination: "/about" },
// 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",
},
],
};
},

Expand Down
Loading
Loading