Skip to content
Draft
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
70 changes: 70 additions & 0 deletions packages/vinext/src/config/config-matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,76 @@ export type RequestContext = {
readonly host: string;
};

export type RequestContextConditionAccess = {
readonly cookies: boolean;
readonly query: boolean;
};

const _conditionAccessCache = new WeakMap<object, RequestContextConditionAccess>();

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.
*
Expand Down
11 changes: 8 additions & 3 deletions packages/vinext/src/server/app-post-middleware-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = Object.fromEntries(ctx.cookies);
let cookiesRecord: Record<string, string> | 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),
};
}
43 changes: 39 additions & 4 deletions packages/vinext/src/server/app-rsc-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -98,6 +101,7 @@ import {
type AppPageParams = Record<string, string | string[]>;
type RequestContext = ReturnType<typeof requestContextFromRequest>;
const STATIC_METADATA_CONFIG_HEADER_OVERRIDES = new Set(["cache-control"]);
const NO_REWRITES: readonly NextRewrite[] = [];
type StaticParamsMap = AppPrerenderStaticParamsMap;
type RootParamNamesMap = AppPrerenderRootParamNamesMap;

Expand Down Expand Up @@ -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);
},
};
}

Expand Down Expand Up @@ -664,6 +675,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
postMiddlewareRequestContext,
resolvedUrl,
url,
requestContextConditionAccessForRule(rewrite),
),
rewrites: [rewrite],
},
Expand Down Expand Up @@ -699,6 +711,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
postMiddlewareRequestContext,
resolvedUrl,
url,
requestContextConditionAccessForRule(rewrite),
),
rewrites: [rewrite],
},
Expand All @@ -723,6 +736,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
postMiddlewareRequestContext,
resolvedUrl,
url,
requestContextConditionAccessForRule(rewrite),
),
rewrites: [rewrite],
},
Expand Down Expand Up @@ -815,6 +829,25 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
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();
Expand Down Expand Up @@ -918,7 +951,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
options.clearRequestContext();
return staticPagesFallbackResponse;
}
if (!resolvedLateRewritesForAction && (!match || match.route.isDynamic)) {
if (canRunLateAfterFiles) {
for (const rewrite of options.configRewrites.afterFiles) {
const afterFilesRewrite = await applyRewrite(
{
Expand All @@ -930,6 +963,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
postMiddlewareRequestContext,
resolvedUrl,
url,
requestContextConditionAccessForRule(rewrite),
),
rewrites: [rewrite],
},
Expand Down Expand Up @@ -963,7 +997,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
return dynamicPagesFallbackResponse;
}

if (!resolvedLateRewritesForAction && !match) {
if (canRunLateFallback && !match) {
for (const rewrite of options.configRewrites.fallback) {
const fallbackRewrite = await applyRewrite(
{
Expand All @@ -975,6 +1009,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
postMiddlewareRequestContext,
resolvedUrl,
url,
requestContextConditionAccessForRule(rewrite),
),
rewrites: [rewrite],
},
Expand Down
85 changes: 75 additions & 10 deletions packages/vinext/src/server/request-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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 });
Expand Down Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions tests/app-post-middleware-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
Expand Down
Loading