Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/cloudflare/src/cache/cdn-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function cdnAdapter(options?: Record<string, never>) {
adapter: fileURLToPath(import.meta.resolve("./cdn-adapter.runtime.js")),
options,
capabilities: {
buildIdentity: "response-header" as const,
responseVary: "verbatim" as const,
},
};
Expand Down
79 changes: 43 additions & 36 deletions packages/cloudflare/src/cdn-warm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export const DEFAULT_CDN_WARM_TIMEOUT_MS = 10_000;
export type PrerenderCdnWarmOptions = Omit<CdnWarmOptions, "paths"> & {
root: string;
includeFallbackShells?: boolean;
/** Use the manifest build ID unless the configured adapter cannot expose it. */
validateBuildIdentity?: boolean;
};

export type CdnWarmResult = {
Expand Down Expand Up @@ -332,10 +334,8 @@ const CDN_CACHE_POLICY_HEADERS = [
"CDN-Cache-Control",
"Cache-Control",
] as const;
const SHARED_CACHE_FRESHNESS_RES = [
/(?:^|,)\s*s-maxage\s*=\s*"?(-?\d+)"?/i,
/(?:^|,)\s*max-age\s*=\s*"?(-?\d+)"?/i,
] as const;
const FIELD_QUALIFIED_SET_COOKIE_RE =
/(?:^|,)\s*(?:private|no-cache)\s*=\s*(?:"[^"]*\bset-cookie\b[^"]*"|[^,]*\bset-cookie\b)/i;

type WarmValidation =
| { outcome: "warmed" }
Expand All @@ -357,28 +357,41 @@ function validateCachePolicy(response: Response, requireCacheStatus: boolean): W
const hasSetCookie = response.headers.has("Set-Cookie");
const cacheStatus = response.headers.get("CF-Cache-Status")?.trim().toUpperCase();

if (nonCacheableHeaders.length > 0 || hasSetCookie) {
// Cloudflare-CDN-Cache-Control is consumed at the edge and is deliberately
// not forwarded to clients. A cacheable Cloudflare-specific policy can
// therefore coexist with a downstream `no-store` policy or a Set-Cookie
// header that is stripped from the cached object. CF-Cache-Status is the
// authoritative admission result: MISS means the response was eligible for
// cache, while response-time rejection is reported as BYPASS. Downstream
// freshness cannot reveal a stripped higher-priority edge policy.
if (cacheStatus && ADMITTED_CF_CACHE_STATUSES.has(cacheStatus)) {
if (
hasSetCookie &&
(!effectivePolicy || !FIELD_QUALIFIED_SET_COOKIE_RE.test(effectivePolicy.value))
) {
return {
outcome: "failed",
error: "response sets a cookie without an observable field-qualified cache policy",
};
}
return { outcome: "warmed" };
}

if (cacheStatus && NON_CACHEABLE_CF_CACHE_STATUSES.has(cacheStatus)) {
const reason =
nonCacheableHeaders.length > 0
? `${nonCacheableHeaders.join(", ")} opts out of caching`
: "response sets a cookie";
if (cacheStatus && NON_CACHEABLE_CF_CACHE_STATUSES.has(cacheStatus)) {
return { outcome: "skipped", reason };
}
return {
outcome: "failed",
error: `${reason}, but CF-Cache-Status is ${cacheStatus ?? "missing"}`,
};
: hasSetCookie
? "response sets a cookie"
: `CF-Cache-Status is ${cacheStatus}`;
return { outcome: "skipped", reason };
}

const sharedFreshness = effectivePolicy
? getSharedCacheFreshnessSeconds(effectivePolicy.value)
: null;
if (sharedFreshness !== null && sharedFreshness <= 0) {
const reason = `${effectivePolicy!.name} has no positive shared-cache freshness`;
if (cacheStatus && NON_CACHEABLE_CF_CACHE_STATUSES.has(cacheStatus)) {
return { outcome: "skipped", reason };
}
if (nonCacheableHeaders.length > 0 || hasSetCookie) {
const reason =
nonCacheableHeaders.length > 0
? `${nonCacheableHeaders.join(", ")} opts out of caching`
: "response sets a cookie";
return {
outcome: "failed",
error: `${reason}, but CF-Cache-Status is ${cacheStatus ?? "missing"}`,
Expand All @@ -390,18 +403,7 @@ function validateCachePolicy(response: Response, requireCacheStatus: boolean): W
? { outcome: "failed", error: "response is missing CF-Cache-Status" }
: { outcome: "warmed" };
}
if (!ADMITTED_CF_CACHE_STATUSES.has(cacheStatus)) {
return { outcome: "failed", error: `CF-Cache-Status is ${cacheStatus}` };
}
return { outcome: "warmed" };
}

function getSharedCacheFreshnessSeconds(cacheControl: string): number | null {
for (const pattern of SHARED_CACHE_FRESHNESS_RES) {
const match = pattern.exec(cacheControl);
if (match) return Number(match[1]);
}
return null;
return { outcome: "failed", error: `CF-Cache-Status is ${cacheStatus}` };
}

function validateBuildIdentity(
Expand Down Expand Up @@ -445,6 +447,8 @@ function validateRscWarmResponse(
error: `response ${VINEXT_RSC_BUILD_ID_HEADER} does not match build ${expectedRscBuildId}`,
};
}
const cachePolicyValidation = validateCachePolicy(response, true);
if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation;
const vary = new Set(
(response.headers.get("Vary") ?? "")
.split(",")
Expand All @@ -459,7 +463,7 @@ function validateRscWarmResponse(
if (extraVary) {
return { outcome: "failed", error: `response Vary has unsupported field ${extraVary}` };
}
return validateCachePolicy(response, true);
return { outcome: "warmed" };
}

function validateHtmlWarmResponse(response: Response, expectedBuildId?: string): WarmValidation {
Expand All @@ -471,7 +475,9 @@ function validateHtmlWarmResponse(response: Response, expectedBuildId?: string):
}
const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
if (buildIdentityValidation) return buildIdentityValidation;
return validateCachePolicy(response, true);
const cachePolicyValidation = validateCachePolicy(response, true);
if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation;
return { outcome: "warmed" };
}

async function warmOnePath(
Expand Down Expand Up @@ -814,6 +820,7 @@ export async function warmCdnCacheFromPrerender(
return warmCdnCache({
...options,
...warmPlan,
expectedBuildId: options.expectedBuildId ?? buildId,
expectedBuildId:
options.expectedBuildId ?? (options.validateBuildIdentity === false ? undefined : buildId),
});
}
3 changes: 2 additions & 1 deletion packages/cloudflare/src/deploy-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ export function formatDeployHelp(): string {
--warm-cdn-concurrency <count>
Maximum number of CDN warmup requests in parallel (default: 25)
--warm-cdn-timeout <ms> Per-request CDN warmup timeout (default: 10000)
--warm-cdn-retries <n> Retries for transient CDN warmup failures (default: 1)
--warm-cdn-retries <n> Retries per failed CDN warmup request (default: 1;
staged-version propagation default: 60)
--warm-cdn-strict Fail deploy when any CDN warmup request fails
--warm-cdn-no-promote Leave the warmed Worker version staged at 0% traffic
--warm-cdn-promotion-delay <ms>
Expand Down
48 changes: 44 additions & 4 deletions packages/cloudflare/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
findVinextPrerenderConfigInPlugins,
findVinextRouteRootConfigInPlugins,
formatVinextPrerenderLabel,
hasBuildIdentityResponseHeader,
hasVerbatimResponseVary,
resolveVinextPrerenderDecision,
type ResolvedVinextPrerenderConfig,
Expand Down Expand Up @@ -580,6 +581,12 @@ export async function deployWithCdnWarmup(
"deploymentId" | "expectedBuildId" | "expectedRscBuildId" | "loadingShellPaths" | "rscPaths"
>,
): Promise<string> {
if (options.warmCdnStrict && paths.length > 0 && options.expectedBuildId === undefined) {
throw new Error(
"Strict CDN HTML warmup requires a CDN adapter that declares build-identity response headers. " +
"Configure that adapter capability or rerun without --warm-cdn-strict.",
);
}
const upload = runWranglerVersionUpload(root, options);
const warmUploadedVersion = (
targetUrl: string,
Expand Down Expand Up @@ -610,6 +617,7 @@ export async function deployWithCdnWarmup(
const wranglerConfig = parseWranglerConfig(root, options.config);
const deploymentStatus = readWranglerDeploymentStatus(root, options);
const stagingTraffic = getZeroPercentStagingTraffic(deploymentStatus, upload.versionId);
const canVerifyStagedHtml = options.expectedBuildId !== undefined;
let staged: ReturnType<typeof runWranglerVersionDeploy> | null = null;
let triggersDeployedUrl: string | null = null;
let stagedCacheFilled = false;
Expand Down Expand Up @@ -642,9 +650,29 @@ export async function deployWithCdnWarmup(
const headers = buildVersionOverrideHeaders(workerName, upload.versionId);
if (targetUrl && headers) {
try {
const warmResult = await warmUploadedVersion(targetUrl, headers, true);
stagedCacheFilled = warmResult.warmed > 0;
remainingWarmPlan = warmResult.retryPlan;
if (!canVerifyStagedHtml && remainingWarmPlan.paths.length > 0) {
console.log(
` CDN warmup: deferring ${remainingWarmPlan.paths.length} HTML request(s) until after promotion because the CDN adapter does not declare build-identity response headers.`,
);
}
const stagedWarmPlan: CdnWarmRequestPlan = {
loadingShellPaths: remainingWarmPlan.loadingShellPaths,
paths: canVerifyStagedHtml ? remainingWarmPlan.paths : [],
rscPaths: remainingWarmPlan.rscPaths,
};
const stagedWarmRequests =
stagedWarmPlan.paths.length +
stagedWarmPlan.rscPaths.length +
stagedWarmPlan.loadingShellPaths.length;
if (stagedWarmRequests > 0) {
const warmResult = await warmUploadedVersion(targetUrl, headers, true, stagedWarmPlan);
stagedCacheFilled = warmResult.warmed > 0;
remainingWarmPlan = {
loadingShellPaths: warmResult.retryPlan.loadingShellPaths,
paths: canVerifyStagedHtml ? warmResult.retryPlan.paths : remainingWarmPlan.paths,
rscPaths: warmResult.retryPlan.rscPaths,
};
}
} catch (error) {
throw withStagedVersionCleanupNote(error);
}
Expand All @@ -668,6 +696,12 @@ export async function deployWithCdnWarmup(
"The current deployment must have exactly one version serving 100% traffic.",
);
}
if (!canVerifyStagedHtml && remainingWarmPlan.paths.length > 0) {
const message =
"CDN warmup cannot verify HTML responses before promotion because the configured CDN adapter " +
"does not declare build-identity response headers.";
console.warn(` ${message} HTML warmup was deferred and promotion is disabled.`);
}
console.log(
" CDN warmup: promotion disabled; uploaded Worker version remains staged at 0% traffic.",
);
Expand Down Expand Up @@ -704,6 +738,11 @@ export async function deployWithCdnWarmup(
remainingWarmPlan.rscPaths.length +
remainingWarmPlan.loadingShellPaths.length;
if (remainingWarmRequests > 0) {
if (!canVerifyStagedHtml && remainingWarmPlan.paths.length > 0) {
console.warn(
" CDN warmup: post-promotion HTML warming is best-effort because the CDN adapter does not declare build-identity response headers.",
);
}
try {
applyTriggers();
} catch (error) {
Expand Down Expand Up @@ -964,6 +1003,7 @@ export async function deploy(options: DeployOptions): Promise<void> {
nextOutput: nextConfig.output,
});
const hasStrictResponseVary = hasVerbatimResponseVary(viteConfigMetadata.cacheConfig);
const hasBuildIdentityHeader = hasBuildIdentityResponseHeader(viteConfigMetadata.cacheConfig);
const shouldEmitPrerenderPathManifest =
options.warmCdnCache || (!options.skipBuild && prerenderDecision);

Expand Down Expand Up @@ -1049,7 +1089,7 @@ export async function deploy(options: DeployOptions): Promise<void> {
url = await deployWithCdnWarmup(root, warmPlan.paths, {
...wranglerOptions,
deploymentId: warmPlan.deploymentId,
expectedBuildId: warmPlan.buildId,
expectedBuildId: hasBuildIdentityHeader ? warmPlan.buildId : undefined,
expectedRscBuildId: warmPlan.rscBuildId,
loadingShellPaths: warmPlan.loadingShellPaths,
rscPaths: warmPlan.rscPaths,
Expand Down
19 changes: 15 additions & 4 deletions packages/vinext/src/build/prerender-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { VinextRouteRootConfig } from "../config/prerender.js";
import { enterPrerenderPhase } from "./prerender-phase.js";
import type { CdnCacheAdapterCapabilities } from "../cache/cache-adapters-virtual.js";
import { pagesRouteHasPriorityOverAppRoute } from "../server/hybrid-route-priority.js";
import { extractLocaleFromUrl } from "../server/pages-i18n.js";

export type PrerenderPathManifest = {
basePath?: string;
Expand Down Expand Up @@ -383,6 +384,7 @@ async function collectAppPaths(options: {

async function resolveAppRscWarmPaths(options: {
appDir: string;
i18n: ResolvedNextConfig["i18n"];
pagesDir: string | null;
pageExtensions: readonly string[];
paths: readonly string[];
Expand All @@ -408,10 +410,18 @@ async function resolveAppRscWarmPaths(options: {
const appRenderEntryPath = getAppRouteRenderEntryPath(matchedAppRoute);
if (!appRenderEntryPath) continue;

const pagesMatch = matchRoute(
pathname,
pathname === "/api" || pathname.startsWith("/api/") ? apiRoutes : pageRoutes,
);
// Pages Router i18n prefixes are routing metadata rather than part of the
// filesystem route. Production strips them before matching Pages/API
// routes, while the App Router still matches the original pathname.
const pagesPathname = options.i18n
? extractLocaleFromUrl(pathname, options.i18n).url
: pathname;
// The App-to-Pages production bridge selects the API handler from the raw
// request pathname before the Pages matcher strips i18n metadata. A
// locale-prefixed `/fr/api/*` path therefore remains a page candidate,
// rather than becoming a Pages API request after normalization.
const isPagesApiRequest = pathname === "/api" || pathname.startsWith("/api/");
const pagesMatch = matchRoute(pagesPathname, isPagesApiRequest ? apiRoutes : pageRoutes);
if (pagesMatch && pagesRouteHasPriorityOverAppRoute(pagesMatch.route, matchedAppRoute)) {
continue;
}
Expand Down Expand Up @@ -546,6 +556,7 @@ export async function emitPrerenderPathManifest(
options.responseVary && appDir
? await resolveAppRscWarmPaths({
appDir,
i18n: config.i18n,
pagesDir,
pageExtensions: config.pageExtensions,
paths: discoveredAppPaths,
Expand Down
13 changes: 13 additions & 0 deletions packages/vinext/src/cache/cache-adapters-virtual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ import { flattenPluginOptions } from "../utils/plugin-options.js";
* registration module and forwarded to the adapter factory at runtime.
*/
export type CdnCacheAdapterCapabilities = {
/**
* Page responses include the current application build identity in the
* framework-owned `X-Vinext-Build-Id` response header, including responses
* that are ultimately marked non-cacheable.
*
* Deploy adapters use this guarantee to distinguish the newly uploaded
* Worker from an older version while staged traffic is propagating.
*/
buildIdentity?: "response-header";
/**
* The shared cache selects response variants using every request header
* named by `Vary`, comparing the header values verbatim.
Expand All @@ -50,6 +59,10 @@ export function hasVerbatimResponseVary(cache?: VinextCacheConfig | null): boole
return cache?.cdn?.capabilities?.responseVary === "verbatim";
}

export function hasBuildIdentityResponseHeader(cache?: VinextCacheConfig | null): boolean {
return cache?.cdn?.capabilities?.buildIdentity === "response-header";
}

/**
* The `cache` option of the vinext() plugin: declaratively register cache
* handlers instead of calling `setDataCacheHandler()` / `setCdnCacheAdapter()`
Expand Down
1 change: 1 addition & 0 deletions packages/vinext/src/config/prerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { flattenPluginOptions } from "../utils/plugin-options.js";
import { isUnknownRecord } from "../utils/record.js";
export {
findVinextCacheConfigInPlugins,
hasBuildIdentityResponseHeader,
hasVerbatimResponseVary,
loadVinextCacheConfigFromViteConfig,
VINEXT_CACHE_CONFIG_PLUGIN_PROPERTY,
Expand Down
8 changes: 7 additions & 1 deletion tests/cache-adapters-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
findVinextCacheConfigInPlugins,
loadVinextCacheConfigFromViteConfig,
generateCacheAdaptersModule,
hasBuildIdentityResponseHeader,
hasVerbatimResponseVary,
VINEXT_CACHE_CONFIG_PLUGIN_PROPERTY,
VIRTUAL_CACHE_ADAPTERS,
Expand Down Expand Up @@ -277,8 +278,13 @@ describe("cdnAdapter builder + factory", () => {
expect(path.isAbsolute(descriptor.adapter)).toBe(true);
expect(descriptor.adapter.endsWith("cdn-adapter.runtime.js")).toBe(true);
expect(descriptor.options).toBeUndefined();
expect(descriptor.capabilities).toEqual({ responseVary: "verbatim" });
expect(descriptor.capabilities).toEqual({
buildIdentity: "response-header",
responseVary: "verbatim",
});
expect(hasBuildIdentityResponseHeader({ cdn: descriptor })).toBe(true);
expect(hasVerbatimResponseVary({ cdn: descriptor })).toBe(true);
expect(hasBuildIdentityResponseHeader({ cdn: { adapter: "custom-cache" } })).toBe(false);
expect(hasVerbatimResponseVary({ cdn: { adapter: "url-only-cache" } })).toBe(false);
});

Expand Down
Loading
Loading