Files
cloudflare__vinext/tests/app-route-handler-cache.test.ts
Nathan Nguyen fefc8e21d1 fix(app-router): preserve request.cf in route handlers (#2886)
* fix(app-router): preserve request.cf in route handlers

Cloudflare Workers attaches request.cf at the inbound request boundary, but App Router dispatch rebuilt Route Handler requests with the standard Request constructor and discarded that runtime metadata.\n\nUse the existing metadata-preserving URL clone boundary so both Node and Edge Route Handlers retain cf while keeping their current URL normalization semantics.

* fix(app-router): preserve request.cf through runtime wrapping

Route Handler request tracking can rebuild an already-normalized request when restoring basePath or applying middleware header overrides. Those reconstructions discarded Cloudflare metadata before the final NextRequest reached user code.\n\nPreserve cf at both reconstruction points while retaining the existing body-transfer and RequestInit behavior, with runtime-level coverage for each path.

* fix(app-router): treat request.cf as dynamic state

Preserving Cloudflare request metadata makes request.cf observable to Route Handlers, but the request proxy did not classify reads as dynamic. Geo-dependent responses could therefore enter the route-wide ISR cache, while static route modes exposed request-specific metadata.\n\nApply the existing ip and geo policy to cf: track reads in automatic mode, hide it under force-static, and reject it under dynamic error mode.

* fix(app-router): preserve request.cf across clones

Tracked Route Handler requests inherit the standard Request clone implementation, which omits Cloudflare request metadata. A handler that cloned before reading cf therefore lost the metadata even though the original tracked request retained it.\n\nCentralize tracked request cloning so cf is reattached before the clone is recursively wrapped with the same dynamic request policy.

* fix(app-router): track reflective request.cf access

Tracked Route Handler requests enforced dynamic policy only through the Proxy get trap. Descriptor, membership, and key-enumeration reads could expose Cloudflare metadata without marking the handler dynamic or respecting static modes.

Route explicit cf reflection through the same policy, filtering configurable cf keys under force-static and rejecting reflective access under dynamic error mode.

* fix(app-router): retain request proxy through valueOf

Binding every Request method to the underlying NextRequest let valueOf return the raw target. Subsequent request.cf reads could then bypass dynamic tracking and static-mode policy.

Bind valueOf to the proxy receiver while leaving branded Request methods on the underlying target, and cover the escape in all three request modes.

* fix(app-router): bind reflection to request proxy

Inherited Object reflection helpers were still bound to the raw NextRequest, allowing ownership checks to bypass request.cf dynamic policy.

Bind exact Object.prototype methods to the proxy receiver while preserving the branded target for Web Request methods, and cover ownership and enumerability checks in every request mode.

* fix(app-router): preserve proxy for request extensions

Binding unknown request properties to the raw NextRequest let user-defined methods and getters bypass request.cf policy.

Snapshot the runtime's built-in Request surface for branded target access while keeping own and unknown extensions on the proxy receiver. Cover both method and accessor escapes in every request mode.

* fix(app-router): hide cf before locking requests

Force-static proxies filtered request.cf from reflective operations, but a non-extensible target made that omission violate Proxy invariants.

Remove the configurable metadata before preventing extensions, and cover preventExtensions, seal, and freeze while preserving force-static policy.

* fix(app-router): bind Worker request members correctly

Cloudflare may expose Web IDL Request members as own properties, so classifying every own member as a user extension caused illegal invocation errors in Workers.

Use the captured built-in API surface regardless of property placement, while keeping unknown extension names on the proxy receiver. Add coverage for an own branded accessor.

* fix(app-router): distinguish request member shadows

Name-only branded member detection fixed Workers own-property layouts but treated post-wrap user shadows as runtime Web IDL members, allowing their this-based reads to escape the proxy.

Snapshot runtime-owned descriptors before route code receives the request and bind only unchanged implementations to the target. Later shadows retain the proxy receiver in every request mode.

* fix(app-router): detect request prototype shadows

Own-descriptor snapshots still treated replaced inherited Request members as branded, allowing prototype getters to bypass request.cf policy.

Snapshot each resolved built-in implementation and compare the currently resolved descriptor before selecting the raw target. Own and prototype shadows now retain the tracked receiver.

* fix(app-router): snapshot request built-ins before routes

* fix(app-router): preserve branded Worker request methods

* fix(app-router): preserve reflected cf after locking

* refactor(app-router): contain request.cf policy on its target

Route handlers need Workers metadata to follow static-generation policy without changing the semantics of every Request property. Use the configurable cf accessor copied onto NextRequest as the single policy boundary, and delegate request reconstruction to the canonical clone helpers.

* fix(app-router): avoid synthesizing absent request.cf

Ordinary requests should not gain an own cf property merely because route-handler dynamic tracking is active. Install the target accessor only when Workers metadata exists, while keeping direct absent reads subject to automatic, force-static, and error policy.

* fix(app-router): harden request.cf tracking

* chore(test): register worker route fixtures

* chore(app-router): deduplicate request.cf descriptor

---------

Co-authored-by: James <james@eli.cx>
2026-08-13 11:39:47 +01:00

524 lines
16 KiB
TypeScript

import { describe, expect, it, vi } from "vite-plus/test";
import { readAppRouteHandlerCacheResponse } from "../packages/vinext/src/server/app-route-handler-cache.js";
import { isKnownDynamicAppRoute } from "../packages/vinext/src/server/app-route-handler-runtime.js";
import type { ISRCacheEntry } from "../packages/vinext/src/server/isr-cache.js";
import type { CachedRouteValue } from "../packages/vinext/src/shims/cache.js";
import type { HeadersAccessPhase } from "../packages/vinext/src/shims/headers.js";
function createDynamicUsageState(): {
consumeDynamicUsage: () => boolean;
markDynamicUsage: () => void;
} {
let didUseDynamic = false;
return {
consumeDynamicUsage() {
const used = didUseDynamic;
didUseDynamic = false;
return used;
},
markDynamicUsage() {
didUseDynamic = true;
},
};
}
function buildISRCacheEntry(value: CachedRouteValue, isStale = false): ISRCacheEntry {
return {
isStale,
value: {
lastModified: Date.now(),
value,
},
};
}
function buildCachedRouteValue(
body: string,
headers: Record<string, string> = {},
): CachedRouteValue {
return {
kind: "APP_ROUTE",
body: new TextEncoder().encode(body).buffer,
status: 200,
headers,
};
}
type ReadAppRouteHandlerCacheOptions = Parameters<typeof readAppRouteHandlerCacheResponse>[0];
function createReadOptions(
overrides: Partial<ReadAppRouteHandlerCacheOptions> = {},
): ReadAppRouteHandlerCacheOptions {
return {
buildPageCacheTags(pathname, extraTags) {
return [pathname, ...extraTags];
},
cleanPathname: "/api/cached",
clearRequestContext() {},
consumeDynamicUsage() {
return false;
},
getCollectedFetchTags() {
return [];
},
async handlerFn() {
return new Response("fresh");
},
isAutoHead: false,
async isrGet() {
return null;
},
isrRouteKey(pathname) {
return `route:${pathname}`;
},
async isrSet() {},
markDynamicUsage() {},
middlewareContext: { headers: null, status: null },
params: {},
requestUrl: "https://example.com/api/cached",
revalidateSearchParams: new URLSearchParams(),
revalidateSeconds: 60,
routePattern: "/api/cached",
async runInRevalidationContext(renderFn) {
await renderFn();
},
scheduleBackgroundRegeneration() {},
setHeadersAccessPhase() {
return "render";
},
setNavigationContext() {},
...overrides,
};
}
describe("app route handler cache helpers", () => {
it("does not serve or background-regenerate hard-expired route handlers", async () => {
const scheduleBackgroundRegeneration = vi.fn();
const clearRequestContext = vi.fn();
const response = await readAppRouteHandlerCacheResponse(
createReadOptions({
clearRequestContext,
async isrGet() {
return {
...buildISRCacheEntry(buildCachedRouteValue("expired"), true),
isExpired: true,
};
},
scheduleBackgroundRegeneration,
}),
);
expect(response).toBeNull();
expect(scheduleBackgroundRegeneration).not.toHaveBeenCalled();
expect(clearRequestContext).not.toHaveBeenCalled();
});
it("returns HIT responses from cached APP_ROUTE entries", async () => {
let didClearRequestContext = false;
const response = await readAppRouteHandlerCacheResponse({
buildPageCacheTags(pathname, extraTags) {
return [pathname, ...extraTags];
},
cleanPathname: "/api/cached",
clearRequestContext() {
didClearRequestContext = true;
},
consumeDynamicUsage() {
return false;
},
getCollectedFetchTags() {
return [];
},
handlerFn() {
throw new Error("should not run");
},
isAutoHead: false,
async isrGet() {
return buildISRCacheEntry(
buildCachedRouteValue("from-cache", { "content-type": "text/plain" }),
);
},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {},
markDynamicUsage() {},
middlewareContext: {
headers: new Headers([["x-middleware", "present"]]),
status: 202,
},
params: {},
requestUrl: "https://example.com/api/cached",
revalidateSearchParams: new URLSearchParams("a=1"),
revalidateSeconds: 60,
routePattern: "/api/cached",
async runInRevalidationContext(renderFn) {
await renderFn();
},
scheduleBackgroundRegeneration() {
throw new Error("should not schedule regeneration");
},
setHeadersAccessPhase() {
return "render";
},
setNavigationContext() {},
});
expect(response?.status).toBe(202);
expect(response?.headers.get("x-vinext-cache")).toBe("HIT");
expect(response?.headers.get("x-middleware")).toBe("present");
await expect(response?.text()).resolves.toBe("from-cache");
expect(didClearRequestContext).toBe(true);
});
it("returns STALE responses and regenerates cached route handlers in the background", async () => {
const dynamicUsage = createDynamicUsageState();
const scheduledRegenerations: Array<() => Promise<void>> = [];
const isrSetCalls: Array<{
key: string;
expireSeconds: number | undefined;
revalidateSeconds: number | false;
tags: string[];
}> = [];
const navigationCalls: Array<string | null> = [];
const response = await readAppRouteHandlerCacheResponse({
basePath: "/base",
buildPageCacheTags(pathname, extraTags) {
return [pathname, ...extraTags];
},
cleanPathname: "/api/stale",
clearRequestContext() {},
consumeDynamicUsage: dynamicUsage.consumeDynamicUsage,
getCollectedFetchTags() {
return ["tag:regen"];
},
handlerFn() {
return Response.json({
ok: true,
});
},
i18n: { locales: ["en"], defaultLocale: "en" },
isAutoHead: false,
async isrGet() {
return buildISRCacheEntry(buildCachedRouteValue("from-stale"), true);
},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet(key, value, policy) {
expect(value.kind).toBe("APP_ROUTE");
isrSetCalls.push({
key,
expireSeconds: policy.cacheControl.expire,
revalidateSeconds: policy.cacheControl.revalidate,
tags: policy.tags ?? [],
});
},
markDynamicUsage: dynamicUsage.markDynamicUsage,
middlewareContext: { headers: null, status: null },
params: { slug: "demo" },
requestUrl: "https://example.com/base/api/stale?ping=pong",
revalidateSearchParams: new URLSearchParams("ping=pong"),
expireSeconds: 300,
revalidateSeconds: 60,
routePattern: "/api/stale",
async runInRevalidationContext(renderFn) {
await renderFn();
},
scheduleBackgroundRegeneration(_key, renderFn) {
scheduledRegenerations.push(renderFn);
},
setHeadersAccessPhase() {
return "render";
},
setNavigationContext(context) {
navigationCalls.push(context?.pathname ?? null);
},
});
expect(response?.headers.get("x-vinext-cache")).toBe("STALE");
await expect(response?.text()).resolves.toBe("from-stale");
expect(scheduledRegenerations).toHaveLength(1);
await scheduledRegenerations[0]();
expect(isrSetCalls).toEqual([
{
key: "route:/api/stale",
expireSeconds: 300,
revalidateSeconds: 60,
tags: ["/api/stale", "tag:regen"],
},
]);
expect(navigationCalls).toEqual(["/api/stale", null]);
});
it("sets the route-handler header access phase while stale route handlers regenerate", async () => {
const dynamicUsage = createDynamicUsageState();
const scheduledRegens: Array<() => Promise<void>> = [];
const phases: string[] = [];
const options = {
buildPageCacheTags(pathname: string, extraTags: string[]) {
return [pathname, ...extraTags];
},
cleanPathname: "/api/stale-force-static",
clearRequestContext() {},
consumeDynamicUsage: dynamicUsage.consumeDynamicUsage,
dynamicConfig: "force-static",
getCollectedFetchTags() {
return [];
},
handlerFn() {
return new Response("regenerated");
},
isAutoHead: false,
async isrGet() {
return buildISRCacheEntry(buildCachedRouteValue("from-stale"), true);
},
isrRouteKey(pathname: string) {
return "route:" + pathname;
},
async isrSet() {},
markDynamicUsage: dynamicUsage.markDynamicUsage,
middlewareContext: { headers: null, status: null },
params: {},
requestUrl: "https://example.com/api/stale-force-static",
revalidateSearchParams: new URLSearchParams(),
revalidateSeconds: 60,
routePattern: "/api/stale-force-static",
async runInRevalidationContext(renderFn: () => Promise<void>) {
await renderFn();
},
scheduleBackgroundRegeneration(_key: string, renderFn: () => Promise<void>) {
scheduledRegens.push(renderFn);
},
setHeadersAccessPhase(phase: HeadersAccessPhase): HeadersAccessPhase {
phases.push(phase);
return "render";
},
setNavigationContext() {},
};
await readAppRouteHandlerCacheResponse(options);
const scheduledRegenRun = scheduledRegens[0];
expect(scheduledRegens).toHaveLength(1);
if (!scheduledRegenRun) {
throw new Error("Expected scheduled route regeneration");
}
await scheduledRegenRun();
expect(phases).toEqual(["route-handler"]);
});
it("skips regeneration writes when the stale handler reads dynamic request data", async () => {
const dynamicUsage = createDynamicUsageState();
const routePattern = "/api/stale-dynamic-" + Date.now();
const scheduledRegens: Array<() => Promise<void>> = [];
let wroteCache = false;
await readAppRouteHandlerCacheResponse({
buildPageCacheTags(pathname, extraTags) {
return [pathname, ...extraTags];
},
cleanPathname: "/api/stale-dynamic",
clearRequestContext() {},
consumeDynamicUsage: dynamicUsage.consumeDynamicUsage,
getCollectedFetchTags() {
return [];
},
handlerFn(request) {
return Response.json({
ping: request.headers.get("x-test"),
});
},
isAutoHead: false,
async isrGet() {
return buildISRCacheEntry(buildCachedRouteValue("from-stale"), true);
},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {
wroteCache = true;
},
markDynamicUsage: dynamicUsage.markDynamicUsage,
middlewareContext: { headers: null, status: null },
params: {},
requestUrl: "https://example.com/api/stale-dynamic",
revalidateSearchParams: new URLSearchParams(),
revalidateSeconds: 60,
routePattern,
async runInRevalidationContext(renderFn) {
await renderFn();
},
scheduleBackgroundRegeneration(_key, renderFn) {
scheduledRegens.push(renderFn);
},
setHeadersAccessPhase() {
return "render";
},
setNavigationContext() {},
});
const scheduledRegenRun = scheduledRegens[0];
expect(scheduledRegens).toHaveLength(1);
if (!scheduledRegenRun) {
throw new Error("Expected scheduled route regeneration");
}
await scheduledRegenRun();
expect(wroteCache).toBe(false);
expect(isKnownDynamicAppRoute(routePattern)).toBe(true);
});
it("skips regeneration writes when a stale handler enumerates a request without cf", async () => {
const dynamicUsage = createDynamicUsageState();
const routePattern = "/api/stale-cf-reflection-" + Date.now();
const scheduledRegens: Array<() => Promise<void>> = [];
let wroteCache = false;
await readAppRouteHandlerCacheResponse(
createReadOptions({
cleanPathname: "/api/stale-cf-reflection",
consumeDynamicUsage: dynamicUsage.consumeDynamicUsage,
handlerFn(request) {
return Response.json({ hasCf: Object.keys(request).includes("cf") });
},
async isrGet() {
return buildISRCacheEntry(buildCachedRouteValue("from-stale"), true);
},
async isrSet() {
wroteCache = true;
},
markDynamicUsage: dynamicUsage.markDynamicUsage,
routePattern,
scheduleBackgroundRegeneration(_key, renderFn) {
scheduledRegens.push(renderFn);
},
}),
);
expect(scheduledRegens).toHaveLength(1);
await scheduledRegens[0]!();
expect(wroteCache).toBe(false);
expect(isKnownDynamicAppRoute(routePattern)).toBe(true);
});
it("rejects invalid route handler responses during background regeneration", async () => {
const dynamicUsage = createDynamicUsageState();
const scheduledRegens: Array<() => Promise<void>> = [];
let wroteCache = false;
const response = await readAppRouteHandlerCacheResponse({
buildPageCacheTags(pathname, extraTags) {
return [pathname, ...extraTags];
},
cleanPathname: "/api/stale-invalid",
clearRequestContext() {},
consumeDynamicUsage: dynamicUsage.consumeDynamicUsage,
getCollectedFetchTags() {
return [];
},
handlerFn() {
return new Response("should not be cached", {
headers: { "x-middleware-next": "1" },
});
},
isAutoHead: false,
async isrGet() {
return buildISRCacheEntry(buildCachedRouteValue("from-stale"), true);
},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {
wroteCache = true;
},
markDynamicUsage: dynamicUsage.markDynamicUsage,
middlewareContext: { headers: null, status: null },
params: {},
requestUrl: "https://example.com/api/stale-invalid",
revalidateSearchParams: new URLSearchParams(),
revalidateSeconds: 60,
routePattern: "/api/stale-invalid",
async runInRevalidationContext(renderFn) {
await renderFn();
},
scheduleBackgroundRegeneration(_key, renderFn) {
scheduledRegens.push(renderFn);
},
setHeadersAccessPhase() {
return "render";
},
setNavigationContext() {},
});
expect(response?.headers.get("x-vinext-cache")).toBe("STALE");
await expect(response?.text()).resolves.toBe("from-stale");
expect(scheduledRegens).toHaveLength(1);
const scheduledRegenRun = scheduledRegens[0];
if (!scheduledRegenRun) {
throw new Error("Expected scheduled route regeneration");
}
await expect(scheduledRegenRun()).rejects.toThrow(
"NextResponse.next() was used in a app route handler",
);
expect(wroteCache).toBe(false);
});
it("falls through on cache read errors", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const response = await readAppRouteHandlerCacheResponse({
buildPageCacheTags(pathname, extraTags) {
return [pathname, ...extraTags];
},
cleanPathname: "/api/cache-error",
clearRequestContext() {},
consumeDynamicUsage() {
return false;
},
getCollectedFetchTags() {
return [];
},
handlerFn() {
throw new Error("should not run");
},
isAutoHead: false,
async isrGet() {
throw new Error("cache blew up");
},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {},
markDynamicUsage() {},
middlewareContext: { headers: null, status: null },
params: {},
requestUrl: "https://example.com/api/cache-error",
revalidateSearchParams: new URLSearchParams(),
revalidateSeconds: 60,
routePattern: "/api/cache-error",
async runInRevalidationContext(renderFn) {
await renderFn();
},
scheduleBackgroundRegeneration() {},
setHeadersAccessPhase() {
return "render";
},
setNavigationContext() {},
});
expect(response).toBeNull();
expect(errorSpy).toHaveBeenCalledOnce();
errorSpy.mockRestore();
});
});