Skip to content
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,11 @@ must prove every planned entry reusable before promotion.

While the data adapter can store entries and serve HIT/STALE itself, the CDN adapter delegates serving to Cloudflare's edge: the origin renders fresh responses and tags them with `Cache-Tag`, and `revalidateTag()` / `revalidatePath()` purge the edge through `ctx.cache.purge({ tags })`. See [examples/workers-cache](examples/workers-cache) for both adapters wired up together.

Keep Cloudflare's incoming cache key query-sensitive when using `cdnAdapter()`.
The two-stage cacheability manifest authorizes exact pathname + query
identities, and a Cache Rule that ignores or normalizes query strings can serve
an edge HIT before the Worker has a chance to enforce that identity.

Each builder returns a plain, serializable `{ adapter, options }` descriptor — **it never touches the Workers runtime**, so nothing throws at build or dev time when bindings aren't available. The actual adapter (and its `env` binding lookup) is instantiated lazily on the first request.

Registration is wired into **every router and runtime** — App Router and Pages Router, on Cloudflare Workers as well as the Node.js server (`vinext start`) and dev. It self-guards (instantiated once per isolate) and is resilient: if an adapter can't initialize on a given runtime (e.g. a KV binding doesn't exist on the Node server), vinext logs a warning and falls back to the default handler instead of failing requests.
Expand Down
4 changes: 4 additions & 0 deletions examples/workers-cache/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ The Workers Cache only exposes `ctx.cache` when `cache.enabled: true` is set
in `wrangler.jsonc`, and the KV adapter needs a matching `VINEXT_KV_CACHE`
namespace binding — both are configured there.

The incoming Cloudflare cache key must retain the full query string. A Cache
Rule that ignores or normalizes query parameters can collapse distinct
manifest identities before the Worker runs.

## What's in the box

- ISR-cached App Router page at `/cached/[slug]` (`revalidate = 60`).
Expand Down
4 changes: 4 additions & 0 deletions packages/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ makes one final fill request per admitted identity. Add `--warm-cdn-certify`
only when you want an opt-in second, header-only request that must prove every
planned entry reusable before promotion.

Do not configure a Cache Rule that ignores or normalizes query strings for
these responses. The two-stage cacheability manifest authorizes the full
pathname + query identity, but an edge HIT happens before Worker admission.

## Deploy

