From 094c4a900be3a705d0a6ae93da84fed700aad083 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:01:15 +1000 Subject: [PATCH 01/22] fix(cloudflare): verify CDN warmup hit the uploaded Worker version before promoting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workers Cache is keyed per Worker version by default. `--experimental-warm-cdn-cache` issued warmup requests against production before confirming which version answered them, so a request routed to the still-live previous version silently warmed the wrong cache partition. The deploy then promoted the new version anyway, reporting success while the cache stayed cold for real traffic. The fix enforces one invariant: a warmup only counts as successful when the response proves it came from the uploaded Worker version. - `vinext init` provisions a `version_metadata` binding under a fixed name (VINEXT_VERSION_METADATA); the Worker entry reads the current version ID from it and stamps every response with `x-vinext-worker-version`. - Deploy stages the uploaded version at 0% traffic, warms build-discovered paths through a `Cloudflare-Workers-Version-Overrides` header, and rejects any response whose version header doesn't match the uploaded version — without following redirects, so the canonical cache key is what gets verified. - Promotion only happens after warmup completes; in strict mode a failed or unverified warmup aborts before promotion, leaving the previous version at 100% and the new one staged at 0% — already the safe state, so no compensating mutation is issued. In non-strict mode the deploy promotes anyway but does not claim the cache was warmed. - Static exports (`output: "export"`) skip verified warmup entirely: Cloudflare Assets serves them without invoking the Worker, so there's no version header to verify against. Scope cut from the original draft: dropped the generic version-traffic reconciliation module (ambiguous-failure classification, auto-rollback, manual recovery command rendering), the configurable binding-name companion variable in favor of the fixed binding name the runtime always reads, and cross_version_cache config parsing/validation. None of those are required to enforce the core invariant, and each is a separable concern better reviewed on its own. Tests: tests/worker-version.test.ts, tests/cloudflare-cdn-warm.test.ts, tests/cloudflare-cdn-warm-deploy.test.ts, tests/deploy.test.ts, tests/init.test.ts, tests/deploy-prerender-config.test.ts. --- .../cloudflare/src/cdn-warm-deployment.ts | 255 +++++++++ packages/cloudflare/src/cdn-warm.ts | 38 +- packages/cloudflare/src/deploy-help.ts | 10 +- packages/cloudflare/src/deploy.ts | 255 +-------- packages/cloudflare/src/tpr.ts | 271 ++++++--- packages/cloudflare/src/utils/toml.ts | 149 +++++ packages/vinext/package.json | 4 + packages/vinext/src/init-cloudflare.ts | 58 ++ .../vinext/src/server/app-router-entry.ts | 3 +- .../vinext/src/server/pages-router-entry.ts | 3 +- packages/vinext/src/server/worker-version.ts | 52 ++ tests/cloudflare-cdn-warm-deploy.test.ts | 536 ++++++++++-------- tests/cloudflare-cdn-warm.test.ts | 144 ++++- tests/deploy-prerender-config.test.ts | 35 ++ tests/deploy.test.ts | 164 ++++-- tests/init.test.ts | 72 +++ tests/worker-version.test.ts | 78 +++ 17 files changed, 1506 insertions(+), 621 deletions(-) create mode 100644 packages/cloudflare/src/cdn-warm-deployment.ts create mode 100644 packages/cloudflare/src/utils/toml.ts create mode 100644 packages/vinext/src/server/worker-version.ts create mode 100644 tests/worker-version.test.ts diff --git a/packages/cloudflare/src/cdn-warm-deployment.ts b/packages/cloudflare/src/cdn-warm-deployment.ts new file mode 100644 index 0000000000..6c4ac8978c --- /dev/null +++ b/packages/cloudflare/src/cdn-warm-deployment.ts @@ -0,0 +1,255 @@ +/** + * Staged CDN-warmup deployment transaction. + * + * Warms Cloudflare's version-isolated CDN cache before a new Worker version + * takes production traffic: + * + * validate → upload → inspect deployment → stage new version at 0% → + * warm through a version override → verify the producing version → + * promote → apply triggers + * + * If warming fails, the new version stays staged at 0% and the previous + * version stays at 100% — that split is already the safe state, so failure + * just reports it rather than issuing another remote mutation to undo it. A + * promotion whose CLI process fails ambiguously is reported the same way: + * the operator is told to check `wrangler deployments status`, not silently + * reconciled. It lives apart from `deploy.ts` so the CLI module stays a thin + * caller and the sequencing can be tested directly. + */ + +import { VINEXT_VERSION_METADATA_BINDING } from "vinext/internal/server/worker-version"; +import { warmCdnCache } from "./cdn-warm.js"; +import { formatUnknownError, type DeployOptions } from "./deploy.js"; +import { parseWranglerConfig } from "./tpr.js"; +import { + runWranglerDeploymentStatus, + runWranglerTriggersDeploy, + runWranglerVersionDeploy, + runWranglerVersionUpload, + type WranglerDeploymentStatus, + type WranglerVersionDeployResult, + type WranglerVersionTraffic, +} from "./version-deploy.js"; + +export async function deployWithCdnWarmup( + root: string, + paths: readonly string[], + options: Pick< + DeployOptions, + | "preview" + | "env" + | "name" + | "config" + | "warmCdnConcurrency" + | "warmCdnTimeout" + | "warmCdnRetries" + | "warmCdnStrict" + >, +): Promise { + const wranglerConfig = validateCdnWarmupConfiguration(root, options); + const upload = runWranglerVersionUpload(root, options); + const deployment = readWranglerDeploymentStatus(root, options); + const currentVersions = deployment?.versions ?? []; + const stagingTraffic = getZeroPercentStagingTraffic(currentVersions, upload.versionId); + + if (!stagingTraffic) { + const message = + "CDN pre-warm requires the current deployment to contain exactly one version at 100%."; + if (options.warmCdnStrict) throw new Error(message); + console.warn(` ${message} Promoting without pre-warming.`); + const deployed = runWranglerVersionDeploy( + root, + [{ versionId: upload.versionId, percentage: 100 }], + options, + "promote-uploaded", + ); + const triggers = runWranglerTriggersDeploy(root, options); + return ( + deployed.deployedUrl ?? + triggers.deployedUrl ?? + upload.previewUrl ?? + "(URL not detected in wrangler output)" + ); + } + + // Workers Cache includes the invoked Worker version in its key unless + // cross_version_cache is enabled. Staging lets overrides warm the uploaded + // version's partition while a failed override can only touch the old one. + const staged = runWranglerVersionDeploy(root, stagingTraffic, options, "stage"); + const previousVersionId = currentVersions[0].versionId; + + // Triggers (routes/schedules/custom domains) are applied AFTER promotion, not + // before warming. `wrangler triggers deploy` PUTs the script's routes and can + // detach the current production hostname; running it inside the warm window + // means a warm/promote failure leaves production routing pointed at a version + // that never got promoted. Warming instead targets the already-attached + // production host via the version-override header, so the risky window only + // ever leaves the new version staged at 0% — already the safe state. + let warmed = false; + try { + const targetUrl = resolveCdnWarmupTargetUrl(root, staged.deployedUrl, options); + const workerName = resolveCdnWarmupWorkerName(wranglerConfig, options); + const headers = buildVersionOverrideHeaders(workerName, upload.versionId); + if (!targetUrl || !headers) { + const message = + "CDN pre-warm requires a production URL and Worker name for version overrides."; + if (options.warmCdnStrict) throw new Error(message); + console.warn(` ${message} Promoting without pre-warming.`); + } else { + const result = await warmCdnCache({ + targetUrl, + paths, + headers, + expectedVersionId: upload.versionId, + concurrency: options.warmCdnConcurrency, + timeoutMs: options.warmCdnTimeout, + retries: options.warmCdnRetries, + strict: options.warmCdnStrict, + }); + warmed = result.failed === 0; + } + } catch (error) { + throw new Error( + `${formatUnknownError(error)}\n\n` + + `CDN warmup failed. The previous Worker version (${previousVersionId}) remains at 100% ` + + `traffic; the uploaded version (${upload.versionId}) is staged at 0% and was not promoted.`, + ); + } + + let deployed: WranglerVersionDeployResult; + try { + deployed = runWranglerVersionDeploy( + root, + [{ versionId: upload.versionId, percentage: 100 }], + options, + warmed ? "promote-warmed" : "promote-uploaded", + ); + } catch (error) { + throw new Error( + `${formatUnknownError(error)}\n\n` + + `Could not confirm the promotion of Worker version ${upload.versionId} succeeded. ` + + "Run `wrangler deployments status` to check the current traffic split, then " + + "re-promote or retry as needed.", + ); + } + const triggers = applyTriggersAfterPromotion(root, options); + return ( + deployed.deployedUrl ?? + staged.deployedUrl ?? + triggers.deployedUrl ?? + upload.previewUrl ?? + "(URL not detected in wrangler output)" + ); +} + +/** + * Apply triggers after a successful promotion. A failure here is far less severe + * than the pre-promotion case: the new version is already live on the previously + * deployed routes, only the route/schedule changes did not apply. Surface that + * plainly so the operator re-runs triggers instead of assuming the whole deploy + * failed and re-uploading. + */ +function applyTriggersAfterPromotion( + root: string, + options: Pick, +): WranglerVersionDeployResult { + try { + return runWranglerTriggersDeploy(root, options); + } catch (error) { + throw new Error( + `${formatUnknownError(error)}\n\n` + + "The uploaded Worker version was promoted to 100%, but applying triggers " + + "(routes/schedules) failed. Production serves the new version on the previously " + + "deployed routes. Re-run `wrangler triggers deploy` to apply the route changes.", + ); + } +} + +function validateCdnWarmupConfiguration( + root: string, + options: Pick, +): NonNullable> { + const config = parseWranglerConfig(root, options.config); + const envName = getWranglerTargetEnv(options); + const selected = envName ? config?.env?.[envName] : config; + const targetLabel = envName + ? `Wrangler environment "${envName}"` + : "the top-level Wrangler config"; + if (!config || selected?.versionMetadataBinding !== VINEXT_VERSION_METADATA_BINDING) { + throw new Error( + `CDN warmup requires a version_metadata binding named "${VINEXT_VERSION_METADATA_BINDING}" in ${targetLabel}. ` + + "Re-run vinext init with CDN warmup enabled or configure the binding before deploying.", + ); + } + return config; +} + +function readWranglerDeploymentStatus( + root: string, + options: Pick, +): WranglerDeploymentStatus | null { + try { + return runWranglerDeploymentStatus(root, options); + } catch (error) { + console.warn( + ` CDN pre-warm could not read the current deployment: ${formatUnknownError(error)}`, + ); + return null; + } +} + +function getZeroPercentStagingTraffic( + current: readonly WranglerVersionTraffic[], + versionId: string, +): WranglerVersionTraffic[] | null { + if (current.length !== 1 || current[0].percentage !== 100) return null; + return [current[0], { versionId, percentage: 0 }]; +} + +function quoteStructuredHeaderString(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function buildVersionOverrideHeaders( + workerName: string | undefined, + versionId: string, +): HeadersInit | undefined { + if (!workerName) return undefined; + return { + "Cloudflare-Workers-Version-Overrides": `${workerName}=${quoteStructuredHeaderString(versionId)}`, + }; +} + +function resolveCdnWarmupWorkerName( + config: NonNullable>, + options: Pick, +): string | undefined { + if (options.name) return options.name; + const envName = getWranglerTargetEnv(options); + if (!envName) return config.name; + const explicitEnvName = config.env?.[envName]?.name; + if (explicitEnvName) return explicitEnvName; + if (!config.name) return undefined; + return config.legacyEnv === false ? config.name : `${config.name}-${envName}`; +} + +export function resolveCdnWarmupTargetUrl(root: string, deployedUrl: string | null): string | null; +export function resolveCdnWarmupTargetUrl( + root: string, + deployedUrl: string | null, + options: Pick, +): string | null; +export function resolveCdnWarmupTargetUrl( + root: string, + deployedUrl: string | null, + options?: Pick, +): string | null { + const config = parseWranglerConfig(root, options?.config); + const env = getWranglerTargetEnv(options ?? {}); + const warmupHost = env ? config?.env?.[env]?.warmupHost : config?.warmupHost; + return warmupHost ? `https://${warmupHost}` : deployedUrl; +} + +function getWranglerTargetEnv(options: Pick): string | undefined { + return options.env || (options.preview ? "preview" : undefined); +} diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index b8f52cc186..53eaa9acb4 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -10,6 +10,7 @@ import { type PrerenderManifest, type PrerenderedPathSelectionOptions, } from "vinext/internal/server/prerender-manifest"; +import { VINEXT_WORKER_VERSION_HEADER } from "vinext/internal/server/worker-version"; export type CdnWarmOptions = { targetUrl: string; @@ -18,7 +19,9 @@ export type CdnWarmOptions = { concurrency?: number; timeoutMs?: number; retries?: number; + retryDelayMs?: number; strict?: boolean; + expectedVersionId?: string; fetchImpl?: typeof fetch; }; @@ -147,6 +150,7 @@ async function fetchWithTimeout( url: URL, timeoutMs: number, headers: HeadersInit | undefined, + redirect: RequestRedirect = "follow", ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -155,7 +159,7 @@ async function fetchWithTimeout( try { return await fetchImpl(url, { method: "GET", - redirect: "follow", + redirect, headers: requestHeaders, signal: controller.signal, }); @@ -166,9 +170,12 @@ async function fetchWithTimeout( async function warmOnePath( pathname: string, - options: Required> & { + options: Required< + Pick + > & { fetchImpl: typeof fetch; headers?: HeadersInit; + expectedVersionId?: string; }, ): Promise<{ path: string; ok: true } | { path: string; ok: false; error: string }> { const url = buildWarmupUrl(options.targetUrl, pathname); @@ -181,9 +188,31 @@ async function warmOnePath( url, options.timeoutMs, options.headers, + "manual", ); await response.arrayBuffer(); + // A staged version can take a few seconds to become globally routable, so + // before the override propagates, the old Worker answers with its own + // status codes (including 404s for newly added routes). Check which + // Worker actually produced the response before trusting its status — + // otherwise a pre-propagation old-Worker 404 looks like a terminal + // failure instead of "not warmed yet". + if (options.expectedVersionId) { + const actualVersionId = response.headers.get(VINEXT_WORKER_VERSION_HEADER); + if (actualVersionId !== options.expectedVersionId) { + lastError = actualVersionId + ? `expected Worker version ${options.expectedVersionId}, received ${actualVersionId}` + : `expected Worker version ${options.expectedVersionId}, but the response did not include ${VINEXT_WORKER_VERSION_HEADER}`; + if (attempt < options.retries && options.retryDelayMs > 0) { + await new Promise((resolve) => + setTimeout(resolve, options.retryDelayMs * 2 ** attempt), + ); + } + continue; + } + } + if (response.status < 400) { return { path: pathname, ok: true }; } @@ -227,7 +256,8 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise Maximum number of routes to pre-render in parallel --experimental-warm-cdn-cache - Upload a Worker version, warm build-discovered paths - through the production URL, then promote it (experimental) + Stage a Worker version at 0%, warm build-discovered + paths through a verified version override, then promote + it to 100% (experimental) --warm-cdn-concurrency Maximum number of CDN warmup requests in parallel --warm-cdn-timeout Per-request CDN warmup timeout (default: 5000) - --warm-cdn-retries Retries for transient CDN warmup failures (default: 1) - --warm-cdn-strict Fail deploy when any CDN warmup request fails + --warm-cdn-retries Retries for warmup failures (default: 3) + --warm-cdn-strict Fail when staging or any CDN warmup request fails. + The previous version remains at 100% until warming succeeds --warm-cdn-include-fallbacks Also warm PPR fallback-shell placeholder paths -h, --help Show this help diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 228f890511..749b47c91c 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -42,8 +42,9 @@ import { getMissingDeps, type ProjectInfo, } from "vinext/internal/utils/project"; -import { parseWranglerConfig, runTPR } from "./tpr.js"; -import { readPrerenderWarmPaths, warmCdnCache } from "./cdn-warm.js"; +import { runTPR } from "./tpr.js"; +import { readPrerenderWarmPaths } from "./cdn-warm.js"; +import { deployWithCdnWarmup } from "./cdn-warm-deployment.js"; import { formatMissingCacheAdapterError, formatImageOptimizationHint, @@ -53,14 +54,6 @@ import { viteConfigHasImageAdapter, workerEntryHasCacheHandler, } from "./deploy-config.js"; -import { - runWranglerDeploymentStatus, - runWranglerTriggersDeploy, - runWranglerVersionDeploy, - runWranglerVersionUpload, - type WranglerDeploymentStatus, - type WranglerVersionTraffic, -} from "./version-deploy.js"; import { parseWorkersDevUrl } from "./workers-dev-url.js"; import { PHASE_PRODUCTION_BUILD } from "vinext/shims/constants"; import { buildPrerenderKVPairs, type KVBulkPair } from "./prerender-kv-populate.js"; @@ -92,7 +85,7 @@ export type DeployOptions = { warmCdnConcurrency?: number; /** Per-request CDN warmup timeout in milliseconds */ warmCdnTimeout?: number; - /** Number of CDN warmup retries for transient failures */ + /** Number of CDN warmup retries */ warmCdnRetries?: number; /** Fail deployment if any CDN warmup request fails */ warmCdnStrict?: boolean; @@ -139,7 +132,7 @@ function parseNonNegativeIntegerArg(raw: string, flag: string): number { return parsed; } -function formatUnknownError(error: unknown): string { +export function formatUnknownError(error: unknown): string { if (error instanceof Error && error.message) return error.message; return String(error); } @@ -538,234 +531,6 @@ export async function runWranglerDeploy( return deployedUrl ?? "(URL not detected in wrangler output)"; } -export async function deployWithCdnWarmup( - root: string, - paths: readonly string[], - options: Pick< - DeployOptions, - | "preview" - | "env" - | "name" - | "config" - | "warmCdnConcurrency" - | "warmCdnTimeout" - | "warmCdnRetries" - | "warmCdnStrict" - >, -): Promise { - const upload = runWranglerVersionUpload(root, options); - const wranglerConfig = parseWranglerConfig(root, options.config); - const deploymentStatus = readWranglerDeploymentStatus(root, options); - const stagingTraffic = getZeroPercentStagingTraffic(deploymentStatus, upload.versionId); - let staged: ReturnType | null = null; - let triggersDeployedUrl: string | null = null; - let warmedBeforePromotion = false; - let triggersApplied = false; - - function applyTriggers(): void { - if (triggersApplied) return; - triggersDeployedUrl = runWranglerTriggersDeploy(root, options).deployedUrl; - triggersApplied = true; - } - - if (stagingTraffic) { - staged = runWranglerVersionDeploy(root, stagingTraffic, options, "stage"); - try { - applyTriggers(); - } catch (error) { - throw withStagedVersionCleanupNote(error); - } - const targetUrl = resolveCdnWarmupTargetUrl( - root, - staged.deployedUrl ?? triggersDeployedUrl, - options, - ); - const workerName = resolveWorkerNameForVersionOverride(wranglerConfig, options); - const headers = buildVersionOverrideHeaders(workerName, upload.versionId); - if (targetUrl && headers) { - try { - await warmCdnCache({ - targetUrl, - paths, - headers, - concurrency: options.warmCdnConcurrency, - timeoutMs: options.warmCdnTimeout, - retries: options.warmCdnRetries, - strict: options.warmCdnStrict, - }); - } catch (error) { - throw withStagedVersionCleanupNote(error); - } - warmedBeforePromotion = true; - } else if (options.warmCdnStrict) { - throw new Error( - "CDN warmup failed: pre-traffic warmup needs a production URL and Worker name for version overrides. " + - "Configure a route/custom domain and Worker name, or rerun without --warm-cdn-strict. " + - getStagedVersionCleanupNote(), - ); - } - } else { - console.warn( - " CDN warmup: pre-traffic version override skipped because the current deployment is not a single 100% version.", - ); - } - - const deployed = runWranglerVersionDeploy( - root, - [{ versionId: upload.versionId, percentage: 100 }], - options, - warmedBeforePromotion ? "promote-warmed" : "promote-uploaded", - ); - if (!warmedBeforePromotion) { - try { - applyTriggers(); - } catch (error) { - throw withPromotedVersionTriggerNote(error); - } - const targetUrl = resolveCdnWarmupTargetUrl( - root, - deployed.deployedUrl ?? triggersDeployedUrl, - options, - ); - if (targetUrl) { - await warmCdnCache({ - targetUrl, - paths, - concurrency: options.warmCdnConcurrency, - timeoutMs: options.warmCdnTimeout, - retries: options.warmCdnRetries, - strict: options.warmCdnStrict, - }); - } else if (options.warmCdnStrict) { - throw new Error( - "CDN warmup failed: no production URL could be inferred from wrangler config or output. " + - "Configure a route/custom domain, ensure Wrangler prints a workers.dev URL, or rerun without --warm-cdn-strict.", - ); - } else { - console.warn( - " CDN warmup skipped: no production URL could be inferred from wrangler config or output.", - ); - } - } - return ( - deployed.deployedUrl ?? - triggersDeployedUrl ?? - staged?.deployedUrl ?? - upload.previewUrl ?? - "(URL not detected in wrangler output)" - ); -} - -export function resolveCdnWarmupTargetUrl(root: string, deployedUrl: string | null): string | null; -export function resolveCdnWarmupTargetUrl( - root: string, - deployedUrl: string | null, - options: Pick, -): string | null; -export function resolveCdnWarmupTargetUrl( - root: string, - deployedUrl: string | null, - options?: Pick, -): string | null { - const config = parseWranglerConfig(root, options?.config); - const env = getWranglerTargetEnv(options ?? {}); - const customDomain = (env ? config?.env?.[env]?.customDomain : undefined) ?? config?.customDomain; - if (customDomain) { - return `https://${customDomain}`; - } - return deployedUrl; -} - -function readWranglerDeploymentStatus( - root: string, - options: Pick, -): WranglerDeploymentStatus | null { - try { - return runWranglerDeploymentStatus(root, options); - } catch { - return null; - } -} - -export function getZeroPercentStagingTraffic( - deployment: WranglerDeploymentStatus | null, - versionId: string, -): WranglerVersionTraffic[] | null { - const current = deployment?.versions ?? []; - if (current.length !== 1 || current[0].percentage !== 100) { - return null; - } - if (current[0].versionId === versionId) { - return null; - } - return [current[0], { versionId, percentage: 0 }]; -} - -function getWranglerTargetEnv(options: Pick): string | undefined { - return options.env || (options.preview ? "preview" : undefined); -} - -type ParsedWranglerConfig = NonNullable>; - -export function resolveWorkerNameForVersionOverride( - config: ParsedWranglerConfig | null, - options: Pick, -): string | undefined { - if (options.name) { - return options.name; - } - - const env = getWranglerTargetEnv(options); - if (!env) { - return config?.name; - } - - if (config?.legacyEnv === false) { - return config.name; - } - - return config?.env?.[env]?.name ?? (config?.name ? `${config.name}-${env}` : undefined); -} - -function quoteStructuredHeaderString(value: string): string { - return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; -} - -export function buildVersionOverrideHeaders( - workerName: string | undefined, - versionId: string, -): HeadersInit | undefined { - if (!workerName) return undefined; - return { - "Cloudflare-Workers-Version-Overrides": `${workerName}=${quoteStructuredHeaderString(versionId)}`, - }; -} - -function withStagedVersionCleanupNote(error: unknown): Error { - const message = error instanceof Error ? error.message : String(error); - return new Error(`${message} ${getStagedVersionCleanupNote()}`, { - cause: error, - }); -} - -function getStagedVersionCleanupNote(): string { - return ( - "The uploaded version may remain staged at 0% with the previous version still serving 100% traffic; " + - "rerun deploy to promote it or use `wrangler versions deploy` to choose the desired version split." - ); -} - -function withPromotedVersionTriggerNote(error: unknown): Error { - const message = error instanceof Error ? error.message : String(error); - return new Error( - `${message} The uploaded version may already be promoted to 100%, but Worker triggers/routes may not be updated; ` + - "rerun deploy or `wrangler triggers deploy` after fixing the trigger error.", - { - cause: error, - }, - ); -} - // ─── Main Entry ────────────────────────────────────────────────────────────── export async function deploy(options: DeployOptions): Promise { @@ -866,8 +631,9 @@ export async function deploy(options: DeployOptions): Promise { vinextPrerenderConfig, nextOutput: nextConfig.output, }); + const canVerifyCdnWarmup = nextConfig.output !== "export"; const shouldEmitPrerenderPathManifest = - options.warmCdnCache || (!options.skipBuild && prerenderDecision); + (options.warmCdnCache && canVerifyCdnWarmup) || (!options.skipBuild && prerenderDecision); // Step 5: Build if (!options.skipBuild) { @@ -941,7 +707,12 @@ export async function deploy(options: DeployOptions): Promise { }; let url: string; - if (options.warmCdnCache) { + if (options.warmCdnCache && !canVerifyCdnWarmup) { + console.log( + "\n CDN warmup skipped: static exports are served by Cloudflare Assets without invoking the Worker, so Worker version metadata cannot verify them.", + ); + url = await runWranglerDeploy(root, wranglerOptions); + } else if (options.warmCdnCache) { const warmPaths = readPrerenderWarmPaths(root, { includeFallbackShells: options.warmCdnIncludeFallbacks, strict: options.warmCdnStrict, diff --git a/packages/cloudflare/src/tpr.ts b/packages/cloudflare/src/tpr.ts index ce1d4e9733..e29130c4df 100644 --- a/packages/cloudflare/src/tpr.ts +++ b/packages/cloudflare/src/tpr.ts @@ -29,6 +29,13 @@ import { VINEXT_REVALIDATE_HEADER } from "vinext/internal/server/headers"; import { isrCacheKey } from "vinext/internal/server/isr-cache"; import { buildAppPageCacheTags } from "vinext/internal/server/app-page-cache"; import { ENTRY_PREFIX } from "@vinext/cloudflare/cache/kv-data-adapter.runtime"; +import { isUnknownRecord } from "./utils/cache-control-metadata.js"; +import { + extractTomlRouteEntries, + extractTomlRoutePatterns, + getTomlRootBody, + getTomlSections, +} from "./utils/toml.js"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -80,21 +87,25 @@ type WranglerConfig = { accountId?: string; kvNamespaceId?: string; customDomain?: string; + warmupHost?: string; name?: string; legacyEnv?: boolean; + versionMetadataBinding?: string; env?: Record; }; export type WranglerEnvironmentConfig = { customDomain?: string; + warmupHost?: string; name?: string; + versionMetadataBinding?: string; }; // ─── Wrangler Config Parsing ───────────────────────────────────────────────── /** - * Parse wrangler config (JSONC or TOML) to extract the fields TPR needs: - * account_id, VINEXT_KV_CACHE KV namespace ID, and custom domain. + * Parse wrangler config (JSONC or TOML) to extract the fields used by TPR and + * CDN warmup target resolution. */ export function parseWranglerConfig(root: string, configPath?: string): WranglerConfig | null { if (configPath) { @@ -274,9 +285,17 @@ function extractFromJSON(config: Record): WranglerConfig { } } - // Custom domain — check routes[] and custom_domains[] - const domain = extractDomainFromRoutes(config.routes) ?? extractDomainFromCustomDomains(config); + // TPR needs a zone-resolvable domain, while CDN warmup needs the exact route + // host. Keep both because zone_name is not the hostname a request should use. + const domain = + extractDomainFromRoute(config.route) ?? + extractDomainFromRoutes(config.routes) ?? + extractDomainFromCustomDomains(config); if (domain) result.customDomain = domain; + const warmupHost = extractWarmupHost(config); + if (warmupHost) result.warmupHost = warmupHost; + const versionMetadataBinding = extractVersionMetadataBinding(config); + if (versionMetadataBinding) result.versionMetadataBinding = versionMetadataBinding; const env = extractEnvConfigs(config.env); if (env) result.env = env; @@ -291,7 +310,12 @@ function extractEnvConfigs(envs: unknown): Record); - if (envConfig.name || envConfig.customDomain) { + if ( + envConfig.name || + envConfig.customDomain || + envConfig.warmupHost || + envConfig.versionMetadataBinding + ) { result[envName] = envConfig; } } @@ -303,35 +327,77 @@ function extractEnvironmentConfig(config: Record): WranglerEnvi if (typeof config.name === "string" && config.name.length > 0) { result.name = config.name; } - const domain = extractDomainFromRoutes(config.routes) ?? extractDomainFromCustomDomains(config); + const domain = + extractDomainFromRoute(config.route) ?? + extractDomainFromRoutes(config.routes) ?? + extractDomainFromCustomDomains(config); if (domain) result.customDomain = domain; + const warmupHost = extractWarmupHost(config); + if (warmupHost) result.warmupHost = warmupHost; + const versionMetadataBinding = extractVersionMetadataBinding(config); + if (versionMetadataBinding) result.versionMetadataBinding = versionMetadataBinding; return result; } +function extractVersionMetadataBinding(config: Record): string | null { + const metadata = config.version_metadata; + return isUnknownRecord(metadata) && + typeof metadata.binding === "string" && + metadata.binding.length > 0 + ? metadata.binding + : null; +} + +function extractDomainFromRoute(route: unknown): string | null { + if (typeof route === "string") { + const domain = cleanDomain(route); + return domain && !domain.includes("workers.dev") ? domain : null; + } + if (!isUnknownRecord(route)) return null; + const domainSource = + typeof route.zone_name === "string" + ? route.zone_name + : typeof route.pattern === "string" + ? route.pattern + : null; + if (!domainSource) return null; + const domain = cleanDomain(domainSource); + return domain && !domain.includes("workers.dev") ? domain : null; +} + function extractDomainFromRoutes(routes: unknown): string | null { if (!Array.isArray(routes)) return null; + return firstMatch(routes, extractDomainFromRoute); +} - for (const route of routes) { - if (typeof route === "string") { - const domain = cleanDomain(route); - if (domain && !domain.includes("workers.dev")) return domain; - } else if (route && typeof route === "object") { - const r = route as Record; - const pattern = - typeof r.zone_name === "string" - ? r.zone_name - : typeof r.pattern === "string" - ? r.pattern - : null; - if (pattern) { - const domain = cleanDomain(pattern); - if (domain && !domain.includes("workers.dev")) return domain; - } - } +function extractWarmupHost(config: Record): string | null { + const singular = extractWarmupHostFromRoute(config.route); + if (singular) return singular; + const fromRoutes = Array.isArray(config.routes) + ? firstMatch(config.routes, extractWarmupHostFromRoute) + : null; + return fromRoutes ?? extractDomainFromCustomDomains(config); +} + +/** + * A route array can mix host-wide and path-scoped (or workers.dev) entries; + * a disqualified earlier entry must not hide a valid later one. Returns the + * first non-null result of `fn`, or null if none qualify. + */ +function firstMatch(items: Iterable, fn: (item: T) => R | null): R | null { + for (const item of items) { + const result = fn(item); + if (result !== null) return result; } return null; } +function extractWarmupHostFromRoute(route: unknown): string | null { + if (isUnknownRecord(route) && route.enabled === false) return null; + const pattern = typeof route === "string" ? route : isUnknownRecord(route) ? route.pattern : null; + return typeof pattern === "string" ? routePatternToWarmupHost(pattern) : null; +} + function extractDomainFromCustomDomains(config: Record): string | null { // Workers Custom Domains: "custom_domains": ["example.com"] if (Array.isArray(config.custom_domains)) { @@ -360,6 +426,7 @@ function cleanDomain(raw: string): string | null { */ function extractFromTOML(content: string): WranglerConfig { const result: WranglerConfig = {}; + const sections = getTomlSections(content); const nameMatch = content.match(/^name\s*=\s*"([^"]+)"/m); if (nameMatch) result.name = nameMatch[1]; @@ -397,22 +464,33 @@ function extractFromTOML(content: string): WranglerConfig { } } - // [[routes]] blocks + // [[routes]] is a TOML array-of-tables: each entry produces its own section + // with the same header, so an earlier disqualified route must not shadow a + // later valid one. + const rootRouteSections = sections.filter( + (section) => section.header === "route" || section.header === "routes", + ); + + // Preserves prior behavior: root-level [[routes]] blocks match `pattern` + // only (unlike the env-scoped path below, which also accepts `zone_name`). if (!result.customDomain) { - const routeBlocks = content.split(/\[\[routes\]\]/); - for (let i = 1; i < routeBlocks.length; i++) { - const block = routeBlocks[i].split(/\[\[/)[0]; - const patternMatch = block.match(/pattern\s*=\s*"([^"]+)"/); - if (patternMatch) { - const domain = cleanDomain(patternMatch[1]); - if (domain && !domain.includes("workers.dev")) { - result.customDomain = domain; - break; - } - } - } + result.customDomain = + firstMatch(rootRouteSections, (section) => extractTomlRoutePatternDomain(section.body)) ?? + undefined; } + result.warmupHost = + extractTomlWarmupHost(getTomlRootBody(content)) ?? + firstMatch(rootRouteSections, (section) => extractTomlWarmupRouteBlockHost(section.body)) ?? + undefined; + + const rootBody = getTomlRootBody(content); + const rootVersionMetadata = sections.find((section) => section.header === "version_metadata"); + const versionMetadataBinding = + extractTomlVersionMetadataBinding(rootBody) ?? + (rootVersionMetadata ? extractTomlVersionMetadataTableBinding(rootVersionMetadata.body) : null); + if (versionMetadataBinding) result.versionMetadataBinding = versionMetadataBinding; + const env = extractEnvConfigsFromTOML(content); if (env) result.env = env; @@ -433,18 +511,50 @@ function extractEnvConfigsFromTOML( const domain = extractTomlScalarRouteDomain(section.body) ?? extractTomlRoutesArrayDomain(section.body); if (domain) envConfig.customDomain = domain; - if (envConfig.name || envConfig.customDomain) { + const warmupHost = extractTomlWarmupHost(section.body); + if (warmupHost) envConfig.warmupHost = warmupHost; + const versionMetadataBinding = extractTomlVersionMetadataBinding(section.body); + if (versionMetadataBinding) envConfig.versionMetadataBinding = versionMetadataBinding; + if ( + envConfig.name || + envConfig.customDomain || + envConfig.warmupHost || + envConfig.versionMetadataBinding + ) { result[envName] = envConfig; } continue; } - const routesEnvName = section.header.match(/^env\.([^.]+)\.routes$/)?.[1]; + const metadataEnvName = section.header.match(/^env\.([^.]+)\.version_metadata$/)?.[1]; + if (metadataEnvName) { + const envConfig = result[metadataEnvName] ?? {}; + const binding = extractTomlVersionMetadataTableBinding(section.body); + if (binding) envConfig.versionMetadataBinding = binding; + if ( + envConfig.name || + envConfig.customDomain || + envConfig.warmupHost || + envConfig.versionMetadataBinding + ) { + result[metadataEnvName] = envConfig; + } + continue; + } + + const routesEnvName = section.header.match(/^env\.([^.]+)\.(?:route|routes)$/)?.[1]; if (routesEnvName) { const envConfig = result[routesEnvName] ?? {}; const domain = extractTomlRouteBlockDomain(section.body); if (domain) envConfig.customDomain = domain; - if (envConfig.name || envConfig.customDomain) { + const warmupHost = extractTomlWarmupRouteBlockHost(section.body); + if (warmupHost) envConfig.warmupHost = warmupHost; + if ( + envConfig.name || + envConfig.customDomain || + envConfig.warmupHost || + envConfig.versionMetadataBinding + ) { result[routesEnvName] = envConfig; } } @@ -453,39 +563,16 @@ function extractEnvConfigsFromTOML( return Object.keys(result).length > 0 ? result : undefined; } -function getTomlSections(content: string): Array<{ header: string; body: string }> { - const sections: Array<{ header: string; body: string }> = []; - let currentHeader: string | null = null; - let currentBody: string[] = []; - - for (const line of content.split("\n")) { - const header = parseTomlSectionHeader(line); - if (header) { - if (currentHeader) { - sections.push({ header: currentHeader, body: currentBody.join("\n") }); - } - currentHeader = header; - currentBody = []; - } else if (currentHeader) { - currentBody.push(line); - } - } - - if (currentHeader) { - sections.push({ header: currentHeader, body: currentBody.join("\n") }); - } - - return sections; +function extractTomlVersionMetadataBinding(section: string): string | null { + const match = section.match( + /^version_metadata\s*=\s*\{[^}]*\bbinding\s*=\s*(?:"([^"]+)"|'([^']+)')[^}]*\}/m, + ); + return match?.slice(1).find((value): value is string => Boolean(value)) ?? null; } -function parseTomlSectionHeader(line: string): string | null { - const trimmed = line.trim(); - if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null; - const isArrayHeader = trimmed.startsWith("[[") && trimmed.endsWith("]]"); - const start = isArrayHeader ? 2 : 1; - const end = isArrayHeader ? trimmed.length - 2 : trimmed.length - 1; - const header = trimmed.slice(start, end).trim(); - return header.length > 0 ? header : null; +function extractTomlVersionMetadataTableBinding(section: string): string | null { + const match = section.match(/^binding\s*=\s*(?:"([^"]+)"|'([^']+)')/m); + return match?.slice(1).find((value): value is string => Boolean(value)) ?? null; } function extractTomlScalarRouteDomain(section: string): string | null { @@ -498,19 +585,57 @@ function extractTomlScalarRouteDomain(section: string): string | null { function extractTomlRoutesArrayDomain(section: string): string | null { const routesMatch = section.match(/^routes\s*=\s*\[([\s\S]*?)\]/m); if (!routesMatch) return null; - const patternMatch = (routesMatch[1] ?? "").match(/(?:pattern\s*=\s*)?"([^"]+)"/); + return firstMatch(extractTomlRoutePatterns(routesMatch[1] ?? ""), (pattern) => { + const domain = cleanDomain(pattern); + return domain && !domain.includes("workers.dev") ? domain : null; + }); +} + +function extractTomlWarmupHost(section: string): string | null { + const scalarRoute = section.match(/^route\s*=\s*"([^"]+)"/m)?.[1]; + if (scalarRoute) return routePatternToWarmupHost(scalarRoute); + + const inlineRoute = section.match(/^route\s*=\s*\{([\s\S]*?)\}\s*$/m)?.[1]; + if (inlineRoute && /\benabled\s*=\s*false\b/.test(inlineRoute)) return null; + const inlinePattern = inlineRoute?.match(/\bpattern\s*=\s*"([^"]+)"/)?.[1]; + if (inlinePattern) return routePatternToWarmupHost(inlinePattern); + + const routesArray = section.match(/^routes\s*=\s*\[([\s\S]*?)\]\s*$/m)?.[1]; + if (!routesArray) return null; + return firstMatch(extractTomlRouteEntries(routesArray), (route) => + route.enabled === false ? null : routePatternToWarmupHost(route.pattern), + ); +} + +function routePatternToWarmupHost(pattern: string): string | null { + const withoutProtocol = pattern.replace(/^https?:\/\//, ""); + const pathStart = withoutProtocol.indexOf("/"); + const routePath = pathStart === -1 ? "" : withoutProtocol.slice(pathStart); + if (routePath !== "" && routePath !== "/*") return null; + const host = cleanDomain(pattern); + return host && !host.includes("*") && !host.includes("workers.dev") ? host : null; +} + +function extractTomlRouteBlockDomain(section: string): string | null { + const patternMatch = section.match(/^(?:pattern|zone_name)\s*=\s*"([^"]+)"/m); if (!patternMatch) return null; const domain = cleanDomain(patternMatch[1]); return domain && !domain.includes("workers.dev") ? domain : null; } -function extractTomlRouteBlockDomain(section: string): string | null { - const patternMatch = section.match(/^(?:pattern|zone_name)\s*=\s*"([^"]+)"/m); +function extractTomlRoutePatternDomain(section: string): string | null { + const patternMatch = section.match(/^pattern\s*=\s*"([^"]+)"/m); if (!patternMatch) return null; const domain = cleanDomain(patternMatch[1]); return domain && !domain.includes("workers.dev") ? domain : null; } +function extractTomlWarmupRouteBlockHost(section: string): string | null { + if (/^enabled\s*=\s*false\b/m.test(section)) return null; + const patternMatch = section.match(/^pattern\s*=\s*"([^"]+)"/m); + return patternMatch ? routePatternToWarmupHost(patternMatch[1]) : null; +} + // ─── Cloudflare API ────────────────────────────────────────────────────────── /** diff --git a/packages/cloudflare/src/utils/toml.ts b/packages/cloudflare/src/utils/toml.ts new file mode 100644 index 0000000000..b100b26e2b --- /dev/null +++ b/packages/cloudflare/src/utils/toml.ts @@ -0,0 +1,149 @@ +/** + * Format-generic TOML lexing helpers used by the Wrangler config reader. + * + * This is deliberately not a full TOML parser — it is the small set of + * source-scanning primitives the deploy path needs (comment stripping, section + * splitting, inline route-array reading). The wrangler-specific semantics + * (which fields mean a custom domain, a warmup host, a KV namespace, etc.) live + * in `tpr.ts`; this module only knows TOML text shape, mirroring how the init + * path keeps generic JSONC parsing in `utils/jsonc`. + */ + +export type TomlSection = { + header: string; + body: string; +}; + +export type TomlRouteEntry = { + pattern: string; + enabled?: boolean; +}; + +/** + * Remove TOML line comments (`#` to end of line) while leaving `#` that appears + * inside quoted strings intact. Basic strings use `"` with `\` escapes; literal + * strings use `'` with no escapes. Callers only pass single-line string values + * (route patterns), so `"""`/`'''` multiline forms are not handled. + */ +function stripTomlLineComments(body: string): string { + let result = ""; + let quote: '"' | "'" | null = null; + let escaped = false; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (quote === '"') { + result += ch; + if (escaped) escaped = false; + else if (ch === "\\") escaped = true; + else if (ch === '"') quote = null; + continue; + } + if (quote === "'") { + result += ch; + if (ch === "'") quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + result += ch; + continue; + } + if (ch === "#") { + while (i < body.length && body[i] !== "\n") i++; + if (i < body.length) result += "\n"; + continue; + } + result += ch; + } + return result; +} + +/** Return the section name from a TOML header line (`[name]` or `[[name]]`), else null. */ +function parseTomlSectionHeader(line: string): string | null { + // TOML allows a trailing comment after a table header (`[env.foo] # note`). + // Strip it first (quote-aware) so the header is still recognized; otherwise + // the section is misread as body and its keys leak into the previous section. + const trimmed = stripTomlLineComments(line).trim(); + if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null; + const isArrayHeader = trimmed.startsWith("[[") && trimmed.endsWith("]]"); + const start = isArrayHeader ? 2 : 1; + const end = isArrayHeader ? trimmed.length - 2 : trimmed.length - 1; + const header = trimmed.slice(start, end).trim(); + return header.length > 0 ? header : null; +} + +/** + * Split TOML into its sections. A repeated `[[header]]` array-of-tables yields + * one entry per occurrence, so callers can walk every block under a header + * instead of only the first. + */ +export function getTomlSections(content: string): TomlSection[] { + const sections: TomlSection[] = []; + let currentHeader: string | null = null; + let currentBody: string[] = []; + + for (const line of content.split("\n")) { + const header = parseTomlSectionHeader(line); + if (header) { + if (currentHeader) { + sections.push({ header: currentHeader, body: currentBody.join("\n") }); + } + currentHeader = header; + currentBody = []; + } else if (currentHeader) { + currentBody.push(line); + } + } + + if (currentHeader) { + sections.push({ header: currentHeader, body: currentBody.join("\n") }); + } + + return sections; +} + +/** Return the top-of-file body before the first TOML section header. */ +export function getTomlRootBody(content: string): string { + const lines: string[] = []; + for (const line of content.split("\n")) { + if (parseTomlSectionHeader(line)) break; + lines.push(line); + } + return lines.join("\n"); +} + +/** + * Extract each route pattern from a TOML inline `routes = [...]` array body. + * Every entry is either a bare string (basic `"..."` or literal `'...'`) or an + * inline table with a `pattern` field; entries without a resolvable pattern + * (e.g. only `zone_name`) are skipped rather than aborting the whole array. + * + * TOML permits `#` comments between array values, so a commented-out route must + * not be read as a live entry. Comments are stripped first, honoring `#` inside + * quoted strings. + */ +export function extractTomlRouteEntries(routesArrayBody: string): TomlRouteEntry[] { + const routes: TomlRouteEntry[] = []; + for (const entry of stripTomlLineComments(routesArrayBody).matchAll( + /\{[^}]*\}|"[^"]*"|'[^']*'/g, + )) { + const raw = entry[0]; + const pattern = raw.startsWith("{") + ? raw + .match(/\bpattern\s*=\s*(?:"([^"]+)"|'([^']+)')/) + ?.slice(1) + .find(Boolean) + : raw.slice(1, -1); + if (!pattern) continue; + const enabledMatch = raw.startsWith("{") ? raw.match(/\benabled\s*=\s*(true|false)\b/) : null; + routes.push({ + pattern, + ...(enabledMatch ? { enabled: enabledMatch[1] === "true" } : {}), + }); + } + return routes; +} + +export function extractTomlRoutePatterns(routesArrayBody: string): string[] { + return extractTomlRouteEntries(routesArrayBody).map((route) => route.pattern); +} diff --git a/packages/vinext/package.json b/packages/vinext/package.json index c08c04e91d..6fd7c0a59d 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -85,6 +85,10 @@ "types": "./dist/server/pregenerated-concrete-paths.d.ts", "import": "./dist/server/pregenerated-concrete-paths.js" }, + "./internal/server/worker-version": { + "types": "./dist/server/worker-version.d.ts", + "import": "./dist/server/worker-version.js" + }, "./internal/build/run-prerender": { "types": "./dist/build/run-prerender.d.ts", "import": "./dist/build/run-prerender.js" diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index 9c44a4f9ca..2655ed89d8 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -5,6 +5,8 @@ import MagicString from "magic-string"; import type { ESTree } from "vite"; import type { CloudflareInitOptions } from "./init-platform.js"; import { detectProject } from "./utils/project.js"; +import { isUnknownRecord } from "./utils/record.js"; +import { VINEXT_VERSION_METADATA_BINDING } from "./server/worker-version.js"; const require = createRequire(import.meta.url); @@ -191,6 +193,10 @@ export function generateWranglerConfig( if (options.cdnCache === "workers-cache") config.cache = { enabled: true }; + if (options.warmCdnCache) { + config.version_metadata = { binding: VINEXT_VERSION_METADATA_BINDING }; + } + if (options.imageOptimization === "cloudflare-images") { config.images = { binding: "IMAGES" }; } @@ -362,6 +368,54 @@ function appendTopLevelJsonProperty(code: string, property: string): string { return `${before}${needsComma ? "," : ""}\n${property}\n${code.slice(closing)}`; } +/** + * The runtime reads the version metadata binding under a fixed name + * (VINEXT_VERSION_METADATA_BINDING), so init must ensure the config declares + * exactly that binding rather than preserving a differently named one. + */ +function ensureVersionMetadataInJsonObject(code: string): string { + const metadataProperty = findTopLevelJsonProperty(code, "version_metadata"); + if (!metadataProperty) { + return appendTopLevelJsonProperty( + code, + ` "version_metadata": { "binding": "${VINEXT_VERSION_METADATA_BINDING}" }`, + ); + } + + const parsedMetadata: unknown = JSON.parse( + stripJsonComments(code.slice(metadataProperty.valueStart, metadataProperty.valueEnd)), + ); + const metadata = isUnknownRecord(parsedMetadata) ? parsedMetadata : null; + if (metadata?.binding === VINEXT_VERSION_METADATA_BINDING) return code; + + const replacement = `{ "binding": "${VINEXT_VERSION_METADATA_BINDING}" }`; + return `${code.slice(0, metadataProperty.valueStart)}${replacement}${code.slice(metadataProperty.valueEnd)}`; +} + +function ensureNamedEnvironmentVersionMetadata(code: string): string { + const envProperty = findTopLevelJsonProperty(code, "env"); + if (!envProperty) return code; + + const envCode = code.slice(envProperty.valueStart, envProperty.valueEnd); + const parsedEnv: unknown = JSON.parse(stripJsonComments(envCode)); + if (!isUnknownRecord(parsedEnv)) { + throw new Error('Wrangler config property "env" must be an object.'); + } + let updatedEnv = envCode; + for (const envName of Object.keys(parsedEnv)) { + const environmentProperty = findTopLevelJsonProperty(updatedEnv, envName); + if (!environmentProperty) continue; + const environmentCode = updatedEnv.slice( + environmentProperty.valueStart, + environmentProperty.valueEnd, + ); + if (!environmentCode.trimStart().startsWith("{")) continue; + const updatedEnvironment = ensureVersionMetadataInJsonObject(environmentCode); + updatedEnv = `${updatedEnv.slice(0, environmentProperty.valueStart)}${updatedEnvironment}${updatedEnv.slice(environmentProperty.valueEnd)}`; + } + return `${code.slice(0, envProperty.valueStart)}${updatedEnv}${code.slice(envProperty.valueEnd)}`; +} + export function updateWranglerConfigForCloudflare( code: string, options: CloudflareInitOptions, @@ -386,6 +440,10 @@ export function updateWranglerConfigForCloudflare( } } } + if (options.warmCdnCache) { + output = ensureVersionMetadataInJsonObject(output); + output = ensureNamedEnvironmentVersionMetadata(output); + } if (options.imageOptimization === "cloudflare-images") { const imagesProperty = findTopLevelJsonProperty(output, "images"); if (!imagesProperty) { diff --git a/packages/vinext/src/server/app-router-entry.ts b/packages/vinext/src/server/app-router-entry.ts index 902d4dfa69..cf79fa02ba 100644 --- a/packages/vinext/src/server/app-router-entry.ts +++ b/packages/vinext/src/server/app-router-entry.ts @@ -56,6 +56,7 @@ import { notFoundStaticAssetResponse, } from "./http-error-responses.js"; import { assetPrefixPathname, isNextStaticPath } from "../utils/asset-prefix.js"; +import { stampWorkerVersion } from "./worker-version.js"; // Precompute the path components used for `_next/static/*` 404 short-circuit // detection. Both `__basePath` and `__assetPrefix` are inlined as @@ -77,7 +78,7 @@ export default { env?: WorkerAssetEnv, ctx?: ExecutionContextLike, ): Promise { - return handleRequest(request, env, ctx); + return stampWorkerVersion(await handleRequest(request, env, ctx), env); }, }; diff --git a/packages/vinext/src/server/pages-router-entry.ts b/packages/vinext/src/server/pages-router-entry.ts index 5084c8ba30..5e7e535b0f 100644 --- a/packages/vinext/src/server/pages-router-entry.ts +++ b/packages/vinext/src/server/pages-router-entry.ts @@ -35,6 +35,7 @@ import { import { notFoundStaticAssetResponse } from "./http-error-responses.js"; import { finalizeMissingStaticAssetResponse } from "./worker-utils.js"; import { assetPrefixPathname, isNextStaticPath } from "../utils/asset-prefix.js"; +import { stampWorkerVersion } from "./worker-version.js"; import { hasBasePath, stripBasePath } from "../utils/base-path.js"; // @ts-expect-error -- virtual module resolved by vinext at build time @@ -94,7 +95,7 @@ export default { env?: PagesWorkerEnv, ctx?: PagesWorkerExecutionContext, ): Promise { - return handleRequest(request, env, ctx); + return stampWorkerVersion(await handleRequest(request, env, ctx), env); }, }; diff --git a/packages/vinext/src/server/worker-version.ts b/packages/vinext/src/server/worker-version.ts new file mode 100644 index 0000000000..6a7f8f890c --- /dev/null +++ b/packages/vinext/src/server/worker-version.ts @@ -0,0 +1,52 @@ +import { isUnknownRecord } from "../utils/record.js"; + +export const VINEXT_VERSION_METADATA_BINDING = "VINEXT_VERSION_METADATA"; +export const VINEXT_WORKER_VERSION_HEADER = "x-vinext-worker-version"; + +type WorkerVersionMetadataLike = { + id: string; + tag: string; + timestamp: string; +}; + +function isWorkerVersionMetadata(value: unknown): value is WorkerVersionMetadataLike { + return ( + isUnknownRecord(value) && + typeof value.id === "string" && + typeof value.tag === "string" && + typeof value.timestamp === "string" + ); +} + +function readWorkerVersionId(env: unknown): string | null { + if (!isUnknownRecord(env)) return null; + const metadata = env[VINEXT_VERSION_METADATA_BINDING]; + return isWorkerVersionMetadata(metadata) ? metadata.id : null; +} + +/** + * Stamp the producing Worker version onto the response representation. The + * edge stores this header with cacheable responses, so a later cache HIT still + * identifies the version that produced the cached body rather than whichever + * deployment is currently active. + */ +export function stampWorkerVersion(response: Response, env: unknown): Response { + const versionId = readWorkerVersionId(env); + // Response.error() has status 0 and cannot be reconstructed with the public + // Response constructor. WebSocket upgrade responses similarly carry host + // state that must not be cloned. + if (!versionId || response.status === 0 || response.status === 101) return response; + + try { + response.headers.set(VINEXT_WORKER_VERSION_HEADER, versionId); + return response; + } catch { + const headers = new Headers(response.headers); + headers.set(VINEXT_WORKER_VERSION_HEADER, versionId); + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); + } +} diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index e473a896b2..e8f5c50019 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -4,13 +4,12 @@ import os from "node:os"; import path from "node:path"; const execFileSyncMock = vi.hoisted(() => vi.fn()); +const UPLOADED_VERSION_ID = "22222222-2222-4222-8222-222222222222"; +const PREVIOUS_VERSION_ID = "11111111-1111-4111-8111-111111111111"; vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); - return { - ...actual, - execFileSync: execFileSyncMock, - }; + return { ...actual, execFileSync: execFileSyncMock }; }); let tmpDir: string; @@ -27,13 +26,55 @@ function formatFetchUrl(url: Parameters[0]): string { return url.url; } +function versionedResponse(versionId = UPLOADED_VERSION_ID): Response { + return new Response("ok", { + status: 200, + headers: { "x-vinext-worker-version": versionId }, + }); +} + +function warmupWranglerConfig(config: Record): string { + const env = config.env; + const configuredEnv = + env && typeof env === "object" && !Array.isArray(env) + ? Object.fromEntries( + Object.entries(env).map(([name, value]) => [ + name, + { + ...(value as Record), + version_metadata: { binding: "VINEXT_VERSION_METADATA" }, + }, + ]), + ) + : undefined; + return JSON.stringify({ + ...config, + version_metadata: { binding: "VINEXT_VERSION_METADATA" }, + ...(configuredEnv ? { env: configuredEnv } : {}), + }); +} + +function currentDeploymentOutput(): string { + return JSON.stringify({ + versions: [{ version_id: PREVIOUS_VERSION_ID, percentage: 100 }], + }); +} + +function isStage(args: string[]): boolean { + return args.includes(`${PREVIOUS_VERSION_ID}@100%`) && args.includes(`${UPLOADED_VERSION_ID}@0%`); +} + +function isPromotion(args: string[]): boolean { + return args.includes(`${UPLOADED_VERSION_ID}@100%`); +} + describe("Cloudflare CDN warmup deploy flow", () => { beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-cdn-warm-deploy-test-")); execFileSyncMock.mockReset(); vi.stubGlobal( "fetch", - vi.fn(async () => new Response("ok", { status: 200 })), + vi.fn(async () => versionedResponse()), ); }); @@ -42,426 +83,419 @@ describe("Cloudflare CDN warmup deploy flow", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it("warms the production custom domain through a 0% staged version override", async () => { - const events: string[] = []; + it("rejects a named environment without its own metadata binding before upload", async () => { writeFile( "wrangler.jsonc", JSON.stringify({ + version_metadata: { binding: "VINEXT_VERSION_METADATA" }, + env: { staging: { name: "my-worker-staging", route: "staging.example.com/*" } }, + }), + ); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + await expect(deployWithCdnWarmup(tmpDir, ["/"], { env: "staging" })).rejects.toThrow( + 'requires a version_metadata binding named "VINEXT_VERSION_METADATA" in Wrangler environment "staging"', + ); + expect(execFileSyncMock).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("rejects a version_metadata binding with a non-default name", async () => { + // The runtime only ever reads env.VINEXT_VERSION_METADATA (worker-version.ts), + // so a differently named binding would silently never stamp responses. + writeFile( + "wrangler.jsonc", + JSON.stringify({ version_metadata: { binding: "CUSTOM_VERSION" } }), + ); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + await expect(deployWithCdnWarmup(tmpDir, ["/"], {})).rejects.toThrow( + 'requires a version_metadata binding named "VINEXT_VERSION_METADATA" in the top-level Wrangler config', + ); + expect(execFileSyncMock).not.toHaveBeenCalled(); + }); + + it("stages, warms the exact route with a version override, then promotes", async () => { + const events: string[] = []; + writeFile( + "wrangler.jsonc", + warmupWranglerConfig({ name: "my-worker", - custom_domains: ["app.example.com"], + cache: { enabled: true }, + routes: [{ pattern: "app.example.com/*", zone_name: "example.com" }], }), ); - vi.mocked(fetch).mockImplementation(async (url) => { - events.push(`fetch:${formatFetchUrl(url)}`); - return new Response("ok", { status: 200 }); + vi.mocked(fetch).mockImplementation(async (url, init) => { + const override = new Headers(init?.headers).get("Cloudflare-Workers-Version-Overrides"); + events.push(`fetch:${formatFetchUrl(url)}:${override}`); + return versionedResponse(); }); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { if (args.includes("upload")) { events.push("upload"); - return "Uploaded version 22222222-2222-4222-8222-222222222222\nhttps://preview.example.workers.dev\n"; + return `Uploaded version ${UPLOADED_VERSION_ID}\n`; } if (args.includes("status")) { events.push("status"); - return JSON.stringify({ - versions: [{ version_id: "11111111-1111-4111-8111-111111111111", percentage: 100 }], - }); + return currentDeploymentOutput(); } - if ( - args.includes("deploy") && - args.includes("11111111-1111-4111-8111-111111111111@100%") && - args.includes("22222222-2222-4222-8222-222222222222@0%") - ) { + if (isStage(args)) { events.push("stage"); return "Staged version\nhttps://stable.example.workers.dev\n"; } - if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@100%")) { - events.push("promote"); - return "Deployed version\nhttps://stable.example.workers.dev\n"; - } if (args.includes("triggers")) { events.push("triggers"); return "Triggers deployed\n"; } + if (isPromotion(args)) { + events.push("promote"); + return "Promoted version\nhttps://stable.example.workers.dev\n"; + } throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); }); - const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); const url = await deployWithCdnWarmup(tmpDir, ["/", "/about"], { warmCdnConcurrency: 1, }); expect(url).toBe("https://stable.example.workers.dev"); - expect(execFileSyncMock).toHaveBeenNthCalledWith( - 1, - process.execPath, - expect.arrayContaining(["versions", "upload"]), - expect.any(Object), - ); - expect(execFileSyncMock).toHaveBeenNthCalledWith( - 2, - process.execPath, - expect.arrayContaining(["deployments", "status", "--json"]), - expect.any(Object), - ); - expect(execFileSyncMock).toHaveBeenNthCalledWith( - 3, - process.execPath, - expect.arrayContaining([ - "versions", - "deploy", - "11111111-1111-4111-8111-111111111111@100%", - "22222222-2222-4222-8222-222222222222@0%", - ]), - expect.any(Object), - ); - expect(fetch).toHaveBeenCalledTimes(2); - const firstInit = vi.mocked(fetch).mock.calls[0]![1] as RequestInit; - expect(fetch).toHaveBeenNthCalledWith( - 1, - new URL("https://app.example.com/"), - expect.any(Object), - ); - expect(fetch).toHaveBeenNthCalledWith( - 2, - new URL("https://app.example.com/about"), - expect.any(Object), - ); - expect(new Headers(firstInit.headers).get("Cloudflare-Workers-Version-Overrides")).toBe( - 'my-worker="22222222-2222-4222-8222-222222222222"', - ); - expect(execFileSyncMock).toHaveBeenNthCalledWith( - 4, - process.execPath, - expect.arrayContaining(["triggers", "deploy"]), - expect.any(Object), - ); - expect(execFileSyncMock).toHaveBeenNthCalledWith( - 5, - process.execPath, - expect.arrayContaining(["versions", "deploy", "22222222-2222-4222-8222-222222222222@100%"]), - expect.any(Object), - ); expect(events).toEqual([ "upload", "status", "stage", - "triggers", - "fetch:https://app.example.com/", - "fetch:https://app.example.com/about", + `fetch:https://app.example.com/:my-worker="${UPLOADED_VERSION_ID}"`, + `fetch:https://app.example.com/about:my-worker="${UPLOADED_VERSION_ID}"`, "promote", + "triggers", ]); }); - it("uses the env Worker name and env custom domain for version override warmup", async () => { + it("uses the selected environment route and Worker name for the override", async () => { writeFile( "wrangler.jsonc", - JSON.stringify({ + warmupWranglerConfig({ name: "my-worker", - custom_domains: ["app.example.com"], + route: "app.example.com/*", env: { staging: { name: "my-worker-staging-custom", - custom_domains: ["staging.example.com"], + route: "staging.example.com/*", }, }, }), ); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { - if (args.includes("upload")) { - return "Uploaded version 22222222-2222-4222-8222-222222222222\n"; - } - if (args.includes("status")) { - return JSON.stringify({ - versions: [{ version_id: "11111111-1111-4111-8111-111111111111", percentage: 100 }], - }); - } - if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@0%")) { - return "Staged version\nhttps://stable.example.workers.dev\n"; - } - if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@100%")) { - return "Deployed version\nhttps://stable.example.workers.dev\n"; - } - if (args.includes("triggers")) { - return "Triggers deployed\n"; - } + if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; + if (args.includes("status")) return currentDeploymentOutput(); + if (isStage(args)) return "Staged version\nhttps://staging-worker.example.workers.dev\n"; + if (args.includes("triggers")) return "Triggers deployed\n"; + if (isPromotion(args)) return "Promoted version\n"; throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); }); - const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); - await deployWithCdnWarmup(tmpDir, ["/"], { - env: "staging", - warmCdnConcurrency: 1, - }); + await deployWithCdnWarmup(tmpDir, ["/"], { env: "staging" }); - expect(fetch).toHaveBeenCalledWith(new URL("https://staging.example.com/"), expect.any(Object)); - const firstInit = vi.mocked(fetch).mock.calls[0]![1] as RequestInit; - expect(new Headers(firstInit.headers).get("Cloudflare-Workers-Version-Overrides")).toBe( - 'my-worker-staging-custom="22222222-2222-4222-8222-222222222222"', + expect(fetch).toHaveBeenCalledWith( + new URL("https://staging.example.com/"), + expect.objectContaining({ + headers: expect.any(Headers), + redirect: "manual", + }), + ); + const headers = new Headers(vi.mocked(fetch).mock.calls[0]![1]?.headers); + expect(headers.get("Cloudflare-Workers-Version-Overrides")).toBe( + `my-worker-staging-custom="${UPLOADED_VERSION_ID}"`, ); for (const [, args] of execFileSyncMock.mock.calls as Array<[string, string[]]>) { expect(args).toEqual(expect.arrayContaining(["--env", "staging"])); } }); - it("applies triggers before post-promotion fallback warmup", async () => { + it("warms workers.dev while the uploaded version remains at 0%", async () => { const events: string[] = []; - writeFile( - "wrangler.jsonc", - JSON.stringify({ - name: "my-worker", - custom_domains: ["app.example.com"], - }), - ); - vi.mocked(fetch).mockImplementation(async (url) => { + writeFile("wrangler.jsonc", warmupWranglerConfig({ name: "workers-cache" })); + vi.mocked(fetch).mockImplementation(async (url, init) => { events.push(`fetch:${formatFetchUrl(url)}`); - return new Response("ok", { status: 200 }); + expect(new Headers(init?.headers).get("Cloudflare-Workers-Version-Overrides")).toBe( + `workers-cache="${UPLOADED_VERSION_ID}"`, + ); + return versionedResponse(); }); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { if (args.includes("upload")) { events.push("upload"); - return "Uploaded version 22222222-2222-4222-8222-222222222222\n"; + return `Uploaded version ${UPLOADED_VERSION_ID}\n`; } if (args.includes("status")) { events.push("status"); - return JSON.stringify({ - versions: [ - { version_id: "11111111-1111-4111-8111-111111111111", percentage: 50 }, - { version_id: "33333333-3333-4333-8333-333333333333", percentage: 50 }, - ], - }); + return currentDeploymentOutput(); } - if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@100%")) { - events.push("promote"); - return "Deployed version\nhttps://stable.example.workers.dev\n"; + if (isStage(args)) { + events.push("stage"); + return "Staged version\nhttps://workers-cache.vinext.workers.dev\n"; } if (args.includes("triggers")) { events.push("triggers"); return "Triggers deployed\n"; } + if (isPromotion(args)) { + events.push("promote"); + return "Promoted version\n"; + } throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); }); - const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); - await deployWithCdnWarmup(tmpDir, ["/"], { - warmCdnConcurrency: 1, - }); + const url = await deployWithCdnWarmup(tmpDir, ["/cached/intro"], {}); + expect(url).toBe("https://workers-cache.vinext.workers.dev"); expect(events).toEqual([ "upload", "status", + "stage", + "fetch:https://workers-cache.vinext.workers.dev/cached/intro", "promote", "triggers", - "fetch:https://app.example.com/", ]); }); - it("uses the triggers deploy URL for post-promotion fallback warmup", async () => { + it("retries a failed override before promoting the uploaded version", async () => { const events: string[] = []; - writeFile( - "wrangler.jsonc", - JSON.stringify({ - name: "workers-cache", - }), - ); - vi.mocked(fetch).mockImplementation(async (url) => { - events.push(`fetch:${formatFetchUrl(url)}`); - return new Response("ok", { status: 200 }); - }); + writeFile("wrangler.jsonc", warmupWranglerConfig({ name: "workers-cache" })); + vi.mocked(fetch) + .mockImplementationOnce(async () => { + events.push("fetch:old-version"); + return versionedResponse(PREVIOUS_VERSION_ID); + }) + .mockImplementationOnce(async () => { + events.push("fetch:new-version"); + return versionedResponse(UPLOADED_VERSION_ID); + }); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { if (args.includes("upload")) { events.push("upload"); - return "Uploaded version 22222222-2222-4222-8222-222222222222\n"; + return `Uploaded version ${UPLOADED_VERSION_ID}\n`; } if (args.includes("status")) { events.push("status"); - return JSON.stringify({ - versions: [ - { version_id: "11111111-1111-4111-8111-111111111111", percentage: 100 }, - { version_id: "33333333-3333-4333-8333-333333333333", percentage: 0 }, - ], - }); + return currentDeploymentOutput(); } - if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@100%")) { - events.push("promote"); - return "Deployed workers-cache version 22222222-2222-4222-8222-222222222222 at 100%\n"; + if (isStage(args)) { + events.push("stage"); + return "Staged version\nhttps://workers-cache.vinext.workers.dev\n"; } if (args.includes("triggers")) { events.push("triggers"); - return "Deployed workers-cache triggers\n https://workers-cache.vinext.workers.dev\n"; + return "Triggers deployed\n"; + } + if (isPromotion(args)) { + events.push("promote"); + return "Promoted version\n"; } throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); }); - const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); - const url = await deployWithCdnWarmup(tmpDir, ["/cached/intro"], { - warmCdnConcurrency: 1, - }); + await deployWithCdnWarmup(tmpDir, ["/cached/intro"], { warmCdnRetries: 1 }); - expect(url).toBe("https://workers-cache.vinext.workers.dev"); - expect(fetch).toHaveBeenCalledWith( - new URL("https://workers-cache.vinext.workers.dev/cached/intro"), - expect.any(Object), - ); expect(events).toEqual([ "upload", "status", + "stage", + "fetch:old-version", + "fetch:new-version", "promote", "triggers", - "fetch:https://workers-cache.vinext.workers.dev/cached/intro", ]); }); - it("uses the explicit Worker name for version upload, override, promotion, and triggers", async () => { + it("leaves the new version staged at 0% when strict warmup fails, without a restore mutation", async () => { + const events: string[] = []; writeFile( "wrangler.jsonc", - JSON.stringify({ - name: "config-worker", - custom_domains: ["app.example.com"], - }), + warmupWranglerConfig({ name: "my-worker", route: "app.example.com/*" }), ); + vi.mocked(fetch).mockImplementation(async () => { + events.push("fetch:old-version"); + return versionedResponse(PREVIOUS_VERSION_ID); + }); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { if (args.includes("upload")) { - return "Uploaded version 22222222-2222-4222-8222-222222222222\n"; + events.push("upload"); + return `Uploaded version ${UPLOADED_VERSION_ID}\n`; } if (args.includes("status")) { - return JSON.stringify({ - versions: [{ version_id: "11111111-1111-4111-8111-111111111111", percentage: 100 }], - }); - } - if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@0%")) { - return "Staged version\nhttps://stable.example.workers.dev\n"; + events.push("status"); + return currentDeploymentOutput(); } - if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@100%")) { - return "Deployed version\nhttps://stable.example.workers.dev\n"; + if (isStage(args)) { + events.push("stage"); + return "Staged version\n"; } if (args.includes("triggers")) { + events.push("triggers"); return "Triggers deployed\n"; } + if (isPromotion(args)) { + events.push("promote"); + return "Promoted version\n"; + } throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); }); - const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); - - await deployWithCdnWarmup(tmpDir, ["/"], { - name: "cli-worker", - warmCdnConcurrency: 1, - }); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); - const firstInit = vi.mocked(fetch).mock.calls[0]![1] as RequestInit; - expect(new Headers(firstInit.headers).get("Cloudflare-Workers-Version-Overrides")).toBe( - 'cli-worker="22222222-2222-4222-8222-222222222222"', + await expect( + deployWithCdnWarmup(tmpDir, ["/"], { + warmCdnRetries: 0, + warmCdnStrict: true, + }), + ).rejects.toThrow( + `the uploaded version (${UPLOADED_VERSION_ID}) is staged at 0% and was not promoted`, ); - for (const [, args] of execFileSyncMock.mock.calls as Array<[string, string[]]>) { - expect(args).toEqual(expect.arrayContaining(["--name", "cli-worker"])); - } + // No restore/promote/triggers call: staging left the previous version at + // 100% and the new one at 0%, which is already the safe state. + expect(events).toEqual(["upload", "status", "stage", "fetch:old-version"]); }); - it("explains staged version cleanup when strict pre-promotion warmup fails", async () => { + it("stages and promotes a fresh attempt after a prior warmup left a version staged", async () => { + const events: string[] = []; writeFile( "wrangler.jsonc", - JSON.stringify({ - name: "my-worker", - custom_domains: ["app.example.com"], - }), + warmupWranglerConfig({ name: "my-worker", route: "app.example.com/*" }), ); - vi.mocked(fetch).mockResolvedValue(new Response("nope", { status: 500 })); + const fetchResponses: Array<() => Response> = [ + () => { + events.push("fetch:old-version"); + return versionedResponse(PREVIOUS_VERSION_ID); + }, + () => { + events.push("fetch:new-version"); + return versionedResponse(UPLOADED_VERSION_ID); + }, + ]; + vi.mocked(fetch).mockImplementation(async () => { + const next = fetchResponses.shift(); + if (!next) throw new Error("Unexpected fetch call"); + return next(); + }); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { if (args.includes("upload")) { - return "Uploaded version 22222222-2222-4222-8222-222222222222\n"; + events.push("upload"); + return `Uploaded version ${UPLOADED_VERSION_ID}\n`; } if (args.includes("status")) { - return JSON.stringify({ - versions: [{ version_id: "11111111-1111-4111-8111-111111111111", percentage: 100 }], - }); + events.push("status"); + return currentDeploymentOutput(); } - if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@0%")) { + if (isStage(args)) { + events.push("stage"); return "Staged version\nhttps://stable.example.workers.dev\n"; } if (args.includes("triggers")) { + events.push("triggers"); return "Triggers deployed\n"; } + if (isPromotion(args)) { + events.push("promote"); + return "Promoted version\nhttps://stable.example.workers.dev\n"; + } throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); }); - const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); await expect( deployWithCdnWarmup(tmpDir, ["/"], { - warmCdnConcurrency: 1, warmCdnRetries: 0, warmCdnStrict: true, }), - ).rejects.toThrow("may remain staged at 0%"); + ).rejects.toThrow("is staged at 0% and was not promoted"); + + const url = await deployWithCdnWarmup(tmpDir, ["/"], { + warmCdnRetries: 0, + warmCdnStrict: true, + }); + + expect(url).toBe("https://stable.example.workers.dev"); + expect(events).toEqual([ + "upload", + "status", + "stage", + "fetch:old-version", + "upload", + "status", + "stage", + "fetch:new-version", + "promote", + "triggers", + ]); }); - it("explains staged version cleanup when trigger deployment fails after staging", async () => { + it("surfaces an actionable error when the promotion CLI call fails, without re-reading status or applying triggers", async () => { + const events: string[] = []; writeFile( "wrangler.jsonc", - JSON.stringify({ - name: "my-worker", - custom_domains: ["app.example.com"], - }), + warmupWranglerConfig({ name: "my-worker", route: "app.example.com/*" }), ); + vi.mocked(fetch).mockImplementation(async () => versionedResponse(UPLOADED_VERSION_ID)); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { - if (args.includes("upload")) { - return "Uploaded version 22222222-2222-4222-8222-222222222222\n"; - } + if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; if (args.includes("status")) { - return JSON.stringify({ - versions: [{ version_id: "11111111-1111-4111-8111-111111111111", percentage: 100 }], - }); - } - if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@0%")) { - return "Staged version\nhttps://stable.example.workers.dev\n"; + events.push("status"); + return currentDeploymentOutput(); } + if (isStage(args)) return "Staged version\nhttps://stable.example.workers.dev\n"; if (args.includes("triggers")) { - throw new Error("trigger deploy failed"); + events.push("triggers"); + return "Triggers deployed\n"; + } + if (isPromotion(args)) { + events.push("promote-attempt"); + throw new Error("network blip during promote"); } throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); }); - const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); - await expect( - deployWithCdnWarmup(tmpDir, ["/"], { - warmCdnConcurrency: 1, - }), - ).rejects.toThrow("may remain staged at 0%"); - expect(fetch).not.toHaveBeenCalled(); + await expect(deployWithCdnWarmup(tmpDir, ["/"], {})).rejects.toThrow( + `Could not confirm the promotion of Worker version ${UPLOADED_VERSION_ID} succeeded`, + ); + // Exactly one status read (before staging) and one promotion attempt — no + // reconciling re-read of deployment status, and triggers never run. + expect(events).toEqual(["status", "promote-attempt"]); }); - it("explains promoted version state when fallback trigger deployment fails", async () => { - writeFile( - "wrangler.jsonc", - JSON.stringify({ - name: "my-worker", - custom_domains: ["app.example.com"], - }), - ); + it("does not mutate canonical cache keys when a safe staging deployment is unavailable", async () => { + writeFile("wrangler.jsonc", warmupWranglerConfig({ name: "workers-cache" })); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { - if (args.includes("upload")) { - return "Uploaded version 22222222-2222-4222-8222-222222222222\n"; - } + if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; if (args.includes("status")) { return JSON.stringify({ versions: [ - { version_id: "11111111-1111-4111-8111-111111111111", percentage: 50 }, + { version_id: PREVIOUS_VERSION_ID, percentage: 50 }, { version_id: "33333333-3333-4333-8333-333333333333", percentage: 50 }, ], }); } - if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@100%")) { - return "Deployed version\nhttps://stable.example.workers.dev\n"; - } - if (args.includes("triggers")) { - throw new Error("trigger deploy failed"); - } throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); }); - const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); - await expect( - deployWithCdnWarmup(tmpDir, ["/"], { - warmCdnConcurrency: 1, - }), - ).rejects.toThrow("may already be promoted to 100%"); + await expect(deployWithCdnWarmup(tmpDir, ["/"], { warmCdnStrict: true })).rejects.toThrow( + "requires the current deployment to contain exactly one version at 100%", + ); expect(fetch).not.toHaveBeenCalled(); + expect(execFileSyncMock).toHaveBeenCalledTimes(2); }); }); diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts index 6409119332..1b5814f872 100644 --- a/tests/cloudflare-cdn-warm.test.ts +++ b/tests/cloudflare-cdn-warm.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import fs from "node:fs"; +import { createServer } from "node:http"; import os from "node:os"; import path from "node:path"; import { @@ -287,7 +288,45 @@ describe("Cloudflare CDN warmup", () => { expect(secondInput).toBeInstanceOf(URL); expect((firstInput as URL).href).toBe("https://app.example.com/"); expect((secondInput as URL).href).toBe("https://app.example.com/about"); - expect(fetchMock.mock.calls[0]![1]).toMatchObject({ redirect: "follow" }); + expect(fetchMock.mock.calls[0]![1]).toMatchObject({ redirect: "manual" }); + }); + + it("verifies a canonical redirect at its original cache key", async () => { + const expectedVersionId = "22222222-2222-4222-8222-222222222222"; + const requestedPaths: string[] = []; + const server = createServer((request, response) => { + requestedPaths.push(request.url ?? ""); + if (request.url === "/old-path") { + response.writeHead(302, { + Location: "/destination", + "x-vinext-worker-version": "11111111-1111-4111-8111-111111111111", + }); + } else { + response.writeHead(200, { "x-vinext-worker-version": expectedVersionId }); + } + response.end(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP test server"); + + const result = await warmCdnCache({ + targetUrl: `http://127.0.0.1:${address.port}`, + paths: ["/old-path"], + expectedVersionId, + retries: 0, + }); + + expect(result).toMatchObject({ total: 1, warmed: 0, failed: 1 }); + expect(result.failures[0]?.error).toContain("expected Worker version"); + expect(requestedPaths).toEqual(["/old-path"]); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } }); it("warms an already resolved path list without rereading the manifest", async () => { @@ -306,6 +345,109 @@ describe("Cloudflare CDN warmup", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it("retries a successful response until the cached representation matches the uploaded version", async () => { + const expectedVersionId = "22222222-2222-4222-8222-222222222222"; + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response("old cached html", { + status: 200, + headers: { "x-vinext-worker-version": "11111111-1111-4111-8111-111111111111" }, + }), + ) + .mockResolvedValueOnce( + new Response("new html", { + status: 200, + headers: { "x-vinext-worker-version": expectedVersionId }, + }), + ); + + const result = await warmCdnCache({ + targetUrl: "https://app.example.com", + paths: ["/"], + expectedVersionId, + retries: 1, + retryDelayMs: 0, + fetchImpl: fetchMock, + }); + + expect(result).toMatchObject({ total: 1, warmed: 1, failed: 0 }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does not count an unverified 200 cache hit as warmed", async () => { + const fetchMock = vi.fn().mockImplementation( + async () => + new Response("old cached html", { + status: 200, + headers: { "x-vinext-worker-version": "11111111-1111-4111-8111-111111111111" }, + }), + ); + + const result = await warmCdnCache({ + targetUrl: "https://app.example.com", + paths: ["/"], + expectedVersionId: "22222222-2222-4222-8222-222222222222", + retries: 1, + retryDelayMs: 0, + fetchImpl: fetchMock, + }); + + expect(result).toMatchObject({ total: 1, warmed: 0, failed: 1 }); + expect(result.failures[0]?.error).toContain("expected Worker version"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does not count a legacy 200 cache hit without producer metadata as warmed", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response("old cached html", { status: 200 })); + + const result = await warmCdnCache({ + targetUrl: "https://app.example.com", + paths: ["/"], + expectedVersionId: "22222222-2222-4222-8222-222222222222", + retries: 0, + retryDelayMs: 0, + fetchImpl: fetchMock, + }); + + expect(result).toMatchObject({ total: 1, warmed: 0, failed: 1 }); + expect(result.failures[0]?.error).toContain("did not include x-vinext-worker-version"); + }); + + it("retries an old-version 404 instead of treating it as terminal", async () => { + const expectedVersionId = "22222222-2222-4222-8222-222222222222"; + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + // Before the override propagates, the old Worker (100% traffic) answers + // a newly added route with a 404 of its own, not the uploaded version's. + new Response("not found", { + status: 404, + headers: { "x-vinext-worker-version": "11111111-1111-4111-8111-111111111111" }, + }), + ) + .mockResolvedValueOnce( + new Response("new html", { + status: 200, + headers: { "x-vinext-worker-version": expectedVersionId }, + }), + ); + + const result = await warmCdnCache({ + targetUrl: "https://app.example.com", + paths: ["/pricing"], + expectedVersionId, + retries: 1, + retryDelayMs: 0, + fetchImpl: fetchMock, + }); + + expect(result).toMatchObject({ total: 1, warmed: 1, failed: 0 }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it("reports warmup failures and throws in strict mode", async () => { writeFile( "dist/server/vinext-prerender.json", diff --git a/tests/deploy-prerender-config.test.ts b/tests/deploy-prerender-config.test.ts index 01443c2552..58f0a91dc7 100644 --- a/tests/deploy-prerender-config.test.ts +++ b/tests/deploy-prerender-config.test.ts @@ -167,6 +167,17 @@ function writeApiOnlyProject(): void { writeFile("dist/server/index.js", "export default {};\n"); } +function configureCdnWarmupVersionMetadata(): void { + writeFile( + "wrangler.jsonc", + JSON.stringify({ + main: "vinext/server/app-router-entry", + assets: { directory: "dist/client" }, + version_metadata: { binding: "VINEXT_VERSION_METADATA" }, + }), + ); +} + describe("deploy prerender config wiring", () => { beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(process.cwd(), ".tmp-vinext-deploy-prerender-")); @@ -176,6 +187,8 @@ describe("deploy prerender config wiring", () => { }); afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -242,6 +255,7 @@ describe("deploy prerender config wiring", () => { it("loads Vite config once for all deploy metadata", async () => { writeProject("true", '{ data: kvDataAdapter({ binding: "MY_KV" }) }'); + configureCdnWarmupVersionMetadata(); writeFile("dist/server/BUILD_ID", "build-a\n"); writeFile("dist/server/index.js", "export default {};\n"); runPrerenderMock.mockImplementationOnce(async () => { @@ -300,6 +314,27 @@ describe("deploy prerender config wiring", () => { ); }); + it("skips Worker-version CDN warming for static exports", async () => { + writeProjectWithInlineNextConfig('{ output: "export" }'); + configureCdnWarmupVersionMetadata(); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const consoleSpy = vi.spyOn(console, "log"); + const { deploy } = await import("../packages/cloudflare/src/deploy.js"); + + await deploy({ root: tmpDir, skipBuild: true, warmCdnCache: true }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(vi.mocked(execFileSync)).not.toHaveBeenCalled(); + expect(vi.mocked(spawn).mock.calls.at(-1)?.[1]).toEqual([ + expect.stringContaining("wrangler"), + "deploy", + ]); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("CDN warmup skipped: static exports are served by Cloudflare Assets"), + ); + }); + it("passes deploy prerender concurrency through config-triggered prerender", async () => { writeProject('{ routes: "*" }'); const { deploy } = await import("../packages/cloudflare/src/deploy.js"); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index c9e16fca56..51ee04dbdc 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -11,15 +11,14 @@ import { buildWranglerKVBulkPutArgs, buildWranglerInvocation, buildWranglerDeployArgs, - getZeroPercentStagingTraffic, parseDeployArgs, - resolveWorkerNameForVersionOverride, resolveWranglerBin, runWranglerKVBulkPut, runWranglerDeploy, validateWranglerEnvName, withCloudflareEnv, } from "../packages/cloudflare/src/deploy.js"; +import { resolveCdnWarmupTargetUrl } from "../packages/cloudflare/src/cdn-warm-deployment.js"; import { detectPackageManager, detectPackageManagerName, @@ -3157,6 +3156,7 @@ describe("parseWranglerConfig — custom domain extraction", () => { expect(config?.env?.staging).toEqual({ name: "my-worker-staging", customDomain: "staging.example.com", + warmupHost: "staging.example.com", }); }); @@ -3209,6 +3209,7 @@ describe("parseWranglerConfig — custom domain extraction", () => { expect(config?.env?.staging).toEqual({ name: "my-worker-staging-custom", customDomain: "staging.example.com", + warmupHost: "staging.example.com", }); }); @@ -3247,75 +3248,148 @@ pattern = "staging.example.com/*" expect(config?.env?.staging).toEqual({ name: "my-worker-staging", customDomain: "staging.example.com", + warmupHost: "staging.example.com", }); }); -}); -// ─── CDN warmup Worker version overrides ─────────────────────────────────── + it("extracts a top-level TOML version metadata table", () => { + writeFile( + tmpDir, + "wrangler.toml", + `[version_metadata] +binding = "TOP_VERSION" +`, + ); -describe("resolveWorkerNameForVersionOverride", () => { - it("uses the top-level Worker name for production", () => { - writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ name: "my-worker" })); - expect(resolveWorkerNameForVersionOverride(parseWranglerConfig(tmpDir), {})).toBe("my-worker"); + const config = parseWranglerConfig(tmpDir); + expect(config?.versionMetadataBinding).toBe("TOP_VERSION"); }); - it("uses the CLI Worker name exactly when provided", () => { - writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ name: "config-worker" })); - expect( - resolveWorkerNameForVersionOverride(parseWranglerConfig(tmpDir), { - name: "cli-worker", - env: "staging", - }), - ).toBe("cli-worker"); + it("extracts an environment-local TOML version metadata table", () => { + writeFile( + tmpDir, + "wrangler.toml", + `[env.staging] +name = "my-worker-staging" + +[env.staging.version_metadata] +binding = "STAGING_VERSION" +`, + ); + + const config = parseWranglerConfig(tmpDir); + expect(config?.env?.staging?.versionMetadataBinding).toBe("STAGING_VERSION"); }); +}); - it("appends the target environment for Wrangler legacy environments", () => { - writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ name: "my-worker" })); - expect( - resolveWorkerNameForVersionOverride(parseWranglerConfig(tmpDir), { env: "staging" }), - ).toBe("my-worker-staging"); +describe("resolveCdnWarmupTargetUrl", () => { + it("skips a disabled custom domain and uses the next active route", () => { + writeFile( + tmpDir, + "wrangler.jsonc", + JSON.stringify({ + routes: [ + { pattern: "disabled.example.com", custom_domain: true, enabled: false }, + { pattern: "app.example.com", custom_domain: true }, + ], + }), + ); + + expect(resolveCdnWarmupTargetUrl(tmpDir, "https://worker.example.workers.dev", {})).toBe( + "https://app.example.com", + ); }); - it("uses env-specific Worker names for Wrangler legacy environments", () => { + it("uses the route pattern host instead of the containing zone", () => { writeFile( tmpDir, "wrangler.jsonc", JSON.stringify({ - name: "my-worker", - env: { staging: { name: "custom-staging-worker" } }, + routes: [{ pattern: "app.example.com/*", zone_name: "example.com" }], }), ); - expect( - resolveWorkerNameForVersionOverride(parseWranglerConfig(tmpDir), { env: "staging" }), - ).toBe("custom-staging-worker"); + + expect(resolveCdnWarmupTargetUrl(tmpDir, "https://worker.example.workers.dev", {})).toBe( + "https://app.example.com", + ); }); - it("keeps the service name for Wrangler service environments", () => { + it("supports the singular JSON route property", () => { + writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ route: "app.example.com/*" })); + + expect(resolveCdnWarmupTargetUrl(tmpDir, "https://worker.example.workers.dev", {})).toBe( + "https://app.example.com", + ); + }); + + it("does not use a route host when the warmup paths may bypass the Worker", () => { + writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ route: "app.example.com/api/*" })); + + expect(resolveCdnWarmupTargetUrl(tmpDir, "https://worker.example.workers.dev", {})).toBe( + "https://worker.example.workers.dev", + ); + }); + + it("uses the pattern from a singular TOML route object", () => { + writeFile( + tmpDir, + "wrangler.toml", + `route = { zone_name = "example.com", pattern = "app.example.com/*" }`, + ); + + expect(resolveCdnWarmupTargetUrl(tmpDir, "https://worker.example.workers.dev", {})).toBe( + "https://app.example.com", + ); + }); + + it("does not leak an env route past a commented environment header", () => { + // A trailing comment on the header must not stop it being recognized as an + // env section — otherwise the staging route leaks in as the production host. + writeFile( + tmpDir, + "wrangler.toml", + `name = "my-worker" + +[env.staging] # staging deployment +route = "staging.example.com/*"`, + ); + + expect(resolveCdnWarmupTargetUrl(tmpDir, "https://my-worker.example.workers.dev", {})).toBe( + "https://my-worker.example.workers.dev", + ); + }); + + it("does not inherit a top-level route into an explicit environment", () => { writeFile( tmpDir, "wrangler.jsonc", JSON.stringify({ - name: "my-worker", - legacy_env: false, - env: { staging: {} }, + routes: ["app.example.com/*"], + env: { staging: { name: "my-worker-staging" } }, }), ); + expect( - resolveWorkerNameForVersionOverride(parseWranglerConfig(tmpDir), { env: "staging" }), - ).toBe("my-worker"); + resolveCdnWarmupTargetUrl(tmpDir, "https://my-worker-staging.example.workers.dev", { + env: "staging", + }), + ).toBe("https://my-worker-staging.example.workers.dev"); }); -}); -describe("getZeroPercentStagingTraffic", () => { - it("does not stage the uploaded version when it is already the current deployment", () => { - expect( - getZeroPercentStagingTraffic( - { - versions: [{ versionId: "22222222-2222-4222-8222-222222222222", percentage: 100 }], - output: "{}", - }, - "22222222-2222-4222-8222-222222222222", - ), - ).toBeNull(); + it("skips a disabled custom domain in an inline TOML routes array", () => { + writeFile( + tmpDir, + "wrangler.toml", + `name = "my-worker" +routes = [ + { pattern = "disabled.example.com", custom_domain = true, enabled = false }, + { pattern = "app.example.com", custom_domain = true } +] +`, + ); + + expect(resolveCdnWarmupTargetUrl(tmpDir, "https://worker.example.workers.dev", {})).toBe( + "https://app.example.com", + ); }); }); diff --git a/tests/init.test.ts b/tests/init.test.ts index 428249b824..92e909a5d2 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -719,6 +719,7 @@ export default { plugins: [vinext({ cache: { data: customData() } })] }; dataCache: "kv", cdnCache: "workers-cache", imageOptimization: "cloudflare-images", + warmCdnCache: true, }, }); @@ -732,6 +733,7 @@ export default { plugins: [vinext({ cache: { data: customData() } })] }; expect(wrangler).toContain('"binding": "OTHER"'); expect(wrangler).toContain('"binding": "VINEXT_KV_CACHE"'); expect(wrangler).toContain('"images": { "binding": "IMAGES" }'); + expect(wrangler).toContain('"version_metadata": { "binding": "VINEXT_VERSION_METADATA" }'); expect(readFile(tmpDir, "worker/index.ts")).toBe("export default { fetch() {} };\n"); }); @@ -858,6 +860,76 @@ export default { plugins: [vinext({ cache: { data: customData() } })] }; expect(pkg.scripts["deploy:vinext"]).toBe( "vinext-cloudflare deploy --config dist/server/wrangler.json", ); + expect(JSON.parse(readFile(tmpDir, "wrangler.jsonc")).version_metadata).toBeUndefined(); + }); + + it("configures Worker version metadata when CDN warmup is requested", async () => { + setupProject(tmpDir, { router: "app" }); + + await runInit(tmpDir, { + cloudflare: { + dataCache: "kv", + cdnCache: "workers-cache", + imageOptimization: "cloudflare-images", + warmCdnCache: true, + }, + }); + + const wrangler = JSON.parse(readFile(tmpDir, "wrangler.jsonc")); + expect(wrangler.version_metadata).toEqual({ binding: "VINEXT_VERSION_METADATA" }); + expect(wrangler.vars).toBeUndefined(); + const pkg = readPkg(tmpDir) as { scripts: Record }; + expect(pkg.scripts["deploy:vinext"]).toContain("--experimental-warm-cdn-cache"); + }); + + it("forces the fixed binding name for named environments, overwriting custom bindings", async () => { + setupProject(tmpDir, { router: "app" }); + writeFile( + tmpDir, + "wrangler.jsonc", + `{ + "version_metadata": { "binding": "TOP_LEVEL_VERSION" }, + "env": { + "staging": { + // environment-local settings stay intact + "name": "my-worker-staging" + }, + "preview": { + "version_metadata": { "binding": "PREVIEW_VERSION" }, + "vars": { "EXISTING": "value" } + } + } +} +`, + ); + + await runInit(tmpDir, { + cloudflare: { + dataCache: "kv", + cdnCache: "workers-cache", + imageOptimization: "cloudflare-images", + warmCdnCache: true, + }, + }); + + const wrangler = readFile(tmpDir, "wrangler.jsonc"); + await runInit(tmpDir, { + cloudflare: { + dataCache: "kv", + cdnCache: "workers-cache", + imageOptimization: "cloudflare-images", + warmCdnCache: true, + }, + }); + expect(readFile(tmpDir, "wrangler.jsonc")).toBe(wrangler); + expect(wrangler).toContain("// environment-local settings stay intact"); + expect(wrangler).not.toContain("TOP_LEVEL_VERSION"); + expect(wrangler).not.toContain("PREVIEW_VERSION"); + // The runtime always reads the fixed binding name, so init overwrites any + // pre-existing custom binding rather than preserving it — top-level, + // staging (newly added), and preview (overwritten) all get it. + expect(wrangler.match(/"binding": "VINEXT_VERSION_METADATA"/g)).toHaveLength(3); + expect(wrangler).toContain('"EXISTING": "value"'); }); it("skips the warm CDN cache deploy flag when Cloudflare init opts out", async () => { diff --git a/tests/worker-version.test.ts b/tests/worker-version.test.ts new file mode 100644 index 0000000000..e42cb24b06 --- /dev/null +++ b/tests/worker-version.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + VINEXT_VERSION_METADATA_BINDING, + VINEXT_WORKER_VERSION_HEADER, + stampWorkerVersion, +} from "../packages/vinext/src/server/worker-version.js"; + +describe("Worker version response metadata", () => { + it("overwrites an application header with the platform version ID", async () => { + const response = stampWorkerVersion( + new Response("new html", { + headers: { [VINEXT_WORKER_VERSION_HEADER]: "application-value" }, + }), + { + [VINEXT_VERSION_METADATA_BINDING]: { + id: "22222222-2222-4222-8222-222222222222", + tag: "", + timestamp: "2026-07-11T00:00:00.000Z", + }, + }, + ); + + expect(response.headers.get(VINEXT_WORKER_VERSION_HEADER)).toBe( + "22222222-2222-4222-8222-222222222222", + ); + expect(await response.text()).toBe("new html"); + }); + + it("leaves the response unchanged without a version metadata binding", () => { + const response = new Response("html"); + expect(stampWorkerVersion(response, {})).toBe(response); + }); + + it("does not guess a version binding from application metadata", () => { + const response = new Response("html"); + expect( + stampWorkerVersion(response, { + APP_RELEASE: { + id: "application-release", + tag: "production", + timestamp: "2026-07-11T00:00:00.000Z", + }, + }), + ).toBe(response); + expect(response.headers.get(VINEXT_WORKER_VERSION_HEADER)).toBeNull(); + }); + + it("clones a response whose headers are immutable", async () => { + const source = Response.redirect("https://example.com/next", 302); + const response = stampWorkerVersion(source, { + [VINEXT_VERSION_METADATA_BINDING]: { + id: "22222222-2222-4222-8222-222222222222", + tag: "", + timestamp: "2026-07-11T00:00:00.000Z", + }, + }); + + expect(response).not.toBe(source); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("https://example.com/next"); + expect(response.headers.get(VINEXT_WORKER_VERSION_HEADER)).toBe( + "22222222-2222-4222-8222-222222222222", + ); + }); + + it("does not throw when a response cannot be reconstructed", () => { + const response = Response.error(); + expect( + stampWorkerVersion(response, { + [VINEXT_VERSION_METADATA_BINDING]: { + id: "22222222-2222-4222-8222-222222222222", + tag: "", + timestamp: "2026-07-11T00:00:00.000Z", + }, + }), + ).toBe(response); + }); +}); From 35ce45f0dafd0a182e5cad18181b8e8af936b199 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:17:32 +1000 Subject: [PATCH 02/22] fix(cloudflare): isolate CDN-warmup Worker-name/host resolution from TPR's config reader, and stop reporting unconfirmed warm-ups as plain success cdn-warm-deployment.ts read Worker name, production host, and legacy_env suffixing directly out of tpr.ts's WranglerConfig/env map. tpr.ts owns TPR-specific config extraction; CDN warmup's env-fallback and Worker-name-suffixing rules do not belong to that module, and the coupling made tpr.ts a de facto shared deployment-semantics resolver. Separately, when non-strict warmup exhausted its retries without ever confirming the uploaded version served a response, deployWithCdnWarmup still promoted and returned a bare URL string. deploy.ts printed a generic "Deployed to: " box identical to a fully-warmed deploy, so an operator had no way to tell a confirmed warm-up from a silent fallback short of scrolling back through mid-deploy warnings. Added wrangler-deployment-target.ts to resolve {workerName, productionHost, versionMetadataBinding} on top of the existing parseWranglerConfig output, so cdn-warm-deployment.ts no longer touches TOML/env-map shape directly. deployWithCdnWarmup now returns {url, warmed}; deploy.ts's closing summary states plainly when a promoted version's cache was not confirmed pre-warmed, and a console.warn now fires at the retry-exhausted fallback site that previously logged nothing beyond per-path failures. Also named the decisions that were previously inlined at effectful call-sites (promotionPhaseFor, isFullyWarmed, pickDeployedUrl in cdn-warm-deployment.ts; describeVersionMismatch in cdn-warm.ts) so they're independently readable and testable from the wrangler/fetch calls around them. Added a test covering the previously-uncovered non-strict-promotes-without- confirmed-warmup path. vp check clean; targeted suite 480/480. --- .../cloudflare/src/cdn-warm-deployment.ts | 112 ++++++++++-------- packages/cloudflare/src/cdn-warm.ts | 14 ++- packages/cloudflare/src/deploy.ts | 12 +- packages/cloudflare/src/tpr.ts | 2 +- .../src/wrangler-deployment-target.ts | 53 +++++++++ tests/cloudflare-cdn-warm-deploy.test.ts | 69 ++++++++++- 6 files changed, 202 insertions(+), 60 deletions(-) create mode 100644 packages/cloudflare/src/wrangler-deployment-target.ts diff --git a/packages/cloudflare/src/cdn-warm-deployment.ts b/packages/cloudflare/src/cdn-warm-deployment.ts index 6c4ac8978c..7cf1d2a21a 100644 --- a/packages/cloudflare/src/cdn-warm-deployment.ts +++ b/packages/cloudflare/src/cdn-warm-deployment.ts @@ -14,13 +14,14 @@ * promotion whose CLI process fails ambiguously is reported the same way: * the operator is told to check `wrangler deployments status`, not silently * reconciled. It lives apart from `deploy.ts` so the CLI module stays a thin - * caller and the sequencing can be tested directly. + * caller and the sequencing can be tested directly. Worker-name/host/binding + * resolution lives in `wrangler-deployment-target.ts`, not here — this module + * only sequences wrangler calls against an already-resolved target. */ import { VINEXT_VERSION_METADATA_BINDING } from "vinext/internal/server/worker-version"; -import { warmCdnCache } from "./cdn-warm.js"; +import { warmCdnCache, type CdnWarmResult } from "./cdn-warm.js"; import { formatUnknownError, type DeployOptions } from "./deploy.js"; -import { parseWranglerConfig } from "./tpr.js"; import { runWranglerDeploymentStatus, runWranglerTriggersDeploy, @@ -30,6 +31,33 @@ import { type WranglerVersionDeployResult, type WranglerVersionTraffic, } from "./version-deploy.js"; +import { + getWranglerTargetEnv, + resolveWranglerDeploymentTarget, + type WranglerDeploymentTarget, +} from "./wrangler-deployment-target.js"; + +export type CdnWarmupDeployResult = { + url: string; + /** False whenever the promoted version's cache was not confirmed pre-warmed. */ + warmed: boolean; +}; + +/** First present URL from a wrangler call chain, in order of specificity. */ +function pickDeployedUrl(...candidates: Array): string { + return ( + candidates.find((url): url is string => Boolean(url)) ?? "(URL not detected in wrangler output)" + ); +} + +/** A warm-up only counts as confirmed when every path proved the uploaded version served it. */ +function isFullyWarmed(result: Pick): boolean { + return result.failed === 0; +} + +function promotionPhaseFor(warmed: boolean): "promote-warmed" | "promote-uploaded" { + return warmed ? "promote-warmed" : "promote-uploaded"; +} export async function deployWithCdnWarmup( root: string, @@ -45,8 +73,8 @@ export async function deployWithCdnWarmup( | "warmCdnRetries" | "warmCdnStrict" >, -): Promise { - const wranglerConfig = validateCdnWarmupConfiguration(root, options); +): Promise { + const target = validateCdnWarmupConfiguration(root, options); const upload = runWranglerVersionUpload(root, options); const deployment = readWranglerDeploymentStatus(root, options); const currentVersions = deployment?.versions ?? []; @@ -64,12 +92,10 @@ export async function deployWithCdnWarmup( "promote-uploaded", ); const triggers = runWranglerTriggersDeploy(root, options); - return ( - deployed.deployedUrl ?? - triggers.deployedUrl ?? - upload.previewUrl ?? - "(URL not detected in wrangler output)" - ); + return { + url: pickDeployedUrl(deployed.deployedUrl, triggers.deployedUrl, upload.previewUrl), + warmed: false, + }; } // Workers Cache includes the invoked Worker version in its key unless @@ -88,8 +114,7 @@ export async function deployWithCdnWarmup( let warmed = false; try { const targetUrl = resolveCdnWarmupTargetUrl(root, staged.deployedUrl, options); - const workerName = resolveCdnWarmupWorkerName(wranglerConfig, options); - const headers = buildVersionOverrideHeaders(workerName, upload.versionId); + const headers = buildVersionOverrideHeaders(target.workerName, upload.versionId); if (!targetUrl || !headers) { const message = "CDN pre-warm requires a production URL and Worker name for version overrides."; @@ -106,7 +131,14 @@ export async function deployWithCdnWarmup( retries: options.warmCdnRetries, strict: options.warmCdnStrict, }); - warmed = result.failed === 0; + warmed = isFullyWarmed(result); + if (!warmed) { + console.warn( + ` CDN pre-warm did not confirm all ${result.total} path(s) served the uploaded ` + + "version. Promoting anyway (non-strict) — the deployed version's cache is not " + + "confirmed pre-warmed.", + ); + } } } catch (error) { throw new Error( @@ -122,7 +154,7 @@ export async function deployWithCdnWarmup( root, [{ versionId: upload.versionId, percentage: 100 }], options, - warmed ? "promote-warmed" : "promote-uploaded", + promotionPhaseFor(warmed), ); } catch (error) { throw new Error( @@ -133,13 +165,15 @@ export async function deployWithCdnWarmup( ); } const triggers = applyTriggersAfterPromotion(root, options); - return ( - deployed.deployedUrl ?? - staged.deployedUrl ?? - triggers.deployedUrl ?? - upload.previewUrl ?? - "(URL not detected in wrangler output)" - ); + return { + url: pickDeployedUrl( + deployed.deployedUrl, + staged.deployedUrl, + triggers.deployedUrl, + upload.previewUrl, + ), + warmed, + }; } /** @@ -167,21 +201,20 @@ function applyTriggersAfterPromotion( function validateCdnWarmupConfiguration( root: string, - options: Pick, -): NonNullable> { - const config = parseWranglerConfig(root, options.config); + options: Pick, +): WranglerDeploymentTarget { + const target = resolveWranglerDeploymentTarget(root, options); const envName = getWranglerTargetEnv(options); - const selected = envName ? config?.env?.[envName] : config; const targetLabel = envName ? `Wrangler environment "${envName}"` : "the top-level Wrangler config"; - if (!config || selected?.versionMetadataBinding !== VINEXT_VERSION_METADATA_BINDING) { + if (!target || target.versionMetadataBinding !== VINEXT_VERSION_METADATA_BINDING) { throw new Error( `CDN warmup requires a version_metadata binding named "${VINEXT_VERSION_METADATA_BINDING}" in ${targetLabel}. ` + "Re-run vinext init with CDN warmup enabled or configure the binding before deploying.", ); } - return config; + return target; } function readWranglerDeploymentStatus( @@ -220,19 +253,6 @@ function buildVersionOverrideHeaders( }; } -function resolveCdnWarmupWorkerName( - config: NonNullable>, - options: Pick, -): string | undefined { - if (options.name) return options.name; - const envName = getWranglerTargetEnv(options); - if (!envName) return config.name; - const explicitEnvName = config.env?.[envName]?.name; - if (explicitEnvName) return explicitEnvName; - if (!config.name) return undefined; - return config.legacyEnv === false ? config.name : `${config.name}-${envName}`; -} - export function resolveCdnWarmupTargetUrl(root: string, deployedUrl: string | null): string | null; export function resolveCdnWarmupTargetUrl( root: string, @@ -244,12 +264,6 @@ export function resolveCdnWarmupTargetUrl( deployedUrl: string | null, options?: Pick, ): string | null { - const config = parseWranglerConfig(root, options?.config); - const env = getWranglerTargetEnv(options ?? {}); - const warmupHost = env ? config?.env?.[env]?.warmupHost : config?.warmupHost; - return warmupHost ? `https://${warmupHost}` : deployedUrl; -} - -function getWranglerTargetEnv(options: Pick): string | undefined { - return options.env || (options.preview ? "preview" : undefined); + const target = resolveWranglerDeploymentTarget(root, options ?? {}); + return target?.productionHost ? `https://${target.productionHost}` : deployedUrl; } diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index 53eaa9acb4..b9e12f570f 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -145,6 +145,16 @@ function isRetryableStatus(status: number): boolean { return status === 408 || status === 429 || status >= 500; } +/** Error text for a response that didn't prove it came from the expected Worker version. */ +function describeVersionMismatch( + expectedVersionId: string, + actualVersionId: string | null, +): string { + return actualVersionId + ? `expected Worker version ${expectedVersionId}, received ${actualVersionId}` + : `expected Worker version ${expectedVersionId}, but the response did not include ${VINEXT_WORKER_VERSION_HEADER}`; +} + async function fetchWithTimeout( fetchImpl: typeof fetch, url: URL, @@ -201,9 +211,7 @@ async function warmOnePath( if (options.expectedVersionId) { const actualVersionId = response.headers.get(VINEXT_WORKER_VERSION_HEADER); if (actualVersionId !== options.expectedVersionId) { - lastError = actualVersionId - ? `expected Worker version ${options.expectedVersionId}, received ${actualVersionId}` - : `expected Worker version ${options.expectedVersionId}, but the response did not include ${VINEXT_WORKER_VERSION_HEADER}`; + lastError = describeVersionMismatch(options.expectedVersionId, actualVersionId); if (attempt < options.retries && options.retryDelayMs > 0) { await new Promise((resolve) => setTimeout(resolve, options.retryDelayMs * 2 ** attempt), diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 749b47c91c..9d41c1e4e8 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -706,6 +706,11 @@ export async function deploy(options: DeployOptions): Promise { config: options.config, }; let url: string; + // Non-null only on the CDN-warmup path, so the closing summary can say + // plainly whether the promoted version's cache is actually pre-warmed — + // a bare "Deployed to" line reads as success even when non-strict mode + // fell back to promoting an unconfirmed version. + let warmupStatus: string | null = null; if (options.warmCdnCache && !canVerifyCdnWarmup) { console.log( @@ -718,13 +723,17 @@ export async function deploy(options: DeployOptions): Promise { strict: options.warmCdnStrict, }); if (warmPaths.length > 0) { - url = await deployWithCdnWarmup(root, warmPaths, { + const result = await deployWithCdnWarmup(root, warmPaths, { ...wranglerOptions, warmCdnConcurrency: options.warmCdnConcurrency, warmCdnTimeout: options.warmCdnTimeout, warmCdnRetries: options.warmCdnRetries, warmCdnStrict: options.warmCdnStrict, }); + url = result.url; + warmupStatus = result.warmed + ? "CDN cache: pre-warmed and confirmed before this version took traffic." + : "CDN cache: NOT confirmed pre-warmed — promoted without a verified warm-up."; } else { console.log("\n CDN warmup skipped: no build-discovered paths found."); url = await runWranglerDeploy(root, wranglerOptions); @@ -735,5 +744,6 @@ export async function deploy(options: DeployOptions): Promise { console.log("\n ─────────────────────────────────────────"); console.log(` Deployed to: ${url}`); + if (warmupStatus) console.log(` ${warmupStatus}`); console.log(" ─────────────────────────────────────────\n"); } diff --git a/packages/cloudflare/src/tpr.ts b/packages/cloudflare/src/tpr.ts index e29130c4df..ffc75da508 100644 --- a/packages/cloudflare/src/tpr.ts +++ b/packages/cloudflare/src/tpr.ts @@ -83,7 +83,7 @@ type PrerenderResult = { headers: Record; }; -type WranglerConfig = { +export type WranglerConfig = { accountId?: string; kvNamespaceId?: string; customDomain?: string; diff --git a/packages/cloudflare/src/wrangler-deployment-target.ts b/packages/cloudflare/src/wrangler-deployment-target.ts new file mode 100644 index 0000000000..0ffc1906b5 --- /dev/null +++ b/packages/cloudflare/src/wrangler-deployment-target.ts @@ -0,0 +1,53 @@ +/** + * Resolves the Worker name, production host, and version metadata binding + * that CDN warmup needs to target a deploy, on top of the raw fields + * `parseWranglerConfig` reads out of wrangler.jsonc/.toml. + * + * The env fallback and legacy_env Worker-name suffixing here are CDN-warmup + * resolution rules, not generic Wrangler config fields — keeping them out of + * tpr.ts keeps that module's `WranglerConfig` an honest TPR-input shape + * instead of a shared deployment-semantics owner. + */ + +import type { DeployOptions } from "./deploy.js"; +import { parseWranglerConfig, type WranglerConfig } from "./tpr.js"; + +export type WranglerDeploymentTarget = { + workerName?: string; + productionHost?: string; + versionMetadataBinding?: string; +}; + +export function getWranglerTargetEnv( + options: Pick, +): string | undefined { + return options.env || (options.preview ? "preview" : undefined); +} + +export function resolveWranglerDeploymentTarget( + root: string, + options: Pick, +): WranglerDeploymentTarget | null { + const config = parseWranglerConfig(root, options.config); + if (!config) return null; + const envName = getWranglerTargetEnv(options); + const selected = envName ? config.env?.[envName] : config; + return { + workerName: resolveWorkerName(config, envName, options.name), + productionHost: selected?.warmupHost, + versionMetadataBinding: selected?.versionMetadataBinding, + }; +} + +function resolveWorkerName( + config: WranglerConfig, + envName: string | undefined, + explicitName: string | undefined, +): string | undefined { + if (explicitName) return explicitName; + if (!envName) return config.name; + const explicitEnvName = config.env?.[envName]?.name; + if (explicitEnvName) return explicitEnvName; + if (!config.name) return undefined; + return config.legacyEnv === false ? config.name : `${config.name}-${envName}`; +} diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index e8f5c50019..642eef3116 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -158,11 +158,11 @@ describe("Cloudflare CDN warmup deploy flow", () => { const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/cdn-warm-deployment.js"); - const url = await deployWithCdnWarmup(tmpDir, ["/", "/about"], { + const result = await deployWithCdnWarmup(tmpDir, ["/", "/about"], { warmCdnConcurrency: 1, }); - expect(url).toBe("https://stable.example.workers.dev"); + expect(result).toEqual({ url: "https://stable.example.workers.dev", warmed: true }); expect(events).toEqual([ "upload", "status", @@ -253,9 +253,9 @@ describe("Cloudflare CDN warmup deploy flow", () => { const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/cdn-warm-deployment.js"); - const url = await deployWithCdnWarmup(tmpDir, ["/cached/intro"], {}); + const result = await deployWithCdnWarmup(tmpDir, ["/cached/intro"], {}); - expect(url).toBe("https://workers-cache.vinext.workers.dev"); + expect(result).toEqual({ url: "https://workers-cache.vinext.workers.dev", warmed: true }); expect(events).toEqual([ "upload", "status", @@ -317,6 +317,63 @@ describe("Cloudflare CDN warmup deploy flow", () => { ]); }); + it("promotes without a confirmed warm-up in non-strict mode, and says so instead of a plain success", async () => { + const events: string[] = []; + const warnSpy = vi.spyOn(console, "warn"); + writeFile("wrangler.jsonc", warmupWranglerConfig({ name: "workers-cache" })); + vi.mocked(fetch).mockImplementation(async () => { + events.push("fetch:old-version"); + return versionedResponse(PREVIOUS_VERSION_ID); + }); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) { + events.push("upload"); + return `Uploaded version ${UPLOADED_VERSION_ID}\n`; + } + if (args.includes("status")) { + events.push("status"); + return currentDeploymentOutput(); + } + if (isStage(args)) { + events.push("stage"); + return "Staged version\nhttps://workers-cache.vinext.workers.dev\n"; + } + if (args.includes("triggers")) { + events.push("triggers"); + return "Triggers deployed\n"; + } + if (isPromotion(args)) { + events.push("promote-uploaded"); + return "Promoted version\n"; + } + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + const result = await deployWithCdnWarmup(tmpDir, ["/cached/intro"], { warmCdnRetries: 0 }); + + // Every override request kept hitting the previous version, so the retries + // exhaust without ever confirming the uploaded version served a response — + // non-strict mode still promotes, but must report warmed: false rather than + // silently treating the deploy as a confirmed warm success. + expect(result).toEqual({ + url: "https://workers-cache.vinext.workers.dev", + warmed: false, + }); + expect(events).toEqual([ + "upload", + "status", + "stage", + "fetch:old-version", + "promote-uploaded", + "triggers", + ]); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("did not confirm all 1 path(s) served the uploaded version"), + ); + }); + it("leaves the new version staged at 0% when strict warmup fails, without a restore mutation", async () => { const events: string[] = []; writeFile( @@ -420,12 +477,12 @@ describe("Cloudflare CDN warmup deploy flow", () => { }), ).rejects.toThrow("is staged at 0% and was not promoted"); - const url = await deployWithCdnWarmup(tmpDir, ["/"], { + const result = await deployWithCdnWarmup(tmpDir, ["/"], { warmCdnRetries: 0, warmCdnStrict: true, }); - expect(url).toBe("https://stable.example.workers.dev"); + expect(result).toEqual({ url: "https://stable.example.workers.dev", warmed: true }); expect(events).toEqual([ "upload", "status", From 993aa059fa31040d67ba16fd4b4117621c6ca68d Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:26:26 +1000 Subject: [PATCH 03/22] fix(cloudflare): resolve the deployment target once instead of reparsing config mid-warm-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveCdnWarmupTargetUrl called resolveWranglerDeploymentTarget a second time inside warmAndPromote's warm attempt, reparsing wrangler.jsonc/.toml from disk even though validateCdnWarmupConfiguration had already resolved the same target moments earlier in the same deploy. The duplicate I/O made the WranglerDeploymentTarget abstraction ceremonial rather than real: two call sites could theoretically observe different config state despite describing one deploy. Deleted resolveCdnWarmupTargetUrl. deployWithCdnWarmup now resolves the target once and threads it through; the production-host fallback (`target.productionHost ? https://... : staged.deployedUrl`) is inlined where it's used. Migrated its direct tests in deploy.test.ts onto resolveWranglerDeploymentTarget's `.productionHost` field, which is what they were actually exercising — the deployedUrl fallback itself stays covered by the existing end-to-end warmAndPromote tests. Also split deployWithCdnWarmup into promoteWithoutWarmup and warmAndPromote: they are genuinely different deployment modes (no safe staging split vs. staged-warm-promote) with different guarantees, not just a long function that happened to have an early return. Removed isFullyWarmed — it restated `result.failed === 0` without adding a decision worth naming separately. vp check clean; targeted suite green (deploy.test.ts, cdn-warm-deploy, deploy-prerender-config, cdn-warm, version-deploy, worker-version, init). --- .../cloudflare/src/cdn-warm-deployment.ts | 138 ++++++++++-------- tests/deploy.test.ts | 41 ++---- 2 files changed, 95 insertions(+), 84 deletions(-) diff --git a/packages/cloudflare/src/cdn-warm-deployment.ts b/packages/cloudflare/src/cdn-warm-deployment.ts index 7cf1d2a21a..e73ea95c8f 100644 --- a/packages/cloudflare/src/cdn-warm-deployment.ts +++ b/packages/cloudflare/src/cdn-warm-deployment.ts @@ -20,7 +20,7 @@ */ import { VINEXT_VERSION_METADATA_BINDING } from "vinext/internal/server/worker-version"; -import { warmCdnCache, type CdnWarmResult } from "./cdn-warm.js"; +import { warmCdnCache } from "./cdn-warm.js"; import { formatUnknownError, type DeployOptions } from "./deploy.js"; import { runWranglerDeploymentStatus, @@ -30,6 +30,7 @@ import { type WranglerDeploymentStatus, type WranglerVersionDeployResult, type WranglerVersionTraffic, + type WranglerVersionUploadResult, } from "./version-deploy.js"; import { getWranglerTargetEnv, @@ -50,29 +51,26 @@ function pickDeployedUrl(...candidates: Array): strin ); } -/** A warm-up only counts as confirmed when every path proved the uploaded version served it. */ -function isFullyWarmed(result: Pick): boolean { - return result.failed === 0; -} - function promotionPhaseFor(warmed: boolean): "promote-warmed" | "promote-uploaded" { return warmed ? "promote-warmed" : "promote-uploaded"; } +type CdnWarmupOptions = Pick< + DeployOptions, + | "preview" + | "env" + | "name" + | "config" + | "warmCdnConcurrency" + | "warmCdnTimeout" + | "warmCdnRetries" + | "warmCdnStrict" +>; + export async function deployWithCdnWarmup( root: string, paths: readonly string[], - options: Pick< - DeployOptions, - | "preview" - | "env" - | "name" - | "config" - | "warmCdnConcurrency" - | "warmCdnTimeout" - | "warmCdnRetries" - | "warmCdnStrict" - >, + options: CdnWarmupOptions, ): Promise { const target = validateCdnWarmupConfiguration(root, options); const upload = runWranglerVersionUpload(root, options); @@ -81,39 +79,80 @@ export async function deployWithCdnWarmup( const stagingTraffic = getZeroPercentStagingTraffic(currentVersions, upload.versionId); if (!stagingTraffic) { - const message = - "CDN pre-warm requires the current deployment to contain exactly one version at 100%."; - if (options.warmCdnStrict) throw new Error(message); - console.warn(` ${message} Promoting without pre-warming.`); - const deployed = runWranglerVersionDeploy( - root, - [{ versionId: upload.versionId, percentage: 100 }], - options, - "promote-uploaded", - ); - const triggers = runWranglerTriggersDeploy(root, options); - return { - url: pickDeployedUrl(deployed.deployedUrl, triggers.deployedUrl, upload.previewUrl), - warmed: false, - }; + return promoteWithoutWarmup(root, upload, options); } + return warmAndPromote( + root, + paths, + target, + upload, + currentVersions[0].versionId, + stagingTraffic, + options, + ); +} + +/** + * No safe staging split exists (the deployment isn't a single version at + * 100%), so there is no version-isolated cache partition to warm into. + * Staging is what makes warming safe to attempt at all — without it, this + * mode only promotes directly. Strict mode refuses instead, since it exists + * to guarantee a confirmed warm-up happened. + */ +function promoteWithoutWarmup( + root: string, + upload: WranglerVersionUploadResult, + options: CdnWarmupOptions, +): CdnWarmupDeployResult { + const message = + "CDN pre-warm requires the current deployment to contain exactly one version at 100%."; + if (options.warmCdnStrict) throw new Error(message); + console.warn(` ${message} Promoting without pre-warming.`); + const deployed = runWranglerVersionDeploy( + root, + [{ versionId: upload.versionId, percentage: 100 }], + options, + "promote-uploaded", + ); + const triggers = runWranglerTriggersDeploy(root, options); + return { + url: pickDeployedUrl(deployed.deployedUrl, triggers.deployedUrl, upload.previewUrl), + warmed: false, + }; +} + +/** + * Stage the uploaded version at 0%, attempt a verified warm-up through a + * version override, then promote to 100% and apply triggers. + * + * Triggers (routes/schedules/custom domains) are applied AFTER promotion, not + * before warming. `wrangler triggers deploy` PUTs the script's routes and can + * detach the current production hostname; running it inside the warm window + * means a warm/promote failure leaves production routing pointed at a version + * that never got promoted. Warming instead targets the already-attached + * production host via the version-override header, so the risky window only + * ever leaves the new version staged at 0% — already the safe state. + */ +async function warmAndPromote( + root: string, + paths: readonly string[], + target: WranglerDeploymentTarget, + upload: WranglerVersionUploadResult, + previousVersionId: string, + stagingTraffic: readonly WranglerVersionTraffic[], + options: CdnWarmupOptions, +): Promise { // Workers Cache includes the invoked Worker version in its key unless // cross_version_cache is enabled. Staging lets overrides warm the uploaded // version's partition while a failed override can only touch the old one. const staged = runWranglerVersionDeploy(root, stagingTraffic, options, "stage"); - const previousVersionId = currentVersions[0].versionId; - // Triggers (routes/schedules/custom domains) are applied AFTER promotion, not - // before warming. `wrangler triggers deploy` PUTs the script's routes and can - // detach the current production hostname; running it inside the warm window - // means a warm/promote failure leaves production routing pointed at a version - // that never got promoted. Warming instead targets the already-attached - // production host via the version-override header, so the risky window only - // ever leaves the new version staged at 0% — already the safe state. let warmed = false; try { - const targetUrl = resolveCdnWarmupTargetUrl(root, staged.deployedUrl, options); + const targetUrl = target.productionHost + ? `https://${target.productionHost}` + : staged.deployedUrl; const headers = buildVersionOverrideHeaders(target.workerName, upload.versionId); if (!targetUrl || !headers) { const message = @@ -131,7 +170,7 @@ export async function deployWithCdnWarmup( retries: options.warmCdnRetries, strict: options.warmCdnStrict, }); - warmed = isFullyWarmed(result); + warmed = result.failed === 0; if (!warmed) { console.warn( ` CDN pre-warm did not confirm all ${result.total} path(s) served the uploaded ` + @@ -252,18 +291,3 @@ function buildVersionOverrideHeaders( "Cloudflare-Workers-Version-Overrides": `${workerName}=${quoteStructuredHeaderString(versionId)}`, }; } - -export function resolveCdnWarmupTargetUrl(root: string, deployedUrl: string | null): string | null; -export function resolveCdnWarmupTargetUrl( - root: string, - deployedUrl: string | null, - options: Pick, -): string | null; -export function resolveCdnWarmupTargetUrl( - root: string, - deployedUrl: string | null, - options?: Pick, -): string | null { - const target = resolveWranglerDeploymentTarget(root, options ?? {}); - return target?.productionHost ? `https://${target.productionHost}` : deployedUrl; -} diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 51ee04dbdc..98361de7f1 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -18,7 +18,7 @@ import { validateWranglerEnvName, withCloudflareEnv, } from "../packages/cloudflare/src/deploy.js"; -import { resolveCdnWarmupTargetUrl } from "../packages/cloudflare/src/cdn-warm-deployment.js"; +import { resolveWranglerDeploymentTarget } from "../packages/cloudflare/src/wrangler-deployment-target.js"; import { detectPackageManager, detectPackageManagerName, @@ -3282,7 +3282,10 @@ binding = "STAGING_VERSION" }); }); -describe("resolveCdnWarmupTargetUrl", () => { +describe("resolveWranglerDeploymentTarget — production host", () => { + // The caller (cdn-warm-deployment.ts) falls back to the wrangler-reported + // deployedUrl when productionHost is undefined, so these only assert the + // raw host resolveWranglerDeploymentTarget itself decides on. it("skips a disabled custom domain and uses the next active route", () => { writeFile( tmpDir, @@ -3295,9 +3298,7 @@ describe("resolveCdnWarmupTargetUrl", () => { }), ); - expect(resolveCdnWarmupTargetUrl(tmpDir, "https://worker.example.workers.dev", {})).toBe( - "https://app.example.com", - ); + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBe("app.example.com"); }); it("uses the route pattern host instead of the containing zone", () => { @@ -3309,25 +3310,19 @@ describe("resolveCdnWarmupTargetUrl", () => { }), ); - expect(resolveCdnWarmupTargetUrl(tmpDir, "https://worker.example.workers.dev", {})).toBe( - "https://app.example.com", - ); + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBe("app.example.com"); }); it("supports the singular JSON route property", () => { writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ route: "app.example.com/*" })); - expect(resolveCdnWarmupTargetUrl(tmpDir, "https://worker.example.workers.dev", {})).toBe( - "https://app.example.com", - ); + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBe("app.example.com"); }); it("does not use a route host when the warmup paths may bypass the Worker", () => { writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ route: "app.example.com/api/*" })); - expect(resolveCdnWarmupTargetUrl(tmpDir, "https://worker.example.workers.dev", {})).toBe( - "https://worker.example.workers.dev", - ); + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBeUndefined(); }); it("uses the pattern from a singular TOML route object", () => { @@ -3337,9 +3332,7 @@ describe("resolveCdnWarmupTargetUrl", () => { `route = { zone_name = "example.com", pattern = "app.example.com/*" }`, ); - expect(resolveCdnWarmupTargetUrl(tmpDir, "https://worker.example.workers.dev", {})).toBe( - "https://app.example.com", - ); + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBe("app.example.com"); }); it("does not leak an env route past a commented environment header", () => { @@ -3354,9 +3347,7 @@ describe("resolveCdnWarmupTargetUrl", () => { route = "staging.example.com/*"`, ); - expect(resolveCdnWarmupTargetUrl(tmpDir, "https://my-worker.example.workers.dev", {})).toBe( - "https://my-worker.example.workers.dev", - ); + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBeUndefined(); }); it("does not inherit a top-level route into an explicit environment", () => { @@ -3370,10 +3361,8 @@ route = "staging.example.com/*"`, ); expect( - resolveCdnWarmupTargetUrl(tmpDir, "https://my-worker-staging.example.workers.dev", { - env: "staging", - }), - ).toBe("https://my-worker-staging.example.workers.dev"); + resolveWranglerDeploymentTarget(tmpDir, { env: "staging" })?.productionHost, + ).toBeUndefined(); }); it("skips a disabled custom domain in an inline TOML routes array", () => { @@ -3388,8 +3377,6 @@ routes = [ `, ); - expect(resolveCdnWarmupTargetUrl(tmpDir, "https://worker.example.workers.dev", {})).toBe( - "https://app.example.com", - ); + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBe("app.example.com"); }); }); From f75b156cf50e11473147c78ecd2a96fa40d401ac Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:36:57 +1000 Subject: [PATCH 04/22] fix(cloudflare): guard against staging a duplicate version split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getZeroPercentStagingTraffic didn't check the uploaded version ID against the currently-live one. If upload ever returned an ID matching the version already at 100%, it built a traffic split with the same version listed twice (v@100% v@0%) and handed that straight to `wrangler versions deploy`. Added the equality check so that case falls back to promoteWithoutWarmup like any other unsafe traffic shape, instead of reaching wrangler with a malformed split. Also stopped re-deriving previousVersionId from currentVersions[0] in deployWithCdnWarmup — that index is only safe because a non-null stagingTraffic already proved current has exactly one entry, and that proof lived in a different function. Read it from stagingTraffic[0] instead, the value that was actually checked. --- packages/cloudflare/src/cdn-warm-deployment.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare/src/cdn-warm-deployment.ts b/packages/cloudflare/src/cdn-warm-deployment.ts index e73ea95c8f..ba16f17389 100644 --- a/packages/cloudflare/src/cdn-warm-deployment.ts +++ b/packages/cloudflare/src/cdn-warm-deployment.ts @@ -87,7 +87,7 @@ export async function deployWithCdnWarmup( paths, target, upload, - currentVersions[0].versionId, + stagingTraffic[0].versionId, stagingTraffic, options, ); @@ -275,6 +275,10 @@ function getZeroPercentStagingTraffic( versionId: string, ): WranglerVersionTraffic[] | null { if (current.length !== 1 || current[0].percentage !== 100) return null; + // If the upload ever returns the same version ID already at 100% traffic, + // staging it would produce a duplicate-version split (v@100% v@0%) — fail + // closed instead of handing wrangler a nonsensical traffic split. + if (current[0].versionId === versionId) return null; return [current[0], { versionId, percentage: 0 }]; } From e22bc00aaf4a01a6e836060c11917eaddb966ac2 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:45:31 +1000 Subject: [PATCH 05/22] fix(cloudflare): make verified warmup retries recoverable Generated environment-specific Wrangler configs flatten the selected environment, failed strict warmups leave zero-percent versions behind, and direct fallback promotion can lose trigger recovery context. That can reject a valid retry or obscure the deployed state after a trigger failure. The deployment flow assumed selected environments always remained nested and that every version in a retry status had positive traffic. Recognize generated configs through Wrangler's targetEnvironment marker, replace stale zero-percent versions when staging a fresh attempt, reuse post-promotion trigger diagnostics, and accept trailing TOML route comments when resolving the production host. --- .../cloudflare/src/cdn-warm-deployment.ts | 18 ++++-- packages/cloudflare/src/tpr.ts | 11 +++- .../src/wrangler-deployment-target.ts | 11 +++- tests/cloudflare-cdn-warm-deploy.test.ts | 58 ++++++++++++++++--- tests/deploy.test.ts | 30 ++++++++++ 5 files changed, 114 insertions(+), 14 deletions(-) diff --git a/packages/cloudflare/src/cdn-warm-deployment.ts b/packages/cloudflare/src/cdn-warm-deployment.ts index ba16f17389..4b75944d3b 100644 --- a/packages/cloudflare/src/cdn-warm-deployment.ts +++ b/packages/cloudflare/src/cdn-warm-deployment.ts @@ -115,7 +115,7 @@ function promoteWithoutWarmup( options, "promote-uploaded", ); - const triggers = runWranglerTriggersDeploy(root, options); + const triggers = applyTriggersAfterPromotion(root, options); return { url: pickDeployedUrl(deployed.deployedUrl, triggers.deployedUrl, upload.previewUrl), warmed: false, @@ -274,12 +274,22 @@ function getZeroPercentStagingTraffic( current: readonly WranglerVersionTraffic[], versionId: string, ): WranglerVersionTraffic[] | null { - if (current.length !== 1 || current[0].percentage !== 100) return null; + const productionVersions = current.filter((version) => version.percentage === 100); + if ( + productionVersions.length !== 1 || + current.some((version) => version.percentage !== 0 && version.percentage !== 100) + ) { + return null; + } + const productionVersion = productionVersions[0]; // If the upload ever returns the same version ID already at 100% traffic, // staging it would produce a duplicate-version split (v@100% v@0%) — fail // closed instead of handing wrangler a nonsensical traffic split. - if (current[0].versionId === versionId) return null; - return [current[0], { versionId, percentage: 0 }]; + if (productionVersion.versionId === versionId) return null; + // A failed strict warmup intentionally leaves its upload at 0%. Omit stale + // 0% versions from the replacement split so a fresh attempt can retry from + // the same safe production-at-100% state without manual cleanup. + return [productionVersion, { versionId, percentage: 0 }]; } function quoteStructuredHeaderString(value: string): string { diff --git a/packages/cloudflare/src/tpr.ts b/packages/cloudflare/src/tpr.ts index 85672b3e2c..7328f1670c 100644 --- a/packages/cloudflare/src/tpr.ts +++ b/packages/cloudflare/src/tpr.ts @@ -90,6 +90,7 @@ export type WranglerConfig = { warmupHost?: string; name?: string; legacyEnv?: boolean; + targetEnvironment?: string; versionMetadataBinding?: string; env?: Record; }; @@ -267,6 +268,14 @@ function extractFromJSON(config: Record): WranglerConfig { result.legacyEnv = config.legacy_env; } + // Cloudflare's generated dist/server/wrangler.json is already flattened to + // the environment selected at build time. Wrangler tags that redirected + // config so deploy-time readers can distinguish it from a source config + // that simply omitted the requested env block. + if (typeof config.targetEnvironment === "string" && config.targetEnvironment.length > 0) { + result.targetEnvironment = config.targetEnvironment; + } + // account_id if (typeof config.account_id === "string") { result.accountId = config.account_id; @@ -600,7 +609,7 @@ function extractTomlWarmupHost(section: string): string | null { const inlinePattern = inlineRoute?.match(/\bpattern\s*=\s*"([^"]+)"/)?.[1]; if (inlinePattern) return routePatternToWarmupHost(inlinePattern); - const routesArray = section.match(/^routes\s*=\s*\[([\s\S]*?)\]\s*$/m)?.[1]; + const routesArray = section.match(/^routes\s*=\s*\[([\s\S]*?)\]/m)?.[1]; if (!routesArray) return null; return firstMatch(extractTomlRouteEntries(routesArray), (route) => route.enabled === false ? null : routePatternToWarmupHost(route.pattern), diff --git a/packages/cloudflare/src/wrangler-deployment-target.ts b/packages/cloudflare/src/wrangler-deployment-target.ts index 0ffc1906b5..9455e1116e 100644 --- a/packages/cloudflare/src/wrangler-deployment-target.ts +++ b/packages/cloudflare/src/wrangler-deployment-target.ts @@ -31,9 +31,14 @@ export function resolveWranglerDeploymentTarget( const config = parseWranglerConfig(root, options.config); if (!config) return null; const envName = getWranglerTargetEnv(options); - const selected = envName ? config.env?.[envName] : config; + const flattenedEnvConfig = Boolean( + envName && !config.env?.[envName] && config.targetEnvironment === envName, + ); + const selected = envName + ? (config.env?.[envName] ?? (flattenedEnvConfig ? config : undefined)) + : config; return { - workerName: resolveWorkerName(config, envName, options.name), + workerName: resolveWorkerName(config, envName, flattenedEnvConfig, options.name), productionHost: selected?.warmupHost, versionMetadataBinding: selected?.versionMetadataBinding, }; @@ -42,12 +47,14 @@ export function resolveWranglerDeploymentTarget( function resolveWorkerName( config: WranglerConfig, envName: string | undefined, + flattenedEnvConfig: boolean, explicitName: string | undefined, ): string | undefined { if (explicitName) return explicitName; if (!envName) return config.name; const explicitEnvName = config.env?.[envName]?.name; if (explicitEnvName) return explicitEnvName; + if (flattenedEnvConfig) return config.name; if (!config.name) return undefined; return config.legacyEnv === false ? config.name : `${config.name}-${envName}`; } diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 642eef3116..e6abd9ef6e 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -425,6 +425,10 @@ describe("Cloudflare CDN warmup deploy flow", () => { it("stages and promotes a fresh attempt after a prior warmup left a version staged", async () => { const events: string[] = []; + const failedVersionId = "33333333-3333-4333-8333-333333333333"; + const stagingSplits: string[][] = []; + let uploadAttempts = 0; + let statusReads = 0; writeFile( "wrangler.jsonc", warmupWranglerConfig({ name: "my-worker", route: "app.example.com/*" }), @@ -446,15 +450,29 @@ describe("Cloudflare CDN warmup deploy flow", () => { }); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { if (args.includes("upload")) { - events.push("upload"); - return `Uploaded version ${UPLOADED_VERSION_ID}\n`; + uploadAttempts++; + const versionId = uploadAttempts === 1 ? failedVersionId : UPLOADED_VERSION_ID; + events.push(uploadAttempts === 1 ? "upload:first" : "upload:retry"); + return `Uploaded version ${versionId}\n`; } if (args.includes("status")) { events.push("status"); - return currentDeploymentOutput(); - } - if (isStage(args)) { + statusReads++; + return statusReads === 1 + ? currentDeploymentOutput() + : JSON.stringify({ + versions: [ + { version_id: PREVIOUS_VERSION_ID, percentage: 100 }, + { version_id: failedVersionId, percentage: 0 }, + ], + }); + } + if ( + args.includes(`${PREVIOUS_VERSION_ID}@100%`) && + (args.includes(`${failedVersionId}@0%`) || args.includes(`${UPLOADED_VERSION_ID}@0%`)) + ) { events.push("stage"); + stagingSplits.push(args); return "Staged version\nhttps://stable.example.workers.dev\n"; } if (args.includes("triggers")) { @@ -484,17 +502,18 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(result).toEqual({ url: "https://stable.example.workers.dev", warmed: true }); expect(events).toEqual([ - "upload", + "upload:first", "status", "stage", "fetch:old-version", - "upload", + "upload:retry", "status", "stage", "fetch:new-version", "promote", "triggers", ]); + expect(stagingSplits[1]).not.toContain(`${failedVersionId}@0%`); }); it("surfaces an actionable error when the promotion CLI call fails, without re-reading status or applying triggers", async () => { @@ -555,4 +574,29 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(fetch).not.toHaveBeenCalled(); expect(execFileSyncMock).toHaveBeenCalledTimes(2); }); + + it("reports trigger recovery after a non-strict direct promotion", async () => { + writeFile("wrangler.jsonc", warmupWranglerConfig({ name: "workers-cache" })); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; + if (args.includes("status")) { + return JSON.stringify({ + versions: [ + { version_id: PREVIOUS_VERSION_ID, percentage: 50 }, + { version_id: "33333333-3333-4333-8333-333333333333", percentage: 50 }, + ], + }); + } + if (isPromotion(args)) return "Promoted version\n"; + if (args.includes("triggers")) throw new Error("trigger update failed"); + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + await expect(deployWithCdnWarmup(tmpDir, ["/"], {})).rejects.toThrow( + "The uploaded Worker version was promoted to 100%, but applying triggers", + ); + expect(fetch).not.toHaveBeenCalled(); + }); }); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index a831c0034c..e4e1a8a0f2 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -3379,6 +3379,30 @@ route = "staging.example.com/*"`, ).toBeUndefined(); }); + it("uses a generated config already flattened to the selected environment", () => { + writeFile( + tmpDir, + "dist/server/wrangler.json", + JSON.stringify({ + name: "my-worker-staging", + targetEnvironment: "staging", + route: "staging.example.com/*", + version_metadata: { binding: "VINEXT_VERSION_METADATA" }, + }), + ); + + expect( + resolveWranglerDeploymentTarget(tmpDir, { + env: "staging", + config: "dist/server/wrangler.json", + }), + ).toEqual({ + workerName: "my-worker-staging", + productionHost: "staging.example.com", + versionMetadataBinding: "VINEXT_VERSION_METADATA", + }); + }); + it("skips a disabled custom domain in an inline TOML routes array", () => { writeFile( tmpDir, @@ -3393,4 +3417,10 @@ routes = [ expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBe("app.example.com"); }); + + it("uses an inline TOML routes array followed by a trailing comment", () => { + writeFile(tmpDir, "wrangler.toml", 'routes = ["app.example.com/*"] # production route\n'); + + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBe("app.example.com"); + }); }); From 7dc17469a5f74608bd5baba6b44072d939958605 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 15 Jul 2026 09:30:01 +0100 Subject: [PATCH 06/22] fix(cloudflare): validate CDN warmup cache targets --- .../cloudflare/src/cdn-warm-deployment.ts | 22 +++- packages/cloudflare/src/tpr.ts | 81 +++++++++++++- packages/cloudflare/src/utils/toml.ts | 2 +- .../src/wrangler-deployment-target.ts | 19 +++- tests/cloudflare-cdn-warm-deploy.test.ts | 102 ++++++++++++++++++ tests/deploy-prerender-config.test.ts | 1 + tests/deploy.test.ts | 61 +++++++++++ 7 files changed, 280 insertions(+), 8 deletions(-) diff --git a/packages/cloudflare/src/cdn-warm-deployment.ts b/packages/cloudflare/src/cdn-warm-deployment.ts index 4b75944d3b..79dffee534 100644 --- a/packages/cloudflare/src/cdn-warm-deployment.ts +++ b/packages/cloudflare/src/cdn-warm-deployment.ts @@ -150,13 +150,21 @@ async function warmAndPromote( let warmed = false; try { + // A workers.dev URL is the production cache key only when the deployment + // has no custom routes. Falling back to it for a path-scoped route would + // verify the right Worker version on the wrong hostname and falsely report + // the production cache as warm. const targetUrl = target.productionHost ? `https://${target.productionHost}` - : staged.deployedUrl; + : target.hasProductionRoute + ? undefined + : staged.deployedUrl; const headers = buildVersionOverrideHeaders(target.workerName, upload.versionId); if (!targetUrl || !headers) { const message = - "CDN pre-warm requires a production URL and Worker name for version overrides."; + target.hasProductionRoute && !target.productionHost + ? "CDN pre-warm cannot safely warm path-scoped Worker routes because workers.dev uses a different cache key." + : "CDN pre-warm requires a production URL and Worker name for version overrides."; if (options.warmCdnStrict) throw new Error(message); console.warn(` ${message} Promoting without pre-warming.`); } else { @@ -253,6 +261,16 @@ function validateCdnWarmupConfiguration( "Re-run vinext init with CDN warmup enabled or configure the binding before deploying.", ); } + if (target.crossVersionCache) { + throw new Error( + "CDN warmup requires cache.cross_version_cache to be false because shared cache entries cannot prove the uploaded version populated an isolated cache partition.", + ); + } + if (!target.cacheEnabled) { + throw new Error( + "CDN warmup requires Cloudflare Workers caching to be enabled with cache.enabled = true.", + ); + } return target; } diff --git a/packages/cloudflare/src/tpr.ts b/packages/cloudflare/src/tpr.ts index 7328f1670c..11db8f0259 100644 --- a/packages/cloudflare/src/tpr.ts +++ b/packages/cloudflare/src/tpr.ts @@ -35,6 +35,7 @@ import { extractTomlRoutePatterns, getTomlRootBody, getTomlSections, + stripTomlLineComments, } from "./utils/toml.js"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -85,6 +86,7 @@ type PrerenderResult = { export type WranglerConfig = { accountId?: string; + cache?: WranglerCacheConfig; kvNamespaceId?: string; customDomain?: string; warmupHost?: string; @@ -96,12 +98,18 @@ export type WranglerConfig = { }; export type WranglerEnvironmentConfig = { + cache?: WranglerCacheConfig; customDomain?: string; warmupHost?: string; name?: string; versionMetadataBinding?: string; }; +export type WranglerCacheConfig = { + enabled?: boolean; + crossVersionCache?: boolean; +}; + // ─── Wrangler Config Parsing ───────────────────────────────────────────────── /** @@ -260,6 +268,9 @@ function isJsonTrailingComma(str: string, start: number): boolean { function extractFromJSON(config: Record): WranglerConfig { const result: WranglerConfig = {}; + const cache = extractCacheConfig(config.cache); + if (cache) result.cache = cache; + if (typeof config.name === "string" && config.name.length > 0) { result.name = config.name; } @@ -321,6 +332,7 @@ function extractEnvConfigs(envs: unknown): Record); if ( envConfig.name || + envConfig.cache || envConfig.customDomain || envConfig.warmupHost || envConfig.versionMetadataBinding @@ -333,6 +345,8 @@ function extractEnvConfigs(envs: unknown): Record): WranglerEnvironmentConfig { const result: WranglerEnvironmentConfig = {}; + const cache = extractCacheConfig(config.cache); + if (cache) result.cache = cache; if (typeof config.name === "string" && config.name.length > 0) { result.name = config.name; } @@ -348,6 +362,16 @@ function extractEnvironmentConfig(config: Record): WranglerEnvi return result; } +function extractCacheConfig(value: unknown): WranglerCacheConfig | null { + if (!isUnknownRecord(value)) return null; + const enabled = typeof value.enabled === "boolean" ? value.enabled : undefined; + const crossVersionCache = + typeof value.cross_version_cache === "boolean" ? value.cross_version_cache : undefined; + return enabled === undefined && crossVersionCache === undefined + ? null + : { enabled, crossVersionCache }; +} + function extractVersionMetadataBinding(config: Record): string | null { const metadata = config.version_metadata; return isUnknownRecord(metadata) && @@ -494,6 +518,11 @@ function extractFromTOML(content: string): WranglerConfig { undefined; const rootBody = getTomlRootBody(content); + const rootCache = sections.find((section) => section.header === "cache"); + const cache = + extractTomlCacheConfig(rootBody) ?? + (rootCache ? extractTomlCacheTableConfig(rootCache.body) : null); + if (cache) result.cache = cache; const rootVersionMetadata = sections.find((section) => section.header === "version_metadata"); const versionMetadataBinding = extractTomlVersionMetadataBinding(rootBody) ?? @@ -515,6 +544,8 @@ function extractEnvConfigsFromTOML( const envName = section.header.match(/^env\.([^.]+)$/)?.[1]; if (envName) { const envConfig = result[envName] ?? {}; + const cache = extractTomlCacheConfig(section.body); + if (cache) envConfig.cache = cache; const nameMatch = section.body.match(/^name\s*=\s*"([^"]+)"/m); if (nameMatch) envConfig.name = nameMatch[1]; const domain = @@ -526,6 +557,7 @@ function extractEnvConfigsFromTOML( if (versionMetadataBinding) envConfig.versionMetadataBinding = versionMetadataBinding; if ( envConfig.name || + envConfig.cache || envConfig.customDomain || envConfig.warmupHost || envConfig.versionMetadataBinding @@ -535,6 +567,23 @@ function extractEnvConfigsFromTOML( continue; } + const cacheEnvName = section.header.match(/^env\.([^.]+)\.cache$/)?.[1]; + if (cacheEnvName) { + const envConfig = result[cacheEnvName] ?? {}; + const cache = extractTomlCacheTableConfig(section.body); + if (cache) envConfig.cache = cache; + if ( + envConfig.name || + envConfig.cache || + envConfig.customDomain || + envConfig.warmupHost || + envConfig.versionMetadataBinding + ) { + result[cacheEnvName] = envConfig; + } + continue; + } + const metadataEnvName = section.header.match(/^env\.([^.]+)\.version_metadata$/)?.[1]; if (metadataEnvName) { const envConfig = result[metadataEnvName] ?? {}; @@ -542,6 +591,7 @@ function extractEnvConfigsFromTOML( if (binding) envConfig.versionMetadataBinding = binding; if ( envConfig.name || + envConfig.cache || envConfig.customDomain || envConfig.warmupHost || envConfig.versionMetadataBinding @@ -560,6 +610,7 @@ function extractEnvConfigsFromTOML( if (warmupHost) envConfig.warmupHost = warmupHost; if ( envConfig.name || + envConfig.cache || envConfig.customDomain || envConfig.warmupHost || envConfig.versionMetadataBinding @@ -572,6 +623,21 @@ function extractEnvConfigsFromTOML( return Object.keys(result).length > 0 ? result : undefined; } +function extractTomlCacheConfig(section: string): WranglerCacheConfig | null { + const match = section.match(/^cache\s*=\s*\{([\s\S]*?)\}/m); + return match ? extractTomlCacheTableConfig(match[1] ?? "") : null; +} + +function extractTomlCacheTableConfig(section: string): WranglerCacheConfig | null { + const enabledMatch = section.match(/(?:^|[,\n])\s*enabled\s*=\s*(true|false)\b/); + const crossVersionMatch = section.match(/(?:^|[,\n])\s*cross_version_cache\s*=\s*(true|false)\b/); + if (!enabledMatch && !crossVersionMatch) return null; + return { + enabled: enabledMatch ? enabledMatch[1] === "true" : undefined, + crossVersionCache: crossVersionMatch ? crossVersionMatch[1] === "true" : undefined, + }; +} + function extractTomlVersionMetadataBinding(section: string): string | null { const match = section.match( /^version_metadata\s*=\s*\{[^}]*\bbinding\s*=\s*(?:"([^"]+)"|'([^']+)')[^}]*\}/m, @@ -601,15 +667,22 @@ function extractTomlRoutesArrayDomain(section: string): string | null { } function extractTomlWarmupHost(section: string): string | null { - const scalarRoute = section.match(/^route\s*=\s*"([^"]+)"/m)?.[1]; + const uncommented = stripTomlLineComments(section); + const scalarRoute = uncommented + .match(/^route\s*=\s*(?:"([^"]+)"|'([^']+)')/m) + ?.slice(1) + .find((value): value is string => Boolean(value)); if (scalarRoute) return routePatternToWarmupHost(scalarRoute); - const inlineRoute = section.match(/^route\s*=\s*\{([\s\S]*?)\}\s*$/m)?.[1]; + const inlineRoute = uncommented.match(/^route\s*=\s*\{([\s\S]*?)\}\s*$/m)?.[1]; if (inlineRoute && /\benabled\s*=\s*false\b/.test(inlineRoute)) return null; - const inlinePattern = inlineRoute?.match(/\bpattern\s*=\s*"([^"]+)"/)?.[1]; + const inlinePattern = inlineRoute + ?.match(/\bpattern\s*=\s*(?:"([^"]+)"|'([^']+)')/) + ?.slice(1) + .find((value): value is string => Boolean(value)); if (inlinePattern) return routePatternToWarmupHost(inlinePattern); - const routesArray = section.match(/^routes\s*=\s*\[([\s\S]*?)\]/m)?.[1]; + const routesArray = uncommented.match(/^routes\s*=\s*\[([\s\S]*?)\]/m)?.[1]; if (!routesArray) return null; return firstMatch(extractTomlRouteEntries(routesArray), (route) => route.enabled === false ? null : routePatternToWarmupHost(route.pattern), diff --git a/packages/cloudflare/src/utils/toml.ts b/packages/cloudflare/src/utils/toml.ts index b100b26e2b..ad39696c2e 100644 --- a/packages/cloudflare/src/utils/toml.ts +++ b/packages/cloudflare/src/utils/toml.ts @@ -25,7 +25,7 @@ export type TomlRouteEntry = { * strings use `'` with no escapes. Callers only pass single-line string values * (route patterns), so `"""`/`'''` multiline forms are not handled. */ -function stripTomlLineComments(body: string): string { +export function stripTomlLineComments(body: string): string { let result = ""; let quote: '"' | "'" | null = null; let escaped = false; diff --git a/packages/cloudflare/src/wrangler-deployment-target.ts b/packages/cloudflare/src/wrangler-deployment-target.ts index 9455e1116e..b5f51a6a00 100644 --- a/packages/cloudflare/src/wrangler-deployment-target.ts +++ b/packages/cloudflare/src/wrangler-deployment-target.ts @@ -10,9 +10,12 @@ */ import type { DeployOptions } from "./deploy.js"; -import { parseWranglerConfig, type WranglerConfig } from "./tpr.js"; +import { parseWranglerConfig, type WranglerCacheConfig, type WranglerConfig } from "./tpr.js"; export type WranglerDeploymentTarget = { + cacheEnabled?: boolean; + crossVersionCache?: boolean; + hasProductionRoute: boolean; workerName?: string; productionHost?: string; versionMetadataBinding?: string; @@ -37,13 +40,27 @@ export function resolveWranglerDeploymentTarget( const selected = envName ? (config.env?.[envName] ?? (flattenedEnvConfig ? config : undefined)) : config; + const cache = resolveCacheConfig(config, envName, flattenedEnvConfig); return { + cacheEnabled: cache?.enabled, + crossVersionCache: cache?.crossVersionCache, + hasProductionRoute: Boolean(selected?.customDomain), workerName: resolveWorkerName(config, envName, flattenedEnvConfig, options.name), productionHost: selected?.warmupHost, versionMetadataBinding: selected?.versionMetadataBinding, }; } +/** Wrangler inherits the whole cache object only when an env omits it. */ +function resolveCacheConfig( + config: WranglerConfig, + envName: string | undefined, + flattenedEnvConfig: boolean, +): WranglerCacheConfig | undefined { + if (!envName || flattenedEnvConfig) return config.cache; + return config.env?.[envName]?.cache ?? config.cache; +} + function resolveWorkerName( config: WranglerConfig, envName: string | undefined, diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index e6abd9ef6e..f7d4fbb757 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -49,6 +49,7 @@ function warmupWranglerConfig(config: Record): string { : undefined; return JSON.stringify({ ...config, + cache: { enabled: true, ...(config.cache as Record | undefined) }, version_metadata: { binding: "VINEXT_VERSION_METADATA" }, ...(configuredEnv ? { env: configuredEnv } : {}), }); @@ -117,6 +118,71 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(execFileSyncMock).not.toHaveBeenCalled(); }); + it("rejects a deploy whose Worker cache is disabled before upload", async () => { + writeFile( + "wrangler.jsonc", + JSON.stringify({ + name: "my-worker", + cache: { enabled: false }, + version_metadata: { binding: "VINEXT_VERSION_METADATA" }, + }), + ); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + await expect(deployWithCdnWarmup(tmpDir, ["/"], {})).rejects.toThrow( + "requires Cloudflare Workers caching to be enabled", + ); + expect(execFileSyncMock).not.toHaveBeenCalled(); + }); + + it("rejects cross-version caching before upload", async () => { + writeFile( + "wrangler.jsonc", + warmupWranglerConfig({ + name: "my-worker", + cache: { cross_version_cache: true }, + }), + ); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + await expect(deployWithCdnWarmup(tmpDir, ["/"], {})).rejects.toThrow( + "requires cache.cross_version_cache to be false", + ); + expect(execFileSyncMock).not.toHaveBeenCalled(); + }); + + it("uses an environment-local cache block instead of the inherited top-level block", async () => { + writeFile( + "wrangler.jsonc", + warmupWranglerConfig({ + name: "my-worker", + cache: { enabled: true, cross_version_cache: true }, + env: { + staging: { + name: "my-worker-staging", + cache: { enabled: true }, + }, + }, + }), + ); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; + if (args.includes("status")) return currentDeploymentOutput(); + if (isStage(args)) return "Staged version\nhttps://my-worker-staging.workers.dev\n"; + if (isPromotion(args)) return "Promoted version\n"; + if (args.includes("triggers")) return "Triggers deployed\n"; + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + await expect(deployWithCdnWarmup(tmpDir, ["/"], { env: "staging" })).resolves.toMatchObject({ + warmed: true, + }); + }); + it("stages, warms the exact route with a version override, then promotes", async () => { const events: string[] = []; writeFile( @@ -266,6 +332,42 @@ describe("Cloudflare CDN warmup deploy flow", () => { ]); }); + it("does not claim workers.dev is warm for a path-scoped production route", async () => { + const events: string[] = []; + writeFile( + "wrangler.jsonc", + warmupWranglerConfig({ name: "workers-cache", route: "app.example.com/api/*" }), + ); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; + if (args.includes("status")) return currentDeploymentOutput(); + if (isStage(args)) { + events.push("stage"); + return "Staged version\nhttps://workers-cache.vinext.workers.dev\n"; + } + if (isPromotion(args)) { + events.push("promote"); + return "Promoted version\n"; + } + if (args.includes("triggers")) { + events.push("triggers"); + return "Triggers deployed\n"; + } + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const warnSpy = vi.spyOn(console, "warn"); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + await expect(deployWithCdnWarmup(tmpDir, ["/api/docs"], {})).resolves.toEqual({ + url: "https://workers-cache.vinext.workers.dev", + warmed: false, + }); + expect(fetch).not.toHaveBeenCalled(); + expect(events).toEqual(["stage", "promote", "triggers"]); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("path-scoped Worker routes")); + }); + it("retries a failed override before promoting the uploaded version", async () => { const events: string[] = []; writeFile("wrangler.jsonc", warmupWranglerConfig({ name: "workers-cache" })); diff --git a/tests/deploy-prerender-config.test.ts b/tests/deploy-prerender-config.test.ts index 58f0a91dc7..018a5c851f 100644 --- a/tests/deploy-prerender-config.test.ts +++ b/tests/deploy-prerender-config.test.ts @@ -173,6 +173,7 @@ function configureCdnWarmupVersionMetadata(): void { JSON.stringify({ main: "vinext/server/app-router-entry", assets: { directory: "dist/client" }, + cache: { enabled: true }, version_metadata: { binding: "VINEXT_VERSION_METADATA" }, }), ); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index e4e1a8a0f2..00db47239c 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -3204,6 +3204,54 @@ describe("parseWranglerConfig — custom domain extraction", () => { expect(config?.kvNamespaceId).toBe("abc123"); }); + it("extracts top-level and environment-local Worker cache settings", () => { + writeFile( + tmpDir, + "wrangler.jsonc", + JSON.stringify({ + cache: { enabled: true, cross_version_cache: true }, + env: { staging: { cache: { enabled: true } } }, + }), + ); + + const config = parseWranglerConfig(tmpDir); + expect(config?.cache).toEqual({ enabled: true, crossVersionCache: true }); + expect(config?.env?.staging?.cache).toEqual({ + enabled: true, + crossVersionCache: undefined, + }); + }); + + it("extracts top-level and environment-local TOML Worker cache settings", () => { + writeFile( + tmpDir, + "wrangler.toml", + `[cache] +enabled = true +cross_version_cache = true + +[env.staging.cache] +enabled = true +`, + ); + + const config = parseWranglerConfig(tmpDir); + expect(config?.cache).toEqual({ enabled: true, crossVersionCache: true }); + expect(config?.env?.staging?.cache).toEqual({ + enabled: true, + crossVersionCache: undefined, + }); + }); + + it("extracts an inline TOML Worker cache table", () => { + writeFile(tmpDir, "wrangler.toml", "cache = { enabled = true, cross_version_cache = false }\n"); + + expect(parseWranglerConfig(tmpDir)?.cache).toEqual({ + enabled: true, + crossVersionCache: false, + }); + }); + it("extracts environment Worker names and custom domains", () => { writeFile( tmpDir, @@ -3397,6 +3445,9 @@ route = "staging.example.com/*"`, config: "dist/server/wrangler.json", }), ).toEqual({ + cacheEnabled: undefined, + crossVersionCache: undefined, + hasProductionRoute: true, workerName: "my-worker-staging", productionHost: "staging.example.com", versionMetadataBinding: "VINEXT_VERSION_METADATA", @@ -3423,4 +3474,14 @@ routes = [ expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBe("app.example.com"); }); + + it("uses an inline TOML route object followed by a trailing comment", () => { + writeFile( + tmpDir, + "wrangler.toml", + `route = { pattern = 'app.example.com/*', custom_domain = true } # production route\n`, + ); + + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBe("app.example.com"); + }); }); From 06e9343b9646f375555b0accdbf3bc77cb525ddf Mon Sep 17 00:00:00 2001 From: James Date: Wed, 15 Jul 2026 23:50:52 +0100 Subject: [PATCH 07/22] fix(cloudflare): address CDN warmup review feedback --- .../cloudflare/src/cdn-warm-deployment.ts | 4 ++- packages/cloudflare/src/cdn-warm.ts | 17 +++++++--- packages/cloudflare/src/deploy-help.ts | 1 + tests/cloudflare-cdn-warm-deploy.test.ts | 2 +- tests/cloudflare-cdn-warm.test.ts | 32 +++++++++++++++++++ tests/deploy.test.ts | 7 ++++ 6 files changed, 56 insertions(+), 7 deletions(-) diff --git a/packages/cloudflare/src/cdn-warm-deployment.ts b/packages/cloudflare/src/cdn-warm-deployment.ts index 79dffee534..c5d4cf9a46 100644 --- a/packages/cloudflare/src/cdn-warm-deployment.ts +++ b/packages/cloudflare/src/cdn-warm-deployment.ts @@ -107,7 +107,9 @@ function promoteWithoutWarmup( ): CdnWarmupDeployResult { const message = "CDN pre-warm requires the current deployment to contain exactly one version at 100%."; - if (options.warmCdnStrict) throw new Error(message); + if (options.warmCdnStrict) { + throw new Error(`${message} Uploaded Worker version ${upload.versionId} remains undeployed.`); + } console.warn(` ${message} Promoting without pre-warming.`); const deployed = runWranglerVersionDeploy( root, diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index b9e12f570f..e03be219ba 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -145,6 +145,15 @@ function isRetryableStatus(status: number): boolean { return status === 408 || status === 429 || status >= 500; } +async function waitBeforeRetry( + attempt: number, + retries: number, + retryDelayMs: number, +): Promise { + if (attempt >= retries || retryDelayMs === 0) return; + await new Promise((resolve) => setTimeout(resolve, retryDelayMs * 2 ** attempt)); +} + /** Error text for a response that didn't prove it came from the expected Worker version. */ function describeVersionMismatch( expectedVersionId: string, @@ -212,11 +221,7 @@ async function warmOnePath( const actualVersionId = response.headers.get(VINEXT_WORKER_VERSION_HEADER); if (actualVersionId !== options.expectedVersionId) { lastError = describeVersionMismatch(options.expectedVersionId, actualVersionId); - if (attempt < options.retries && options.retryDelayMs > 0) { - await new Promise((resolve) => - setTimeout(resolve, options.retryDelayMs * 2 ** attempt), - ); - } + await waitBeforeRetry(attempt, options.retries, options.retryDelayMs); continue; } } @@ -227,12 +232,14 @@ async function warmOnePath( lastError = `HTTP ${response.status}`; if (!isRetryableStatus(response.status)) break; + await waitBeforeRetry(attempt, options.retries, options.retryDelayMs); } catch (error) { if (error instanceof DOMException && error.name === "AbortError") { lastError = `timed out after ${options.timeoutMs}ms`; } else { lastError = error instanceof Error ? error.message : String(error); } + await waitBeforeRetry(attempt, options.retries, options.retryDelayMs); } } diff --git a/packages/cloudflare/src/deploy-help.ts b/packages/cloudflare/src/deploy-help.ts index 3fc7c67f23..baa866491f 100644 --- a/packages/cloudflare/src/deploy-help.ts +++ b/packages/cloudflare/src/deploy-help.ts @@ -31,6 +31,7 @@ export function formatDeployHelp(): string { --warm-cdn-retries Retries for warmup failures (default: 3) --warm-cdn-strict Fail when staging or any CDN warmup request fails. The previous version remains at 100% until warming succeeds + (static exports skip Worker-version warmup because Assets serve them) --warm-cdn-include-fallbacks Also warm PPR fallback-shell placeholder paths -h, --help Show this help diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index f7d4fbb757..6dbfc61866 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -671,7 +671,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { await import("../packages/cloudflare/src/cdn-warm-deployment.js"); await expect(deployWithCdnWarmup(tmpDir, ["/"], { warmCdnStrict: true })).rejects.toThrow( - "requires the current deployment to contain exactly one version at 100%", + `requires the current deployment to contain exactly one version at 100%. Uploaded Worker version ${UPLOADED_VERSION_ID} remains undeployed`, ); expect(fetch).not.toHaveBeenCalled(); expect(execFileSyncMock).toHaveBeenCalledTimes(2); diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts index 1b5814f872..e62909d0a6 100644 --- a/tests/cloudflare-cdn-warm.test.ts +++ b/tests/cloudflare-cdn-warm.test.ts @@ -375,6 +375,38 @@ describe("Cloudflare CDN warmup", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it.each([ + [ + "retryable status", + async (): Promise => new Response("unavailable", { status: 503 }), + ], + ["network error", async (): Promise => Promise.reject(new Error("connection reset"))], + ])("backs off before retrying a %s", async (_case, firstResult) => { + vi.useFakeTimers(); + try { + const fetchMock = vi + .fn() + .mockImplementationOnce(firstResult) + .mockResolvedValueOnce(new Response("ok", { status: 200 })); + + const resultPromise = warmCdnCache({ + targetUrl: "https://app.example.com", + paths: ["/"], + retries: 1, + retryDelayMs: 250, + fetchImpl: fetchMock, + }); + + await vi.advanceTimersByTimeAsync(249); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + await expect(resultPromise).resolves.toMatchObject({ total: 1, warmed: 1, failed: 0 }); + expect(fetchMock).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + it("does not count an unverified 200 cache hit as warmed", async () => { const fetchMock = vi.fn().mockImplementation( async () => diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 00db47239c..9543df951c 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -62,6 +62,7 @@ import { resolveStaticAssetSignal, } from "../packages/vinext/src/server/worker-utils.js"; import { domainCandidates, parseWranglerConfig, runTPR } from "../packages/cloudflare/src/tpr.js"; +import { formatDeployHelp } from "../packages/cloudflare/src/deploy-help.js"; // ─── Test Helpers ──────────────────────────────────────────────────────────── @@ -724,6 +725,12 @@ describe("parseDeployArgs", () => { expect(parsed.warmCdnIncludeFallbacks).toBe(true); }); + it("documents that static exports skip Worker-version warmup", () => { + expect(formatDeployHelp()).toContain( + "static exports skip Worker-version warmup because Assets serve them", + ); + }); + it("throws for invalid CDN warmup numeric flags", () => { expect(() => parseDeployArgs(["--warm-cdn-concurrency=0"])).toThrow( '--warm-cdn-concurrency expects a positive integer, but got "0".', From 865c0717bcdde9fa122f183fe9fa15e89baae94c Mon Sep 17 00:00:00 2001 From: James Date: Thu, 16 Jul 2026 00:00:11 +0100 Subject: [PATCH 08/22] fix(cloudflare): clarify CDN warmup diagnostics --- packages/cloudflare/src/cdn-warm-deployment.ts | 2 +- packages/cloudflare/src/deploy-help.ts | 1 + tests/cloudflare-cdn-warm-deploy.test.ts | 2 +- tests/deploy.test.ts | 8 ++++---- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/cloudflare/src/cdn-warm-deployment.ts b/packages/cloudflare/src/cdn-warm-deployment.ts index c5d4cf9a46..a46459c8fa 100644 --- a/packages/cloudflare/src/cdn-warm-deployment.ts +++ b/packages/cloudflare/src/cdn-warm-deployment.ts @@ -183,7 +183,7 @@ async function warmAndPromote( warmed = result.failed === 0; if (!warmed) { console.warn( - ` CDN pre-warm did not confirm all ${result.total} path(s) served the uploaded ` + + ` CDN pre-warm confirmed ${result.warmed}/${result.total} path(s) served the uploaded ` + "version. Promoting anyway (non-strict) — the deployed version's cache is not " + "confirmed pre-warmed.", ); diff --git a/packages/cloudflare/src/deploy-help.ts b/packages/cloudflare/src/deploy-help.ts index baa866491f..360ac26239 100644 --- a/packages/cloudflare/src/deploy-help.ts +++ b/packages/cloudflare/src/deploy-help.ts @@ -29,6 +29,7 @@ export function formatDeployHelp(): string { Maximum number of CDN warmup requests in parallel --warm-cdn-timeout Per-request CDN warmup timeout (default: 5000) --warm-cdn-retries Retries for warmup failures (default: 3) + Increase when Worker version propagation is slow --warm-cdn-strict Fail when staging or any CDN warmup request fails. The previous version remains at 100% until warming succeeds (static exports skip Worker-version warmup because Assets serve them) diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 6dbfc61866..613bf475e9 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -472,7 +472,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { "triggers", ]); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("did not confirm all 1 path(s) served the uploaded version"), + expect.stringContaining("confirmed 0/1 path(s) served the uploaded version"), ); }); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 9543df951c..e0b8c89bbd 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -725,10 +725,10 @@ describe("parseDeployArgs", () => { expect(parsed.warmCdnIncludeFallbacks).toBe(true); }); - it("documents that static exports skip Worker-version warmup", () => { - expect(formatDeployHelp()).toContain( - "static exports skip Worker-version warmup because Assets serve them", - ); + it("documents CDN warmup retry tuning and the static-export carve-out", () => { + const help = formatDeployHelp(); + expect(help).toContain("static exports skip Worker-version warmup because Assets serve them"); + expect(help).toContain("Increase when Worker version propagation is slow"); }); it("throws for invalid CDN warmup numeric flags", () => { From 5f0be5c54d59bdfa902cdae0b13cb319dfe96bc8 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 16 Jul 2026 00:09:18 +0100 Subject: [PATCH 09/22] fix(cloudflare): clarify CDN warmup fallback failures --- .../cloudflare/src/cdn-warm-deployment.ts | 24 +++++++++++-------- packages/cloudflare/src/cdn-warm.ts | 3 +++ tests/cloudflare-cdn-warm-deploy.test.ts | 24 +++++++++++++++++++ tests/cloudflare-cdn-warm.test.ts | 21 ++++++++++++++++ 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/packages/cloudflare/src/cdn-warm-deployment.ts b/packages/cloudflare/src/cdn-warm-deployment.ts index a46459c8fa..d4560211a0 100644 --- a/packages/cloudflare/src/cdn-warm-deployment.ts +++ b/packages/cloudflare/src/cdn-warm-deployment.ts @@ -74,8 +74,16 @@ export async function deployWithCdnWarmup( ): Promise { const target = validateCdnWarmupConfiguration(root, options); const upload = runWranglerVersionUpload(root, options); - const deployment = readWranglerDeploymentStatus(root, options); - const currentVersions = deployment?.versions ?? []; + const statusRead = readWranglerDeploymentStatus(root, options); + if ("error" in statusRead) { + return promoteWithoutWarmup( + root, + upload, + options, + `CDN pre-warm could not read the current deployment (${statusRead.error}).`, + ); + } + const currentVersions = statusRead.deployment.versions; const stagingTraffic = getZeroPercentStagingTraffic(currentVersions, upload.versionId); if (!stagingTraffic) { @@ -104,9 +112,8 @@ function promoteWithoutWarmup( root: string, upload: WranglerVersionUploadResult, options: CdnWarmupOptions, + message = "CDN pre-warm requires the current deployment to contain exactly one version at 100%.", ): CdnWarmupDeployResult { - const message = - "CDN pre-warm requires the current deployment to contain exactly one version at 100%."; if (options.warmCdnStrict) { throw new Error(`${message} Uploaded Worker version ${upload.versionId} remains undeployed.`); } @@ -279,14 +286,11 @@ function validateCdnWarmupConfiguration( function readWranglerDeploymentStatus( root: string, options: Pick, -): WranglerDeploymentStatus | null { +): { deployment: WranglerDeploymentStatus } | { error: string } { try { - return runWranglerDeploymentStatus(root, options); + return { deployment: runWranglerDeploymentStatus(root, options) }; } catch (error) { - console.warn( - ` CDN pre-warm could not read the current deployment: ${formatUnknownError(error)}`, - ); - return null; + return { error: formatUnknownError(error) }; } } diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index e03be219ba..bcd4934773 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -230,6 +230,9 @@ async function warmOnePath( return { path: pathname, ok: true }; } + // These paths came from this build's prerender manifest, so even a 4xx + // from the expected Worker version means the intended cache entry was + // not populated and must remain a warmup failure. lastError = `HTTP ${response.status}`; if (!isRetryableStatus(response.status)) break; await waitBeforeRetry(attempt, options.retries, options.retryDelayMs); diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 613bf475e9..c5bb8e82bf 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -677,6 +677,30 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(execFileSyncMock).toHaveBeenCalledTimes(2); }); + it("reports a deployment-status read failure before promoting without warmup", async () => { + const warnSpy = vi.spyOn(console, "warn"); + writeFile("wrangler.jsonc", warmupWranglerConfig({ name: "workers-cache" })); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; + if (args.includes("status")) throw new Error("status request timed out"); + if (isPromotion(args)) return "Promoted version\n"; + if (args.includes("triggers")) return "Triggers deployed\n"; + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + await expect(deployWithCdnWarmup(tmpDir, ["/"], {})).resolves.toMatchObject({ + warmed: false, + }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "CDN pre-warm could not read the current deployment (status request timed out)", + ), + ); + expect(fetch).not.toHaveBeenCalled(); + }); + it("reports trigger recovery after a non-strict direct promotion", async () => { writeFile("wrangler.jsonc", warmupWranglerConfig({ name: "workers-cache" })); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts index e62909d0a6..86ef3cbb4e 100644 --- a/tests/cloudflare-cdn-warm.test.ts +++ b/tests/cloudflare-cdn-warm.test.ts @@ -480,6 +480,27 @@ describe("Cloudflare CDN warmup", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it("fails when a build-discovered path returns a 404 from the expected version", async () => { + const expectedVersionId = "22222222-2222-4222-8222-222222222222"; + const fetchMock = vi.fn().mockResolvedValue( + new Response("not found", { + status: 404, + headers: { "x-vinext-worker-version": expectedVersionId }, + }), + ); + + const result = await warmCdnCache({ + targetUrl: "https://app.example.com", + paths: ["/removed"], + expectedVersionId, + retries: 0, + fetchImpl: fetchMock, + }); + + expect(result).toMatchObject({ total: 1, warmed: 0, failed: 1 }); + expect(result.failures[0]?.error).toBe("HTTP 404"); + }); + it("reports warmup failures and throws in strict mode", async () => { writeFile( "dist/server/vinext-prerender.json", From 6f014fbba4a44614c59583708a2993d398a63b48 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 16 Jul 2026 00:16:45 +0100 Subject: [PATCH 10/22] fix(cloudflare): harden warmup config edge cases --- .../cloudflare/src/cdn-warm-deployment.ts | 16 +++++++++++- packages/cloudflare/src/tpr.ts | 4 +-- tests/cloudflare-cdn-warm-deploy.test.ts | 26 +++++++++++++++++++ tests/deploy.test.ts | 20 ++++++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/cloudflare/src/cdn-warm-deployment.ts b/packages/cloudflare/src/cdn-warm-deployment.ts index d4560211a0..85e6a63065 100644 --- a/packages/cloudflare/src/cdn-warm-deployment.ts +++ b/packages/cloudflare/src/cdn-warm-deployment.ts @@ -87,6 +87,19 @@ export async function deployWithCdnWarmup( const stagingTraffic = getZeroPercentStagingTraffic(currentVersions, upload.versionId); if (!stagingTraffic) { + if ( + currentVersions.some( + (version) => version.versionId === upload.versionId && version.percentage === 100, + ) + ) { + return promoteWithoutWarmup( + root, + upload, + options, + `CDN pre-warm cannot stage Worker version ${upload.versionId} because it is already serving 100% traffic.`, + `Worker version ${upload.versionId} remains at 100% traffic.`, + ); + } return promoteWithoutWarmup(root, upload, options); } @@ -113,9 +126,10 @@ function promoteWithoutWarmup( upload: WranglerVersionUploadResult, options: CdnWarmupOptions, message = "CDN pre-warm requires the current deployment to contain exactly one version at 100%.", + strictUploadState = `Uploaded Worker version ${upload.versionId} remains undeployed.`, ): CdnWarmupDeployResult { if (options.warmCdnStrict) { - throw new Error(`${message} Uploaded Worker version ${upload.versionId} remains undeployed.`); + throw new Error(`${message} ${strictUploadState}`); } console.warn(` ${message} Promoting without pre-warming.`); const deployed = runWranglerVersionDeploy( diff --git a/packages/cloudflare/src/tpr.ts b/packages/cloudflare/src/tpr.ts index 11db8f0259..da72bce92c 100644 --- a/packages/cloudflare/src/tpr.ts +++ b/packages/cloudflare/src/tpr.ts @@ -624,7 +624,7 @@ function extractEnvConfigsFromTOML( } function extractTomlCacheConfig(section: string): WranglerCacheConfig | null { - const match = section.match(/^cache\s*=\s*\{([\s\S]*?)\}/m); + const match = stripTomlLineComments(section).match(/^cache\s*=\s*\{([\s\S]*?)\}/m); return match ? extractTomlCacheTableConfig(match[1] ?? "") : null; } @@ -639,7 +639,7 @@ function extractTomlCacheTableConfig(section: string): WranglerCacheConfig | nul } function extractTomlVersionMetadataBinding(section: string): string | null { - const match = section.match( + const match = stripTomlLineComments(section).match( /^version_metadata\s*=\s*\{[^}]*\bbinding\s*=\s*(?:"([^"]+)"|'([^']+)')[^}]*\}/m, ); return match?.slice(1).find((value): value is string => Boolean(value)) ?? null; diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index c5bb8e82bf..9096cdd1c8 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -677,6 +677,32 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(execFileSyncMock).toHaveBeenCalledTimes(2); }); + it("does not stage a duplicate split when the upload is already at 100%", async () => { + const warnSpy = vi.spyOn(console, "warn"); + writeFile("wrangler.jsonc", warmupWranglerConfig({ name: "workers-cache" })); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) return `Uploaded version ${PREVIOUS_VERSION_ID}\n`; + if (args.includes("status")) return currentDeploymentOutput(); + if (args.includes(`${PREVIOUS_VERSION_ID}@100%`)) return "Promoted version\n"; + if (args.includes("triggers")) return "Triggers deployed\n"; + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + await expect(deployWithCdnWarmup(tmpDir, ["/"], {})).resolves.toMatchObject({ warmed: false }); + expect(fetch).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("because it is already serving 100% traffic"), + ); + expect(execFileSyncMock).toHaveBeenCalledTimes(4); + expect( + execFileSyncMock.mock.calls.some(([, args]) => + (args as string[]).some((arg) => arg === `${PREVIOUS_VERSION_ID}@0%`), + ), + ).toBe(false); + }); + it("reports a deployment-status read failure before promoting without warmup", async () => { const warnSpy = vi.spyOn(console, "warn"); writeFile("wrangler.jsonc", warmupWranglerConfig({ name: "workers-cache" })); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index e0b8c89bbd..d661edc258 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -3259,6 +3259,26 @@ enabled = true }); }); + it("ignores braces in TOML inline-table comments", () => { + writeFile( + tmpDir, + "wrangler.toml", + `cache = { + enabled = true, # misleading } + cross_version_cache = false +} +version_metadata = { + # another misleading } + binding = "VINEXT_VERSION_METADATA" +} +`, + ); + + const config = parseWranglerConfig(tmpDir); + expect(config?.cache).toEqual({ enabled: true, crossVersionCache: false }); + expect(config?.versionMetadataBinding).toBe("VINEXT_VERSION_METADATA"); + }); + it("extracts environment Worker names and custom domains", () => { writeFile( tmpDir, From 28641d8d7aa1af19da4d0354665e139d0abcbf03 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 16 Jul 2026 00:24:20 +0100 Subject: [PATCH 11/22] fix(cloudflare): improve warmup target diagnostics --- packages/cloudflare/src/cdn-warm-deployment.ts | 10 +++++++++- packages/cloudflare/src/tpr.ts | 9 ++++++++- tests/cloudflare-cdn-warm-deploy.test.ts | 8 +++++++- tests/deploy.test.ts | 12 ++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/cloudflare/src/cdn-warm-deployment.ts b/packages/cloudflare/src/cdn-warm-deployment.ts index 85e6a63065..499514957d 100644 --- a/packages/cloudflare/src/cdn-warm-deployment.ts +++ b/packages/cloudflare/src/cdn-warm-deployment.ts @@ -100,7 +100,15 @@ export async function deployWithCdnWarmup( `Worker version ${upload.versionId} remains at 100% traffic.`, ); } - return promoteWithoutWarmup(root, upload, options); + const observedTraffic = currentVersions.length + ? currentVersions.map((version) => `${version.versionId}@${version.percentage}%`).join(", ") + : "no deployed versions"; + return promoteWithoutWarmup( + root, + upload, + options, + `CDN pre-warm requires the current deployment to contain exactly one version at 100%. Observed traffic: ${observedTraffic}.`, + ); } return warmAndPromote( diff --git a/packages/cloudflare/src/tpr.ts b/packages/cloudflare/src/tpr.ts index da72bce92c..3ad385986e 100644 --- a/packages/cloudflare/src/tpr.ts +++ b/packages/cloudflare/src/tpr.ts @@ -409,7 +409,7 @@ function extractWarmupHost(config: Record): string | null { const fromRoutes = Array.isArray(config.routes) ? firstMatch(config.routes, extractWarmupHostFromRoute) : null; - return fromRoutes ?? extractDomainFromCustomDomains(config); + return fromRoutes ?? extractWarmupHostFromCustomDomains(config); } /** @@ -443,6 +443,13 @@ function extractDomainFromCustomDomains(config: Record): string return null; } +function extractWarmupHostFromCustomDomains(config: Record): string | null { + if (!Array.isArray(config.custom_domains)) return null; + return firstMatch(config.custom_domains, (domain) => + typeof domain === "string" ? routePatternToWarmupHost(domain) : null, + ); +} + /** Strip protocol and trailing wildcards from a route pattern to get a bare domain. */ function cleanDomain(raw: string): string | null { const cleaned = raw diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 9096cdd1c8..a2d05483b2 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -671,7 +671,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { await import("../packages/cloudflare/src/cdn-warm-deployment.js"); await expect(deployWithCdnWarmup(tmpDir, ["/"], { warmCdnStrict: true })).rejects.toThrow( - `requires the current deployment to contain exactly one version at 100%. Uploaded Worker version ${UPLOADED_VERSION_ID} remains undeployed`, + `Observed traffic: ${PREVIOUS_VERSION_ID}@50%, 33333333-3333-4333-8333-333333333333@50%. Uploaded Worker version ${UPLOADED_VERSION_ID} remains undeployed`, ); expect(fetch).not.toHaveBeenCalled(); expect(execFileSyncMock).toHaveBeenCalledTimes(2); @@ -728,6 +728,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { }); it("reports trigger recovery after a non-strict direct promotion", async () => { + const warnSpy = vi.spyOn(console, "warn"); writeFile("wrangler.jsonc", warmupWranglerConfig({ name: "workers-cache" })); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; @@ -749,6 +750,11 @@ describe("Cloudflare CDN warmup deploy flow", () => { await expect(deployWithCdnWarmup(tmpDir, ["/"], {})).rejects.toThrow( "The uploaded Worker version was promoted to 100%, but applying triggers", ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Observed traffic: ${PREVIOUS_VERSION_ID}@50%, 33333333-3333-4333-8333-333333333333@50%`, + ), + ); expect(fetch).not.toHaveBeenCalled(); }); }); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index d661edc258..9e67de3f30 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -3414,6 +3414,18 @@ describe("resolveWranglerDeploymentTarget — production host", () => { expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBeUndefined(); }); + it("does not turn a path-scoped custom domain into a host-wide warmup target", () => { + writeFile( + tmpDir, + "wrangler.jsonc", + JSON.stringify({ custom_domains: ["app.example.com/private"] }), + ); + + const target = resolveWranglerDeploymentTarget(tmpDir, {}); + expect(target?.hasProductionRoute).toBe(true); + expect(target?.productionHost).toBeUndefined(); + }); + it("uses the pattern from a singular TOML route object", () => { writeFile( tmpDir, From a938001dcb16832eeb8f7ddd70c0572361af545b Mon Sep 17 00:00:00 2001 From: James Date: Thu, 16 Jul 2026 00:32:56 +0100 Subject: [PATCH 12/22] fix(cloudflare): avoid redundant warmup promotion --- .../cloudflare/src/cdn-warm-deployment.ts | 39 ++++++++++++------- packages/cloudflare/src/deploy-help.ts | 2 + tests/cloudflare-cdn-warm-deploy.test.ts | 3 +- tests/deploy.test.ts | 3 ++ 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/packages/cloudflare/src/cdn-warm-deployment.ts b/packages/cloudflare/src/cdn-warm-deployment.ts index 499514957d..15356b5574 100644 --- a/packages/cloudflare/src/cdn-warm-deployment.ts +++ b/packages/cloudflare/src/cdn-warm-deployment.ts @@ -92,13 +92,7 @@ export async function deployWithCdnWarmup( (version) => version.versionId === upload.versionId && version.percentage === 100, ) ) { - return promoteWithoutWarmup( - root, - upload, - options, - `CDN pre-warm cannot stage Worker version ${upload.versionId} because it is already serving 100% traffic.`, - `Worker version ${upload.versionId} remains at 100% traffic.`, - ); + return finishAlreadyCurrentVersion(root, upload, options); } const observedTraffic = currentVersions.length ? currentVersions.map((version) => `${version.versionId}@${version.percentage}%`).join(", ") @@ -122,6 +116,27 @@ export async function deployWithCdnWarmup( ); } +function finishAlreadyCurrentVersion( + root: string, + upload: WranglerVersionUploadResult, + options: CdnWarmupOptions, +): CdnWarmupDeployResult { + const message = `CDN pre-warm cannot stage Worker version ${upload.versionId} because it is already serving 100% traffic.`; + if (options.warmCdnStrict) throw new Error(message); + console.warn(` ${message} Skipping pre-warm and re-promotion.`); + const triggers = applyTriggersAfterPromotion( + root, + options, + `Worker version ${upload.versionId} is already serving 100% traffic, but applying triggers ` + + "(routes/schedules) failed. Production still serves that version on the previously " + + "deployed routes. Re-run `wrangler triggers deploy` to apply the route changes.", + ); + return { + url: pickDeployedUrl(triggers.deployedUrl, upload.previewUrl), + warmed: false, + }; +} + /** * No safe staging split exists (the deployment isn't a single version at * 100%), so there is no version-isolated cache partition to warm into. @@ -264,16 +279,14 @@ async function warmAndPromote( function applyTriggersAfterPromotion( root: string, options: Pick, + failureMessage = "The uploaded Worker version was promoted to 100%, but applying triggers " + + "(routes/schedules) failed. Production serves the new version on the previously " + + "deployed routes. Re-run `wrangler triggers deploy` to apply the route changes.", ): WranglerVersionDeployResult { try { return runWranglerTriggersDeploy(root, options); } catch (error) { - throw new Error( - `${formatUnknownError(error)}\n\n` + - "The uploaded Worker version was promoted to 100%, but applying triggers " + - "(routes/schedules) failed. Production serves the new version on the previously " + - "deployed routes. Re-run `wrangler triggers deploy` to apply the route changes.", - ); + throw new Error(`${formatUnknownError(error)}\n\n${failureMessage}`); } } diff --git a/packages/cloudflare/src/deploy-help.ts b/packages/cloudflare/src/deploy-help.ts index 360ac26239..d0abc8757a 100644 --- a/packages/cloudflare/src/deploy-help.ts +++ b/packages/cloudflare/src/deploy-help.ts @@ -51,6 +51,8 @@ export function formatDeployHelp(): string { CDN warmup requests populate the edge cache only in the Cloudflare data centers reached by the warmup run; they do not globally prefill every edge location. + Verified warmup exposes x-vinext-worker-version on Worker-served responses so + cached representations can identify the Worker version that produced them. Examples: npx @vinext/cloudflare deploy Build and deploy to production diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index a2d05483b2..bbc8867cdb 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -683,7 +683,6 @@ describe("Cloudflare CDN warmup deploy flow", () => { execFileSyncMock.mockImplementation((_file: string, args: string[]) => { if (args.includes("upload")) return `Uploaded version ${PREVIOUS_VERSION_ID}\n`; if (args.includes("status")) return currentDeploymentOutput(); - if (args.includes(`${PREVIOUS_VERSION_ID}@100%`)) return "Promoted version\n"; if (args.includes("triggers")) return "Triggers deployed\n"; throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); }); @@ -695,7 +694,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("because it is already serving 100% traffic"), ); - expect(execFileSyncMock).toHaveBeenCalledTimes(4); + expect(execFileSyncMock).toHaveBeenCalledTimes(3); expect( execFileSyncMock.mock.calls.some(([, args]) => (args as string[]).some((arg) => arg === `${PREVIOUS_VERSION_ID}@0%`), diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 9e67de3f30..e527139965 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -729,6 +729,9 @@ describe("parseDeployArgs", () => { const help = formatDeployHelp(); expect(help).toContain("static exports skip Worker-version warmup because Assets serve them"); expect(help).toContain("Increase when Worker version propagation is slow"); + expect(help).toContain( + "Verified warmup exposes x-vinext-worker-version on Worker-served responses", + ); }); it("throws for invalid CDN warmup numeric flags", () => { From 7b00b1473101c262a609930c1ded9e746d73027e Mon Sep 17 00:00:00 2001 From: James Date: Thu, 16 Jul 2026 00:37:50 +0100 Subject: [PATCH 13/22] test(cloudflare): cover strict warmup idempotence --- tests/cloudflare-cdn-warm-deploy.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index bbc8867cdb..d25f964425 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -702,6 +702,23 @@ describe("Cloudflare CDN warmup deploy flow", () => { ).toBe(false); }); + it("fails strict warmup clearly when the uploaded version is already at 100%", async () => { + writeFile("wrangler.jsonc", warmupWranglerConfig({ name: "workers-cache" })); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) return `Uploaded version ${PREVIOUS_VERSION_ID}\n`; + if (args.includes("status")) return currentDeploymentOutput(); + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + await expect(deployWithCdnWarmup(tmpDir, ["/"], { warmCdnStrict: true })).rejects.toThrow( + `Worker version ${PREVIOUS_VERSION_ID} because it is already serving 100% traffic`, + ); + expect(fetch).not.toHaveBeenCalled(); + expect(execFileSyncMock).toHaveBeenCalledTimes(2); + }); + it("reports a deployment-status read failure before promoting without warmup", async () => { const warnSpy = vi.spyOn(console, "warn"); writeFile("wrangler.jsonc", warmupWranglerConfig({ name: "workers-cache" })); From a1ad362d3b6c56d6f10aaf86f4004579cc48b01b Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:17:13 +1000 Subject: [PATCH 14/22] fix(cloudflare): preserve strict warmup verification counts Strict CDN warmup errors named the first failure but did not make the verified path count obvious after deployment wrapped the error with its safe-state message. This made truncated CI logs harder to diagnose.\n\nInclude the warmed path count alongside the failure count in the original error so the deployment wrapper preserves both values. Extend the strict staged-deployment regression test to assert the diagnostic and the existing safe-state guidance. --- packages/cloudflare/src/cdn-warm.ts | 3 ++- tests/cloudflare-cdn-warm-deploy.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index bcd4934773..035f278e44 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -320,7 +320,8 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise 0) { throw new Error( - `CDN warmup failed for ${failures.length}/${paths.length} path(s). ` + + `CDN warmup failed for ${failures.length}/${paths.length} path(s); ` + + `verified ${warmed}/${paths.length}. ` + `First failure: ${failures[0].path}: ${failures[0].error}`, ); } diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index d25f964425..8da1d4adda 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -518,7 +518,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { warmCdnStrict: true, }), ).rejects.toThrow( - `the uploaded version (${UPLOADED_VERSION_ID}) is staged at 0% and was not promoted`, + /CDN warmup failed for 1\/1 path\(s\); verified 0\/1\.[\s\S]*staged at 0% and was not promoted/, ); // No restore/promote/triggers call: staging left the previous version at // 100% and the new one at 0%, which is already the safe state. From c13d76d6ca884a4ebbf913822fe3f6f3d3d2a97c Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:27:19 +1000 Subject: [PATCH 15/22] fix(init): reject conflicting version_metadata bindings instead of renaming them When CDN warmup is enabled, init silently replaces any existing version_metadata binding with VINEXT_VERSION_METADATA, top-level and in every named environment. The binding name is the property user code reads off env, so a project with a custom Worker entrypoint reading e.g. env.CF_VERSION_METADATA keeps deploying but its worker breaks at runtime. The mistaken assumption was that the binding is vinext-owned config; Workers allows arbitrary names and permits only one version_metadata binding per scope, so an existing differently named binding is a real conflict with user code, not stale config. Init now fails fast on a conflicting binding with a migration message (rename the binding or re-run init without warmup) before mutating any project file, and still adds the binding additively where none exists. --- packages/vinext/src/init-cloudflare.ts | 28 ++++++++--- tests/init.test.ts | 68 +++++++++++++++++++++++--- 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index 2655ed89d8..d81861ef50 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -370,10 +370,12 @@ function appendTopLevelJsonProperty(code: string, property: string): string { /** * The runtime reads the version metadata binding under a fixed name - * (VINEXT_VERSION_METADATA_BINDING), so init must ensure the config declares - * exactly that binding rather than preserving a differently named one. + * (VINEXT_VERSION_METADATA_BINDING), and Workers allows only one + * version_metadata binding per config scope. A differently named existing + * binding is user-owned — code may read it off `env` — so init must not + * silently rename it. Fail with a migration path instead. */ -function ensureVersionMetadataInJsonObject(code: string): string { +function ensureVersionMetadataInJsonObject(code: string, scopeLabel: string): string { const metadataProperty = findTopLevelJsonProperty(code, "version_metadata"); if (!metadataProperty) { return appendTopLevelJsonProperty( @@ -388,8 +390,17 @@ function ensureVersionMetadataInJsonObject(code: string): string { const metadata = isUnknownRecord(parsedMetadata) ? parsedMetadata : null; if (metadata?.binding === VINEXT_VERSION_METADATA_BINDING) return code; - const replacement = `{ "binding": "${VINEXT_VERSION_METADATA_BINDING}" }`; - return `${code.slice(0, metadataProperty.valueStart)}${replacement}${code.slice(metadataProperty.valueEnd)}`; + const existingBinding = + typeof metadata?.binding === "string" + ? `a version_metadata binding named "${metadata.binding}"` + : "an unrecognized version_metadata entry"; + throw new Error( + `CDN warmup needs the version_metadata binding named "${VINEXT_VERSION_METADATA_BINDING}", ` + + `but ${scopeLabel} of the Wrangler config already declares ${existingBinding} and Workers ` + + "allows only one version_metadata binding. Rename the existing binding to " + + `"${VINEXT_VERSION_METADATA_BINDING}" (updating any code that reads the old name from env), ` + + "or re-run vinext init without CDN warmup.", + ); } function ensureNamedEnvironmentVersionMetadata(code: string): string { @@ -410,7 +421,10 @@ function ensureNamedEnvironmentVersionMetadata(code: string): string { environmentProperty.valueEnd, ); if (!environmentCode.trimStart().startsWith("{")) continue; - const updatedEnvironment = ensureVersionMetadataInJsonObject(environmentCode); + const updatedEnvironment = ensureVersionMetadataInJsonObject( + environmentCode, + `environment "${envName}"`, + ); updatedEnv = `${updatedEnv.slice(0, environmentProperty.valueStart)}${updatedEnvironment}${updatedEnv.slice(environmentProperty.valueEnd)}`; } return `${code.slice(0, envProperty.valueStart)}${updatedEnv}${code.slice(envProperty.valueEnd)}`; @@ -441,7 +455,7 @@ export function updateWranglerConfigForCloudflare( } } if (options.warmCdnCache) { - output = ensureVersionMetadataInJsonObject(output); + output = ensureVersionMetadataInJsonObject(output, "the top level"); output = ensureNamedEnvironmentVersionMetadata(output); } if (options.imageOptimization === "cloudflare-images") { diff --git a/tests/init.test.ts b/tests/init.test.ts index 81c1abce41..2bb6d0484a 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -898,20 +898,19 @@ export default { plugins: [vinext({ cache: { data: customData() } })] }; expect(pkg.scripts["deploy:vinext"]).toContain("--experimental-warm-cdn-cache"); }); - it("forces the fixed binding name for named environments, overwriting custom bindings", async () => { + it("adds the fixed binding to named environments and stays idempotent", async () => { setupProject(tmpDir, { router: "app" }); writeFile( tmpDir, "wrangler.jsonc", `{ - "version_metadata": { "binding": "TOP_LEVEL_VERSION" }, + "version_metadata": { "binding": "VINEXT_VERSION_METADATA" }, "env": { "staging": { // environment-local settings stay intact "name": "my-worker-staging" }, "preview": { - "version_metadata": { "binding": "PREVIEW_VERSION" }, "vars": { "EXISTING": "value" } } } @@ -939,15 +938,68 @@ export default { plugins: [vinext({ cache: { data: customData() } })] }; }); expect(readFile(tmpDir, "wrangler.jsonc")).toBe(wrangler); expect(wrangler).toContain("// environment-local settings stay intact"); - expect(wrangler).not.toContain("TOP_LEVEL_VERSION"); - expect(wrangler).not.toContain("PREVIEW_VERSION"); - // The runtime always reads the fixed binding name, so init overwrites any - // pre-existing custom binding rather than preserving it — top-level, - // staging (newly added), and preview (overwritten) all get it. + // Matching top-level binding preserved; staging and preview each get it added. expect(wrangler.match(/"binding": "VINEXT_VERSION_METADATA"/g)).toHaveLength(3); expect(wrangler).toContain('"EXISTING": "value"'); }); + it("rejects a conflicting user-owned version_metadata binding without mutating the project", async () => { + setupProject(tmpDir, { router: "app" }); + writeFile( + tmpDir, + "wrangler.jsonc", + `{ + "version_metadata": { "binding": "CF_VERSION_METADATA" } +} +`, + ); + const before = snapshotProject(tmpDir); + const exec = vi.fn(); + + await expect( + runInit(tmpDir, { + _exec: exec, + cloudflare: { + dataCache: "kv", + cdnCache: "workers-cache", + imageOptimization: "cloudflare-images", + warmCdnCache: true, + }, + }), + ).rejects.toThrow('already declares a version_metadata binding named "CF_VERSION_METADATA"'); + expect(exec).not.toHaveBeenCalled(); + expect(snapshotProject(tmpDir)).toBe(before); + }); + + it("rejects a conflicting environment-local version_metadata binding", async () => { + setupProject(tmpDir, { router: "app" }); + writeFile( + tmpDir, + "wrangler.jsonc", + `{ + "env": { + "preview": { + "version_metadata": { "binding": "PREVIEW_VERSION" } + } + } +} +`, + ); + const before = snapshotProject(tmpDir); + + await expect( + runInit(tmpDir, { + cloudflare: { + dataCache: "kv", + cdnCache: "workers-cache", + imageOptimization: "cloudflare-images", + warmCdnCache: true, + }, + }), + ).rejects.toThrow('environment "preview"'); + expect(snapshotProject(tmpDir)).toBe(before); + }); + it("skips the warm CDN cache deploy flag when Cloudflare init opts out", async () => { setupProject(tmpDir, { router: "app" }); From 43b43ca50adff7c7ac0ee935a63e3d13755ba61d Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:27:52 +1000 Subject: [PATCH 16/22] fix(cloudflare): require cf-cache-status proof before reporting a path warmed The staged deployment counted a path as warmed once the uploaded version answered with its version header and a sub-400 status. That proves the right Worker produced the response, not that Workers Cache stored it: a per-entrypoint cache override or a response-level bypass (Set-Cookie, Cache-Control: no-store/private) returns a healthy 200 the cache never writes, and the deploy summary still printed "pre-warmed and confirmed". The violated invariant is that "the uploaded version produced a 200" and "the edge stored that response in the version's cache partition" are independent facts; only the first was checked. Warmup requests in the deployment transaction now demand cache proof via cf-cache-status: a cache-served status (HIT/STALE/UPDATING/REVALIDATED) counts immediately, a MISS/EXPIRED fill is confirmed by a second identical request that must come back cache-served from the expected version, and BYPASS/DYNAMIC or a missing header fails the path immediately since those are deterministic for the response shape. Standalone warmCdnCache callers keep the old semantics unless they opt in through the new confirmCache option. Tests cover each cf-cache-status branch and assert end-to-end that a producer-valid BYPASS deploy reports warmed: false. --- .../cloudflare/src/cdn-warm-deployment.ts | 4 + packages/cloudflare/src/cdn-warm.ts | 74 +++++++++++++- tests/cloudflare-cdn-warm-deploy.test.ts | 60 +++++++++++- tests/cloudflare-cdn-warm.test.ts | 97 +++++++++++++++++++ 4 files changed, 232 insertions(+), 3 deletions(-) diff --git a/packages/cloudflare/src/cdn-warm-deployment.ts b/packages/cloudflare/src/cdn-warm-deployment.ts index 15356b5574..dd6383d2ea 100644 --- a/packages/cloudflare/src/cdn-warm-deployment.ts +++ b/packages/cloudflare/src/cdn-warm-deployment.ts @@ -219,6 +219,10 @@ async function warmAndPromote( paths, headers, expectedVersionId: upload.versionId, + // The deployment summary claims "pre-warmed and confirmed", so a + // producer-only 200 is not enough — require cf-cache-status proof + // that the entry was stored and is servable from cache. + confirmCache: true, concurrency: options.warmCdnConcurrency, timeoutMs: options.warmCdnTimeout, retries: options.warmCdnRetries, diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index 035f278e44..af7fad1ac2 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -22,6 +22,12 @@ export type CdnWarmOptions = { retryDelayMs?: number; strict?: boolean; expectedVersionId?: string; + /** + * Require cf-cache-status proof that Workers Cache stored the response, not + * just that the expected Worker produced it. A MISS fill is confirmed with a + * second identical request that must come back cache-served. + */ + confirmCache?: boolean; fetchImpl?: typeof fetch; }; @@ -154,6 +160,36 @@ async function waitBeforeRetry( await new Promise((resolve) => setTimeout(resolve, retryDelayMs * 2 ** attempt)); } +const CF_CACHE_STATUS_HEADER = "cf-cache-status"; +/** Statuses proving the edge served the response from the cache partition. */ +const CACHE_SERVED_STATUSES = new Set(["HIT", "STALE", "UPDATING", "REVALIDATED"]); +/** Statuses where the Worker ran and the response may have been written to cache. */ +const CACHE_FILL_STATUSES = new Set(["MISS", "EXPIRED"]); + +function getCacheStatus(response: Response): string | null { + return response.headers.get(CF_CACHE_STATUS_HEADER)?.toUpperCase() ?? null; +} + +/** + * Null when the response proves the cache served it from the expected Worker + * version's partition; otherwise the reason it does not. + */ +function describeUnconfirmedCacheServe( + response: Response, + expectedVersionId: string | undefined, +): string | null { + if (expectedVersionId) { + const actualVersionId = response.headers.get(VINEXT_WORKER_VERSION_HEADER); + if (actualVersionId !== expectedVersionId) { + return describeVersionMismatch(expectedVersionId, actualVersionId); + } + } + if (response.status >= 400) return `HTTP ${response.status}`; + const cacheStatus = getCacheStatus(response); + if (cacheStatus && CACHE_SERVED_STATUSES.has(cacheStatus)) return null; + return `cache entry not confirmed (${CF_CACHE_STATUS_HEADER}: ${cacheStatus ?? "missing"})`; +} + /** Error text for a response that didn't prove it came from the expected Worker version. */ function describeVersionMismatch( expectedVersionId: string, @@ -195,6 +231,7 @@ async function warmOnePath( fetchImpl: typeof fetch; headers?: HeadersInit; expectedVersionId?: string; + confirmCache?: boolean; }, ): Promise<{ path: string; ok: true } | { path: string; ok: false; error: string }> { const url = buildWarmupUrl(options.targetUrl, pathname); @@ -227,7 +264,41 @@ async function warmOnePath( } if (response.status < 400) { - return { path: pathname, ok: true }; + if (!options.confirmCache) return { path: pathname, ok: true }; + + // "The expected version produced a 200" and "the edge stored that + // response in the version's cache partition" are different facts. + // Per-entrypoint cache overrides and response-level bypasses + // (Set-Cookie, Cache-Control: no-store/private) return a healthy 200 + // that Workers Cache never stores — only cf-cache-status can tell + // those apart from a real warm. + const cacheStatus = getCacheStatus(response); + if (cacheStatus && CACHE_SERVED_STATUSES.has(cacheStatus)) { + return { path: pathname, ok: true }; + } + if (!cacheStatus || !CACHE_FILL_STATUSES.has(cacheStatus)) { + // BYPASS/DYNAMIC or a missing header is deterministic for this + // response shape: the cache will never store it, so retrying only + // burns the retry budget on the same answer. + lastError = `response was not stored by Workers Cache (${CF_CACHE_STATUS_HEADER}: ${cacheStatus ?? "missing"})`; + break; + } + + // MISS/EXPIRED started a fill. Only a second identical request coming + // back cache-served proves the fill became a reusable entry. + const confirm = await fetchWithTimeout( + options.fetchImpl, + url, + options.timeoutMs, + options.headers, + "manual", + ); + await confirm.arrayBuffer(); + const confirmError = describeUnconfirmedCacheServe(confirm, options.expectedVersionId); + if (!confirmError) return { path: pathname, ok: true }; + lastError = confirmError; + await waitBeforeRetry(attempt, options.retries, options.retryDelayMs); + continue; } // These paths came from this build's prerender manifest, so even a 4xx @@ -293,6 +364,7 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise[0]): string { return url.url; } -function versionedResponse(versionId = UPLOADED_VERSION_ID): Response { +function versionedResponse(versionId = UPLOADED_VERSION_ID, cacheStatus = "HIT"): Response { return new Response("ok", { status: 200, - headers: { "x-vinext-worker-version": versionId }, + headers: { "x-vinext-worker-version": versionId, "cf-cache-status": cacheStatus }, }); } @@ -419,6 +419,62 @@ describe("Cloudflare CDN warmup deploy flow", () => { ]); }); + it("does not report warmed when the uploaded version answers 200 but the cache never stores it", async () => { + // The uploaded version producing a 200 and Workers Cache storing that + // response are different facts — a BYPASS (e.g. Set-Cookie or no-store) + // passes the producer check while leaving the cache partition cold. + writeFile("wrangler.jsonc", warmupWranglerConfig({ name: "workers-cache" })); + vi.mocked(fetch).mockImplementation(async () => + versionedResponse(UPLOADED_VERSION_ID, "BYPASS"), + ); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; + if (args.includes("status")) return currentDeploymentOutput(); + if (isStage(args)) return "Staged version\nhttps://workers-cache.vinext.workers.dev\n"; + if (args.includes("triggers")) return "Triggers deployed\n"; + if (isPromotion(args)) return "Promoted version\n"; + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + await expect(deployWithCdnWarmup(tmpDir, ["/cached/intro"], {})).resolves.toMatchObject({ + warmed: false, + }); + }); + + it("confirms a MISS fill with a second request before promoting", async () => { + const events: string[] = []; + writeFile("wrangler.jsonc", warmupWranglerConfig({ name: "workers-cache" })); + vi.mocked(fetch) + .mockImplementationOnce(async () => { + events.push("fetch:miss"); + return versionedResponse(UPLOADED_VERSION_ID, "MISS"); + }) + .mockImplementationOnce(async () => { + events.push("fetch:hit"); + return versionedResponse(UPLOADED_VERSION_ID, "HIT"); + }); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; + if (args.includes("status")) return currentDeploymentOutput(); + if (isStage(args)) return "Staged version\nhttps://workers-cache.vinext.workers.dev\n"; + if (args.includes("triggers")) return "Triggers deployed\n"; + if (isPromotion(args)) { + events.push("promote"); + return "Promoted version\n"; + } + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + await expect(deployWithCdnWarmup(tmpDir, ["/cached/intro"], {})).resolves.toMatchObject({ + warmed: true, + }); + expect(events).toEqual(["fetch:miss", "fetch:hit", "promote"]); + }); + it("promotes without a confirmed warm-up in non-strict mode, and says so instead of a plain success", async () => { const events: string[] = []; const warnSpy = vi.spyOn(console, "warn"); diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts index 86ef3cbb4e..b26d5834ee 100644 --- a/tests/cloudflare-cdn-warm.test.ts +++ b/tests/cloudflare-cdn-warm.test.ts @@ -501,6 +501,103 @@ describe("Cloudflare CDN warmup", () => { expect(result.failures[0]?.error).toBe("HTTP 404"); }); + describe("cache-write confirmation (confirmCache)", () => { + const expectedVersionId = "22222222-2222-4222-8222-222222222222"; + const cacheResponse = (cacheStatus: string | null, versionId = expectedVersionId): Response => + new Response("html", { + status: 200, + headers: { + "x-vinext-worker-version": versionId, + ...(cacheStatus ? { "cf-cache-status": cacheStatus } : {}), + }, + }); + const warmOne = (fetchMock: typeof fetch, retries = 0) => + warmCdnCache({ + targetUrl: "https://app.example.com", + paths: ["/"], + expectedVersionId, + confirmCache: true, + retries, + retryDelayMs: 0, + fetchImpl: fetchMock, + }); + + it("accepts an immediate cache HIT without a confirm request", async () => { + const fetchMock = vi.fn().mockResolvedValue(cacheResponse("HIT")); + + const result = await warmOne(fetchMock); + + expect(result).toMatchObject({ total: 1, warmed: 1, failed: 0 }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("confirms a MISS fill with a second cache-served request", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(cacheResponse("MISS")) + .mockResolvedValueOnce(cacheResponse("HIT")); + + const result = await warmOne(fetchMock); + + expect(result).toMatchObject({ total: 1, warmed: 1, failed: 0 }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does not count a fill that never comes back cache-served as warmed", async () => { + const fetchMock = vi.fn().mockImplementation(async () => cacheResponse("MISS")); + + const result = await warmOne(fetchMock, 1); + + expect(result).toMatchObject({ total: 1, warmed: 0, failed: 1 }); + expect(result.failures[0]?.error).toBe("cache entry not confirmed (cf-cache-status: MISS)"); + // Two attempts, each a fill request plus a confirm request. + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it("treats an uncacheable BYPASS response as terminal without burning retries", async () => { + // Cache-Control: no-store / private / Set-Cookie produce BYPASS + // deterministically — the same response shape returns on every retry. + const fetchMock = vi + .fn() + .mockImplementation(async () => cacheResponse("BYPASS")); + + const result = await warmOne(fetchMock, 3); + + expect(result).toMatchObject({ total: 1, warmed: 0, failed: 1 }); + expect(result.failures[0]?.error).toBe( + "response was not stored by Workers Cache (cf-cache-status: BYPASS)", + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("fails when the expected version responds without any cache status", async () => { + // A per-entrypoint cache override can disable Workers Cache while the + // top-level config still says enabled: the Worker answers 200 with the + // right version header but the cache never runs. + const fetchMock = vi.fn().mockResolvedValue(cacheResponse(null)); + + const result = await warmOne(fetchMock); + + expect(result).toMatchObject({ total: 1, warmed: 0, failed: 1 }); + expect(result.failures[0]?.error).toBe( + "response was not stored by Workers Cache (cf-cache-status: missing)", + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("rejects a confirm response served by a different Worker version", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(cacheResponse("MISS")) + .mockResolvedValueOnce(cacheResponse("HIT", "11111111-1111-4111-8111-111111111111")); + + const result = await warmOne(fetchMock); + + expect(result).toMatchObject({ total: 1, warmed: 0, failed: 1 }); + expect(result.failures[0]?.error).toContain("expected Worker version"); + }); + }); + it("reports warmup failures and throws in strict mode", async () => { writeFile( "dist/server/vinext-prerender.json", From e62363919bf7a1dd03a9f7610d26a451208006f4 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:46:57 +1000 Subject: [PATCH 17/22] fix(cloudflare): re-verify the staged split before promoting the uploaded version warmAndPromote promoted the uploaded version to 100% using only the deployment status read before staging. If another deploy promoted its own version during the warmup window, this deploy's version overrides stopped resolving (overrides only apply to versions in the current deployment), non-strict mode recorded the resulting mismatches as ordinary warmup degradation, and the unconditional promotion silently overwrote the other actor's deployment. The violated invariant is ownership: promotion mutates whatever deployment is currently active, and a warmup degradation never grants permission to overwrite a deployment this transaction did not stage. Promotion is now preceded by a fresh `wrangler deployments status` read that must show exactly the staged split (previous@100%, uploaded@0%, order-insensitive). Any other state, or a failed re-read, aborts without promoting and reports the observed traffic split. The check applies in strict and non-strict mode alike. Wrangler exposes no compare-and-swap, so a race remains between this read and the promote command; the check shrinks the exposed window from the whole warmup duration to that gap. --- .../cloudflare/src/cdn-warm-deployment.ts | 52 ++++++- tests/cloudflare-cdn-warm-deploy.test.ts | 146 +++++++++++++++--- 2 files changed, 173 insertions(+), 25 deletions(-) diff --git a/packages/cloudflare/src/cdn-warm-deployment.ts b/packages/cloudflare/src/cdn-warm-deployment.ts index dd6383d2ea..6b68e0c4e7 100644 --- a/packages/cloudflare/src/cdn-warm-deployment.ts +++ b/packages/cloudflare/src/cdn-warm-deployment.ts @@ -6,7 +6,7 @@ * * validate → upload → inspect deployment → stage new version at 0% → * warm through a version override → verify the producing version → - * promote → apply triggers + * re-verify the staged split is still active → promote → apply triggers * * If warming fails, the new version stays staged at 0% and the previous * version stays at 100% — that split is already the safe state, so failure @@ -245,6 +245,8 @@ async function warmAndPromote( ); } + verifyStagedSplitBeforePromotion(root, options, previousVersionId, upload.versionId); + let deployed: WranglerVersionDeployResult; try { deployed = runWranglerVersionDeploy( @@ -322,6 +324,54 @@ function validateCdnWarmupConfiguration( return target; } +/** + * Promotion mutates whatever deployment is currently active, so it must first + * prove that deployment is still the split this transaction staged. If another + * deploy promoted its own version during the warmup window, this upload's + * version overrides stopped applying (overrides only resolve inside the + * current deployment) and promoting here would silently overwrite the other + * actor's deployment — a warmup degradation never grants that permission, in + * strict or non-strict mode. Wrangler offers no compare-and-swap, so a race + * remains between this read and the promote command; revalidating shrinks the + * exposed window from the whole warmup duration to that gap. + */ +function verifyStagedSplitBeforePromotion( + root: string, + options: Pick, + previousVersionId: string, + uploadedVersionId: string, +): void { + const unpromotedState = + `Worker version ${uploadedVersionId} was not promoted; ` + + "re-run the deploy once the current deployment state is understood."; + const recheck = readWranglerDeploymentStatus(root, options); + if ("error" in recheck) { + throw new Error( + `Could not re-read the current deployment before promotion (${recheck.error}). ` + + `Promotion requires confirming the staged traffic split is still active. ${unpromotedState}`, + ); + } + const expectedSplit = new Map([ + [previousVersionId, 100], + [uploadedVersionId, 0], + ]); + const versions = recheck.deployment.versions; + const matchesStagedSplit = + versions.length === expectedSplit.size && + new Set(versions.map((version) => version.versionId)).size === versions.length && + versions.every((version) => expectedSplit.get(version.versionId) === version.percentage); + if (!matchesStagedSplit) { + const observedTraffic = versions.length + ? versions.map((version) => `${version.versionId}@${version.percentage}%`).join(", ") + : "no deployed versions"; + throw new Error( + "The current deployment no longer matches the staged traffic split " + + `(expected ${previousVersionId}@100%, ${uploadedVersionId}@0%; observed ${observedTraffic}). ` + + `Another deploy likely ran during the warmup window. ${unpromotedState}`, + ); + } +} + function readWranglerDeploymentStatus( root: string, options: Pick, diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index cc02d1abab..f16e8c3984 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -61,6 +61,24 @@ function currentDeploymentOutput(): string { }); } +function stagedDeploymentOutput(): string { + return JSON.stringify({ + versions: [ + { version_id: PREVIOUS_VERSION_ID, percentage: 100 }, + { version_id: UPLOADED_VERSION_ID, percentage: 0 }, + ], + }); +} + +/** + * Real `wrangler deployments status` reflects the staged split once the stage + * command has run, which the pre-promotion ownership check re-reads. + */ +function deploymentStatusOutput(): string { + const hasStaged = execFileSyncMock.mock.calls.some(([, args]) => isStage(args as string[])); + return hasStaged ? stagedDeploymentOutput() : currentDeploymentOutput(); +} + function isStage(args: string[]): boolean { return args.includes(`${PREVIOUS_VERSION_ID}@100%`) && args.includes(`${UPLOADED_VERSION_ID}@0%`); } @@ -169,7 +187,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { ); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; - if (args.includes("status")) return currentDeploymentOutput(); + if (args.includes("status")) return deploymentStatusOutput(); if (isStage(args)) return "Staged version\nhttps://my-worker-staging.workers.dev\n"; if (isPromotion(args)) return "Promoted version\n"; if (args.includes("triggers")) return "Triggers deployed\n"; @@ -205,7 +223,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { } if (args.includes("status")) { events.push("status"); - return currentDeploymentOutput(); + return deploymentStatusOutput(); } if (isStage(args)) { events.push("stage"); @@ -235,6 +253,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { "stage", `fetch:https://app.example.com/:my-worker="${UPLOADED_VERSION_ID}"`, `fetch:https://app.example.com/about:my-worker="${UPLOADED_VERSION_ID}"`, + "status", "promote", "triggers", ]); @@ -256,7 +275,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { ); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; - if (args.includes("status")) return currentDeploymentOutput(); + if (args.includes("status")) return deploymentStatusOutput(); if (isStage(args)) return "Staged version\nhttps://staging-worker.example.workers.dev\n"; if (args.includes("triggers")) return "Triggers deployed\n"; if (isPromotion(args)) return "Promoted version\n"; @@ -300,7 +319,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { } if (args.includes("status")) { events.push("status"); - return currentDeploymentOutput(); + return deploymentStatusOutput(); } if (isStage(args)) { events.push("stage"); @@ -327,6 +346,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { "status", "stage", "fetch:https://workers-cache.vinext.workers.dev/cached/intro", + "status", "promote", "triggers", ]); @@ -340,7 +360,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { ); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; - if (args.includes("status")) return currentDeploymentOutput(); + if (args.includes("status")) return deploymentStatusOutput(); if (isStage(args)) { events.push("stage"); return "Staged version\nhttps://workers-cache.vinext.workers.dev\n"; @@ -387,7 +407,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { } if (args.includes("status")) { events.push("status"); - return currentDeploymentOutput(); + return deploymentStatusOutput(); } if (isStage(args)) { events.push("stage"); @@ -414,6 +434,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { "stage", "fetch:old-version", "fetch:new-version", + "status", "promote", "triggers", ]); @@ -429,7 +450,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { ); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; - if (args.includes("status")) return currentDeploymentOutput(); + if (args.includes("status")) return deploymentStatusOutput(); if (isStage(args)) return "Staged version\nhttps://workers-cache.vinext.workers.dev\n"; if (args.includes("triggers")) return "Triggers deployed\n"; if (isPromotion(args)) return "Promoted version\n"; @@ -457,7 +478,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { }); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; - if (args.includes("status")) return currentDeploymentOutput(); + if (args.includes("status")) return deploymentStatusOutput(); if (isStage(args)) return "Staged version\nhttps://workers-cache.vinext.workers.dev\n"; if (args.includes("triggers")) return "Triggers deployed\n"; if (isPromotion(args)) { @@ -490,7 +511,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { } if (args.includes("status")) { events.push("status"); - return currentDeploymentOutput(); + return deploymentStatusOutput(); } if (isStage(args)) { events.push("stage"); @@ -524,6 +545,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { "status", "stage", "fetch:old-version", + "status", "promote-uploaded", "triggers", ]); @@ -549,7 +571,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { } if (args.includes("status")) { events.push("status"); - return currentDeploymentOutput(); + return deploymentStatusOutput(); } if (isStage(args)) { events.push("stage"); @@ -616,14 +638,19 @@ describe("Cloudflare CDN warmup deploy flow", () => { if (args.includes("status")) { events.push("status"); statusReads++; - return statusReads === 1 - ? currentDeploymentOutput() - : JSON.stringify({ - versions: [ - { version_id: PREVIOUS_VERSION_ID, percentage: 100 }, - { version_id: failedVersionId, percentage: 0 }, - ], - }); + // Read 1: clean pre-deploy state. Read 2: the failed attempt left its + // version staged at 0%. Read 3: the retry's own staged split, which + // the pre-promotion ownership check re-reads. + if (statusReads === 1) return currentDeploymentOutput(); + if (statusReads === 2) { + return JSON.stringify({ + versions: [ + { version_id: PREVIOUS_VERSION_ID, percentage: 100 }, + { version_id: failedVersionId, percentage: 0 }, + ], + }); + } + return stagedDeploymentOutput(); } if ( args.includes(`${PREVIOUS_VERSION_ID}@100%`) && @@ -668,6 +695,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { "status", "stage", "fetch:new-version", + "status", "promote", "triggers", ]); @@ -685,7 +713,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; if (args.includes("status")) { events.push("status"); - return currentDeploymentOutput(); + return deploymentStatusOutput(); } if (isStage(args)) return "Staged version\nhttps://stable.example.workers.dev\n"; if (args.includes("triggers")) { @@ -704,9 +732,79 @@ describe("Cloudflare CDN warmup deploy flow", () => { await expect(deployWithCdnWarmup(tmpDir, ["/"], {})).rejects.toThrow( `Could not confirm the promotion of Worker version ${UPLOADED_VERSION_ID} succeeded`, ); - // Exactly one status read (before staging) and one promotion attempt — no - // reconciling re-read of deployment status, and triggers never run. - expect(events).toEqual(["status", "promote-attempt"]); + // One status read before staging, one ownership re-check before the + // promotion attempt — but no reconciling re-read after the promotion + // fails, and triggers never run. + expect(events).toEqual(["status", "status", "promote-attempt"]); + }); + + it("aborts promotion when another deploy replaces the staged split during warmup", async () => { + const otherDeployVersionId = "33333333-3333-4333-8333-333333333333"; + const commands: string[] = []; + let statusReads = 0; + writeFile("wrangler.jsonc", warmupWranglerConfig({ name: "workers-cache" })); + vi.mocked(fetch).mockImplementation(async () => versionedResponse(UPLOADED_VERSION_ID)); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; + if (args.includes("status")) { + statusReads++; + // Deploy B promoted its own version while this deploy was warming. + return statusReads === 1 + ? currentDeploymentOutput() + : JSON.stringify({ + versions: [{ version_id: otherDeployVersionId, percentage: 100 }], + }); + } + if (isStage(args)) return "Staged version\nhttps://workers-cache.vinext.workers.dev\n"; + if (isPromotion(args)) { + commands.push("promote"); + return "Promoted version\n"; + } + if (args.includes("triggers")) { + commands.push("triggers"); + return "Triggers deployed\n"; + } + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + // Non-strict mode: relaxed warmup requirements never grant permission to + // overwrite a deployment created by another actor. + await expect(deployWithCdnWarmup(tmpDir, ["/"], {})).rejects.toThrow( + new RegExp( + `no longer matches the staged traffic split[\\s\\S]*observed ${otherDeployVersionId}@100%[\\s\\S]*was not promoted`, + ), + ); + expect(commands).toEqual([]); + }); + + it("aborts promotion when the pre-promotion status re-read fails", async () => { + const commands: string[] = []; + let statusReads = 0; + writeFile("wrangler.jsonc", warmupWranglerConfig({ name: "workers-cache" })); + vi.mocked(fetch).mockImplementation(async () => versionedResponse(UPLOADED_VERSION_ID)); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; + if (args.includes("status")) { + statusReads++; + if (statusReads === 1) return currentDeploymentOutput(); + throw new Error("status request timed out"); + } + if (isStage(args)) return "Staged version\nhttps://workers-cache.vinext.workers.dev\n"; + if (isPromotion(args)) { + commands.push("promote"); + return "Promoted version\n"; + } + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + await expect(deployWithCdnWarmup(tmpDir, ["/"], {})).rejects.toThrow( + /Could not re-read the current deployment before promotion[\s\S]*was not promoted/, + ); + expect(commands).toEqual([]); }); it("does not mutate canonical cache keys when a safe staging deployment is unavailable", async () => { @@ -738,7 +836,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { writeFile("wrangler.jsonc", warmupWranglerConfig({ name: "workers-cache" })); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { if (args.includes("upload")) return `Uploaded version ${PREVIOUS_VERSION_ID}\n`; - if (args.includes("status")) return currentDeploymentOutput(); + if (args.includes("status")) return deploymentStatusOutput(); if (args.includes("triggers")) return "Triggers deployed\n"; throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); }); @@ -762,7 +860,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { writeFile("wrangler.jsonc", warmupWranglerConfig({ name: "workers-cache" })); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { if (args.includes("upload")) return `Uploaded version ${PREVIOUS_VERSION_ID}\n`; - if (args.includes("status")) return currentDeploymentOutput(); + if (args.includes("status")) return deploymentStatusOutput(); throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); }); const { deployWithCdnWarmup } = From c7572ab8a0f9103d71bfee7584dbe897a59ace26 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:47:16 +1000 Subject: [PATCH 18/22] refactor(cloudflare): move Wrangler config parsing out of tpr.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseWranglerConfig and its WranglerConfig projection lived in tpr.ts while serving both TPR and CDN warmup target resolution, so wrangler-deployment-target.ts imported general deployment-config concerns (cache settings, warmup hosts, environments, version-metadata bindings) from an experimental feature module. That dependency direction invites future deploy features to attach themselves to tpr.ts. Mechanical move of the parser, its helpers, and the projected types into wrangler-config.ts, with no parser behaviour change. tpr.ts consumes parseWranglerConfig for its account/zone/KV fields; wrangler-deployment-target.ts consumes the cache/route/environment/ Worker-name/metadata fields; utils/toml.ts stays a format-level helper. WranglerEnvironmentConfig is no longer exported — nothing outside the parser referenced it. --- packages/cloudflare/src/tpr.ts | 650 +---------------- packages/cloudflare/src/wrangler-config.ts | 661 ++++++++++++++++++ .../src/wrangler-deployment-target.ts | 11 +- tests/deploy.test.ts | 3 +- 4 files changed, 671 insertions(+), 654 deletions(-) create mode 100644 packages/cloudflare/src/wrangler-config.ts diff --git a/packages/cloudflare/src/tpr.ts b/packages/cloudflare/src/tpr.ts index 3ad385986e..0695cb206f 100644 --- a/packages/cloudflare/src/tpr.ts +++ b/packages/cloudflare/src/tpr.ts @@ -29,14 +29,7 @@ import { VINEXT_REVALIDATE_HEADER } from "vinext/internal/server/headers"; import { isrCacheKey } from "vinext/internal/server/isr-cache"; import { buildAppPageCacheTags } from "vinext/internal/server/app-page-cache"; import { createKvKeySpace } from "./cache/kv-key.js"; -import { isUnknownRecord } from "./utils/cache-control-metadata.js"; -import { - extractTomlRouteEntries, - extractTomlRoutePatterns, - getTomlRootBody, - getTomlSections, - stripTomlLineComments, -} from "./utils/toml.js"; +import { parseWranglerConfig } from "./wrangler-config.js"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -84,647 +77,6 @@ type PrerenderResult = { headers: Record; }; -export type WranglerConfig = { - accountId?: string; - cache?: WranglerCacheConfig; - kvNamespaceId?: string; - customDomain?: string; - warmupHost?: string; - name?: string; - legacyEnv?: boolean; - targetEnvironment?: string; - versionMetadataBinding?: string; - env?: Record; -}; - -export type WranglerEnvironmentConfig = { - cache?: WranglerCacheConfig; - customDomain?: string; - warmupHost?: string; - name?: string; - versionMetadataBinding?: string; -}; - -export type WranglerCacheConfig = { - enabled?: boolean; - crossVersionCache?: boolean; -}; - -// ─── Wrangler Config Parsing ───────────────────────────────────────────────── - -/** - * Parse wrangler config (JSONC or TOML) to extract the fields used by TPR and - * CDN warmup target resolution. - */ -export function parseWranglerConfig(root: string, configPath?: string): WranglerConfig | null { - if (configPath) { - const filepath = path.resolve(root, configPath); - if (!fs.existsSync(filepath)) return null; - const content = fs.readFileSync(filepath, "utf-8"); - if (filepath.endsWith(".toml")) { - return extractFromTOML(content); - } - try { - const json = JSON.parse(stripJsonCommentsAndTrailingCommas(content)); - return extractFromJSON(json); - } catch { - return null; - } - } - - // Try JSONC / JSON first - for (const filename of ["wrangler.jsonc", "wrangler.json"]) { - const filepath = path.join(root, filename); - if (fs.existsSync(filepath)) { - const content = fs.readFileSync(filepath, "utf-8"); - try { - const json = JSON.parse(stripJsonCommentsAndTrailingCommas(content)); - return extractFromJSON(json); - } catch { - continue; - } - } - } - - // Try TOML - const tomlPath = path.join(root, "wrangler.toml"); - if (fs.existsSync(tomlPath)) { - const content = fs.readFileSync(tomlPath, "utf-8"); - return extractFromTOML(content); - } - - return null; -} - -/** - * Strip single-line (//), multi-line comments, and trailing commas from JSONC - * while preserving strings that contain comment-like text or commas. - */ -function stripJsonCommentsAndTrailingCommas(str: string): string { - let result = ""; - let inString = false; - let inSingleLine = false; - let inMultiLine = false; - let escapeNext = false; - - for (let i = 0; i < str.length; i++) { - const ch = str[i]; - const next = str[i + 1]; - - if (escapeNext) { - if (!inSingleLine && !inMultiLine) result += ch; - escapeNext = false; - continue; - } - - if (ch === "\\" && inString) { - result += ch; - escapeNext = true; - continue; - } - - if (inSingleLine) { - if (ch === "\n") { - inSingleLine = false; - result += ch; - } - continue; - } - - if (inMultiLine) { - if (ch === "*" && next === "/") { - inMultiLine = false; - i++; - } - continue; - } - - if (ch === '"' && !inString) { - inString = true; - result += ch; - continue; - } - - if (ch === '"' && inString) { - inString = false; - result += ch; - continue; - } - - if (!inString && ch === "/" && next === "/") { - inSingleLine = true; - i++; - continue; - } - - if (!inString && ch === "/" && next === "*") { - inMultiLine = true; - i++; - continue; - } - - if (!inString && ch === "," && isJsonTrailingComma(str, i + 1)) { - continue; - } - - result += ch; - } - - return result; -} - -function isJsonTrailingComma(str: string, start: number): boolean { - for (let i = start; i < str.length; i++) { - const ch = str[i]; - const next = str[i + 1]; - if (ch === undefined) return false; - if (/\s/.test(ch)) { - continue; - } - if (ch === "/" && next === "/") { - i += 2; - while (i < str.length && str[i] !== "\n") { - i++; - } - continue; - } - if (ch === "/" && next === "*") { - i += 2; - while (i < str.length) { - if (str[i] === "*" && str[i + 1] === "/") { - i++; - break; - } - i++; - } - continue; - } - return ch === "}" || ch === "]"; - } - - return false; -} - -function extractFromJSON(config: Record): WranglerConfig { - const result: WranglerConfig = {}; - - const cache = extractCacheConfig(config.cache); - if (cache) result.cache = cache; - - if (typeof config.name === "string" && config.name.length > 0) { - result.name = config.name; - } - - if (typeof config.legacy_env === "boolean") { - result.legacyEnv = config.legacy_env; - } - - // Cloudflare's generated dist/server/wrangler.json is already flattened to - // the environment selected at build time. Wrangler tags that redirected - // config so deploy-time readers can distinguish it from a source config - // that simply omitted the requested env block. - if (typeof config.targetEnvironment === "string" && config.targetEnvironment.length > 0) { - result.targetEnvironment = config.targetEnvironment; - } - - // account_id - if (typeof config.account_id === "string") { - result.accountId = config.account_id; - } - - // KV namespace ID for VINEXT_KV_CACHE - if (Array.isArray(config.kv_namespaces)) { - const vinextKV = config.kv_namespaces.find( - (ns: Record) => - ns && - typeof ns === "object" && - (ns.binding === "VINEXT_KV_CACHE" || ns.binding === "VINEXT_CACHE"), - ); - if (vinextKV && typeof vinextKV.id === "string" && vinextKV.id !== "") { - result.kvNamespaceId = vinextKV.id; - } - } - - // TPR needs a zone-resolvable domain, while CDN warmup needs the exact route - // host. Keep both because zone_name is not the hostname a request should use. - const domain = - extractDomainFromRoute(config.route) ?? - extractDomainFromRoutes(config.routes) ?? - extractDomainFromCustomDomains(config); - if (domain) result.customDomain = domain; - const warmupHost = extractWarmupHost(config); - if (warmupHost) result.warmupHost = warmupHost; - const versionMetadataBinding = extractVersionMetadataBinding(config); - if (versionMetadataBinding) result.versionMetadataBinding = versionMetadataBinding; - - const env = extractEnvConfigs(config.env); - if (env) result.env = env; - - return result; -} - -function extractEnvConfigs(envs: unknown): Record | undefined { - if (!envs || typeof envs !== "object" || Array.isArray(envs)) return undefined; - - const result: Record = {}; - for (const [envName, rawConfig] of Object.entries(envs)) { - if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) continue; - const envConfig = extractEnvironmentConfig(rawConfig as Record); - if ( - envConfig.name || - envConfig.cache || - envConfig.customDomain || - envConfig.warmupHost || - envConfig.versionMetadataBinding - ) { - result[envName] = envConfig; - } - } - return Object.keys(result).length > 0 ? result : undefined; -} - -function extractEnvironmentConfig(config: Record): WranglerEnvironmentConfig { - const result: WranglerEnvironmentConfig = {}; - const cache = extractCacheConfig(config.cache); - if (cache) result.cache = cache; - if (typeof config.name === "string" && config.name.length > 0) { - result.name = config.name; - } - const domain = - extractDomainFromRoute(config.route) ?? - extractDomainFromRoutes(config.routes) ?? - extractDomainFromCustomDomains(config); - if (domain) result.customDomain = domain; - const warmupHost = extractWarmupHost(config); - if (warmupHost) result.warmupHost = warmupHost; - const versionMetadataBinding = extractVersionMetadataBinding(config); - if (versionMetadataBinding) result.versionMetadataBinding = versionMetadataBinding; - return result; -} - -function extractCacheConfig(value: unknown): WranglerCacheConfig | null { - if (!isUnknownRecord(value)) return null; - const enabled = typeof value.enabled === "boolean" ? value.enabled : undefined; - const crossVersionCache = - typeof value.cross_version_cache === "boolean" ? value.cross_version_cache : undefined; - return enabled === undefined && crossVersionCache === undefined - ? null - : { enabled, crossVersionCache }; -} - -function extractVersionMetadataBinding(config: Record): string | null { - const metadata = config.version_metadata; - return isUnknownRecord(metadata) && - typeof metadata.binding === "string" && - metadata.binding.length > 0 - ? metadata.binding - : null; -} - -function extractDomainFromRoute(route: unknown): string | null { - if (typeof route === "string") { - const domain = cleanDomain(route); - return domain && !domain.includes("workers.dev") ? domain : null; - } - if (!isUnknownRecord(route)) return null; - const domainSource = - typeof route.zone_name === "string" - ? route.zone_name - : typeof route.pattern === "string" - ? route.pattern - : null; - if (!domainSource) return null; - const domain = cleanDomain(domainSource); - return domain && !domain.includes("workers.dev") ? domain : null; -} - -function extractDomainFromRoutes(routes: unknown): string | null { - if (!Array.isArray(routes)) return null; - return firstMatch(routes, extractDomainFromRoute); -} - -function extractWarmupHost(config: Record): string | null { - const singular = extractWarmupHostFromRoute(config.route); - if (singular) return singular; - const fromRoutes = Array.isArray(config.routes) - ? firstMatch(config.routes, extractWarmupHostFromRoute) - : null; - return fromRoutes ?? extractWarmupHostFromCustomDomains(config); -} - -/** - * A route array can mix host-wide and path-scoped (or workers.dev) entries; - * a disqualified earlier entry must not hide a valid later one. Returns the - * first non-null result of `fn`, or null if none qualify. - */ -function firstMatch(items: Iterable, fn: (item: T) => R | null): R | null { - for (const item of items) { - const result = fn(item); - if (result !== null) return result; - } - return null; -} - -function extractWarmupHostFromRoute(route: unknown): string | null { - if (isUnknownRecord(route) && route.enabled === false) return null; - const pattern = typeof route === "string" ? route : isUnknownRecord(route) ? route.pattern : null; - return typeof pattern === "string" ? routePatternToWarmupHost(pattern) : null; -} - -function extractDomainFromCustomDomains(config: Record): string | null { - // Workers Custom Domains: "custom_domains": ["example.com"] - if (Array.isArray(config.custom_domains)) { - for (const d of config.custom_domains) { - if (typeof d === "string" && !d.includes("workers.dev")) { - return cleanDomain(d); - } - } - } - return null; -} - -function extractWarmupHostFromCustomDomains(config: Record): string | null { - if (!Array.isArray(config.custom_domains)) return null; - return firstMatch(config.custom_domains, (domain) => - typeof domain === "string" ? routePatternToWarmupHost(domain) : null, - ); -} - -/** Strip protocol and trailing wildcards from a route pattern to get a bare domain. */ -function cleanDomain(raw: string): string | null { - const cleaned = raw - .replace(/^https?:\/\//, "") - .replace(/\/\*$/, "") - .replace(/\/+$/, "") - .split("/")[0]; // Take only the host part - return cleaned || null; -} - -/** - * Simple extraction of specific fields from wrangler.toml content. - * Not a full TOML parser — just enough for the fields we need. - */ -function extractFromTOML(content: string): WranglerConfig { - const result: WranglerConfig = {}; - const sections = getTomlSections(content); - - const nameMatch = content.match(/^name\s*=\s*"([^"]+)"/m); - if (nameMatch) result.name = nameMatch[1]; - - const legacyEnvMatch = content.match(/^legacy_env\s*=\s*(true|false)\s*$/m); - if (legacyEnvMatch) result.legacyEnv = legacyEnvMatch[1] === "true"; - - // account_id = "..." - const accountMatch = content.match(/^account_id\s*=\s*"([^"]+)"/m); - if (accountMatch) result.accountId = accountMatch[1]; - - // KV namespace with binding = "VINEXT_KV_CACHE" - // Look for [[kv_namespaces]] blocks - const kvBlocks = content.split(/\[\[kv_namespaces\]\]/); - for (let i = 1; i < kvBlocks.length; i++) { - const block = kvBlocks[i].split(/\[\[/)[0]; // Take until next section - const bindingMatch = block.match(/binding\s*=\s*"([^"]+)"/); - const idMatch = block.match(/\bid\s*=\s*"([^"]+)"/); - if ( - (bindingMatch?.[1] === "VINEXT_KV_CACHE" || bindingMatch?.[1] === "VINEXT_CACHE") && - idMatch?.[1] && - idMatch[1] !== "" - ) { - result.kvNamespaceId = idMatch[1]; - } - } - - // routes — both string and table forms - // route = "example.com/*" - const routeMatch = content.match(/^route\s*=\s*"([^"]+)"/m); - if (routeMatch) { - const domain = cleanDomain(routeMatch[1]); - if (domain && !domain.includes("workers.dev")) { - result.customDomain = domain; - } - } - - // [[routes]] is a TOML array-of-tables: each entry produces its own section - // with the same header, so an earlier disqualified route must not shadow a - // later valid one. - const rootRouteSections = sections.filter( - (section) => section.header === "route" || section.header === "routes", - ); - - // Preserves prior behavior: root-level [[routes]] blocks match `pattern` - // only (unlike the env-scoped path below, which also accepts `zone_name`). - if (!result.customDomain) { - result.customDomain = - firstMatch(rootRouteSections, (section) => extractTomlRoutePatternDomain(section.body)) ?? - undefined; - } - - result.warmupHost = - extractTomlWarmupHost(getTomlRootBody(content)) ?? - firstMatch(rootRouteSections, (section) => extractTomlWarmupRouteBlockHost(section.body)) ?? - undefined; - - const rootBody = getTomlRootBody(content); - const rootCache = sections.find((section) => section.header === "cache"); - const cache = - extractTomlCacheConfig(rootBody) ?? - (rootCache ? extractTomlCacheTableConfig(rootCache.body) : null); - if (cache) result.cache = cache; - const rootVersionMetadata = sections.find((section) => section.header === "version_metadata"); - const versionMetadataBinding = - extractTomlVersionMetadataBinding(rootBody) ?? - (rootVersionMetadata ? extractTomlVersionMetadataTableBinding(rootVersionMetadata.body) : null); - if (versionMetadataBinding) result.versionMetadataBinding = versionMetadataBinding; - - const env = extractEnvConfigsFromTOML(content); - if (env) result.env = env; - - return result; -} - -function extractEnvConfigsFromTOML( - content: string, -): Record | undefined { - const result: Record = {}; - - for (const section of getTomlSections(content)) { - const envName = section.header.match(/^env\.([^.]+)$/)?.[1]; - if (envName) { - const envConfig = result[envName] ?? {}; - const cache = extractTomlCacheConfig(section.body); - if (cache) envConfig.cache = cache; - const nameMatch = section.body.match(/^name\s*=\s*"([^"]+)"/m); - if (nameMatch) envConfig.name = nameMatch[1]; - const domain = - extractTomlScalarRouteDomain(section.body) ?? extractTomlRoutesArrayDomain(section.body); - if (domain) envConfig.customDomain = domain; - const warmupHost = extractTomlWarmupHost(section.body); - if (warmupHost) envConfig.warmupHost = warmupHost; - const versionMetadataBinding = extractTomlVersionMetadataBinding(section.body); - if (versionMetadataBinding) envConfig.versionMetadataBinding = versionMetadataBinding; - if ( - envConfig.name || - envConfig.cache || - envConfig.customDomain || - envConfig.warmupHost || - envConfig.versionMetadataBinding - ) { - result[envName] = envConfig; - } - continue; - } - - const cacheEnvName = section.header.match(/^env\.([^.]+)\.cache$/)?.[1]; - if (cacheEnvName) { - const envConfig = result[cacheEnvName] ?? {}; - const cache = extractTomlCacheTableConfig(section.body); - if (cache) envConfig.cache = cache; - if ( - envConfig.name || - envConfig.cache || - envConfig.customDomain || - envConfig.warmupHost || - envConfig.versionMetadataBinding - ) { - result[cacheEnvName] = envConfig; - } - continue; - } - - const metadataEnvName = section.header.match(/^env\.([^.]+)\.version_metadata$/)?.[1]; - if (metadataEnvName) { - const envConfig = result[metadataEnvName] ?? {}; - const binding = extractTomlVersionMetadataTableBinding(section.body); - if (binding) envConfig.versionMetadataBinding = binding; - if ( - envConfig.name || - envConfig.cache || - envConfig.customDomain || - envConfig.warmupHost || - envConfig.versionMetadataBinding - ) { - result[metadataEnvName] = envConfig; - } - continue; - } - - const routesEnvName = section.header.match(/^env\.([^.]+)\.(?:route|routes)$/)?.[1]; - if (routesEnvName) { - const envConfig = result[routesEnvName] ?? {}; - const domain = extractTomlRouteBlockDomain(section.body); - if (domain) envConfig.customDomain = domain; - const warmupHost = extractTomlWarmupRouteBlockHost(section.body); - if (warmupHost) envConfig.warmupHost = warmupHost; - if ( - envConfig.name || - envConfig.cache || - envConfig.customDomain || - envConfig.warmupHost || - envConfig.versionMetadataBinding - ) { - result[routesEnvName] = envConfig; - } - } - } - - return Object.keys(result).length > 0 ? result : undefined; -} - -function extractTomlCacheConfig(section: string): WranglerCacheConfig | null { - const match = stripTomlLineComments(section).match(/^cache\s*=\s*\{([\s\S]*?)\}/m); - return match ? extractTomlCacheTableConfig(match[1] ?? "") : null; -} - -function extractTomlCacheTableConfig(section: string): WranglerCacheConfig | null { - const enabledMatch = section.match(/(?:^|[,\n])\s*enabled\s*=\s*(true|false)\b/); - const crossVersionMatch = section.match(/(?:^|[,\n])\s*cross_version_cache\s*=\s*(true|false)\b/); - if (!enabledMatch && !crossVersionMatch) return null; - return { - enabled: enabledMatch ? enabledMatch[1] === "true" : undefined, - crossVersionCache: crossVersionMatch ? crossVersionMatch[1] === "true" : undefined, - }; -} - -function extractTomlVersionMetadataBinding(section: string): string | null { - const match = stripTomlLineComments(section).match( - /^version_metadata\s*=\s*\{[^}]*\bbinding\s*=\s*(?:"([^"]+)"|'([^']+)')[^}]*\}/m, - ); - return match?.slice(1).find((value): value is string => Boolean(value)) ?? null; -} - -function extractTomlVersionMetadataTableBinding(section: string): string | null { - const match = section.match(/^binding\s*=\s*(?:"([^"]+)"|'([^']+)')/m); - return match?.slice(1).find((value): value is string => Boolean(value)) ?? null; -} - -function extractTomlScalarRouteDomain(section: string): string | null { - const routeMatch = section.match(/^route\s*=\s*"([^"]+)"/m); - if (!routeMatch) return null; - const domain = cleanDomain(routeMatch[1]); - return domain && !domain.includes("workers.dev") ? domain : null; -} - -function extractTomlRoutesArrayDomain(section: string): string | null { - const routesMatch = section.match(/^routes\s*=\s*\[([\s\S]*?)\]/m); - if (!routesMatch) return null; - return firstMatch(extractTomlRoutePatterns(routesMatch[1] ?? ""), (pattern) => { - const domain = cleanDomain(pattern); - return domain && !domain.includes("workers.dev") ? domain : null; - }); -} - -function extractTomlWarmupHost(section: string): string | null { - const uncommented = stripTomlLineComments(section); - const scalarRoute = uncommented - .match(/^route\s*=\s*(?:"([^"]+)"|'([^']+)')/m) - ?.slice(1) - .find((value): value is string => Boolean(value)); - if (scalarRoute) return routePatternToWarmupHost(scalarRoute); - - const inlineRoute = uncommented.match(/^route\s*=\s*\{([\s\S]*?)\}\s*$/m)?.[1]; - if (inlineRoute && /\benabled\s*=\s*false\b/.test(inlineRoute)) return null; - const inlinePattern = inlineRoute - ?.match(/\bpattern\s*=\s*(?:"([^"]+)"|'([^']+)')/) - ?.slice(1) - .find((value): value is string => Boolean(value)); - if (inlinePattern) return routePatternToWarmupHost(inlinePattern); - - const routesArray = uncommented.match(/^routes\s*=\s*\[([\s\S]*?)\]/m)?.[1]; - if (!routesArray) return null; - return firstMatch(extractTomlRouteEntries(routesArray), (route) => - route.enabled === false ? null : routePatternToWarmupHost(route.pattern), - ); -} - -function routePatternToWarmupHost(pattern: string): string | null { - const withoutProtocol = pattern.replace(/^https?:\/\//, ""); - const pathStart = withoutProtocol.indexOf("/"); - const routePath = pathStart === -1 ? "" : withoutProtocol.slice(pathStart); - if (routePath !== "" && routePath !== "/*") return null; - const host = cleanDomain(pattern); - return host && !host.includes("*") && !host.includes("workers.dev") ? host : null; -} - -function extractTomlRouteBlockDomain(section: string): string | null { - const patternMatch = section.match(/^(?:pattern|zone_name)\s*=\s*"([^"]+)"/m); - if (!patternMatch) return null; - const domain = cleanDomain(patternMatch[1]); - return domain && !domain.includes("workers.dev") ? domain : null; -} - -function extractTomlRoutePatternDomain(section: string): string | null { - const patternMatch = section.match(/^pattern\s*=\s*"([^"]+)"/m); - if (!patternMatch) return null; - const domain = cleanDomain(patternMatch[1]); - return domain && !domain.includes("workers.dev") ? domain : null; -} - -function extractTomlWarmupRouteBlockHost(section: string): string | null { - if (/^enabled\s*=\s*false\b/m.test(section)) return null; - const patternMatch = section.match(/^pattern\s*=\s*"([^"]+)"/m); - return patternMatch ? routePatternToWarmupHost(patternMatch[1]) : null; -} - // ─── Cloudflare API ────────────────────────────────────────────────────────── /** diff --git a/packages/cloudflare/src/wrangler-config.ts b/packages/cloudflare/src/wrangler-config.ts new file mode 100644 index 0000000000..7f2722cc02 --- /dev/null +++ b/packages/cloudflare/src/wrangler-config.ts @@ -0,0 +1,661 @@ +/** + * Reads the narrow projection of wrangler.jsonc/.json/.toml that vinext's + * deploy-time features consume: account and KV fields for TPR, cache/route/ + * environment/Worker-name/version-metadata fields for CDN warmup target + * resolution. Deliberately partial parsing — `utils/toml.ts` holds the + * format-level TOML helpers, and unknown fields are ignored rather than + * validated. Owned here so deploy features depend on a config module instead + * of reaching into feature modules like tpr.ts. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { isUnknownRecord } from "./utils/cache-control-metadata.js"; +import { + extractTomlRouteEntries, + extractTomlRoutePatterns, + getTomlRootBody, + getTomlSections, + stripTomlLineComments, +} from "./utils/toml.js"; + +export type WranglerConfig = { + accountId?: string; + cache?: WranglerCacheConfig; + kvNamespaceId?: string; + customDomain?: string; + warmupHost?: string; + name?: string; + legacyEnv?: boolean; + targetEnvironment?: string; + versionMetadataBinding?: string; + env?: Record; +}; + +type WranglerEnvironmentConfig = { + cache?: WranglerCacheConfig; + customDomain?: string; + warmupHost?: string; + name?: string; + versionMetadataBinding?: string; +}; + +export type WranglerCacheConfig = { + enabled?: boolean; + crossVersionCache?: boolean; +}; + +// ─── Wrangler Config Parsing ───────────────────────────────────────────────── + +/** + * Parse wrangler config (JSONC or TOML) to extract the fields used by TPR and + * CDN warmup target resolution. + */ +export function parseWranglerConfig(root: string, configPath?: string): WranglerConfig | null { + if (configPath) { + const filepath = path.resolve(root, configPath); + if (!fs.existsSync(filepath)) return null; + const content = fs.readFileSync(filepath, "utf-8"); + if (filepath.endsWith(".toml")) { + return extractFromTOML(content); + } + try { + const json = JSON.parse(stripJsonCommentsAndTrailingCommas(content)); + return extractFromJSON(json); + } catch { + return null; + } + } + + // Try JSONC / JSON first + for (const filename of ["wrangler.jsonc", "wrangler.json"]) { + const filepath = path.join(root, filename); + if (fs.existsSync(filepath)) { + const content = fs.readFileSync(filepath, "utf-8"); + try { + const json = JSON.parse(stripJsonCommentsAndTrailingCommas(content)); + return extractFromJSON(json); + } catch { + continue; + } + } + } + + // Try TOML + const tomlPath = path.join(root, "wrangler.toml"); + if (fs.existsSync(tomlPath)) { + const content = fs.readFileSync(tomlPath, "utf-8"); + return extractFromTOML(content); + } + + return null; +} + +/** + * Strip single-line (//), multi-line comments, and trailing commas from JSONC + * while preserving strings that contain comment-like text or commas. + */ +function stripJsonCommentsAndTrailingCommas(str: string): string { + let result = ""; + let inString = false; + let inSingleLine = false; + let inMultiLine = false; + let escapeNext = false; + + for (let i = 0; i < str.length; i++) { + const ch = str[i]; + const next = str[i + 1]; + + if (escapeNext) { + if (!inSingleLine && !inMultiLine) result += ch; + escapeNext = false; + continue; + } + + if (ch === "\\" && inString) { + result += ch; + escapeNext = true; + continue; + } + + if (inSingleLine) { + if (ch === "\n") { + inSingleLine = false; + result += ch; + } + continue; + } + + if (inMultiLine) { + if (ch === "*" && next === "/") { + inMultiLine = false; + i++; + } + continue; + } + + if (ch === '"' && !inString) { + inString = true; + result += ch; + continue; + } + + if (ch === '"' && inString) { + inString = false; + result += ch; + continue; + } + + if (!inString && ch === "/" && next === "/") { + inSingleLine = true; + i++; + continue; + } + + if (!inString && ch === "/" && next === "*") { + inMultiLine = true; + i++; + continue; + } + + if (!inString && ch === "," && isJsonTrailingComma(str, i + 1)) { + continue; + } + + result += ch; + } + + return result; +} + +function isJsonTrailingComma(str: string, start: number): boolean { + for (let i = start; i < str.length; i++) { + const ch = str[i]; + const next = str[i + 1]; + if (ch === undefined) return false; + if (/\s/.test(ch)) { + continue; + } + if (ch === "/" && next === "/") { + i += 2; + while (i < str.length && str[i] !== "\n") { + i++; + } + continue; + } + if (ch === "/" && next === "*") { + i += 2; + while (i < str.length) { + if (str[i] === "*" && str[i + 1] === "/") { + i++; + break; + } + i++; + } + continue; + } + return ch === "}" || ch === "]"; + } + + return false; +} + +function extractFromJSON(config: Record): WranglerConfig { + const result: WranglerConfig = {}; + + const cache = extractCacheConfig(config.cache); + if (cache) result.cache = cache; + + if (typeof config.name === "string" && config.name.length > 0) { + result.name = config.name; + } + + if (typeof config.legacy_env === "boolean") { + result.legacyEnv = config.legacy_env; + } + + // Cloudflare's generated dist/server/wrangler.json is already flattened to + // the environment selected at build time. Wrangler tags that redirected + // config so deploy-time readers can distinguish it from a source config + // that simply omitted the requested env block. + if (typeof config.targetEnvironment === "string" && config.targetEnvironment.length > 0) { + result.targetEnvironment = config.targetEnvironment; + } + + // account_id + if (typeof config.account_id === "string") { + result.accountId = config.account_id; + } + + // KV namespace ID for VINEXT_KV_CACHE + if (Array.isArray(config.kv_namespaces)) { + const vinextKV = config.kv_namespaces.find( + (ns: Record) => + ns && + typeof ns === "object" && + (ns.binding === "VINEXT_KV_CACHE" || ns.binding === "VINEXT_CACHE"), + ); + if (vinextKV && typeof vinextKV.id === "string" && vinextKV.id !== "") { + result.kvNamespaceId = vinextKV.id; + } + } + + // TPR needs a zone-resolvable domain, while CDN warmup needs the exact route + // host. Keep both because zone_name is not the hostname a request should use. + const domain = + extractDomainFromRoute(config.route) ?? + extractDomainFromRoutes(config.routes) ?? + extractDomainFromCustomDomains(config); + if (domain) result.customDomain = domain; + const warmupHost = extractWarmupHost(config); + if (warmupHost) result.warmupHost = warmupHost; + const versionMetadataBinding = extractVersionMetadataBinding(config); + if (versionMetadataBinding) result.versionMetadataBinding = versionMetadataBinding; + + const env = extractEnvConfigs(config.env); + if (env) result.env = env; + + return result; +} + +function extractEnvConfigs(envs: unknown): Record | undefined { + if (!envs || typeof envs !== "object" || Array.isArray(envs)) return undefined; + + const result: Record = {}; + for (const [envName, rawConfig] of Object.entries(envs)) { + if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) continue; + const envConfig = extractEnvironmentConfig(rawConfig as Record); + if ( + envConfig.name || + envConfig.cache || + envConfig.customDomain || + envConfig.warmupHost || + envConfig.versionMetadataBinding + ) { + result[envName] = envConfig; + } + } + return Object.keys(result).length > 0 ? result : undefined; +} + +function extractEnvironmentConfig(config: Record): WranglerEnvironmentConfig { + const result: WranglerEnvironmentConfig = {}; + const cache = extractCacheConfig(config.cache); + if (cache) result.cache = cache; + if (typeof config.name === "string" && config.name.length > 0) { + result.name = config.name; + } + const domain = + extractDomainFromRoute(config.route) ?? + extractDomainFromRoutes(config.routes) ?? + extractDomainFromCustomDomains(config); + if (domain) result.customDomain = domain; + const warmupHost = extractWarmupHost(config); + if (warmupHost) result.warmupHost = warmupHost; + const versionMetadataBinding = extractVersionMetadataBinding(config); + if (versionMetadataBinding) result.versionMetadataBinding = versionMetadataBinding; + return result; +} + +function extractCacheConfig(value: unknown): WranglerCacheConfig | null { + if (!isUnknownRecord(value)) return null; + const enabled = typeof value.enabled === "boolean" ? value.enabled : undefined; + const crossVersionCache = + typeof value.cross_version_cache === "boolean" ? value.cross_version_cache : undefined; + return enabled === undefined && crossVersionCache === undefined + ? null + : { enabled, crossVersionCache }; +} + +function extractVersionMetadataBinding(config: Record): string | null { + const metadata = config.version_metadata; + return isUnknownRecord(metadata) && + typeof metadata.binding === "string" && + metadata.binding.length > 0 + ? metadata.binding + : null; +} + +function extractDomainFromRoute(route: unknown): string | null { + if (typeof route === "string") { + const domain = cleanDomain(route); + return domain && !domain.includes("workers.dev") ? domain : null; + } + if (!isUnknownRecord(route)) return null; + const domainSource = + typeof route.zone_name === "string" + ? route.zone_name + : typeof route.pattern === "string" + ? route.pattern + : null; + if (!domainSource) return null; + const domain = cleanDomain(domainSource); + return domain && !domain.includes("workers.dev") ? domain : null; +} + +function extractDomainFromRoutes(routes: unknown): string | null { + if (!Array.isArray(routes)) return null; + return firstMatch(routes, extractDomainFromRoute); +} + +function extractWarmupHost(config: Record): string | null { + const singular = extractWarmupHostFromRoute(config.route); + if (singular) return singular; + const fromRoutes = Array.isArray(config.routes) + ? firstMatch(config.routes, extractWarmupHostFromRoute) + : null; + return fromRoutes ?? extractWarmupHostFromCustomDomains(config); +} + +/** + * A route array can mix host-wide and path-scoped (or workers.dev) entries; + * a disqualified earlier entry must not hide a valid later one. Returns the + * first non-null result of `fn`, or null if none qualify. + */ +function firstMatch(items: Iterable, fn: (item: T) => R | null): R | null { + for (const item of items) { + const result = fn(item); + if (result !== null) return result; + } + return null; +} + +function extractWarmupHostFromRoute(route: unknown): string | null { + if (isUnknownRecord(route) && route.enabled === false) return null; + const pattern = typeof route === "string" ? route : isUnknownRecord(route) ? route.pattern : null; + return typeof pattern === "string" ? routePatternToWarmupHost(pattern) : null; +} + +function extractDomainFromCustomDomains(config: Record): string | null { + // Workers Custom Domains: "custom_domains": ["example.com"] + if (Array.isArray(config.custom_domains)) { + for (const d of config.custom_domains) { + if (typeof d === "string" && !d.includes("workers.dev")) { + return cleanDomain(d); + } + } + } + return null; +} + +function extractWarmupHostFromCustomDomains(config: Record): string | null { + if (!Array.isArray(config.custom_domains)) return null; + return firstMatch(config.custom_domains, (domain) => + typeof domain === "string" ? routePatternToWarmupHost(domain) : null, + ); +} + +/** Strip protocol and trailing wildcards from a route pattern to get a bare domain. */ +function cleanDomain(raw: string): string | null { + const cleaned = raw + .replace(/^https?:\/\//, "") + .replace(/\/\*$/, "") + .replace(/\/+$/, "") + .split("/")[0]; // Take only the host part + return cleaned || null; +} + +/** + * Simple extraction of specific fields from wrangler.toml content. + * Not a full TOML parser — just enough for the fields we need. + */ +function extractFromTOML(content: string): WranglerConfig { + const result: WranglerConfig = {}; + const sections = getTomlSections(content); + + const nameMatch = content.match(/^name\s*=\s*"([^"]+)"/m); + if (nameMatch) result.name = nameMatch[1]; + + const legacyEnvMatch = content.match(/^legacy_env\s*=\s*(true|false)\s*$/m); + if (legacyEnvMatch) result.legacyEnv = legacyEnvMatch[1] === "true"; + + // account_id = "..." + const accountMatch = content.match(/^account_id\s*=\s*"([^"]+)"/m); + if (accountMatch) result.accountId = accountMatch[1]; + + // KV namespace with binding = "VINEXT_KV_CACHE" + // Look for [[kv_namespaces]] blocks + const kvBlocks = content.split(/\[\[kv_namespaces\]\]/); + for (let i = 1; i < kvBlocks.length; i++) { + const block = kvBlocks[i].split(/\[\[/)[0]; // Take until next section + const bindingMatch = block.match(/binding\s*=\s*"([^"]+)"/); + const idMatch = block.match(/\bid\s*=\s*"([^"]+)"/); + if ( + (bindingMatch?.[1] === "VINEXT_KV_CACHE" || bindingMatch?.[1] === "VINEXT_CACHE") && + idMatch?.[1] && + idMatch[1] !== "" + ) { + result.kvNamespaceId = idMatch[1]; + } + } + + // routes — both string and table forms + // route = "example.com/*" + const routeMatch = content.match(/^route\s*=\s*"([^"]+)"/m); + if (routeMatch) { + const domain = cleanDomain(routeMatch[1]); + if (domain && !domain.includes("workers.dev")) { + result.customDomain = domain; + } + } + + // [[routes]] is a TOML array-of-tables: each entry produces its own section + // with the same header, so an earlier disqualified route must not shadow a + // later valid one. + const rootRouteSections = sections.filter( + (section) => section.header === "route" || section.header === "routes", + ); + + // Preserves prior behavior: root-level [[routes]] blocks match `pattern` + // only (unlike the env-scoped path below, which also accepts `zone_name`). + if (!result.customDomain) { + result.customDomain = + firstMatch(rootRouteSections, (section) => extractTomlRoutePatternDomain(section.body)) ?? + undefined; + } + + result.warmupHost = + extractTomlWarmupHost(getTomlRootBody(content)) ?? + firstMatch(rootRouteSections, (section) => extractTomlWarmupRouteBlockHost(section.body)) ?? + undefined; + + const rootBody = getTomlRootBody(content); + const rootCache = sections.find((section) => section.header === "cache"); + const cache = + extractTomlCacheConfig(rootBody) ?? + (rootCache ? extractTomlCacheTableConfig(rootCache.body) : null); + if (cache) result.cache = cache; + const rootVersionMetadata = sections.find((section) => section.header === "version_metadata"); + const versionMetadataBinding = + extractTomlVersionMetadataBinding(rootBody) ?? + (rootVersionMetadata ? extractTomlVersionMetadataTableBinding(rootVersionMetadata.body) : null); + if (versionMetadataBinding) result.versionMetadataBinding = versionMetadataBinding; + + const env = extractEnvConfigsFromTOML(content); + if (env) result.env = env; + + return result; +} + +function extractEnvConfigsFromTOML( + content: string, +): Record | undefined { + const result: Record = {}; + + for (const section of getTomlSections(content)) { + const envName = section.header.match(/^env\.([^.]+)$/)?.[1]; + if (envName) { + const envConfig = result[envName] ?? {}; + const cache = extractTomlCacheConfig(section.body); + if (cache) envConfig.cache = cache; + const nameMatch = section.body.match(/^name\s*=\s*"([^"]+)"/m); + if (nameMatch) envConfig.name = nameMatch[1]; + const domain = + extractTomlScalarRouteDomain(section.body) ?? extractTomlRoutesArrayDomain(section.body); + if (domain) envConfig.customDomain = domain; + const warmupHost = extractTomlWarmupHost(section.body); + if (warmupHost) envConfig.warmupHost = warmupHost; + const versionMetadataBinding = extractTomlVersionMetadataBinding(section.body); + if (versionMetadataBinding) envConfig.versionMetadataBinding = versionMetadataBinding; + if ( + envConfig.name || + envConfig.cache || + envConfig.customDomain || + envConfig.warmupHost || + envConfig.versionMetadataBinding + ) { + result[envName] = envConfig; + } + continue; + } + + const cacheEnvName = section.header.match(/^env\.([^.]+)\.cache$/)?.[1]; + if (cacheEnvName) { + const envConfig = result[cacheEnvName] ?? {}; + const cache = extractTomlCacheTableConfig(section.body); + if (cache) envConfig.cache = cache; + if ( + envConfig.name || + envConfig.cache || + envConfig.customDomain || + envConfig.warmupHost || + envConfig.versionMetadataBinding + ) { + result[cacheEnvName] = envConfig; + } + continue; + } + + const metadataEnvName = section.header.match(/^env\.([^.]+)\.version_metadata$/)?.[1]; + if (metadataEnvName) { + const envConfig = result[metadataEnvName] ?? {}; + const binding = extractTomlVersionMetadataTableBinding(section.body); + if (binding) envConfig.versionMetadataBinding = binding; + if ( + envConfig.name || + envConfig.cache || + envConfig.customDomain || + envConfig.warmupHost || + envConfig.versionMetadataBinding + ) { + result[metadataEnvName] = envConfig; + } + continue; + } + + const routesEnvName = section.header.match(/^env\.([^.]+)\.(?:route|routes)$/)?.[1]; + if (routesEnvName) { + const envConfig = result[routesEnvName] ?? {}; + const domain = extractTomlRouteBlockDomain(section.body); + if (domain) envConfig.customDomain = domain; + const warmupHost = extractTomlWarmupRouteBlockHost(section.body); + if (warmupHost) envConfig.warmupHost = warmupHost; + if ( + envConfig.name || + envConfig.cache || + envConfig.customDomain || + envConfig.warmupHost || + envConfig.versionMetadataBinding + ) { + result[routesEnvName] = envConfig; + } + } + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +function extractTomlCacheConfig(section: string): WranglerCacheConfig | null { + const match = stripTomlLineComments(section).match(/^cache\s*=\s*\{([\s\S]*?)\}/m); + return match ? extractTomlCacheTableConfig(match[1] ?? "") : null; +} + +function extractTomlCacheTableConfig(section: string): WranglerCacheConfig | null { + const enabledMatch = section.match(/(?:^|[,\n])\s*enabled\s*=\s*(true|false)\b/); + const crossVersionMatch = section.match(/(?:^|[,\n])\s*cross_version_cache\s*=\s*(true|false)\b/); + if (!enabledMatch && !crossVersionMatch) return null; + return { + enabled: enabledMatch ? enabledMatch[1] === "true" : undefined, + crossVersionCache: crossVersionMatch ? crossVersionMatch[1] === "true" : undefined, + }; +} + +function extractTomlVersionMetadataBinding(section: string): string | null { + const match = stripTomlLineComments(section).match( + /^version_metadata\s*=\s*\{[^}]*\bbinding\s*=\s*(?:"([^"]+)"|'([^']+)')[^}]*\}/m, + ); + return match?.slice(1).find((value): value is string => Boolean(value)) ?? null; +} + +function extractTomlVersionMetadataTableBinding(section: string): string | null { + const match = section.match(/^binding\s*=\s*(?:"([^"]+)"|'([^']+)')/m); + return match?.slice(1).find((value): value is string => Boolean(value)) ?? null; +} + +function extractTomlScalarRouteDomain(section: string): string | null { + const routeMatch = section.match(/^route\s*=\s*"([^"]+)"/m); + if (!routeMatch) return null; + const domain = cleanDomain(routeMatch[1]); + return domain && !domain.includes("workers.dev") ? domain : null; +} + +function extractTomlRoutesArrayDomain(section: string): string | null { + const routesMatch = section.match(/^routes\s*=\s*\[([\s\S]*?)\]/m); + if (!routesMatch) return null; + return firstMatch(extractTomlRoutePatterns(routesMatch[1] ?? ""), (pattern) => { + const domain = cleanDomain(pattern); + return domain && !domain.includes("workers.dev") ? domain : null; + }); +} + +function extractTomlWarmupHost(section: string): string | null { + const uncommented = stripTomlLineComments(section); + const scalarRoute = uncommented + .match(/^route\s*=\s*(?:"([^"]+)"|'([^']+)')/m) + ?.slice(1) + .find((value): value is string => Boolean(value)); + if (scalarRoute) return routePatternToWarmupHost(scalarRoute); + + const inlineRoute = uncommented.match(/^route\s*=\s*\{([\s\S]*?)\}\s*$/m)?.[1]; + if (inlineRoute && /\benabled\s*=\s*false\b/.test(inlineRoute)) return null; + const inlinePattern = inlineRoute + ?.match(/\bpattern\s*=\s*(?:"([^"]+)"|'([^']+)')/) + ?.slice(1) + .find((value): value is string => Boolean(value)); + if (inlinePattern) return routePatternToWarmupHost(inlinePattern); + + const routesArray = uncommented.match(/^routes\s*=\s*\[([\s\S]*?)\]/m)?.[1]; + if (!routesArray) return null; + return firstMatch(extractTomlRouteEntries(routesArray), (route) => + route.enabled === false ? null : routePatternToWarmupHost(route.pattern), + ); +} + +function routePatternToWarmupHost(pattern: string): string | null { + const withoutProtocol = pattern.replace(/^https?:\/\//, ""); + const pathStart = withoutProtocol.indexOf("/"); + const routePath = pathStart === -1 ? "" : withoutProtocol.slice(pathStart); + if (routePath !== "" && routePath !== "/*") return null; + const host = cleanDomain(pattern); + return host && !host.includes("*") && !host.includes("workers.dev") ? host : null; +} + +function extractTomlRouteBlockDomain(section: string): string | null { + const patternMatch = section.match(/^(?:pattern|zone_name)\s*=\s*"([^"]+)"/m); + if (!patternMatch) return null; + const domain = cleanDomain(patternMatch[1]); + return domain && !domain.includes("workers.dev") ? domain : null; +} + +function extractTomlRoutePatternDomain(section: string): string | null { + const patternMatch = section.match(/^pattern\s*=\s*"([^"]+)"/m); + if (!patternMatch) return null; + const domain = cleanDomain(patternMatch[1]); + return domain && !domain.includes("workers.dev") ? domain : null; +} + +function extractTomlWarmupRouteBlockHost(section: string): string | null { + if (/^enabled\s*=\s*false\b/m.test(section)) return null; + const patternMatch = section.match(/^pattern\s*=\s*"([^"]+)"/m); + return patternMatch ? routePatternToWarmupHost(patternMatch[1]) : null; +} diff --git a/packages/cloudflare/src/wrangler-deployment-target.ts b/packages/cloudflare/src/wrangler-deployment-target.ts index b5f51a6a00..5f352b46e0 100644 --- a/packages/cloudflare/src/wrangler-deployment-target.ts +++ b/packages/cloudflare/src/wrangler-deployment-target.ts @@ -4,13 +4,16 @@ * `parseWranglerConfig` reads out of wrangler.jsonc/.toml. * * The env fallback and legacy_env Worker-name suffixing here are CDN-warmup - * resolution rules, not generic Wrangler config fields — keeping them out of - * tpr.ts keeps that module's `WranglerConfig` an honest TPR-input shape - * instead of a shared deployment-semantics owner. + * resolution rules, not generic Wrangler config fields — they layer on the + * raw projection `wrangler-config.ts` owns. */ import type { DeployOptions } from "./deploy.js"; -import { parseWranglerConfig, type WranglerCacheConfig, type WranglerConfig } from "./tpr.js"; +import { + parseWranglerConfig, + type WranglerCacheConfig, + type WranglerConfig, +} from "./wrangler-config.js"; export type WranglerDeploymentTarget = { cacheEnabled?: boolean; diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index e527139965..6e2d895230 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -61,7 +61,8 @@ import { mergeHeaders, resolveStaticAssetSignal, } from "../packages/vinext/src/server/worker-utils.js"; -import { domainCandidates, parseWranglerConfig, runTPR } from "../packages/cloudflare/src/tpr.js"; +import { domainCandidates, runTPR } from "../packages/cloudflare/src/tpr.js"; +import { parseWranglerConfig } from "../packages/cloudflare/src/wrangler-config.js"; import { formatDeployHelp } from "../packages/cloudflare/src/deploy-help.js"; // ─── Test Helpers ──────────────────────────────────────────────────────────── From 6f3fa1e2a790e41a6e90310216feb5695f9cdd49 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:57:49 +1000 Subject: [PATCH 19/22] fix(cloudflare): warm every host-wide origin before claiming a confirmed pre-warm Warmup previously resolved a single production host: the config parser kept only the first eligible host-wide route or Custom Domain, and warmAndPromote warmed every path against that one origin. The hostname is part of Cloudflare's cache key, so when several hosts are attached to the Worker (for example apex and www routes), each host owns its own version-isolated cache partition. Every non-first host was promoted cold while the deploy summary still claimed "pre-warmed and confirmed", and strict mode succeeded on the same false guarantee. Represent the target as the full set of cache-key origins instead: the parser collects every eligible host-wide origin from route, routes, and custom_domains (deduped, JSONC and TOML alike), the deployment target exposes productionHosts, and the warm transaction covers every origin and path combination. The deployment is reported warmed only when all of them are confirmed; strict mode fails before promotion when any origin cannot be. Repeated TOML [[env..routes]] blocks previously kept only the last block's host and now accumulate into the same origin set. --- .../cloudflare/src/cdn-warm-deployment.ts | 68 ++++++---- packages/cloudflare/src/wrangler-config.ts | 109 ++++++++++------ .../src/wrangler-deployment-target.ts | 11 +- tests/cloudflare-cdn-warm-deploy.test.ts | 110 ++++++++++++++++ tests/deploy.test.ts | 119 +++++++++++++++--- 5 files changed, 332 insertions(+), 85 deletions(-) diff --git a/packages/cloudflare/src/cdn-warm-deployment.ts b/packages/cloudflare/src/cdn-warm-deployment.ts index 6b68e0c4e7..dc584979f0 100644 --- a/packages/cloudflare/src/cdn-warm-deployment.ts +++ b/packages/cloudflare/src/cdn-warm-deployment.ts @@ -200,40 +200,58 @@ async function warmAndPromote( // has no custom routes. Falling back to it for a path-scoped route would // verify the right Worker version on the wrong hostname and falsely report // the production cache as warm. - const targetUrl = target.productionHost - ? `https://${target.productionHost}` - : target.hasProductionRoute - ? undefined - : staged.deployedUrl; + // + // The hostname is part of Cloudflare's cache key, so every attached + // host-wide origin is a separate cache partition: warming one route's host + // proves nothing about another's, and "warmed" may only be reported when + // every origin × path pair is confirmed. + const targetUrls = target.productionHosts.length + ? target.productionHosts.map((host) => `https://${host}`) + : !target.hasProductionRoute && staged.deployedUrl + ? [staged.deployedUrl] + : []; const headers = buildVersionOverrideHeaders(target.workerName, upload.versionId); - if (!targetUrl || !headers) { + if (targetUrls.length === 0 || !headers) { const message = - target.hasProductionRoute && !target.productionHost + target.hasProductionRoute && target.productionHosts.length === 0 ? "CDN pre-warm cannot safely warm path-scoped Worker routes because workers.dev uses a different cache key." : "CDN pre-warm requires a production URL and Worker name for version overrides."; if (options.warmCdnStrict) throw new Error(message); console.warn(` ${message} Promoting without pre-warming.`); } else { - const result = await warmCdnCache({ - targetUrl, - paths, - headers, - expectedVersionId: upload.versionId, - // The deployment summary claims "pre-warmed and confirmed", so a - // producer-only 200 is not enough — require cf-cache-status proof - // that the entry was stored and is servable from cache. - confirmCache: true, - concurrency: options.warmCdnConcurrency, - timeoutMs: options.warmCdnTimeout, - retries: options.warmCdnRetries, - strict: options.warmCdnStrict, - }); - warmed = result.failed === 0; + let confirmedPaths = 0; + let totalPaths = 0; + warmed = true; + for (const targetUrl of targetUrls) { + if (targetUrls.length > 1) { + console.log(` CDN pre-warm origin: ${targetUrl}`); + } + // In strict mode warmCdnCache throws on the first unconfirmed origin, + // which the surrounding catch reports with the staged-at-0% state. + const result = await warmCdnCache({ + targetUrl, + paths, + headers, + expectedVersionId: upload.versionId, + // The deployment summary claims "pre-warmed and confirmed", so a + // producer-only 200 is not enough — require cf-cache-status proof + // that the entry was stored and is servable from cache. + confirmCache: true, + concurrency: options.warmCdnConcurrency, + timeoutMs: options.warmCdnTimeout, + retries: options.warmCdnRetries, + strict: options.warmCdnStrict, + }); + confirmedPaths += result.warmed; + totalPaths += result.total; + if (result.failed !== 0) warmed = false; + } if (!warmed) { + const originSuffix = targetUrls.length > 1 ? ` across ${targetUrls.length} origins` : ""; console.warn( - ` CDN pre-warm confirmed ${result.warmed}/${result.total} path(s) served the uploaded ` + - "version. Promoting anyway (non-strict) — the deployed version's cache is not " + - "confirmed pre-warmed.", + ` CDN pre-warm confirmed ${confirmedPaths}/${totalPaths} path(s)${originSuffix} ` + + "served the uploaded version. Promoting anyway (non-strict) — the deployed " + + "version's cache is not confirmed pre-warmed.", ); } } diff --git a/packages/cloudflare/src/wrangler-config.ts b/packages/cloudflare/src/wrangler-config.ts index 7f2722cc02..661b6421a3 100644 --- a/packages/cloudflare/src/wrangler-config.ts +++ b/packages/cloudflare/src/wrangler-config.ts @@ -24,7 +24,7 @@ export type WranglerConfig = { cache?: WranglerCacheConfig; kvNamespaceId?: string; customDomain?: string; - warmupHost?: string; + warmupHosts?: readonly string[]; name?: string; legacyEnv?: boolean; targetEnvironment?: string; @@ -35,7 +35,7 @@ export type WranglerConfig = { type WranglerEnvironmentConfig = { cache?: WranglerCacheConfig; customDomain?: string; - warmupHost?: string; + warmupHosts?: readonly string[]; name?: string; versionMetadataBinding?: string; }; @@ -247,8 +247,8 @@ function extractFromJSON(config: Record): WranglerConfig { extractDomainFromRoutes(config.routes) ?? extractDomainFromCustomDomains(config); if (domain) result.customDomain = domain; - const warmupHost = extractWarmupHost(config); - if (warmupHost) result.warmupHost = warmupHost; + const warmupHosts = extractWarmupHosts(config); + if (warmupHosts.length > 0) result.warmupHosts = warmupHosts; const versionMetadataBinding = extractVersionMetadataBinding(config); if (versionMetadataBinding) result.versionMetadataBinding = versionMetadataBinding; @@ -269,7 +269,7 @@ function extractEnvConfigs(envs: unknown): Record): WranglerEnvi extractDomainFromRoutes(config.routes) ?? extractDomainFromCustomDomains(config); if (domain) result.customDomain = domain; - const warmupHost = extractWarmupHost(config); - if (warmupHost) result.warmupHost = warmupHost; + const warmupHosts = extractWarmupHosts(config); + if (warmupHosts.length > 0) result.warmupHosts = warmupHosts; const versionMetadataBinding = extractVersionMetadataBinding(config); if (versionMetadataBinding) result.versionMetadataBinding = versionMetadataBinding; return result; @@ -338,13 +338,32 @@ function extractDomainFromRoutes(routes: unknown): string | null { return firstMatch(routes, extractDomainFromRoute); } -function extractWarmupHost(config: Record): string | null { +/** + * Every eligible host-wide origin, not only the first. The hostname is part of + * Cloudflare's cache key, so each attached host owns its own cache partition: + * a deployment with several routes or Custom Domains is only warm once every + * one of those origins has been warmed. + */ +function extractWarmupHosts(config: Record): string[] { + const hosts: string[] = []; const singular = extractWarmupHostFromRoute(config.route); - if (singular) return singular; - const fromRoutes = Array.isArray(config.routes) - ? firstMatch(config.routes, extractWarmupHostFromRoute) - : null; - return fromRoutes ?? extractWarmupHostFromCustomDomains(config); + if (singular) hosts.push(singular); + if (Array.isArray(config.routes)) { + hosts.push(...collectMatches(config.routes, extractWarmupHostFromRoute)); + } + if (Array.isArray(config.custom_domains)) { + hosts.push( + ...collectMatches(config.custom_domains, (domain) => + typeof domain === "string" ? routePatternToWarmupHost(domain) : null, + ), + ); + } + return dedupeHosts(hosts); +} + +/** A host attached both as a route and a Custom Domain is still one cache-key origin. */ +function dedupeHosts(hosts: readonly string[]): string[] { + return [...new Set(hosts)]; } /** @@ -360,6 +379,16 @@ function firstMatch(items: Iterable, fn: (item: T) => R | null): R | nu return null; } +/** Every non-null result of `fn`, preserving input order. */ +function collectMatches(items: Iterable, fn: (item: T) => R | null): R[] { + const results: R[] = []; + for (const item of items) { + const result = fn(item); + if (result !== null) results.push(result); + } + return results; +} + function extractWarmupHostFromRoute(route: unknown): string | null { if (isUnknownRecord(route) && route.enabled === false) return null; const pattern = typeof route === "string" ? route : isUnknownRecord(route) ? route.pattern : null; @@ -378,13 +407,6 @@ function extractDomainFromCustomDomains(config: Record): string return null; } -function extractWarmupHostFromCustomDomains(config: Record): string | null { - if (!Array.isArray(config.custom_domains)) return null; - return firstMatch(config.custom_domains, (domain) => - typeof domain === "string" ? routePatternToWarmupHost(domain) : null, - ); -} - /** Strip protocol and trailing wildcards from a route pattern to get a bare domain. */ function cleanDomain(raw: string): string | null { const cleaned = raw @@ -454,10 +476,13 @@ function extractFromTOML(content: string): WranglerConfig { undefined; } - result.warmupHost = - extractTomlWarmupHost(getTomlRootBody(content)) ?? - firstMatch(rootRouteSections, (section) => extractTomlWarmupRouteBlockHost(section.body)) ?? - undefined; + const warmupHosts = dedupeHosts([ + ...extractTomlWarmupHosts(getTomlRootBody(content)), + ...collectMatches(rootRouteSections, (section) => + extractTomlWarmupRouteBlockHost(section.body), + ), + ]); + if (warmupHosts.length > 0) result.warmupHosts = warmupHosts; const rootBody = getTomlRootBody(content); const rootCache = sections.find((section) => section.header === "cache"); @@ -493,15 +518,17 @@ function extractEnvConfigsFromTOML( const domain = extractTomlScalarRouteDomain(section.body) ?? extractTomlRoutesArrayDomain(section.body); if (domain) envConfig.customDomain = domain; - const warmupHost = extractTomlWarmupHost(section.body); - if (warmupHost) envConfig.warmupHost = warmupHost; + const warmupHosts = extractTomlWarmupHosts(section.body); + if (warmupHosts.length > 0) { + envConfig.warmupHosts = dedupeHosts([...(envConfig.warmupHosts ?? []), ...warmupHosts]); + } const versionMetadataBinding = extractTomlVersionMetadataBinding(section.body); if (versionMetadataBinding) envConfig.versionMetadataBinding = versionMetadataBinding; if ( envConfig.name || envConfig.cache || envConfig.customDomain || - envConfig.warmupHost || + envConfig.warmupHosts || envConfig.versionMetadataBinding ) { result[envName] = envConfig; @@ -518,7 +545,7 @@ function extractEnvConfigsFromTOML( envConfig.name || envConfig.cache || envConfig.customDomain || - envConfig.warmupHost || + envConfig.warmupHosts || envConfig.versionMetadataBinding ) { result[cacheEnvName] = envConfig; @@ -535,7 +562,7 @@ function extractEnvConfigsFromTOML( envConfig.name || envConfig.cache || envConfig.customDomain || - envConfig.warmupHost || + envConfig.warmupHosts || envConfig.versionMetadataBinding ) { result[metadataEnvName] = envConfig; @@ -549,12 +576,14 @@ function extractEnvConfigsFromTOML( const domain = extractTomlRouteBlockDomain(section.body); if (domain) envConfig.customDomain = domain; const warmupHost = extractTomlWarmupRouteBlockHost(section.body); - if (warmupHost) envConfig.warmupHost = warmupHost; + if (warmupHost) { + envConfig.warmupHosts = dedupeHosts([...(envConfig.warmupHosts ?? []), warmupHost]); + } if ( envConfig.name || envConfig.cache || envConfig.customDomain || - envConfig.warmupHost || + envConfig.warmupHosts || envConfig.versionMetadataBinding ) { result[routesEnvName] = envConfig; @@ -608,25 +637,31 @@ function extractTomlRoutesArrayDomain(section: string): string | null { }); } -function extractTomlWarmupHost(section: string): string | null { +function extractTomlWarmupHosts(section: string): string[] { const uncommented = stripTomlLineComments(section); const scalarRoute = uncommented .match(/^route\s*=\s*(?:"([^"]+)"|'([^']+)')/m) ?.slice(1) .find((value): value is string => Boolean(value)); - if (scalarRoute) return routePatternToWarmupHost(scalarRoute); + if (scalarRoute) { + const host = routePatternToWarmupHost(scalarRoute); + return host ? [host] : []; + } const inlineRoute = uncommented.match(/^route\s*=\s*\{([\s\S]*?)\}\s*$/m)?.[1]; - if (inlineRoute && /\benabled\s*=\s*false\b/.test(inlineRoute)) return null; + if (inlineRoute && /\benabled\s*=\s*false\b/.test(inlineRoute)) return []; const inlinePattern = inlineRoute ?.match(/\bpattern\s*=\s*(?:"([^"]+)"|'([^']+)')/) ?.slice(1) .find((value): value is string => Boolean(value)); - if (inlinePattern) return routePatternToWarmupHost(inlinePattern); + if (inlinePattern) { + const host = routePatternToWarmupHost(inlinePattern); + return host ? [host] : []; + } const routesArray = uncommented.match(/^routes\s*=\s*\[([\s\S]*?)\]/m)?.[1]; - if (!routesArray) return null; - return firstMatch(extractTomlRouteEntries(routesArray), (route) => + if (!routesArray) return []; + return collectMatches(extractTomlRouteEntries(routesArray), (route) => route.enabled === false ? null : routePatternToWarmupHost(route.pattern), ); } diff --git a/packages/cloudflare/src/wrangler-deployment-target.ts b/packages/cloudflare/src/wrangler-deployment-target.ts index 5f352b46e0..1d4f837625 100644 --- a/packages/cloudflare/src/wrangler-deployment-target.ts +++ b/packages/cloudflare/src/wrangler-deployment-target.ts @@ -1,5 +1,5 @@ /** - * Resolves the Worker name, production host, and version metadata binding + * Resolves the Worker name, production hosts, and version metadata binding * that CDN warmup needs to target a deploy, on top of the raw fields * `parseWranglerConfig` reads out of wrangler.jsonc/.toml. * @@ -20,7 +20,12 @@ export type WranglerDeploymentTarget = { crossVersionCache?: boolean; hasProductionRoute: boolean; workerName?: string; - productionHost?: string; + /** + * Every host-wide origin (route or Custom Domain) attached to the Worker. + * The hostname is part of Cloudflare's cache key, so each entry is its own + * cache partition and warmup must cover all of them. + */ + productionHosts: readonly string[]; versionMetadataBinding?: string; }; @@ -49,7 +54,7 @@ export function resolveWranglerDeploymentTarget( crossVersionCache: cache?.crossVersionCache, hasProductionRoute: Boolean(selected?.customDomain), workerName: resolveWorkerName(config, envName, flattenedEnvConfig, options.name), - productionHost: selected?.warmupHost, + productionHosts: selected?.warmupHosts ?? [], versionMetadataBinding: selected?.versionMetadataBinding, }; } diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index f16e8c3984..89ee09f406 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -259,6 +259,116 @@ describe("Cloudflare CDN warmup deploy flow", () => { ]); }); + it("warms every host-wide origin before reporting the deployment warmed", async () => { + // The hostname is part of Cloudflare's cache key: each attached route has + // its own partition, so a two-route deployment is only warm when both + // origins were warmed for every path. + writeFile( + "wrangler.jsonc", + warmupWranglerConfig({ + name: "my-worker", + routes: [ + { pattern: "app.example.com/*", zone_name: "example.com" }, + { pattern: "www.example.com/*", zone_name: "example.com" }, + ], + }), + ); + const fetchedUrls: string[] = []; + vi.mocked(fetch).mockImplementation(async (url) => { + fetchedUrls.push(formatFetchUrl(url)); + return versionedResponse(); + }); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; + if (args.includes("status")) return deploymentStatusOutput(); + if (isStage(args)) return "Staged version\n"; + if (args.includes("triggers")) return "Triggers deployed\n"; + if (isPromotion(args)) return "Promoted version\nhttps://stable.example.workers.dev\n"; + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + const result = await deployWithCdnWarmup(tmpDir, ["/", "/about"], { + warmCdnConcurrency: 1, + }); + + expect(result.warmed).toBe(true); + expect(fetchedUrls.sort()).toEqual([ + "https://app.example.com/", + "https://app.example.com/about", + "https://www.example.com/", + "https://www.example.com/about", + ]); + }); + + it("does not report warmed when a second origin fails cache confirmation", async () => { + writeFile( + "wrangler.jsonc", + warmupWranglerConfig({ + name: "my-worker", + routes: ["app.example.com/*", "www.example.com/*"], + }), + ); + vi.mocked(fetch).mockImplementation(async (url) => { + const host = new URL(formatFetchUrl(url)).host; + // BYPASS is a response Workers Cache will never store, so the second + // origin's partition provably stays cold. + return host === "www.example.com" + ? versionedResponse(UPLOADED_VERSION_ID, "BYPASS") + : versionedResponse(); + }); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; + if (args.includes("status")) return deploymentStatusOutput(); + if (isStage(args)) return "Staged version\n"; + if (args.includes("triggers")) return "Triggers deployed\n"; + if (isPromotion(args)) return "Promoted version\nhttps://stable.example.workers.dev\n"; + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + const result = await deployWithCdnWarmup(tmpDir, ["/"], { warmCdnConcurrency: 1 }); + + // Non-strict still promotes, but must not claim a confirmed warm-up. + expect(result.warmed).toBe(false); + expect(execFileSyncMock.mock.calls.some(([, args]) => isPromotion(args as string[]))).toBe( + true, + ); + }); + + it("fails a strict deploy before promotion when any origin cannot be confirmed", async () => { + writeFile( + "wrangler.jsonc", + warmupWranglerConfig({ + name: "my-worker", + routes: ["app.example.com/*", "www.example.com/*"], + }), + ); + vi.mocked(fetch).mockImplementation(async (url) => { + const host = new URL(formatFetchUrl(url)).host; + return host === "www.example.com" + ? versionedResponse(UPLOADED_VERSION_ID, "BYPASS") + : versionedResponse(); + }); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; + if (args.includes("status")) return deploymentStatusOutput(); + if (isStage(args)) return "Staged version\n"; + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + await expect( + deployWithCdnWarmup(tmpDir, ["/"], { warmCdnConcurrency: 1, warmCdnStrict: true }), + ).rejects.toThrow(`staged at 0% and was not promoted`); + expect(execFileSyncMock.mock.calls.some(([, args]) => isPromotion(args as string[]))).toBe( + false, + ); + }); + it("uses the selected environment route and Worker name for the override", async () => { writeFile( "wrangler.jsonc", diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 6e2d895230..dde7bef503 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -3181,7 +3181,7 @@ describe("parseWranglerConfig — custom domain extraction", () => { expect(config?.env?.staging).toEqual({ name: "my-worker-staging", customDomain: "staging.example.com", - warmupHost: "staging.example.com", + warmupHosts: ["staging.example.com"], }); }); @@ -3302,7 +3302,7 @@ version_metadata = { expect(config?.env?.staging).toEqual({ name: "my-worker-staging-custom", customDomain: "staging.example.com", - warmupHost: "staging.example.com", + warmupHosts: ["staging.example.com"], }); }); @@ -3341,7 +3341,7 @@ pattern = "staging.example.com/*" expect(config?.env?.staging).toEqual({ name: "my-worker-staging", customDomain: "staging.example.com", - warmupHost: "staging.example.com", + warmupHosts: ["staging.example.com"], }); }); @@ -3375,10 +3375,10 @@ binding = "STAGING_VERSION" }); }); -describe("resolveWranglerDeploymentTarget — production host", () => { +describe("resolveWranglerDeploymentTarget — production hosts", () => { // The caller (cdn-warm-deployment.ts) falls back to the wrangler-reported - // deployedUrl when productionHost is undefined, so these only assert the - // raw host resolveWranglerDeploymentTarget itself decides on. + // deployedUrl when productionHosts is empty, so these only assert the + // raw hosts resolveWranglerDeploymentTarget itself decides on. it("skips a disabled custom domain and uses the next active route", () => { writeFile( tmpDir, @@ -3391,7 +3391,9 @@ describe("resolveWranglerDeploymentTarget — production host", () => { }), ); - expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBe("app.example.com"); + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHosts).toEqual([ + "app.example.com", + ]); }); it("uses the route pattern host instead of the containing zone", () => { @@ -3403,19 +3405,23 @@ describe("resolveWranglerDeploymentTarget — production host", () => { }), ); - expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBe("app.example.com"); + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHosts).toEqual([ + "app.example.com", + ]); }); it("supports the singular JSON route property", () => { writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ route: "app.example.com/*" })); - expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBe("app.example.com"); + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHosts).toEqual([ + "app.example.com", + ]); }); it("does not use a route host when the warmup paths may bypass the Worker", () => { writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ route: "app.example.com/api/*" })); - expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBeUndefined(); + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHosts).toEqual([]); }); it("does not turn a path-scoped custom domain into a host-wide warmup target", () => { @@ -3427,7 +3433,7 @@ describe("resolveWranglerDeploymentTarget — production host", () => { const target = resolveWranglerDeploymentTarget(tmpDir, {}); expect(target?.hasProductionRoute).toBe(true); - expect(target?.productionHost).toBeUndefined(); + expect(target?.productionHosts).toEqual([]); }); it("uses the pattern from a singular TOML route object", () => { @@ -3437,7 +3443,9 @@ describe("resolveWranglerDeploymentTarget — production host", () => { `route = { zone_name = "example.com", pattern = "app.example.com/*" }`, ); - expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBe("app.example.com"); + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHosts).toEqual([ + "app.example.com", + ]); }); it("does not leak an env route past a commented environment header", () => { @@ -3452,7 +3460,7 @@ describe("resolveWranglerDeploymentTarget — production host", () => { route = "staging.example.com/*"`, ); - expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBeUndefined(); + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHosts).toEqual([]); }); it("does not inherit a top-level route into an explicit environment", () => { @@ -3465,9 +3473,9 @@ route = "staging.example.com/*"`, }), ); - expect( - resolveWranglerDeploymentTarget(tmpDir, { env: "staging" })?.productionHost, - ).toBeUndefined(); + expect(resolveWranglerDeploymentTarget(tmpDir, { env: "staging" })?.productionHosts).toEqual( + [], + ); }); it("uses a generated config already flattened to the selected environment", () => { @@ -3492,7 +3500,7 @@ route = "staging.example.com/*"`, crossVersionCache: undefined, hasProductionRoute: true, workerName: "my-worker-staging", - productionHost: "staging.example.com", + productionHosts: ["staging.example.com"], versionMetadataBinding: "VINEXT_VERSION_METADATA", }); }); @@ -3509,13 +3517,17 @@ routes = [ `, ); - expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBe("app.example.com"); + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHosts).toEqual([ + "app.example.com", + ]); }); it("uses an inline TOML routes array followed by a trailing comment", () => { writeFile(tmpDir, "wrangler.toml", 'routes = ["app.example.com/*"] # production route\n'); - expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBe("app.example.com"); + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHosts).toEqual([ + "app.example.com", + ]); }); it("uses an inline TOML route object followed by a trailing comment", () => { @@ -3525,6 +3537,73 @@ routes = [ `route = { pattern = 'app.example.com/*', custom_domain = true } # production route\n`, ); - expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHost).toBe("app.example.com"); + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHosts).toEqual([ + "app.example.com", + ]); + }); + + it("collects every host-wide route origin instead of only the first", () => { + // Each hostname is its own Cloudflare cache-key partition, so dropping the + // second route would let warmup falsely claim the whole deployment warm. + writeFile( + tmpDir, + "wrangler.jsonc", + JSON.stringify({ + routes: [ + { pattern: "app.example.com/*", zone_name: "example.com" }, + { pattern: "www.example.com/*", zone_name: "example.com" }, + ], + }), + ); + + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHosts).toEqual([ + "app.example.com", + "www.example.com", + ]); + }); + + it("unions route and custom-domain origins and dedupes shared hosts", () => { + writeFile( + tmpDir, + "wrangler.jsonc", + JSON.stringify({ + routes: ["app.example.com/*"], + custom_domains: ["app.example.com", "www.example.com"], + }), + ); + + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHosts).toEqual([ + "app.example.com", + "www.example.com", + ]); + }); + + it("collects every host-wide origin from an inline TOML routes array", () => { + writeFile(tmpDir, "wrangler.toml", 'routes = ["app.example.com/*", "www.example.com/*"]\n'); + + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHosts).toEqual([ + "app.example.com", + "www.example.com", + ]); + }); + + it("collects every host-wide origin from repeated TOML route blocks", () => { + writeFile( + tmpDir, + "wrangler.toml", + `name = "my-worker" + +[[routes]] +pattern = "app.example.com/*" + +[[routes]] +pattern = "www.example.com/*" +`, + ); + + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHosts).toEqual([ + "app.example.com", + "www.example.com", + ]); }); }); From 490f7e6d623e97a10e40309e0559eafde74b547e Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:12:28 +1000 Subject: [PATCH 20/22] fix(cloudflare): refuse a confirmed-warm claim when a route cannot be warmed Warmup target resolution kept only routes reducible to a concrete host-wide origin. A path-scoped or wildcard-host route alongside a warmable one was silently dropped: productionHosts stayed non-empty, every path confirmed on the supported origin, and the deploy reported "pre-warmed and confirmed" while the dropped route's cache partition remained cold. Cloudflare's cache key includes the full URL, so warming the same pathname on another host does not cover that route. The only refusal path required the origin set to be completely empty. Record completeness instead of inferring it: the parser sets hasUnwarmableRoute when any enabled non-workers.dev attachment yields no warmup host (JSONC and TOML, root and env scoped), and the resolved target carries hasUnwarmableProductionRoute. The warm transaction then fails closed: strict mode refuses before warming, and non-strict mode still warms the reachable origins and promotes but reports warmed: false even when every reachable origin confirmed. Disabled routes and workers.dev patterns do not set the flag, and a top-level path-scoped route does not veto an environment whose own routes are all host-wide. --- .../cloudflare/src/cdn-warm-deployment.ts | 19 +++- packages/cloudflare/src/wrangler-config.ts | 75 ++++++++++++++ .../src/wrangler-deployment-target.ts | 8 ++ tests/cloudflare-cdn-warm-deploy.test.ts | 67 +++++++++++++ tests/deploy.test.ts | 99 +++++++++++++++++++ 5 files changed, 265 insertions(+), 3 deletions(-) diff --git a/packages/cloudflare/src/cdn-warm-deployment.ts b/packages/cloudflare/src/cdn-warm-deployment.ts index dc584979f0..cfb2e02274 100644 --- a/packages/cloudflare/src/cdn-warm-deployment.ts +++ b/packages/cloudflare/src/cdn-warm-deployment.ts @@ -219,9 +219,21 @@ async function warmAndPromote( if (options.warmCdnStrict) throw new Error(message); console.warn(` ${message} Promoting without pre-warming.`); } else { + // A path-scoped or wildcard-host route has a cache partition this + // transaction cannot reach, so its presence makes `productionHosts` an + // incomplete picture of the production cache surface: strict mode must + // refuse, and non-strict may warm the reachable origins but must not + // report the deployment as confirmed pre-warmed. + if (target.hasUnwarmableProductionRoute) { + const message = + "CDN pre-warm cannot cover every production route: an enabled route is " + + "path-scoped or wildcard-hosted, so its cache partition cannot be verified."; + if (options.warmCdnStrict) throw new Error(message); + console.warn(` ${message} The deployment will not be reported as confirmed pre-warmed.`); + } let confirmedPaths = 0; let totalPaths = 0; - warmed = true; + let allPathsConfirmed = true; for (const targetUrl of targetUrls) { if (targetUrls.length > 1) { console.log(` CDN pre-warm origin: ${targetUrl}`); @@ -244,9 +256,9 @@ async function warmAndPromote( }); confirmedPaths += result.warmed; totalPaths += result.total; - if (result.failed !== 0) warmed = false; + if (result.failed !== 0) allPathsConfirmed = false; } - if (!warmed) { + if (!allPathsConfirmed) { const originSuffix = targetUrls.length > 1 ? ` across ${targetUrls.length} origins` : ""; console.warn( ` CDN pre-warm confirmed ${confirmedPaths}/${totalPaths} path(s)${originSuffix} ` + @@ -254,6 +266,7 @@ async function warmAndPromote( "version's cache is not confirmed pre-warmed.", ); } + warmed = allPathsConfirmed && !target.hasUnwarmableProductionRoute; } } catch (error) { throw new Error( diff --git a/packages/cloudflare/src/wrangler-config.ts b/packages/cloudflare/src/wrangler-config.ts index 661b6421a3..030381d82e 100644 --- a/packages/cloudflare/src/wrangler-config.ts +++ b/packages/cloudflare/src/wrangler-config.ts @@ -25,6 +25,7 @@ export type WranglerConfig = { kvNamespaceId?: string; customDomain?: string; warmupHosts?: readonly string[]; + hasUnwarmableRoute?: boolean; name?: string; legacyEnv?: boolean; targetEnvironment?: string; @@ -36,6 +37,7 @@ type WranglerEnvironmentConfig = { cache?: WranglerCacheConfig; customDomain?: string; warmupHosts?: readonly string[]; + hasUnwarmableRoute?: boolean; name?: string; versionMetadataBinding?: string; }; @@ -249,6 +251,7 @@ function extractFromJSON(config: Record): WranglerConfig { if (domain) result.customDomain = domain; const warmupHosts = extractWarmupHosts(config); if (warmupHosts.length > 0) result.warmupHosts = warmupHosts; + if (extractHasUnwarmableRoute(config)) result.hasUnwarmableRoute = true; const versionMetadataBinding = extractVersionMetadataBinding(config); if (versionMetadataBinding) result.versionMetadataBinding = versionMetadataBinding; @@ -292,6 +295,7 @@ function extractEnvironmentConfig(config: Record): WranglerEnvi if (domain) result.customDomain = domain; const warmupHosts = extractWarmupHosts(config); if (warmupHosts.length > 0) result.warmupHosts = warmupHosts; + if (extractHasUnwarmableRoute(config)) result.hasUnwarmableRoute = true; const versionMetadataBinding = extractVersionMetadataBinding(config); if (versionMetadataBinding) result.versionMetadataBinding = versionMetadataBinding; return result; @@ -366,6 +370,36 @@ function dedupeHosts(hosts: readonly string[]): string[] { return [...new Set(hosts)]; } +/** + * True when any enabled production attachment cannot be reduced to a concrete + * host-wide origin (path-scoped or wildcard-host patterns). Such a route's + * cache partition is unreachable to warmup, so its presence must veto any + * "confirmed warm" claim even when every concrete origin succeeds. + */ +function extractHasUnwarmableRoute(config: Record): boolean { + if (isUnwarmableRoute(config.route)) return true; + if (Array.isArray(config.routes) && config.routes.some(isUnwarmableRoute)) return true; + return ( + Array.isArray(config.custom_domains) && + config.custom_domains.some( + (domain) => typeof domain === "string" && patternIsUnwarmable(domain), + ) + ); +} + +function isUnwarmableRoute(route: unknown): boolean { + if (isUnknownRecord(route) && route.enabled === false) return false; + const pattern = typeof route === "string" ? route : isUnknownRecord(route) ? route.pattern : null; + return typeof pattern === "string" && patternIsUnwarmable(pattern); +} + +/** The pattern attaches production traffic but yields no warmable host-wide origin. */ +function patternIsUnwarmable(pattern: string): boolean { + const host = cleanDomain(pattern); + if (!host || host.includes("workers.dev")) return false; + return routePatternToWarmupHost(pattern) === null; +} + /** * A route array can mix host-wide and path-scoped (or workers.dev) entries; * a disqualified earlier entry must not hide a valid later one. Returns the @@ -483,6 +517,12 @@ function extractFromTOML(content: string): WranglerConfig { ), ]); if (warmupHosts.length > 0) result.warmupHosts = warmupHosts; + if ( + extractTomlHasUnwarmableRoute(getTomlRootBody(content)) || + rootRouteSections.some((section) => tomlRouteBlockIsUnwarmable(section.body)) + ) { + result.hasUnwarmableRoute = true; + } const rootBody = getTomlRootBody(content); const rootCache = sections.find((section) => section.header === "cache"); @@ -522,6 +562,7 @@ function extractEnvConfigsFromTOML( if (warmupHosts.length > 0) { envConfig.warmupHosts = dedupeHosts([...(envConfig.warmupHosts ?? []), ...warmupHosts]); } + if (extractTomlHasUnwarmableRoute(section.body)) envConfig.hasUnwarmableRoute = true; const versionMetadataBinding = extractTomlVersionMetadataBinding(section.body); if (versionMetadataBinding) envConfig.versionMetadataBinding = versionMetadataBinding; if ( @@ -529,6 +570,7 @@ function extractEnvConfigsFromTOML( envConfig.cache || envConfig.customDomain || envConfig.warmupHosts || + envConfig.hasUnwarmableRoute || envConfig.versionMetadataBinding ) { result[envName] = envConfig; @@ -579,11 +621,13 @@ function extractEnvConfigsFromTOML( if (warmupHost) { envConfig.warmupHosts = dedupeHosts([...(envConfig.warmupHosts ?? []), warmupHost]); } + if (tomlRouteBlockIsUnwarmable(section.body)) envConfig.hasUnwarmableRoute = true; if ( envConfig.name || envConfig.cache || envConfig.customDomain || envConfig.warmupHosts || + envConfig.hasUnwarmableRoute || envConfig.versionMetadataBinding ) { result[routesEnvName] = envConfig; @@ -666,6 +710,37 @@ function extractTomlWarmupHosts(section: string): string[] { ); } +/** TOML counterpart of `extractHasUnwarmableRoute` for the same body-level route forms. */ +function extractTomlHasUnwarmableRoute(section: string): boolean { + const uncommented = stripTomlLineComments(section); + const scalarRoute = uncommented + .match(/^route\s*=\s*(?:"([^"]+)"|'([^']+)')/m) + ?.slice(1) + .find((value): value is string => Boolean(value)); + if (scalarRoute && patternIsUnwarmable(scalarRoute)) return true; + + const inlineRoute = uncommented.match(/^route\s*=\s*\{([\s\S]*?)\}\s*$/m)?.[1]; + if (inlineRoute && !/\benabled\s*=\s*false\b/.test(inlineRoute)) { + const inlinePattern = inlineRoute + .match(/\bpattern\s*=\s*(?:"([^"]+)"|'([^']+)')/) + ?.slice(1) + .find((value): value is string => Boolean(value)); + if (inlinePattern && patternIsUnwarmable(inlinePattern)) return true; + } + + const routesArray = uncommented.match(/^routes\s*=\s*\[([\s\S]*?)\]/m)?.[1]; + if (!routesArray) return false; + return extractTomlRouteEntries(routesArray).some( + (route) => route.enabled !== false && patternIsUnwarmable(route.pattern), + ); +} + +function tomlRouteBlockIsUnwarmable(section: string): boolean { + if (/^enabled\s*=\s*false\b/m.test(section)) return false; + const patternMatch = section.match(/^pattern\s*=\s*"([^"]+)"/m); + return patternMatch ? patternIsUnwarmable(patternMatch[1]) : false; +} + function routePatternToWarmupHost(pattern: string): string | null { const withoutProtocol = pattern.replace(/^https?:\/\//, ""); const pathStart = withoutProtocol.indexOf("/"); diff --git a/packages/cloudflare/src/wrangler-deployment-target.ts b/packages/cloudflare/src/wrangler-deployment-target.ts index 1d4f837625..82c888f0a4 100644 --- a/packages/cloudflare/src/wrangler-deployment-target.ts +++ b/packages/cloudflare/src/wrangler-deployment-target.ts @@ -26,6 +26,13 @@ export type WranglerDeploymentTarget = { * cache partition and warmup must cover all of them. */ productionHosts: readonly string[]; + /** + * True when some enabled route could not be reduced to a concrete host-wide + * origin (path-scoped or wildcard-host patterns). Its cache partition is + * unreachable to warmup, so `productionHosts` is an incomplete picture of + * the production cache surface and no "confirmed warm" claim may be made. + */ + hasUnwarmableProductionRoute: boolean; versionMetadataBinding?: string; }; @@ -55,6 +62,7 @@ export function resolveWranglerDeploymentTarget( hasProductionRoute: Boolean(selected?.customDomain), workerName: resolveWorkerName(config, envName, flattenedEnvConfig, options.name), productionHosts: selected?.warmupHosts ?? [], + hasUnwarmableProductionRoute: Boolean(selected?.hasUnwarmableRoute), versionMetadataBinding: selected?.versionMetadataBinding, }; } diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 89ee09f406..390dc3f03c 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -369,6 +369,73 @@ describe("Cloudflare CDN warmup deploy flow", () => { ); }); + it("does not report warmed when a path-scoped route coexists with a warmed origin", async () => { + // blog.example.com/blog/* has its own cache partition that warming + // app.example.com cannot reach, so a fully confirmed app origin must not + // produce a "confirmed pre-warmed" deployment. + writeFile( + "wrangler.jsonc", + warmupWranglerConfig({ + name: "my-worker", + routes: [ + { pattern: "app.example.com/*", zone_name: "example.com" }, + { pattern: "blog.example.com/blog/*", zone_name: "example.com" }, + ], + }), + ); + const fetchedUrls: string[] = []; + vi.mocked(fetch).mockImplementation(async (url) => { + fetchedUrls.push(formatFetchUrl(url)); + return versionedResponse(); + }); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; + if (args.includes("status")) return deploymentStatusOutput(); + if (isStage(args)) return "Staged version\n"; + if (args.includes("triggers")) return "Triggers deployed\n"; + if (isPromotion(args)) return "Promoted version\nhttps://stable.example.workers.dev\n"; + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + const result = await deployWithCdnWarmup(tmpDir, ["/"], { warmCdnConcurrency: 1 }); + + // The reachable origin is still warmed and the promotion still happens, + // but the deployment must not claim a confirmed warm-up. + expect(result.warmed).toBe(false); + expect(fetchedUrls).toEqual(["https://app.example.com/"]); + expect(execFileSyncMock.mock.calls.some(([, args]) => isPromotion(args as string[]))).toBe( + true, + ); + }); + + it("fails a strict deploy before warming when a route cannot be covered", async () => { + writeFile( + "wrangler.jsonc", + warmupWranglerConfig({ + name: "my-worker", + routes: ["app.example.com/*", "blog.example.com/blog/*"], + }), + ); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) return `Uploaded version ${UPLOADED_VERSION_ID}\n`; + if (args.includes("status")) return deploymentStatusOutput(); + if (isStage(args)) return "Staged version\n"; + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); + + await expect( + deployWithCdnWarmup(tmpDir, ["/"], { warmCdnConcurrency: 1, warmCdnStrict: true }), + ).rejects.toThrow("cannot cover every production route"); + expect(fetch).not.toHaveBeenCalled(); + expect(execFileSyncMock.mock.calls.some(([, args]) => isPromotion(args as string[]))).toBe( + false, + ); + }); + it("uses the selected environment route and Worker name for the override", async () => { writeFile( "wrangler.jsonc", diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index dde7bef503..85679b98d0 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -3434,6 +3434,7 @@ describe("resolveWranglerDeploymentTarget — production hosts", () => { const target = resolveWranglerDeploymentTarget(tmpDir, {}); expect(target?.hasProductionRoute).toBe(true); expect(target?.productionHosts).toEqual([]); + expect(target?.hasUnwarmableProductionRoute).toBe(true); }); it("uses the pattern from a singular TOML route object", () => { @@ -3501,6 +3502,7 @@ route = "staging.example.com/*"`, hasProductionRoute: true, workerName: "my-worker-staging", productionHosts: ["staging.example.com"], + hasUnwarmableProductionRoute: false, versionMetadataBinding: "VINEXT_VERSION_METADATA", }); }); @@ -3606,4 +3608,101 @@ pattern = "www.example.com/*" "www.example.com", ]); }); + + it("flags a path-scoped route that coexists with a warmable origin", () => { + // Warming app.example.com says nothing about the blog route's partition, + // so the supported origin must not hide the unsupported route. + writeFile( + tmpDir, + "wrangler.jsonc", + JSON.stringify({ + routes: [ + { pattern: "app.example.com/*", zone_name: "example.com" }, + { pattern: "blog.example.com/blog/*", zone_name: "example.com" }, + ], + }), + ); + + const target = resolveWranglerDeploymentTarget(tmpDir, {}); + expect(target?.productionHosts).toEqual(["app.example.com"]); + expect(target?.hasUnwarmableProductionRoute).toBe(true); + }); + + it("flags a wildcard-host route that coexists with a warmable origin", () => { + writeFile( + tmpDir, + "wrangler.jsonc", + JSON.stringify({ routes: ["*.example.com/*", "app.example.com/*"] }), + ); + + const target = resolveWranglerDeploymentTarget(tmpDir, {}); + expect(target?.productionHosts).toEqual(["app.example.com"]); + expect(target?.hasUnwarmableProductionRoute).toBe(true); + }); + + it("does not flag disabled or workers.dev routes as unwarmable", () => { + writeFile( + tmpDir, + "wrangler.jsonc", + JSON.stringify({ + routes: [ + { pattern: "disabled.example.com/api/*", enabled: false }, + "my-app.workers.dev/*", + "app.example.com/*", + ], + }), + ); + + const target = resolveWranglerDeploymentTarget(tmpDir, {}); + expect(target?.productionHosts).toEqual(["app.example.com"]); + expect(target?.hasUnwarmableProductionRoute).toBe(false); + }); + + it("flags a path-scoped route in an inline TOML routes array", () => { + writeFile( + tmpDir, + "wrangler.toml", + 'routes = ["app.example.com/*", "blog.example.com/blog/*"]\n', + ); + + const target = resolveWranglerDeploymentTarget(tmpDir, {}); + expect(target?.productionHosts).toEqual(["app.example.com"]); + expect(target?.hasUnwarmableProductionRoute).toBe(true); + }); + + it("flags a path-scoped route in repeated TOML route blocks", () => { + writeFile( + tmpDir, + "wrangler.toml", + `name = "my-worker" + +[[routes]] +pattern = "app.example.com/*" + +[[routes]] +pattern = "blog.example.com/blog/*" +`, + ); + + const target = resolveWranglerDeploymentTarget(tmpDir, {}); + expect(target?.productionHosts).toEqual(["app.example.com"]); + expect(target?.hasUnwarmableProductionRoute).toBe(true); + }); + + it("scopes the unwarmable flag to the selected environment", () => { + // A path-scoped route at the top level must not veto a warmup that + // deploys an environment whose own routes are all host-wide. + writeFile( + tmpDir, + "wrangler.jsonc", + JSON.stringify({ + routes: ["app.example.com/api/*"], + env: { staging: { routes: ["staging.example.com/*"] } }, + }), + ); + + const target = resolveWranglerDeploymentTarget(tmpDir, { env: "staging" }); + expect(target?.productionHosts).toEqual(["staging.example.com"]); + expect(target?.hasUnwarmableProductionRoute).toBe(false); + }); }); From 878f30c8137ceccf8f0c1f29889a0768ea6a10e0 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:01:37 +1000 Subject: [PATCH 21/22] refactor(cloudflare): break the deploy/warmup module cycle cdn-warm-deployment.ts imported the runtime formatUnknownError function and the DeployOptions type from deploy.ts, and version-deploy.ts imported the Wrangler CLI helpers from there as well, while deploy.ts imports both modules. The cycle only worked because nothing involved is evaluated eagerly, and it contradicted the layering the transaction extraction was meant to establish: the CLI as a thin caller above the transaction and its adapters. Give the shared pieces homes below every consumer. wrangler-cli.ts owns resolveWranglerBin, buildNodeCliInvocation, validateWranglerEnvName and the WranglerTargetOptions type that previously existed only as ad hoc Pick projections; utils/format-unknown-error.ts owns formatUnknownError, replacing a private duplicate in prerender-kv-populate.ts. The warm transaction now defines its own CdnWarmupOptions, and DeployOptions composes the shared types instead of being their source, so imports flow one way: deploy.ts -> cdn-warm-deployment.ts -> version-deploy.ts / target resolver -> wrangler-cli.ts. No behavior change; existing suites cover the moved helpers. --- .../cloudflare/src/cdn-warm-deployment.ts | 32 ++--- packages/cloudflare/src/deploy.ts | 123 +++++------------- .../cloudflare/src/prerender-kv-populate.ts | 6 +- .../src/utils/format-unknown-error.ts | 5 + packages/cloudflare/src/version-deploy.ts | 20 +-- packages/cloudflare/src/wrangler-cli.ts | 69 ++++++++++ .../src/wrangler-deployment-target.ts | 6 +- tests/deploy.test.ts | 8 +- 8 files changed, 142 insertions(+), 127 deletions(-) create mode 100644 packages/cloudflare/src/utils/format-unknown-error.ts create mode 100644 packages/cloudflare/src/wrangler-cli.ts diff --git a/packages/cloudflare/src/cdn-warm-deployment.ts b/packages/cloudflare/src/cdn-warm-deployment.ts index cfb2e02274..dd64032995 100644 --- a/packages/cloudflare/src/cdn-warm-deployment.ts +++ b/packages/cloudflare/src/cdn-warm-deployment.ts @@ -21,7 +21,8 @@ import { VINEXT_VERSION_METADATA_BINDING } from "vinext/internal/server/worker-version"; import { warmCdnCache } from "./cdn-warm.js"; -import { formatUnknownError, type DeployOptions } from "./deploy.js"; +import { formatUnknownError } from "./utils/format-unknown-error.js"; +import type { WranglerTargetOptions } from "./wrangler-cli.js"; import { runWranglerDeploymentStatus, runWranglerTriggersDeploy, @@ -55,17 +56,16 @@ function promotionPhaseFor(warmed: boolean): "promote-warmed" | "promote-uploade return warmed ? "promote-warmed" : "promote-uploaded"; } -type CdnWarmupOptions = Pick< - DeployOptions, - | "preview" - | "env" - | "name" - | "config" - | "warmCdnConcurrency" - | "warmCdnTimeout" - | "warmCdnRetries" - | "warmCdnStrict" ->; +export type CdnWarmupOptions = WranglerTargetOptions & { + /** Maximum number of CDN warmup requests to issue in parallel */ + warmCdnConcurrency?: number; + /** Per-request CDN warmup timeout in milliseconds */ + warmCdnTimeout?: number; + /** Number of CDN warmup retries */ + warmCdnRetries?: number; + /** Fail deployment if any CDN warmup request fails */ + warmCdnStrict?: boolean; +}; export async function deployWithCdnWarmup( root: string, @@ -315,7 +315,7 @@ async function warmAndPromote( */ function applyTriggersAfterPromotion( root: string, - options: Pick, + options: WranglerTargetOptions, failureMessage = "The uploaded Worker version was promoted to 100%, but applying triggers " + "(routes/schedules) failed. Production serves the new version on the previously " + "deployed routes. Re-run `wrangler triggers deploy` to apply the route changes.", @@ -329,7 +329,7 @@ function applyTriggersAfterPromotion( function validateCdnWarmupConfiguration( root: string, - options: Pick, + options: WranglerTargetOptions, ): WranglerDeploymentTarget { const target = resolveWranglerDeploymentTarget(root, options); const envName = getWranglerTargetEnv(options); @@ -368,7 +368,7 @@ function validateCdnWarmupConfiguration( */ function verifyStagedSplitBeforePromotion( root: string, - options: Pick, + options: WranglerTargetOptions, previousVersionId: string, uploadedVersionId: string, ): void { @@ -405,7 +405,7 @@ function verifyStagedSplitBeforePromotion( function readWranglerDeploymentStatus( root: string, - options: Pick, + options: WranglerTargetOptions, ): { deployment: WranglerDeploymentStatus } | { error: string } { try { return { deployment: runWranglerDeploymentStatus(root, options) }; diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 9d41c1e4e8..6585c5f98e 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -37,14 +37,20 @@ import { } from "vinext/internal/config/prerender"; import { detectProject, - findInNodeModules, formatMissingCloudflarePluginError, getMissingDeps, type ProjectInfo, } from "vinext/internal/utils/project"; import { runTPR } from "./tpr.js"; import { readPrerenderWarmPaths } from "./cdn-warm.js"; -import { deployWithCdnWarmup } from "./cdn-warm-deployment.js"; +import { deployWithCdnWarmup, type CdnWarmupOptions } from "./cdn-warm-deployment.js"; +import { formatUnknownError } from "./utils/format-unknown-error.js"; +import { + buildNodeCliInvocation, + resolveWranglerBin, + validateWranglerEnvName, + type WranglerTargetOptions, +} from "./wrangler-cli.js"; import { formatMissingCacheAdapterError, formatImageOptimizationHint, @@ -60,46 +66,31 @@ import { buildPrerenderKVPairs, type KVBulkPair } from "./prerender-kv-populate. // ─── Types ─────────────────────────────────────────────────────────────────── -export type DeployOptions = { - /** Project root directory */ - root: string; - /** Deploy to preview environment (default: production) */ - preview?: boolean; - /** Wrangler environment name from wrangler.jsonc env. */ - env?: string; - /** Custom project name for the Worker */ - name?: string; - /** Wrangler config path, relative to root unless absolute */ - config?: string; - /** Skip the build step (assume already built) */ - skipBuild?: boolean; - /** Dry run — validate setup but don't build or deploy */ - dryRun?: boolean; - /** Pre-render all discovered routes into the dist output after building */ - prerenderAll?: boolean; - /** Maximum number of routes to prerender in parallel */ - prerenderConcurrency?: number; - /** Warm Cloudflare's CDN cache by requesting build-discovered paths for the uploaded version */ - warmCdnCache?: boolean; - /** Maximum number of CDN warmup requests to issue in parallel */ - warmCdnConcurrency?: number; - /** Per-request CDN warmup timeout in milliseconds */ - warmCdnTimeout?: number; - /** Number of CDN warmup retries */ - warmCdnRetries?: number; - /** Fail deployment if any CDN warmup request fails */ - warmCdnStrict?: boolean; - /** Include PPR fallback-shell placeholder paths during CDN warmup */ - warmCdnIncludeFallbacks?: boolean; - /** Enable experimental TPR (Traffic-aware Pre-Rendering) */ - experimentalTPR?: boolean; - /** TPR: traffic coverage percentage target (0–100, default: 90) */ - tprCoverage?: number; - /** TPR: hard cap on number of pages to pre-render (default: 1000) */ - tprLimit?: number; - /** TPR: analytics lookback window in hours (default: 24) */ - tprWindow?: number; -}; +export type DeployOptions = WranglerTargetOptions & + CdnWarmupOptions & { + /** Project root directory */ + root: string; + /** Skip the build step (assume already built) */ + skipBuild?: boolean; + /** Dry run — validate setup but don't build or deploy */ + dryRun?: boolean; + /** Pre-render all discovered routes into the dist output after building */ + prerenderAll?: boolean; + /** Maximum number of routes to prerender in parallel */ + prerenderConcurrency?: number; + /** Warm Cloudflare's CDN cache by requesting build-discovered paths for the uploaded version */ + warmCdnCache?: boolean; + /** Include PPR fallback-shell placeholder paths during CDN warmup */ + warmCdnIncludeFallbacks?: boolean; + /** Enable experimental TPR (Traffic-aware Pre-Rendering) */ + experimentalTPR?: boolean; + /** TPR: traffic coverage percentage target (0–100, default: 90) */ + tprCoverage?: number; + /** TPR: hard cap on number of pages to pre-render (default: 1000) */ + tprLimit?: number; + /** TPR: analytics lookback window in hours (default: 24) */ + tprWindow?: number; + }; type ProjectViteApi = Pick; @@ -132,11 +123,6 @@ function parseNonNegativeIntegerArg(raw: string, flag: string): number { return parsed; } -export function formatUnknownError(error: unknown): string { - if (error instanceof Error && error.message) return error.message; - return String(error); -} - // ─── CLI arg parsing (uses Node.js util.parseArgs) ────────────────────────── /** Deploy command flag definitions for util.parseArgs. */ @@ -343,13 +329,6 @@ type WranglerKVBulkPutArgs = { const KV_BULK_PUT_CHUNK_SIZE = 25; -export function validateWranglerEnvName(env: string): string { - if (env.includes("\0")) { - throw new Error("Wrangler environment names cannot contain null bytes."); - } - return env; -} - export function buildWranglerDeployArgs( options: Pick, ): WranglerDeployArgs { @@ -380,42 +359,6 @@ export function buildWranglerKVBulkPutArgs(options: { return { args, env }; } -/** - * Resolve Wrangler's JavaScript CLI entrypoint in node_modules. - * - * Invoking the JavaScript file through `process.execPath` avoids the `.cmd` - * shim and command shell that package managers create on Windows. - */ -export function resolveWranglerBin( - root: string, - resolvePackageJson: (root: string) => string | null = (projectRoot) => { - try { - return createRequire(path.join(projectRoot, "package.json")).resolve("wrangler/package.json"); - } catch { - return findInNodeModules(projectRoot, "wrangler/package.json"); - } - }, -): string { - const packageJsonPath = resolvePackageJson(root); - if (packageJsonPath) { - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { - bin?: string | Record; - }; - const bin = typeof packageJson.bin === "string" ? packageJson.bin : packageJson.bin?.wrangler; - if (bin) return path.resolve(path.dirname(packageJsonPath), bin); - } - - return path.join(root, "node_modules", "wrangler", "bin", "wrangler.js"); -} - -export function buildNodeCliInvocation( - scriptPath: string, - args: string[], - nodeExecutable: string = process.execPath, -): { file: string; args: string[] } { - return { file: nodeExecutable, args: [scriptPath, ...args] }; -} - export function buildWranglerInvocation( root: string, options: Pick, diff --git a/packages/cloudflare/src/prerender-kv-populate.ts b/packages/cloudflare/src/prerender-kv-populate.ts index 26c721b3eb..144bcae116 100644 --- a/packages/cloudflare/src/prerender-kv-populate.ts +++ b/packages/cloudflare/src/prerender-kv-populate.ts @@ -9,6 +9,7 @@ import fs from "node:fs"; import path from "node:path"; +import { formatUnknownError } from "./utils/format-unknown-error.js"; import { appIsrCacheKey } from "vinext/internal/server/isr-cache"; import { buildAppPageCacheTags } from "vinext/internal/server/app-page-cache"; import { @@ -44,11 +45,6 @@ function resolveContainedFile(rootDir: string, relativePath: string): string { return resolvedFile; } -function formatUnknownError(error: unknown): string { - if (error instanceof Error && error.message) return error.message; - return String(error); -} - function buildCacheEntry( value: Record, tags: string[], diff --git a/packages/cloudflare/src/utils/format-unknown-error.ts b/packages/cloudflare/src/utils/format-unknown-error.ts new file mode 100644 index 0000000000..3bd7f28be5 --- /dev/null +++ b/packages/cloudflare/src/utils/format-unknown-error.ts @@ -0,0 +1,5 @@ +/** Prefer an Error's message over its stringified form; fall back to String(). */ +export function formatUnknownError(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + return String(error); +} diff --git a/packages/cloudflare/src/version-deploy.ts b/packages/cloudflare/src/version-deploy.ts index d4cf8178b2..8fb7796881 100644 --- a/packages/cloudflare/src/version-deploy.ts +++ b/packages/cloudflare/src/version-deploy.ts @@ -3,8 +3,8 @@ import { buildNodeCliInvocation, resolveWranglerBin, validateWranglerEnvName, - type DeployOptions, -} from "./deploy.js"; + type WranglerTargetOptions, +} from "./wrangler-cli.js"; import { parseWorkersDevUrl } from "./workers-dev-url.js"; export { parseWorkersDevUrl } from "./workers-dev-url.js"; @@ -100,7 +100,7 @@ export function parseWranglerVersionUploadOutput(output: string): WranglerVersio } export function buildWranglerVersionUploadArgs( - options: Pick & { previewAlias?: string }, + options: WranglerTargetOptions & { previewAlias?: string }, ): WranglerVersionArgs { const args = ["versions", "upload"]; const env = options.env || (options.preview ? "preview" : undefined); @@ -121,7 +121,7 @@ export function buildWranglerVersionUploadArgs( export function buildWranglerVersionDeployArgs( versionTraffic: readonly WranglerVersionTraffic[], - options: Pick, + options: WranglerTargetOptions, ): WranglerVersionArgs { const args = [ "versions", @@ -143,7 +143,7 @@ export function buildWranglerVersionDeployArgs( } export function buildWranglerDeploymentsStatusArgs( - options: Pick, + options: WranglerTargetOptions, ): WranglerVersionArgs { const args = ["deployments", "status", "--json"]; const env = options.env || (options.preview ? "preview" : undefined); @@ -160,7 +160,7 @@ export function buildWranglerDeploymentsStatusArgs( } export function buildWranglerTriggersDeployArgs( - options: Pick, + options: WranglerTargetOptions, ): WranglerVersionArgs { const args = ["triggers", "deploy"]; const env = options.env || (options.preview ? "preview" : undefined); @@ -257,7 +257,7 @@ export function parseWranglerDeploymentStatusOutput(output: string): WranglerDep export function runWranglerVersionUpload( root: string, - options: Pick & { previewAlias?: string }, + options: WranglerTargetOptions & { previewAlias?: string }, execute: typeof execFileSync = execFileSync, ): WranglerVersionUploadResult { const { args, env } = buildWranglerVersionUploadArgs(options); @@ -279,7 +279,7 @@ export function runWranglerVersionUpload( export function runWranglerVersionDeploy( root: string, versionTraffic: readonly WranglerVersionTraffic[], - options: Pick, + options: WranglerTargetOptions, phase: "stage" | "promote-warmed" | "promote-uploaded" = "promote-uploaded", execute: typeof execFileSync = execFileSync, ): WranglerVersionDeployResult { @@ -298,7 +298,7 @@ export function runWranglerVersionDeploy( export function runWranglerDeploymentStatus( root: string, - options: Pick, + options: WranglerTargetOptions, execute: typeof execFileSync = execFileSync, ): WranglerDeploymentStatus { const { args, env } = buildWranglerDeploymentsStatusArgs(options); @@ -312,7 +312,7 @@ export function runWranglerDeploymentStatus( export function runWranglerTriggersDeploy( root: string, - options: Pick, + options: WranglerTargetOptions, execute: typeof execFileSync = execFileSync, ): WranglerVersionDeployResult { const { args, env } = buildWranglerTriggersDeployArgs(options); diff --git a/packages/cloudflare/src/wrangler-cli.ts b/packages/cloudflare/src/wrangler-cli.ts new file mode 100644 index 0000000000..997b7e6378 --- /dev/null +++ b/packages/cloudflare/src/wrangler-cli.ts @@ -0,0 +1,69 @@ +/** + * Bottom-layer Wrangler CLI helpers and the target-selection options type. + * + * Shared by the deploy command and the modules it orchestrates + * (version-deploy.ts, cdn-warm-deployment.ts, wrangler-deployment-target.ts). + * Lives below all of them so the dependency direction stays + * deploy.ts → transaction/adapters → this module, with no imports pointing + * back into the CLI entrypoint. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { findInNodeModules } from "vinext/internal/utils/project"; + +/** The subset of deploy options that selects which Wrangler target a command addresses. */ +export type WranglerTargetOptions = { + /** Deploy to preview environment (default: production) */ + preview?: boolean; + /** Wrangler environment name from wrangler.jsonc env. */ + env?: string; + /** Custom project name for the Worker */ + name?: string; + /** Wrangler config path, relative to root unless absolute */ + config?: string; +}; + +export function validateWranglerEnvName(env: string): string { + if (env.includes("\0")) { + throw new Error("Wrangler environment names cannot contain null bytes."); + } + return env; +} + +/** + * Resolve Wrangler's JavaScript CLI entrypoint in node_modules. + * + * Invoking the JavaScript file through `process.execPath` avoids the `.cmd` + * shim and command shell that package managers create on Windows. + */ +export function resolveWranglerBin( + root: string, + resolvePackageJson: (root: string) => string | null = (projectRoot) => { + try { + return createRequire(path.join(projectRoot, "package.json")).resolve("wrangler/package.json"); + } catch { + return findInNodeModules(projectRoot, "wrangler/package.json"); + } + }, +): string { + const packageJsonPath = resolvePackageJson(root); + if (packageJsonPath) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { + bin?: string | Record; + }; + const bin = typeof packageJson.bin === "string" ? packageJson.bin : packageJson.bin?.wrangler; + if (bin) return path.resolve(path.dirname(packageJsonPath), bin); + } + + return path.join(root, "node_modules", "wrangler", "bin", "wrangler.js"); +} + +export function buildNodeCliInvocation( + scriptPath: string, + args: string[], + nodeExecutable: string = process.execPath, +): { file: string; args: string[] } { + return { file: nodeExecutable, args: [scriptPath, ...args] }; +} diff --git a/packages/cloudflare/src/wrangler-deployment-target.ts b/packages/cloudflare/src/wrangler-deployment-target.ts index 82c888f0a4..fa7a59a6a2 100644 --- a/packages/cloudflare/src/wrangler-deployment-target.ts +++ b/packages/cloudflare/src/wrangler-deployment-target.ts @@ -8,7 +8,7 @@ * raw projection `wrangler-config.ts` owns. */ -import type { DeployOptions } from "./deploy.js"; +import type { WranglerTargetOptions } from "./wrangler-cli.js"; import { parseWranglerConfig, type WranglerCacheConfig, @@ -37,14 +37,14 @@ export type WranglerDeploymentTarget = { }; export function getWranglerTargetEnv( - options: Pick, + options: Pick, ): string | undefined { return options.env || (options.preview ? "preview" : undefined); } export function resolveWranglerDeploymentTarget( root: string, - options: Pick, + options: WranglerTargetOptions, ): WranglerDeploymentTarget | null { const config = parseWranglerConfig(root, options.config); if (!config) return null; diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 85679b98d0..f4913b5912 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -7,17 +7,19 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import { deploy, - buildNodeCliInvocation, buildWranglerKVBulkPutArgs, buildWranglerInvocation, buildWranglerDeployArgs, parseDeployArgs, - resolveWranglerBin, runWranglerKVBulkPut, runWranglerDeploy, - validateWranglerEnvName, withCloudflareEnv, } from "../packages/cloudflare/src/deploy.js"; +import { + buildNodeCliInvocation, + resolveWranglerBin, + validateWranglerEnvName, +} from "../packages/cloudflare/src/wrangler-cli.js"; import { resolveWranglerDeploymentTarget } from "../packages/cloudflare/src/wrangler-deployment-target.js"; import { detectPackageManager, From 766f8ce46a6ded0ac8687c6756fb929cd9f406a3 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 19 Jul 2026 03:22:34 +1000 Subject: [PATCH 22/22] refactor(cloudflare): read wrangler config via Wrangler's own parser Replace the hand-rolled TOML lexer (utils/toml.ts) with Wrangler's experimental_readRawConfig, which dispatches on file extension to its real TOML/JSONC grammar. Both formats resolve to the same snake_case shape, so the field-extraction logic is unchanged. wrangler is added as an optional peer dependency so runtime-only consumers are not forced to install it; a malformed config degrades to null via the surrounding try/catch, matching missing-file behavior. --- packages/cloudflare/package.json | 11 +- packages/cloudflare/src/utils/toml.ts | 149 ------- packages/cloudflare/src/wrangler-config.ts | 479 ++------------------- pnpm-lock.yaml | 5 + tests/deploy.test.ts | 16 +- 5 files changed, 47 insertions(+), 613 deletions(-) delete mode 100644 packages/cloudflare/src/utils/toml.ts diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index ccd14e5f00..a766fde03c 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -59,10 +59,17 @@ "@types/node": "catalog:", "vinext": "workspace:*", "vite": "catalog:", - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "wrangler": "catalog:" }, "peerDependencies": { - "vinext": "workspace:^" + "vinext": "workspace:^", + "wrangler": "^4.80.0" + }, + "peerDependenciesMeta": { + "wrangler": { + "optional": true + } }, "engines": { "node": ">=22" diff --git a/packages/cloudflare/src/utils/toml.ts b/packages/cloudflare/src/utils/toml.ts deleted file mode 100644 index ad39696c2e..0000000000 --- a/packages/cloudflare/src/utils/toml.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Format-generic TOML lexing helpers used by the Wrangler config reader. - * - * This is deliberately not a full TOML parser — it is the small set of - * source-scanning primitives the deploy path needs (comment stripping, section - * splitting, inline route-array reading). The wrangler-specific semantics - * (which fields mean a custom domain, a warmup host, a KV namespace, etc.) live - * in `tpr.ts`; this module only knows TOML text shape, mirroring how the init - * path keeps generic JSONC parsing in `utils/jsonc`. - */ - -export type TomlSection = { - header: string; - body: string; -}; - -export type TomlRouteEntry = { - pattern: string; - enabled?: boolean; -}; - -/** - * Remove TOML line comments (`#` to end of line) while leaving `#` that appears - * inside quoted strings intact. Basic strings use `"` with `\` escapes; literal - * strings use `'` with no escapes. Callers only pass single-line string values - * (route patterns), so `"""`/`'''` multiline forms are not handled. - */ -export function stripTomlLineComments(body: string): string { - let result = ""; - let quote: '"' | "'" | null = null; - let escaped = false; - for (let i = 0; i < body.length; i++) { - const ch = body[i]; - if (quote === '"') { - result += ch; - if (escaped) escaped = false; - else if (ch === "\\") escaped = true; - else if (ch === '"') quote = null; - continue; - } - if (quote === "'") { - result += ch; - if (ch === "'") quote = null; - continue; - } - if (ch === '"' || ch === "'") { - quote = ch; - result += ch; - continue; - } - if (ch === "#") { - while (i < body.length && body[i] !== "\n") i++; - if (i < body.length) result += "\n"; - continue; - } - result += ch; - } - return result; -} - -/** Return the section name from a TOML header line (`[name]` or `[[name]]`), else null. */ -function parseTomlSectionHeader(line: string): string | null { - // TOML allows a trailing comment after a table header (`[env.foo] # note`). - // Strip it first (quote-aware) so the header is still recognized; otherwise - // the section is misread as body and its keys leak into the previous section. - const trimmed = stripTomlLineComments(line).trim(); - if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null; - const isArrayHeader = trimmed.startsWith("[[") && trimmed.endsWith("]]"); - const start = isArrayHeader ? 2 : 1; - const end = isArrayHeader ? trimmed.length - 2 : trimmed.length - 1; - const header = trimmed.slice(start, end).trim(); - return header.length > 0 ? header : null; -} - -/** - * Split TOML into its sections. A repeated `[[header]]` array-of-tables yields - * one entry per occurrence, so callers can walk every block under a header - * instead of only the first. - */ -export function getTomlSections(content: string): TomlSection[] { - const sections: TomlSection[] = []; - let currentHeader: string | null = null; - let currentBody: string[] = []; - - for (const line of content.split("\n")) { - const header = parseTomlSectionHeader(line); - if (header) { - if (currentHeader) { - sections.push({ header: currentHeader, body: currentBody.join("\n") }); - } - currentHeader = header; - currentBody = []; - } else if (currentHeader) { - currentBody.push(line); - } - } - - if (currentHeader) { - sections.push({ header: currentHeader, body: currentBody.join("\n") }); - } - - return sections; -} - -/** Return the top-of-file body before the first TOML section header. */ -export function getTomlRootBody(content: string): string { - const lines: string[] = []; - for (const line of content.split("\n")) { - if (parseTomlSectionHeader(line)) break; - lines.push(line); - } - return lines.join("\n"); -} - -/** - * Extract each route pattern from a TOML inline `routes = [...]` array body. - * Every entry is either a bare string (basic `"..."` or literal `'...'`) or an - * inline table with a `pattern` field; entries without a resolvable pattern - * (e.g. only `zone_name`) are skipped rather than aborting the whole array. - * - * TOML permits `#` comments between array values, so a commented-out route must - * not be read as a live entry. Comments are stripped first, honoring `#` inside - * quoted strings. - */ -export function extractTomlRouteEntries(routesArrayBody: string): TomlRouteEntry[] { - const routes: TomlRouteEntry[] = []; - for (const entry of stripTomlLineComments(routesArrayBody).matchAll( - /\{[^}]*\}|"[^"]*"|'[^']*'/g, - )) { - const raw = entry[0]; - const pattern = raw.startsWith("{") - ? raw - .match(/\bpattern\s*=\s*(?:"([^"]+)"|'([^']+)')/) - ?.slice(1) - .find(Boolean) - : raw.slice(1, -1); - if (!pattern) continue; - const enabledMatch = raw.startsWith("{") ? raw.match(/\benabled\s*=\s*(true|false)\b/) : null; - routes.push({ - pattern, - ...(enabledMatch ? { enabled: enabledMatch[1] === "true" } : {}), - }); - } - return routes; -} - -export function extractTomlRoutePatterns(routesArrayBody: string): string[] { - return extractTomlRouteEntries(routesArrayBody).map((route) => route.pattern); -} diff --git a/packages/cloudflare/src/wrangler-config.ts b/packages/cloudflare/src/wrangler-config.ts index 030381d82e..30bbd3f47f 100644 --- a/packages/cloudflare/src/wrangler-config.ts +++ b/packages/cloudflare/src/wrangler-config.ts @@ -2,22 +2,17 @@ * Reads the narrow projection of wrangler.jsonc/.json/.toml that vinext's * deploy-time features consume: account and KV fields for TPR, cache/route/ * environment/Worker-name/version-metadata fields for CDN warmup target - * resolution. Deliberately partial parsing — `utils/toml.ts` holds the - * format-level TOML helpers, and unknown fields are ignored rather than - * validated. Owned here so deploy features depend on a config module instead - * of reaching into feature modules like tpr.ts. + * resolution. Deliberately partial: file parsing is delegated to Wrangler's + * own reader (real TOML/JSONC grammar, not a hand-rolled subset), and + * unknown fields are ignored rather than validated. Owned here so deploy + * features depend on a config module instead of reaching into feature + * modules like tpr.ts. */ import fs from "node:fs"; import path from "node:path"; +import { experimental_readRawConfig } from "wrangler"; import { isUnknownRecord } from "./utils/cache-control-metadata.js"; -import { - extractTomlRouteEntries, - extractTomlRoutePatterns, - getTomlRootBody, - getTomlSections, - stripTomlLineComments, -} from "./utils/toml.js"; export type WranglerConfig = { accountId?: string; @@ -57,149 +52,37 @@ export function parseWranglerConfig(root: string, configPath?: string): Wrangler if (configPath) { const filepath = path.resolve(root, configPath); if (!fs.existsSync(filepath)) return null; - const content = fs.readFileSync(filepath, "utf-8"); - if (filepath.endsWith(".toml")) { - return extractFromTOML(content); - } - try { - const json = JSON.parse(stripJsonCommentsAndTrailingCommas(content)); - return extractFromJSON(json); - } catch { - return null; - } + const rawConfig = readRawWranglerConfig(filepath); + return rawConfig && extractFromJSON(rawConfig); } - // Try JSONC / JSON first - for (const filename of ["wrangler.jsonc", "wrangler.json"]) { + for (const filename of ["wrangler.jsonc", "wrangler.json", "wrangler.toml"]) { const filepath = path.join(root, filename); - if (fs.existsSync(filepath)) { - const content = fs.readFileSync(filepath, "utf-8"); - try { - const json = JSON.parse(stripJsonCommentsAndTrailingCommas(content)); - return extractFromJSON(json); - } catch { - continue; - } - } - } - - // Try TOML - const tomlPath = path.join(root, "wrangler.toml"); - if (fs.existsSync(tomlPath)) { - const content = fs.readFileSync(tomlPath, "utf-8"); - return extractFromTOML(content); + if (!fs.existsSync(filepath)) continue; + const rawConfig = readRawWranglerConfig(filepath); + if (rawConfig) return extractFromJSON(rawConfig); } return null; } /** - * Strip single-line (//), multi-line comments, and trailing commas from JSONC - * while preserving strings that contain comment-like text or commas. + * Delegates to Wrangler's own config reader, which dispatches on file + * extension to its real TOML or JSONC parser. Both formats resolve to the + * same snake_case field shape (`account_id`, `kv_namespaces`, `routes`, ...), + * so `extractFromJSON` below applies unchanged to either. A file that exists + * but fails to parse (malformed syntax) degrades to null like a missing file, + * matching the discovery loop's "try the next candidate" behavior above. */ -function stripJsonCommentsAndTrailingCommas(str: string): string { - let result = ""; - let inString = false; - let inSingleLine = false; - let inMultiLine = false; - let escapeNext = false; - - for (let i = 0; i < str.length; i++) { - const ch = str[i]; - const next = str[i + 1]; - - if (escapeNext) { - if (!inSingleLine && !inMultiLine) result += ch; - escapeNext = false; - continue; - } - - if (ch === "\\" && inString) { - result += ch; - escapeNext = true; - continue; - } - - if (inSingleLine) { - if (ch === "\n") { - inSingleLine = false; - result += ch; - } - continue; - } - - if (inMultiLine) { - if (ch === "*" && next === "/") { - inMultiLine = false; - i++; - } - continue; - } - - if (ch === '"' && !inString) { - inString = true; - result += ch; - continue; - } - - if (ch === '"' && inString) { - inString = false; - result += ch; - continue; - } - - if (!inString && ch === "/" && next === "/") { - inSingleLine = true; - i++; - continue; - } - - if (!inString && ch === "/" && next === "*") { - inMultiLine = true; - i++; - continue; - } - - if (!inString && ch === "," && isJsonTrailingComma(str, i + 1)) { - continue; - } - - result += ch; - } - - return result; -} - -function isJsonTrailingComma(str: string, start: number): boolean { - for (let i = start; i < str.length; i++) { - const ch = str[i]; - const next = str[i + 1]; - if (ch === undefined) return false; - if (/\s/.test(ch)) { - continue; - } - if (ch === "/" && next === "/") { - i += 2; - while (i < str.length && str[i] !== "\n") { - i++; - } - continue; - } - if (ch === "/" && next === "*") { - i += 2; - while (i < str.length) { - if (str[i] === "*" && str[i + 1] === "/") { - i++; - break; - } - i++; - } - continue; - } - return ch === "}" || ch === "]"; +function readRawWranglerConfig(filepath: string): Record | null { + try { + return experimental_readRawConfig({ config: filepath }, {}).rawConfig as Record< + string, + unknown + >; + } catch { + return null; } - - return false; } function extractFromJSON(config: Record): WranglerConfig { @@ -451,296 +334,6 @@ function cleanDomain(raw: string): string | null { return cleaned || null; } -/** - * Simple extraction of specific fields from wrangler.toml content. - * Not a full TOML parser — just enough for the fields we need. - */ -function extractFromTOML(content: string): WranglerConfig { - const result: WranglerConfig = {}; - const sections = getTomlSections(content); - - const nameMatch = content.match(/^name\s*=\s*"([^"]+)"/m); - if (nameMatch) result.name = nameMatch[1]; - - const legacyEnvMatch = content.match(/^legacy_env\s*=\s*(true|false)\s*$/m); - if (legacyEnvMatch) result.legacyEnv = legacyEnvMatch[1] === "true"; - - // account_id = "..." - const accountMatch = content.match(/^account_id\s*=\s*"([^"]+)"/m); - if (accountMatch) result.accountId = accountMatch[1]; - - // KV namespace with binding = "VINEXT_KV_CACHE" - // Look for [[kv_namespaces]] blocks - const kvBlocks = content.split(/\[\[kv_namespaces\]\]/); - for (let i = 1; i < kvBlocks.length; i++) { - const block = kvBlocks[i].split(/\[\[/)[0]; // Take until next section - const bindingMatch = block.match(/binding\s*=\s*"([^"]+)"/); - const idMatch = block.match(/\bid\s*=\s*"([^"]+)"/); - if ( - (bindingMatch?.[1] === "VINEXT_KV_CACHE" || bindingMatch?.[1] === "VINEXT_CACHE") && - idMatch?.[1] && - idMatch[1] !== "" - ) { - result.kvNamespaceId = idMatch[1]; - } - } - - // routes — both string and table forms - // route = "example.com/*" - const routeMatch = content.match(/^route\s*=\s*"([^"]+)"/m); - if (routeMatch) { - const domain = cleanDomain(routeMatch[1]); - if (domain && !domain.includes("workers.dev")) { - result.customDomain = domain; - } - } - - // [[routes]] is a TOML array-of-tables: each entry produces its own section - // with the same header, so an earlier disqualified route must not shadow a - // later valid one. - const rootRouteSections = sections.filter( - (section) => section.header === "route" || section.header === "routes", - ); - - // Preserves prior behavior: root-level [[routes]] blocks match `pattern` - // only (unlike the env-scoped path below, which also accepts `zone_name`). - if (!result.customDomain) { - result.customDomain = - firstMatch(rootRouteSections, (section) => extractTomlRoutePatternDomain(section.body)) ?? - undefined; - } - - const warmupHosts = dedupeHosts([ - ...extractTomlWarmupHosts(getTomlRootBody(content)), - ...collectMatches(rootRouteSections, (section) => - extractTomlWarmupRouteBlockHost(section.body), - ), - ]); - if (warmupHosts.length > 0) result.warmupHosts = warmupHosts; - if ( - extractTomlHasUnwarmableRoute(getTomlRootBody(content)) || - rootRouteSections.some((section) => tomlRouteBlockIsUnwarmable(section.body)) - ) { - result.hasUnwarmableRoute = true; - } - - const rootBody = getTomlRootBody(content); - const rootCache = sections.find((section) => section.header === "cache"); - const cache = - extractTomlCacheConfig(rootBody) ?? - (rootCache ? extractTomlCacheTableConfig(rootCache.body) : null); - if (cache) result.cache = cache; - const rootVersionMetadata = sections.find((section) => section.header === "version_metadata"); - const versionMetadataBinding = - extractTomlVersionMetadataBinding(rootBody) ?? - (rootVersionMetadata ? extractTomlVersionMetadataTableBinding(rootVersionMetadata.body) : null); - if (versionMetadataBinding) result.versionMetadataBinding = versionMetadataBinding; - - const env = extractEnvConfigsFromTOML(content); - if (env) result.env = env; - - return result; -} - -function extractEnvConfigsFromTOML( - content: string, -): Record | undefined { - const result: Record = {}; - - for (const section of getTomlSections(content)) { - const envName = section.header.match(/^env\.([^.]+)$/)?.[1]; - if (envName) { - const envConfig = result[envName] ?? {}; - const cache = extractTomlCacheConfig(section.body); - if (cache) envConfig.cache = cache; - const nameMatch = section.body.match(/^name\s*=\s*"([^"]+)"/m); - if (nameMatch) envConfig.name = nameMatch[1]; - const domain = - extractTomlScalarRouteDomain(section.body) ?? extractTomlRoutesArrayDomain(section.body); - if (domain) envConfig.customDomain = domain; - const warmupHosts = extractTomlWarmupHosts(section.body); - if (warmupHosts.length > 0) { - envConfig.warmupHosts = dedupeHosts([...(envConfig.warmupHosts ?? []), ...warmupHosts]); - } - if (extractTomlHasUnwarmableRoute(section.body)) envConfig.hasUnwarmableRoute = true; - const versionMetadataBinding = extractTomlVersionMetadataBinding(section.body); - if (versionMetadataBinding) envConfig.versionMetadataBinding = versionMetadataBinding; - if ( - envConfig.name || - envConfig.cache || - envConfig.customDomain || - envConfig.warmupHosts || - envConfig.hasUnwarmableRoute || - envConfig.versionMetadataBinding - ) { - result[envName] = envConfig; - } - continue; - } - - const cacheEnvName = section.header.match(/^env\.([^.]+)\.cache$/)?.[1]; - if (cacheEnvName) { - const envConfig = result[cacheEnvName] ?? {}; - const cache = extractTomlCacheTableConfig(section.body); - if (cache) envConfig.cache = cache; - if ( - envConfig.name || - envConfig.cache || - envConfig.customDomain || - envConfig.warmupHosts || - envConfig.versionMetadataBinding - ) { - result[cacheEnvName] = envConfig; - } - continue; - } - - const metadataEnvName = section.header.match(/^env\.([^.]+)\.version_metadata$/)?.[1]; - if (metadataEnvName) { - const envConfig = result[metadataEnvName] ?? {}; - const binding = extractTomlVersionMetadataTableBinding(section.body); - if (binding) envConfig.versionMetadataBinding = binding; - if ( - envConfig.name || - envConfig.cache || - envConfig.customDomain || - envConfig.warmupHosts || - envConfig.versionMetadataBinding - ) { - result[metadataEnvName] = envConfig; - } - continue; - } - - const routesEnvName = section.header.match(/^env\.([^.]+)\.(?:route|routes)$/)?.[1]; - if (routesEnvName) { - const envConfig = result[routesEnvName] ?? {}; - const domain = extractTomlRouteBlockDomain(section.body); - if (domain) envConfig.customDomain = domain; - const warmupHost = extractTomlWarmupRouteBlockHost(section.body); - if (warmupHost) { - envConfig.warmupHosts = dedupeHosts([...(envConfig.warmupHosts ?? []), warmupHost]); - } - if (tomlRouteBlockIsUnwarmable(section.body)) envConfig.hasUnwarmableRoute = true; - if ( - envConfig.name || - envConfig.cache || - envConfig.customDomain || - envConfig.warmupHosts || - envConfig.hasUnwarmableRoute || - envConfig.versionMetadataBinding - ) { - result[routesEnvName] = envConfig; - } - } - } - - return Object.keys(result).length > 0 ? result : undefined; -} - -function extractTomlCacheConfig(section: string): WranglerCacheConfig | null { - const match = stripTomlLineComments(section).match(/^cache\s*=\s*\{([\s\S]*?)\}/m); - return match ? extractTomlCacheTableConfig(match[1] ?? "") : null; -} - -function extractTomlCacheTableConfig(section: string): WranglerCacheConfig | null { - const enabledMatch = section.match(/(?:^|[,\n])\s*enabled\s*=\s*(true|false)\b/); - const crossVersionMatch = section.match(/(?:^|[,\n])\s*cross_version_cache\s*=\s*(true|false)\b/); - if (!enabledMatch && !crossVersionMatch) return null; - return { - enabled: enabledMatch ? enabledMatch[1] === "true" : undefined, - crossVersionCache: crossVersionMatch ? crossVersionMatch[1] === "true" : undefined, - }; -} - -function extractTomlVersionMetadataBinding(section: string): string | null { - const match = stripTomlLineComments(section).match( - /^version_metadata\s*=\s*\{[^}]*\bbinding\s*=\s*(?:"([^"]+)"|'([^']+)')[^}]*\}/m, - ); - return match?.slice(1).find((value): value is string => Boolean(value)) ?? null; -} - -function extractTomlVersionMetadataTableBinding(section: string): string | null { - const match = section.match(/^binding\s*=\s*(?:"([^"]+)"|'([^']+)')/m); - return match?.slice(1).find((value): value is string => Boolean(value)) ?? null; -} - -function extractTomlScalarRouteDomain(section: string): string | null { - const routeMatch = section.match(/^route\s*=\s*"([^"]+)"/m); - if (!routeMatch) return null; - const domain = cleanDomain(routeMatch[1]); - return domain && !domain.includes("workers.dev") ? domain : null; -} - -function extractTomlRoutesArrayDomain(section: string): string | null { - const routesMatch = section.match(/^routes\s*=\s*\[([\s\S]*?)\]/m); - if (!routesMatch) return null; - return firstMatch(extractTomlRoutePatterns(routesMatch[1] ?? ""), (pattern) => { - const domain = cleanDomain(pattern); - return domain && !domain.includes("workers.dev") ? domain : null; - }); -} - -function extractTomlWarmupHosts(section: string): string[] { - const uncommented = stripTomlLineComments(section); - const scalarRoute = uncommented - .match(/^route\s*=\s*(?:"([^"]+)"|'([^']+)')/m) - ?.slice(1) - .find((value): value is string => Boolean(value)); - if (scalarRoute) { - const host = routePatternToWarmupHost(scalarRoute); - return host ? [host] : []; - } - - const inlineRoute = uncommented.match(/^route\s*=\s*\{([\s\S]*?)\}\s*$/m)?.[1]; - if (inlineRoute && /\benabled\s*=\s*false\b/.test(inlineRoute)) return []; - const inlinePattern = inlineRoute - ?.match(/\bpattern\s*=\s*(?:"([^"]+)"|'([^']+)')/) - ?.slice(1) - .find((value): value is string => Boolean(value)); - if (inlinePattern) { - const host = routePatternToWarmupHost(inlinePattern); - return host ? [host] : []; - } - - const routesArray = uncommented.match(/^routes\s*=\s*\[([\s\S]*?)\]/m)?.[1]; - if (!routesArray) return []; - return collectMatches(extractTomlRouteEntries(routesArray), (route) => - route.enabled === false ? null : routePatternToWarmupHost(route.pattern), - ); -} - -/** TOML counterpart of `extractHasUnwarmableRoute` for the same body-level route forms. */ -function extractTomlHasUnwarmableRoute(section: string): boolean { - const uncommented = stripTomlLineComments(section); - const scalarRoute = uncommented - .match(/^route\s*=\s*(?:"([^"]+)"|'([^']+)')/m) - ?.slice(1) - .find((value): value is string => Boolean(value)); - if (scalarRoute && patternIsUnwarmable(scalarRoute)) return true; - - const inlineRoute = uncommented.match(/^route\s*=\s*\{([\s\S]*?)\}\s*$/m)?.[1]; - if (inlineRoute && !/\benabled\s*=\s*false\b/.test(inlineRoute)) { - const inlinePattern = inlineRoute - .match(/\bpattern\s*=\s*(?:"([^"]+)"|'([^']+)')/) - ?.slice(1) - .find((value): value is string => Boolean(value)); - if (inlinePattern && patternIsUnwarmable(inlinePattern)) return true; - } - - const routesArray = uncommented.match(/^routes\s*=\s*\[([\s\S]*?)\]/m)?.[1]; - if (!routesArray) return false; - return extractTomlRouteEntries(routesArray).some( - (route) => route.enabled !== false && patternIsUnwarmable(route.pattern), - ); -} - -function tomlRouteBlockIsUnwarmable(section: string): boolean { - if (/^enabled\s*=\s*false\b/m.test(section)) return false; - const patternMatch = section.match(/^pattern\s*=\s*"([^"]+)"/m); - return patternMatch ? patternIsUnwarmable(patternMatch[1]) : false; -} - function routePatternToWarmupHost(pattern: string): string | null { const withoutProtocol = pattern.replace(/^https?:\/\//, ""); const pathStart = withoutProtocol.indexOf("/"); @@ -749,23 +342,3 @@ function routePatternToWarmupHost(pattern: string): string | null { const host = cleanDomain(pattern); return host && !host.includes("*") && !host.includes("workers.dev") ? host : null; } - -function extractTomlRouteBlockDomain(section: string): string | null { - const patternMatch = section.match(/^(?:pattern|zone_name)\s*=\s*"([^"]+)"/m); - if (!patternMatch) return null; - const domain = cleanDomain(patternMatch[1]); - return domain && !domain.includes("workers.dev") ? domain : null; -} - -function extractTomlRoutePatternDomain(section: string): string | null { - const patternMatch = section.match(/^pattern\s*=\s*"([^"]+)"/m); - if (!patternMatch) return null; - const domain = cleanDomain(patternMatch[1]); - return domain && !domain.includes("workers.dev") ? domain : null; -} - -function extractTomlWarmupRouteBlockHost(section: string): string | null { - if (/^enabled\s*=\s*false\b/m.test(section)) return null; - const patternMatch = section.match(/^pattern\s*=\s*"([^"]+)"/m); - return patternMatch ? routePatternToWarmupHost(patternMatch[1]) : null; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87db1f5eba..96757b6c7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -984,6 +984,9 @@ importers: vite-plus: specifier: 'catalog:' version: 0.2.2(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.9(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2))(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0) + wrangler: + specifier: 'catalog:' + version: 4.80.0(@cloudflare/workers-types@4.20260313.1) packages/create-vinext-app: devDependencies: @@ -1575,6 +1578,8 @@ importers: specifier: 'catalog:' version: 0.2.2(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.9(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2))(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0) + tests/fixtures/middleware-matcher-auth: {} + tests/fixtures/og-font-package: {} tests/fixtures/pages-basepath-trailing-slash: diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index f4913b5912..ae0924fd4e 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -3265,18 +3265,16 @@ enabled = true }); }); - it("ignores braces in TOML inline-table comments", () => { + it("ignores braces in a trailing TOML comment", () => { + // TOML inline tables must stay on one line, so a comment can only trail + // after the real closing brace — never sit inside it. A misleading `}` + // in that trailing comment must not confuse cache/version_metadata + // extraction. writeFile( tmpDir, "wrangler.toml", - `cache = { - enabled = true, # misleading } - cross_version_cache = false -} -version_metadata = { - # another misleading } - binding = "VINEXT_VERSION_METADATA" -} + `cache = { enabled = true, cross_version_cache = false } # misleading } +version_metadata = { binding = "VINEXT_VERSION_METADATA" } # another misleading } `, );