diff --git a/packages/vinext/src/config/config-matchers.ts b/packages/vinext/src/config/config-matchers.ts index c12e8ed95..4536f821a 100644 --- a/packages/vinext/src/config/config-matchers.ts +++ b/packages/vinext/src/config/config-matchers.ts @@ -480,6 +480,76 @@ export type RequestContext = { readonly host: string; }; +export type RequestContextConditionAccess = { + readonly cookies: boolean; + readonly query: boolean; +}; + +const _conditionAccessCache = new WeakMap(); + +function mergeConditionAccess( + target: { cookies: boolean; query: boolean }, + conditions: readonly HasCondition[] | undefined, +): void { + if (!conditions) return; + for (const condition of conditions) { + if (condition.type === "cookie") { + target.cookies = true; + } else if (condition.type === "query") { + target.query = true; + } + } +} + +function mergeMatchingRewriteConditionAccess( + target: { cookies: boolean; query: boolean }, + pathname: string, + rewrites: readonly NextRewrite[], + basePathState: BasePathMatchState, +): boolean { + for (const rewrite of rewrites) { + if (!matchesRewriteSource(pathname, rewrite, basePathState)) continue; + const access = requestContextConditionAccessForRule(rewrite); + target.cookies ||= access.cookies; + target.query ||= access.query; + if (target.cookies && target.query) return true; + } + return false; +} + +export function requestContextConditionAccessForRule(rule: { + has?: readonly HasCondition[]; + missing?: readonly HasCondition[]; +}): RequestContextConditionAccess { + const cached = _conditionAccessCache.get(rule); + if (cached) return cached; + const access = { cookies: false, query: false }; + mergeConditionAccess(access, rule.has); + mergeConditionAccess(access, rule.missing); + _conditionAccessCache.set(rule, access); + return access; +} + +export function requestContextConditionAccessForMatchingRewrites( + pathname: string, + rewrites: { + readonly beforeFiles: readonly NextRewrite[]; + readonly afterFiles: readonly NextRewrite[]; + readonly fallback: readonly NextRewrite[]; + }, + basePathState: BasePathMatchState = _BASEPATH_DEFAULT, +): RequestContextConditionAccess { + const access = { cookies: false, query: false }; + if (mergeMatchingRewriteConditionAccess(access, pathname, rewrites.beforeFiles, basePathState)) { + return access; + } + if (mergeMatchingRewriteConditionAccess(access, pathname, rewrites.afterFiles, basePathState)) { + return access; + } + mergeMatchingRewriteConditionAccess(access, pathname, rewrites.fallback, basePathState); + return access; +} + /** * basePath gating state passed alongside the pathname to every matcher. * diff --git a/packages/vinext/src/server/app-post-middleware-context.ts b/packages/vinext/src/server/app-post-middleware-context.ts index afd4a7c75..6b777e506 100644 --- a/packages/vinext/src/server/app-post-middleware-context.ts +++ b/packages/vinext/src/server/app-post-middleware-context.ts @@ -15,11 +15,16 @@ export function buildPostMwRequestContext(request: Request): RequestContext { const url = new URL(request.url); const ctx = getHeadersContext(); if (!ctx) return requestContextFromRequest(request); - const cookiesRecord: Record = Object.fromEntries(ctx.cookies); + let cookiesRecord: Record | undefined; + let query: URLSearchParams | undefined; return { headers: ctx.headers, - cookies: cookiesRecord, - query: url.searchParams, + get cookies() { + return (cookiesRecord ??= Object.fromEntries(ctx.cookies)); + }, + get query() { + return (query ??= url.searchParams); + }, host: normalizeHost(ctx.headers.get("host"), url.hostname), }; } diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index b6c617fe6..383f43121 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -11,8 +11,11 @@ import { preserveRedirectDestinationQuery, proxyExternalRequest, requestContextFromRequest, + requestContextConditionAccessForRule, + requestContextConditionAccessForMatchingRewrites, sanitizeDestination, type BasePathMatchState, + type RequestContextConditionAccess, } from "../config/config-matchers.js"; import { headersContextFromRequest } from "vinext/shims/headers"; import { @@ -98,6 +101,7 @@ import { type AppPageParams = Record; type RequestContext = ReturnType; const STATIC_METADATA_CONFIG_HEADER_OVERRIDES = new Set(["cache-control"]); +const NO_REWRITES: readonly NextRewrite[] = []; type StaticParamsMap = AppPrerenderStaticParamsMap; type RootParamNamesMap = AppPrerenderRootParamNamesMap; @@ -405,12 +409,19 @@ function requestContextForResolvedUrl( requestContext: RequestContext, resolvedUrl: string, baseUrl: URL, + conditionAccess: RequestContextConditionAccess, ): RequestContext { + let query: URLSearchParams | undefined; return { - cookies: requestContext.cookies, headers: requestContext.headers, host: requestContext.host, - query: new URL(resolvedUrl, baseUrl).searchParams, + get cookies() { + return conditionAccess.cookies ? requestContext.cookies : {}; + }, + get query() { + if (!conditionAccess.query) return new URLSearchParams(); + return (query ??= new URL(resolvedUrl, baseUrl).searchParams); + }, }; } @@ -664,6 +675,7 @@ async function handleAppRscRequest( postMiddlewareRequestContext, resolvedUrl, url, + requestContextConditionAccessForRule(rewrite), ), rewrites: [rewrite], }, @@ -699,6 +711,7 @@ async function handleAppRscRequest( postMiddlewareRequestContext, resolvedUrl, url, + requestContextConditionAccessForRule(rewrite), ), rewrites: [rewrite], }, @@ -723,6 +736,7 @@ async function handleAppRscRequest( postMiddlewareRequestContext, resolvedUrl, url, + requestContextConditionAccessForRule(rewrite), ), rewrites: [rewrite], }, @@ -815,6 +829,25 @@ async function handleAppRscRequest( setRootParams(pickRootParams(preActionMatch.params, preActionMatch.route.rootParamNames)); } + const canRunLateAfterFiles = + !resolvedLateRewritesForAction && (!preActionMatch || preActionMatch.route.isDynamic); + const canRunLateFallback = !resolvedLateRewritesForAction && !preActionMatch; + if (isProgressiveActionRequest && (canRunLateAfterFiles || canRunLateFallback)) { + const lateRewriteAccess = requestContextConditionAccessForMatchingRewrites( + matchPathname(cleanPathname), + { + beforeFiles: NO_REWRITES, + afterFiles: canRunLateAfterFiles ? options.configRewrites.afterFiles : NO_REWRITES, + fallback: canRunLateFallback ? options.configRewrites.fallback : NO_REWRITES, + }, + basePathState, + ); + // Progressive actions can mutate the shared cookie map before the late + // rewrite loops below. Cookie predicates must still see the post-middleware + // request snapshot that existed before user action code ran. + if (lateRewriteAccess.cookies) void postMiddlewareRequestContext.cookies; + } + if (!filesystemRouteEligible && isPostRequest && actionId) { options.clearRequestContext(); return notFoundResponse(); @@ -918,7 +951,7 @@ async function handleAppRscRequest( options.clearRequestContext(); return staticPagesFallbackResponse; } - if (!resolvedLateRewritesForAction && (!match || match.route.isDynamic)) { + if (canRunLateAfterFiles) { for (const rewrite of options.configRewrites.afterFiles) { const afterFilesRewrite = await applyRewrite( { @@ -930,6 +963,7 @@ async function handleAppRscRequest( postMiddlewareRequestContext, resolvedUrl, url, + requestContextConditionAccessForRule(rewrite), ), rewrites: [rewrite], }, @@ -963,7 +997,7 @@ async function handleAppRscRequest( return dynamicPagesFallbackResponse; } - if (!resolvedLateRewritesForAction && !match) { + if (canRunLateFallback && !match) { for (const rewrite of options.configRewrites.fallback) { const fallbackRewrite = await applyRewrite( { @@ -975,6 +1009,7 @@ async function handleAppRscRequest( postMiddlewareRequestContext, resolvedUrl, url, + requestContextConditionAccessForRule(rewrite), ), rewrites: [rewrite], }, diff --git a/packages/vinext/src/server/request-pipeline.ts b/packages/vinext/src/server/request-pipeline.ts index 3ffe8af1b..484f2f91a 100644 --- a/packages/vinext/src/server/request-pipeline.ts +++ b/packages/vinext/src/server/request-pipeline.ts @@ -572,8 +572,59 @@ export function processMiddlewareHeaders(headers: Headers): void { export { INTERNAL_HEADERS, VINEXT_INTERNAL_HEADERS } from "./headers.js"; const STRIPPED_INTERNAL_HEADERS = new Set([...INTERNAL_HEADERS, ...VINEXT_INTERNAL_HEADERS]); +const NOOP_FILTERED_HEADERS_STATE = Symbol("vinext.noopFilteredHeadersState"); +const HEADERS_MUTATING_METHODS = new Set(["append", "delete", "set"]); type RequestInitWithCf = RequestInit & { cf?: unknown }; +type NoopFilteredHeadersState = { + readonly original: Headers; + materialized: Headers | null; +}; +type HeadersWithNoopFilteredState = Headers & { + [NOOP_FILTERED_HEADERS_STATE]?: NoopFilteredHeadersState; +}; + +function noopFilteredHeadersState(headers: Headers): NoopFilteredHeadersState | undefined { + return (headers as HeadersWithNoopFilteredState)[NOOP_FILTERED_HEADERS_STATE]; +} + +function createNoopFilteredHeaders(headers: Headers): Headers { + const state: NoopFilteredHeadersState = { original: headers, materialized: null }; + + return new Proxy(headers as HeadersWithNoopFilteredState, { + get(target, prop, receiver) { + if (prop === NOOP_FILTERED_HEADERS_STATE) return state; + + if (typeof prop === "string" && HEADERS_MUTATING_METHODS.has(prop)) { + return (...args: unknown[]) => { + state.materialized ??= new Headers(target); + const method = Reflect.get(state.materialized, prop, state.materialized); + return (method as (...methodArgs: unknown[]) => unknown)(...args); + }; + } + + const source = state.materialized ?? target; + const value = Reflect.get(source, prop, receiver); + return typeof value === "function" ? value.bind(source) : value; + }, + getOwnPropertyDescriptor(target, prop) { + if (prop === NOOP_FILTERED_HEADERS_STATE) { + return { + configurable: true, + enumerable: false, + value: state, + }; + } + return Reflect.getOwnPropertyDescriptor(state.materialized ?? target, prop); + }, + has(target, prop) { + return prop === NOOP_FILTERED_HEADERS_STATE || prop in (state.materialized ?? target); + }, + ownKeys(target) { + return Reflect.ownKeys(state.materialized ?? target); + }, + }) as Headers; +} /** * Strip internal headers from an inbound request so they cannot be forged by @@ -582,22 +633,29 @@ type RequestInitWithCf = RequestInit & { cf?: unknown }; * Must be called at every request entry point BEFORE middleware, routing, * or any handler logic accesses the request headers. * - * Returns a new Headers object with internal headers removed. The input - * is never mutated — Request.headers is immutable in Workers/miniflare - * environments (see applyMiddlewareRequestHeaders in config-matchers.ts - * for the same cloning pattern). + * Returns a fresh Headers object when internal headers are present. When the + * filter is a no-op, returns a lazy copy-on-write Headers proxy so callers can + * still attach trusted framework headers without mutating the original request, + * while cloneRequestWithHeaders() can skip rebuilding the Request if nothing was + * changed. * * @param headers - The source Headers (never modified) - * @returns A new Headers with internal framework headers removed + * @returns Headers with internal framework headers removed */ export function filterInternalHeaders(headers: Headers): Headers { - const filtered = new Headers(); - for (const [key, value] of headers) { - if (!STRIPPED_INTERNAL_HEADERS.has(key.toLowerCase())) { - filtered.append(key, value); + for (const key of headers.keys()) { + if (STRIPPED_INTERNAL_HEADERS.has(key.toLowerCase())) { + const filtered = new Headers(); + for (const [entryKey, value] of headers) { + if (!STRIPPED_INTERNAL_HEADERS.has(entryKey.toLowerCase())) { + filtered.append(entryKey, value); + } + } + return filtered; } } - return filtered; + + return createNoopFilteredHeaders(headers); } function getRequestCf(request: Request): unknown { @@ -614,6 +672,11 @@ function getRequestCf(request: Request): unknown { * a RequestInit with best-effort metadata. */ export function cloneRequestWithHeaders(request: Request, headers: Headers): Request { + const filteredState = noopFilteredHeadersState(headers); + if (filteredState?.original === request.headers && filteredState.materialized === null) { + return request; + } + let cloned: Request; try { cloned = new Request(request, { headers }); @@ -662,6 +725,8 @@ export function cloneRequestWithHeaders(request: Request, headers: Headers): Req * exists. */ export function cloneRequestWithUrl(request: Request, url: string): Request { + if (url === request.url) return request; + let cloned: Request; try { cloned = new Request(url, request); diff --git a/tests/app-post-middleware-context.test.ts b/tests/app-post-middleware-context.test.ts index ebca51184..00e51e37b 100644 --- a/tests/app-post-middleware-context.test.ts +++ b/tests/app-post-middleware-context.test.ts @@ -54,6 +54,33 @@ describe("buildPostMwRequestContext", () => { expect(ctx.cookies).toEqual({ token: "xyz", lang: "en" }); }); + it("does not materialise middleware cookies until cookies are read", () => { + const mwHeaders = new Headers({ host: "mw.example.com" }); + const mwCookies = new Map([["token", "xyz"]]); + let cookieIterations = 0; + const iterateCookies = mwCookies[Symbol.iterator].bind(mwCookies); + Object.defineProperty(mwCookies, Symbol.iterator, { + value() { + cookieIterations++; + return iterateCookies(); + }, + }); + + setHeadersContext({ headers: mwHeaders, cookies: mwCookies }); + + const ctx = buildPostMwRequestContext(makeRequest("https://original.example.com/path")); + + expect(ctx.headers.get("host")).toBe("mw.example.com"); + expect(ctx.host).toBe("mw.example.com"); + expect(cookieIterations).toBe(0); + + expect(ctx.cookies).toEqual({ token: "xyz" }); + expect(cookieIterations).toBe(1); + + expect(ctx.cookies).toEqual({ token: "xyz" }); + expect(cookieIterations).toBe(1); + }); + it("preserves query parameters from the original request URL", () => { setHeadersContext({ headers: new Headers({ host: "x.com" }), diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 2ccfa094f..1d0da2dc9 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -23,6 +23,8 @@ import { } from "../packages/vinext/src/server/headers.js"; import { applyAppMiddleware } from "../packages/vinext/src/server/app-middleware.js"; import type { NextRequest } from "../packages/vinext/src/shims/server.js"; +import { NextResponse } from "../packages/vinext/src/shims/server.js"; +import { getHeadersContext } from "../packages/vinext/src/shims/headers.js"; import { handleMetadataRouteRequest, type MetadataRuntimeRoute, @@ -169,6 +171,39 @@ describe("createAppRscHandler", () => { expect(await response.text()).toBe("page"); }); + it("matches a cookie-gated rewrite after middleware mutates the request cookie header", async () => { + const dispatchMatchedPage = vi.fn(async () => new Response("cookie gated page")); + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: [ + { + source: "/mw-cookie-gated", + has: [{ type: "cookie", key: "mw-auth" }], + destination: "/about", + }, + ], + afterFiles: [], + fallback: [], + }, + dispatchMatchedPage, + middlewareModule: { + middleware(request: NextRequest) { + const headers = new Headers(request.headers); + const existing = headers.get("cookie"); + headers.set("cookie", existing ? `${existing}; mw-auth=1` : "mw-auth=1"); + return NextResponse.next({ request: { headers } }); + }, + }, + }); + + const response = await handler(new Request("https://example.test/docs/mw-cookie-gated"), null); + + expect(response.status).toBe(200); + expect(await response.text()).toBe("cookie gated page"); + expect(dispatchMatchedPage).toHaveBeenCalledTimes(1); + }); + it("allows identity basePath: false rewrites to claim App routes", async () => { const handler = createHandler({ configHeaders: [], @@ -811,6 +846,68 @@ describe("createAppRscHandler", () => { expect(response.headers.get("x-action-revalidated")).toBe("1"); }); + it("evaluates late cookie-gated rewrites against the post-middleware cookie snapshot", async () => { + const actionRoute = createPageRoute({ + isDynamic: true, + pattern: "/late-action-cookie", + routeSegments: ["late-action-cookie"], + }); + const aboutRoute = createPageRoute(); + const dispatchMatchedPage = vi.fn( + async ({ cleanPathname }) => new Response(`page:${cleanPathname}`), + ); + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: [], + afterFiles: [ + { + source: "/late-action-cookie", + has: [{ type: "cookie", key: "late-action" }], + destination: "/about", + }, + ], + fallback: [], + }, + dispatchMatchedPage, + async handleProgressiveActionRequest() { + const headersContext = getHeadersContext(); + if (!headersContext) throw new Error("expected active headers context"); + headersContext.cookies.set("late-action", "1"); + return { + kind: "form-state", + formState: null, + pendingCookies: [], + draftCookie: null, + revalidationKind: 0, + }; + }, + matchRoute(pathname: string) { + if (pathname === "/late-action-cookie") { + return { params: {}, route: actionRoute }; + } + if (pathname === "/about") { + return { params: {}, route: aboutRoute }; + } + return null; + }, + }); + + const response = await handler( + new Request("https://example.test/docs/late-action-cookie", { + method: "POST", + headers: { "content-type": "multipart/form-data; boundary=vinext" }, + }), + null, + ); + + expect(response.status).toBe(200); + expect(await response.text()).toBe("page:/late-action-cookie"); + expect(dispatchMatchedPage).toHaveBeenCalledWith( + expect.objectContaining({ cleanPathname: "/late-action-cookie" }), + ); + }); + // When an action did not mutate cookies and did not request a revalidation, // the page response should NOT carry an x-action-revalidated marker — that // header tells the client router cache to invalidate, and emitting it diff --git a/tests/request-pipeline-noop-clone.test.ts b/tests/request-pipeline-noop-clone.test.ts new file mode 100644 index 000000000..a5db030ed --- /dev/null +++ b/tests/request-pipeline-noop-clone.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + cloneRequestWithHeaders, + cloneRequestWithUrl, + filterInternalHeaders, +} from "../packages/vinext/src/server/request-pipeline.js"; +import { + MIDDLEWARE_NEXT_HEADER, + VINEXT_PRERENDER_ROUTE_PARAMS_HEADER, +} from "../packages/vinext/src/server/headers.js"; + +describe("request-pipeline no-op request cloning", () => { + it("keeps the original request when internal-header filtering is a no-op", () => { + const request = new Request("https://example.test/about", { + headers: { accept: "text/html" }, + }); + + const filteredHeaders = filterInternalHeaders(request.headers); + const filteredRequest = cloneRequestWithHeaders(request, filteredHeaders); + + expect(filteredHeaders.get("accept")).toBe("text/html"); + expect(filteredRequest).toBe(request); + }); + + it("materialises headers and clones when trusted framework headers are attached", () => { + const request = new Request("https://example.test/about", { + headers: { accept: "text/html" }, + }); + + const filteredHeaders = filterInternalHeaders(request.headers); + filteredHeaders.set(VINEXT_PRERENDER_ROUTE_PARAMS_HEADER, "%7B%7D"); + const filteredRequest = cloneRequestWithHeaders(request, filteredHeaders); + + expect(filteredRequest).not.toBe(request); + expect(filteredRequest.headers.get("accept")).toBe("text/html"); + expect(filteredRequest.headers.get(VINEXT_PRERENDER_ROUTE_PARAMS_HEADER)).toBe("%7B%7D"); + expect(request.headers.get(VINEXT_PRERENDER_ROUTE_PARAMS_HEADER)).toBeNull(); + }); + + it("strips forged internal headers and clones the request when filtering changes headers", () => { + const request = new Request("https://example.test/about", { + headers: { + accept: "text/html", + [MIDDLEWARE_NEXT_HEADER]: "1", + }, + }); + + const filteredHeaders = filterInternalHeaders(request.headers); + const filteredRequest = cloneRequestWithHeaders(request, filteredHeaders); + + expect(filteredHeaders.get(MIDDLEWARE_NEXT_HEADER)).toBeNull(); + expect(filteredRequest).not.toBe(request); + expect(filteredRequest.headers.get("accept")).toBe("text/html"); + expect(filteredRequest.headers.get(MIDDLEWARE_NEXT_HEADER)).toBeNull(); + }); + + it("preserves Workers cf metadata when a header clone is still needed", () => { + const request = new Request("https://example.test/about", { + headers: { accept: "text/html" }, + }); + const cf = { colo: "SYD" }; + Object.defineProperty(request, "cf", { + value: cf, + enumerable: true, + configurable: true, + }); + + const filteredHeaders = filterInternalHeaders(request.headers); + filteredHeaders.set(VINEXT_PRERENDER_ROUTE_PARAMS_HEADER, "%7B%7D"); + const filteredRequest = cloneRequestWithHeaders(request, filteredHeaders); + + expect(filteredRequest).not.toBe(request); + expect(Reflect.get(filteredRequest, "cf")).toBe(cf); + }); + + it("keeps the original request when URL cloning is a no-op", () => { + const request = new Request("https://example.test/about"); + + expect(cloneRequestWithUrl(request, request.url)).toBe(request); + }); +}); diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 3734b0b50..e7bbe9c40 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -11074,6 +11074,60 @@ describe("checkHasConditions", () => { expect(checkHasConditions(undefined, undefined, makeCtx())).toBe(true); }); + it("reports cookie and query condition access separately", async () => { + const { + requestContextConditionAccessForMatchingRewrites, + requestContextConditionAccessForRule, + } = await import("../packages/vinext/src/config/config-matchers.js"); + + expect( + requestContextConditionAccessForRule({ + has: [{ type: "cookie", key: "session" }], + missing: [{ type: "header", key: "x-skip" }], + }), + ).toEqual({ cookies: true, query: false }); + + expect( + requestContextConditionAccessForRule({ + has: [{ type: "query", key: "preview" }], + missing: [{ type: "host", key: "example.com" }], + }), + ).toEqual({ cookies: false, query: true }); + + expect( + requestContextConditionAccessForRule({ + has: [{ type: "header", key: "x-user-tier" }], + missing: [{ type: "host", key: "example.com" }], + }), + ).toEqual({ cookies: false, query: false }); + + expect( + requestContextConditionAccessForMatchingRewrites("/account", { + beforeFiles: [ + { + source: "/account", + has: [{ type: "cookie", key: "session" }], + destination: "/dashboard", + }, + ], + afterFiles: [ + { + source: "/other", + has: [{ type: "query", key: "preview" }], + destination: "/preview", + }, + ], + fallback: [ + { + source: "/account", + missing: [{ type: "query", key: "tab" }], + destination: "/account/default", + }, + ], + }), + ).toEqual({ cookies: true, query: true }); + }); + // -- header conditions -- it("has header: passes when header present", async () => { const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");