Deploy Cloudflare Workers projects with the package CLI:
Expand Down
4 changes: 4 additions & 0 deletions packages/cloudflare/src/cache/cdn-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export type CdnAdapterOptions = {
* ```
* Wrangler does not inherit `version_metadata` into named environments. Repeat
* the binding in every `env.<name>` used for CDN warmup.
*
* Cache Rules must preserve the full query string in the incoming cache key.
* Two-stage admission certifies exact pathname + search identities, while an
* edge HIT is served before the Worker can reject a differently keyed request.
*/
export function cdnAdapter(options?: CdnAdapterOptions) {
if (
Expand Down
4 changes: 3 additions & 1 deletion packages/cloudflare/src/cacheability-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,9 @@ export async function probeStagedWorkerCacheability(options: {
}
if (
result.version !== 1 ||
(result.kind !== "app-page" && result.kind !== "pages-page") ||
(result.kind !== "app-page" &&
result.kind !== "app-route" &&
result.kind !== "pages-page") ||
typeof result.pattern !== "string" ||
!result.pattern.startsWith("/") ||
!isProbeRouteState(result.state) ||
Expand Down
51 changes: 45 additions & 6 deletions packages/cloudflare/src/cdn-warm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export type CdnWarmOptions = {
paths: readonly string[];
/** Pages Router JSON data identities used by client navigation. */
pagesDataPaths?: readonly string[];
/** Statically eligible App Route Handler request identities. */
routeHandlerPaths?: readonly string[];
/** App Router ISR paths whose definitive client-navigation payload is warmed. */
rscPaths?: readonly string[];
/** App Router paths whose deterministic loading-boundary payload is warmed. */
Expand Down Expand Up @@ -84,6 +86,7 @@ export type CdnWarmRequestPlan = {
pagesDataPaths: string[];
paths: string[];
rscPaths: string[];
routeHandlerPaths?: string[];
};

export type CdnWarmReadinessResult = { ready: true } | { error: string; ready: false };
Expand All @@ -97,6 +100,7 @@ export type PrerenderWarmPlan = {
pagesDataPaths?: string[];
pagesPaths?: string[];
paths: string[];
routeHandlerPaths?: string[];
rscBuildId?: string;
rscPaths: string[];
};
Expand Down Expand Up @@ -150,6 +154,9 @@ function readPrerenderPathManifest(manifestPath: string): PrerenderPathManifest
(manifest.rscPaths !== undefined &&
(!Array.isArray(manifest.rscPaths) ||
!manifest.rscPaths.every((pathname) => typeof pathname === "string"))) ||
(manifest.routeHandlerPaths !== undefined &&
(!Array.isArray(manifest.routeHandlerPaths) ||
!manifest.routeHandlerPaths.every((pathname) => typeof pathname === "string"))) ||
(manifest.loadingShellPaths !== undefined &&
(!Array.isArray(manifest.loadingShellPaths) ||
!manifest.loadingShellPaths.every((pathname) => typeof pathname === "string"))) ||
Expand Down Expand Up @@ -254,6 +261,9 @@ export function readPrerenderWarmPlan(
paths: htmlPaths,
...(supportsCanonicalRsc ? { rscBuildId: manifest.rscBuildId } : {}),
rscPaths: supportsCanonicalRsc ? manifest.rscPaths!.map(applyConfig) : [],
...(manifest.routeHandlerPaths
? { routeHandlerPaths: manifest.routeHandlerPaths.map(applyConfig) }
: {}),
};
}

Expand Down Expand Up @@ -364,7 +374,7 @@ async function fetchHeadersWithTimeout(

export type CdnWarmTarget = {
headers?: HeadersInit;
kind: "html" | "pages-data" | "rsc-full" | "rsc-loading-shell";
kind: "app-route" | "html" | "pages-data" | "rsc-full" | "rsc-loading-shell";
label: string;
pathname: string;
sourcePathname: string;
Expand All @@ -373,7 +383,13 @@ export type CdnWarmTarget = {
export async function createCdnWarmTargets(
options: Pick<
CdnWarmOptions,
"deploymentId" | "headers" | "loadingShellPaths" | "pagesDataPaths" | "paths" | "rscPaths"
| "deploymentId"
| "headers"
| "loadingShellPaths"
| "pagesDataPaths"
| "paths"
| "routeHandlerPaths"
| "rscPaths"
>,
): Promise<CdnWarmTarget[]> {
const requests: CdnWarmTarget[] = [];
Expand Down Expand Up @@ -434,6 +450,17 @@ export async function createCdnWarmTargets(
sourcePathname: pathname,
});
}
for (const pathname of new Set(options.routeHandlerPaths ?? [])) {
const routeHeaders = new Headers(commonHeaders);
routeHeaders.set("Accept", "*/*");
requests.push({
headers: routeHeaders,
kind: "app-route",
label: `${pathname} (Route Handler)`,
pathname,
sourcePathname: pathname,
});
}
return requests;
}

Expand Down Expand Up @@ -717,7 +744,7 @@ function validatePagesDataWarmResponse(

function validateReadinessResponse(
response: Response,
kind: "html" | "pages-data" | "rsc",
kind: "app-route" | "html" | "pages-data" | "rsc",
expectedBuildId?: string,
expectedRscBuildId?: string,
): string | null {
Expand Down Expand Up @@ -774,8 +801,9 @@ export async function waitForCdnWarmTargetReadiness(
const rscPath = options.plan.rscPaths[0] ?? options.plan.loadingShellPaths[0];
const htmlPath = options.plan.paths[0];
const pagesDataPath = options.plan.pagesDataPaths[0];
const kind = rscPath ? "rsc" : htmlPath ? "html" : "pages-data";
const pathname = rscPath ?? htmlPath ?? pagesDataPath;
const routeHandlerPath = options.plan.routeHandlerPaths?.[0];
const kind = rscPath ? "rsc" : htmlPath ? "html" : pagesDataPath ? "pages-data" : "app-route";
const pathname = rscPath ?? htmlPath ?? pagesDataPath ?? routeHandlerPath;
if (!pathname) return { ready: true };
if (options.expectedBuildId === undefined && options.expectedRscBuildId === undefined) {
return {
Expand All @@ -792,8 +820,10 @@ export async function waitForCdnWarmTargetReadiness(
}
} else if (kind === "html") {
headers.set("Accept", "text/html");
} else {
} else if (kind === "pages-data") {
headers.set("Accept", "application/json");
} else {
headers.set("Accept", "*/*");
}
headers.set("Cache-Control", "no-cache");
headers.set("Pragma", "no-cache");
Expand Down Expand Up @@ -1242,6 +1272,12 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise<CdnWarmResu
return result.ok && !result.skipped;
});
const warmed = results.length - failures.length - skippedResults.length;
const warmedRouteHandlerPaths = warmedRequests
.filter((target) => target.kind === "app-route")
.map((target) => target.sourcePathname);
const failedRouteHandlerPaths = failedRequests
.filter(({ target }) => target.kind === "app-route")
.map(({ target }) => target.sourcePathname);

console.log(
` CDN warmup: ${warmed} warmed, ${skippedResults.length} skipped, ${failures.length} failed.`,
Expand Down Expand Up @@ -1277,6 +1313,7 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise<CdnWarmResu
rscPaths: warmedRequests
.filter((target) => target.kind === "rsc-full")
.map((target) => target.sourcePathname),
...(warmedRouteHandlerPaths.length > 0 ? { routeHandlerPaths: warmedRouteHandlerPaths } : {}),
},
retryPlan: {
loadingShellPaths: failedRequests
Expand All @@ -1291,6 +1328,7 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise<CdnWarmResu
rscPaths: failedRequests
.filter(({ target }) => target.kind === "rsc-full")
.map(({ target }) => target.sourcePathname),
...(failedRouteHandlerPaths.length > 0 ? { routeHandlerPaths: failedRouteHandlerPaths } : {}),
},
};

Expand All @@ -1316,6 +1354,7 @@ export async function warmCdnCacheFromPrerender(
loadingShellPaths: plan.loadingShellPaths,
pagesDataPaths: plan.pagesDataPaths,
paths: plan.paths,
routeHandlerPaths: plan.routeHandlerPaths,
rscPaths: plan.rscPaths,
};
return warmCdnCache({
Expand Down
36 changes: 31 additions & 5 deletions packages/cloudflare/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,7 @@ export function hasCdnWarmRequests(
return (
plan.paths.length +
(plan.pagesDataPaths?.length ?? 0) +
(plan.routeHandlerPaths?.length ?? 0) +
plan.rscPaths.length +
plan.loadingShellPaths.length >
0
Expand Down Expand Up @@ -736,6 +737,7 @@ type CdnWarmDeployOptions = Pick<
| "expectedRscBuildId"
| "loadingShellPaths"
| "pagesDataPaths"
| "routeHandlerPaths"
| "rscPaths"
> & {
/** Probe a staged Worker and upload the resulting manifest as a second version. */
Expand Down Expand Up @@ -822,32 +824,41 @@ async function deployUploadedVersionWithCdnWarmup(
loadingShellPaths: [...(options.loadingShellPaths ?? [])],
pagesDataPaths: [...(options.pagesDataPaths ?? [])],
paths: [...paths],
routeHandlerPaths: [...(options.routeHandlerPaths ?? [])],
rscPaths: [...(options.rscPaths ?? [])],
};
let discoveredWarmRequests =
remainingWarmPlan.paths.length +
remainingWarmPlan.pagesDataPaths.length +
(remainingWarmPlan.routeHandlerPaths?.length ?? 0) +
remainingWarmPlan.rscPaths.length +
remainingWarmPlan.loadingShellPaths.length;

const prepareWarmPlan = (plan: CdnWarmRequestPlan): CdnWarmRequestPlan => {
if (
(plan.paths.length === 0 && plan.pagesDataPaths.length === 0) ||
(plan.paths.length === 0 &&
plan.pagesDataPaths.length === 0 &&
(plan.routeHandlerPaths?.length ?? 0) === 0) ||
expectedBuildId !== undefined
) {
return plan;
}
if (!allowUnverifiedPromotion) {
const warmupKind = plan.paths.length > 0 ? "CDN HTML warmup" : "CDN Pages data warmup";
const warmupKind =
plan.paths.length > 0
? "CDN HTML warmup"
: plan.pagesDataPaths.length > 0
? "CDN Pages data warmup"
: "CDN Route Handler warmup";
throw new Error(
`${warmupKind} requires a CDN adapter that declares build-identity response headers. ` +
"Configure that adapter capability or deploy without --experimental-warm-cdn-cache.",
);
}
console.warn(
` CDN warmup: skipping ${plan.paths.length} HTML and ${plan.pagesDataPaths.length} Pages data request(s) because the CDN adapter does not declare build-identity response headers.`,
` CDN warmup: skipping ${plan.paths.length} HTML, ${plan.pagesDataPaths.length} Pages data, and ${plan.routeHandlerPaths?.length ?? 0} Route Handler request(s) because the CDN adapter does not declare build-identity response headers.`,
);
return { ...plan, pagesDataPaths: [], paths: [] };
return { ...plan, pagesDataPaths: [], paths: [], routeHandlerPaths: [] };
};

const discoverWarmPlan = async (targetUrl: string, headers?: HeadersInit): Promise<void> => {
Expand All @@ -860,11 +871,13 @@ async function deployUploadedVersionWithCdnWarmup(
loadingShellPaths: [...plan.loadingShellPaths],
pagesDataPaths: [...(plan.pagesDataPaths ?? [])],
paths: [...plan.paths],
routeHandlerPaths: [...(plan.routeHandlerPaths ?? [])],
rscPaths: [...plan.rscPaths],
};
discoveredWarmRequests =
remainingWarmPlan.paths.length +
remainingWarmPlan.pagesDataPaths.length +
(remainingWarmPlan.routeHandlerPaths?.length ?? 0) +
remainingWarmPlan.rscPaths.length +
remainingWarmPlan.loadingShellPaths.length;
warmPlanDiscovered = true;
Expand All @@ -883,6 +896,7 @@ async function deployUploadedVersionWithCdnWarmup(
loadingShellPaths: remainingWarmPlan.loadingShellPaths,
pagesDataPaths: remainingWarmPlan.pagesDataPaths,
paths: remainingWarmPlan.paths,
routeHandlerPaths: remainingWarmPlan.routeHandlerPaths,
rscPaths: remainingWarmPlan.rscPaths,
},
requireCacheHit = false,
Expand All @@ -897,6 +911,7 @@ async function deployUploadedVersionWithCdnWarmup(
expectedRscBuildId,
loadingShellPaths: plan.loadingShellPaths,
pagesDataPaths: plan.pagesDataPaths,
routeHandlerPaths: plan.routeHandlerPaths,
rscPaths: plan.rscPaths,
concurrency: options.warmCdnConcurrency,
phaseTimeoutMs: hasPreparedWarmPlan ? DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS : undefined,
Expand Down Expand Up @@ -930,6 +945,7 @@ async function deployUploadedVersionWithCdnWarmup(
options.discoverWarmPlan === undefined || hasPreparedWarmPlan
? remainingWarmPlan.paths.length +
remainingWarmPlan.pagesDataPaths.length +
(remainingWarmPlan.routeHandlerPaths?.length ?? 0) +
remainingWarmPlan.rscPaths.length +
remainingWarmPlan.loadingShellPaths.length
: 1;
Expand Down Expand Up @@ -981,18 +997,20 @@ async function deployUploadedVersionWithCdnWarmup(
await discoverWarmPlan(targetUrl, headers);
remainingWarmPlan = prepareWarmPlan(remainingWarmPlan);
console.log(
` CDN warmup: discovered ${remainingWarmPlan.paths.length} HTML, ${remainingWarmPlan.pagesDataPaths.length} Pages data, ${remainingWarmPlan.rscPaths.length} RSC, and ${remainingWarmPlan.loadingShellPaths.length} loading-shell request(s).`,
` CDN warmup: discovered ${remainingWarmPlan.paths.length} HTML, ${remainingWarmPlan.pagesDataPaths.length} Pages data, ${remainingWarmPlan.routeHandlerPaths?.length ?? 0} Route Handler, ${remainingWarmPlan.rscPaths.length} RSC, and ${remainingWarmPlan.loadingShellPaths.length} loading-shell request(s).`,
);
}
const stagedWarmPlan: CdnWarmRequestPlan = {
loadingShellPaths: remainingWarmPlan.loadingShellPaths,
pagesDataPaths: remainingWarmPlan.pagesDataPaths,
paths: remainingWarmPlan.paths,
routeHandlerPaths: remainingWarmPlan.routeHandlerPaths,
rscPaths: remainingWarmPlan.rscPaths,
};
const stagedWarmRequests =
stagedWarmPlan.paths.length +
stagedWarmPlan.pagesDataPaths.length +
(stagedWarmPlan.routeHandlerPaths?.length ?? 0) +
stagedWarmPlan.rscPaths.length +
stagedWarmPlan.loadingShellPaths.length;
if (stagedWarmRequests > 0) {
Expand Down Expand Up @@ -1045,6 +1063,7 @@ async function deployUploadedVersionWithCdnWarmup(
loadingShellPaths: warmResult.retryPlan.loadingShellPaths,
pagesDataPaths: warmResult.retryPlan.pagesDataPaths,
paths: warmResult.retryPlan.paths,
routeHandlerPaths: warmResult.retryPlan.routeHandlerPaths,
rscPaths: warmResult.retryPlan.rscPaths,
};
if (hasPreparedWarmPlan && options.warmCdnCertify && warmResult.warmed > 0) {
Expand Down Expand Up @@ -1098,6 +1117,7 @@ async function deployUploadedVersionWithCdnWarmup(
const countRemainingWarmRequests = (): number =>
remainingWarmPlan.paths.length +
remainingWarmPlan.pagesDataPaths.length +
(remainingWarmPlan.routeHandlerPaths?.length ?? 0) +
remainingWarmPlan.rscPaths.length +
remainingWarmPlan.loadingShellPaths.length;

Expand Down Expand Up @@ -1415,6 +1435,7 @@ async function deployWithCacheabilityProbe(
pagesDataPaths: [...(discovered.pagesDataPaths ?? [])],
pagesPaths: discovered.pagesPaths ? [...discovered.pagesPaths] : undefined,
paths: [...discovered.paths],
routeHandlerPaths: [...(discovered.routeHandlerPaths ?? [])],
rscPaths: [...discovered.rscPaths],
};
if (!plan.appPaths && !plan.pagesPaths) {
Expand All @@ -1430,6 +1451,7 @@ async function deployWithCacheabilityProbe(
loadingShellPaths: plan.loadingShellPaths,
pagesDataPaths: plan.pagesDataPaths,
paths: plan.paths,
routeHandlerPaths: plan.routeHandlerPaths,
rscPaths: plan.rscPaths,
});
if (targets.length > 0) {
Expand Down Expand Up @@ -1490,6 +1512,9 @@ async function deployWithCacheabilityProbe(
paths: probe.cacheableTargets
.filter((target) => target.kind === "html")
.map((target) => target.sourcePathname),
routeHandlerPaths: probe.cacheableTargets
.filter((target) => target.kind === "app-route")
.map((target) => target.sourcePathname),
rscPaths: probe.cacheableTargets
.filter((target) => target.kind === "rsc-full")
.map((target) => target.sourcePathname),
Expand Down Expand Up @@ -1533,6 +1558,7 @@ async function deployWithCacheabilityProbe(
expectedDeploymentState: stagedProbeDeployment,
loadingShellPaths: prepared.plan.loadingShellPaths,
pagesDataPaths: prepared.plan.pagesDataPaths,
routeHandlerPaths: prepared.plan.routeHandlerPaths,
rscPaths: prepared.plan.rscPaths,
uploadedVersion: prepared.upload,
});
Expand Down
Loading
Loading