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/cdn-warm-deployment.ts b/packages/cloudflare/src/cdn-warm-deployment.ts new file mode 100644 index 0000000000..dd64032995 --- /dev/null +++ b/packages/cloudflare/src/cdn-warm-deployment.ts @@ -0,0 +1,451 @@ +/** + * 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 → + * 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 + * 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. 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 { formatUnknownError } from "./utils/format-unknown-error.js"; +import type { WranglerTargetOptions } from "./wrangler-cli.js"; +import { + runWranglerDeploymentStatus, + runWranglerTriggersDeploy, + runWranglerVersionDeploy, + runWranglerVersionUpload, + type WranglerDeploymentStatus, + type WranglerVersionDeployResult, + type WranglerVersionTraffic, + type WranglerVersionUploadResult, +} 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)" + ); +} + +function promotionPhaseFor(warmed: boolean): "promote-warmed" | "promote-uploaded" { + return warmed ? "promote-warmed" : "promote-uploaded"; +} + +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, + paths: readonly string[], + options: CdnWarmupOptions, +): Promise { + const target = validateCdnWarmupConfiguration(root, options); + const upload = runWranglerVersionUpload(root, options); + 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) { + if ( + currentVersions.some( + (version) => version.versionId === upload.versionId && version.percentage === 100, + ) + ) { + return finishAlreadyCurrentVersion(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( + root, + paths, + target, + upload, + stagingTraffic[0].versionId, + stagingTraffic, + options, + ); +} + +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. + * 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, + 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} ${strictUploadState}`); + } + console.warn(` ${message} Promoting without pre-warming.`); + const deployed = runWranglerVersionDeploy( + root, + [{ versionId: upload.versionId, percentage: 100 }], + options, + "promote-uploaded", + ); + const triggers = applyTriggersAfterPromotion(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"); + + 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. + // + // 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 (targetUrls.length === 0 || !headers) { + const message = + 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 { + // 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; + let allPathsConfirmed = 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) allPathsConfirmed = false; + } + if (!allPathsConfirmed) { + const originSuffix = targetUrls.length > 1 ? ` across ${targetUrls.length} origins` : ""; + console.warn( + ` 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.", + ); + } + warmed = allPathsConfirmed && !target.hasUnwarmableProductionRoute; + } + } 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.`, + ); + } + + verifyStagedSplitBeforePromotion(root, options, previousVersionId, upload.versionId); + + let deployed: WranglerVersionDeployResult; + try { + deployed = runWranglerVersionDeploy( + root, + [{ versionId: upload.versionId, percentage: 100 }], + options, + promotionPhaseFor(warmed), + ); + } 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 { + url: pickDeployedUrl( + deployed.deployedUrl, + staged.deployedUrl, + triggers.deployedUrl, + upload.previewUrl, + ), + warmed, + }; +} + +/** + * 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: 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.", +): WranglerVersionDeployResult { + try { + return runWranglerTriggersDeploy(root, options); + } catch (error) { + throw new Error(`${formatUnknownError(error)}\n\n${failureMessage}`); + } +} + +function validateCdnWarmupConfiguration( + root: string, + options: WranglerTargetOptions, +): WranglerDeploymentTarget { + const target = resolveWranglerDeploymentTarget(root, options); + const envName = getWranglerTargetEnv(options); + const targetLabel = envName + ? `Wrangler environment "${envName}"` + : "the top-level Wrangler config"; + 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.", + ); + } + 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; +} + +/** + * 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: WranglerTargetOptions, + 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: WranglerTargetOptions, +): { deployment: WranglerDeploymentStatus } | { error: string } { + try { + return { deployment: runWranglerDeploymentStatus(root, options) }; + } catch (error) { + return { error: formatUnknownError(error) }; + } +} + +function getZeroPercentStagingTraffic( + current: readonly WranglerVersionTraffic[], + versionId: string, +): WranglerVersionTraffic[] | 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 (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 { + 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)}`, + }; +} diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index b8f52cc186..af7fad1ac2 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,15 @@ export type CdnWarmOptions = { concurrency?: number; timeoutMs?: number; retries?: number; + 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; }; @@ -142,11 +151,61 @@ 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)); +} + +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, + 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, timeoutMs: number, headers: HeadersInit | undefined, + redirect: RequestRedirect = "follow", ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -155,7 +214,7 @@ async function fetchWithTimeout( try { return await fetchImpl(url, { method: "GET", - redirect: "follow", + redirect, headers: requestHeaders, signal: controller.signal, }); @@ -166,9 +225,13 @@ async function fetchWithTimeout( async function warmOnePath( pathname: string, - options: Required> & { + options: Required< + Pick + > & { 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); @@ -181,21 +244,76 @@ 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 = describeVersionMismatch(options.expectedVersionId, actualVersionId); + await waitBeforeRetry(attempt, options.retries, options.retryDelayMs); + continue; + } + } + 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 + // 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); } 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); } } @@ -227,7 +345,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/packages/cloudflare/src/deploy-help.ts b/packages/cloudflare/src/deploy-help.ts index 85f8c4d208..d0abc8757a 100644 --- a/packages/cloudflare/src/deploy-help.ts +++ b/packages/cloudflare/src/deploy-help.ts @@ -22,13 +22,17 @@ export function formatDeployHelp(): string { --prerender-concurrency 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) + 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) --warm-cdn-include-fallbacks Also warm PPR fallback-shell placeholder paths -h, --help Show this help @@ -47,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/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 228f890511..6585c5f98e 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -37,13 +37,20 @@ import { } from "vinext/internal/config/prerender"; import { detectProject, - findInNodeModules, formatMissingCloudflarePluginError, 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, 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, @@ -53,60 +60,37 @@ 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"; // ─── 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 for transient failures */ - 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; @@ -139,11 +123,6 @@ function parseNonNegativeIntegerArg(raw: string, flag: string): number { return parsed; } -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. */ @@ -350,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 { @@ -387,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, @@ -538,234 +474,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 +574,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) { @@ -940,20 +649,34 @@ 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) { + 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, }); 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); @@ -964,5 +687,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/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/tpr.ts b/packages/cloudflare/src/tpr.ts index c26d4546e5..0695cb206f 100644 --- a/packages/cloudflare/src/tpr.ts +++ b/packages/cloudflare/src/tpr.ts @@ -29,6 +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 { parseWranglerConfig } from "./wrangler-config.js"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -76,441 +77,6 @@ type PrerenderResult = { headers: Record; }; -type WranglerConfig = { - accountId?: string; - kvNamespaceId?: string; - customDomain?: string; - name?: string; - legacyEnv?: boolean; - env?: Record; -}; - -export type WranglerEnvironmentConfig = { - customDomain?: string; - name?: 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. - */ -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 = {}; - - if (typeof config.name === "string" && config.name.length > 0) { - result.name = config.name; - } - - if (typeof config.legacy_env === "boolean") { - result.legacyEnv = config.legacy_env; - } - - // 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; - } - } - - // Custom domain — check routes[] and custom_domains[] - const domain = extractDomainFromRoutes(config.routes) ?? extractDomainFromCustomDomains(config); - if (domain) result.customDomain = domain; - - 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.customDomain) { - result[envName] = envConfig; - } - } - return Object.keys(result).length > 0 ? result : undefined; -} - -function extractEnvironmentConfig(config: Record): WranglerEnvironmentConfig { - const result: WranglerEnvironmentConfig = {}; - if (typeof config.name === "string" && config.name.length > 0) { - result.name = config.name; - } - const domain = extractDomainFromRoutes(config.routes) ?? extractDomainFromCustomDomains(config); - if (domain) result.customDomain = domain; - return result; -} - -function extractDomainFromRoutes(routes: unknown): string | null { - if (!Array.isArray(routes)) return null; - - 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; - } - } - } - return 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; -} - -/** 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 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]] blocks - 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; - } - } - } - } - - 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 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; - if (envConfig.name || envConfig.customDomain) { - result[envName] = envConfig; - } - continue; - } - - const routesEnvName = section.header.match(/^env\.([^.]+)\.routes$/)?.[1]; - if (routesEnvName) { - const envConfig = result[routesEnvName] ?? {}; - const domain = extractTomlRouteBlockDomain(section.body); - if (domain) envConfig.customDomain = domain; - if (envConfig.name || envConfig.customDomain) { - result[routesEnvName] = envConfig; - } - } - } - - 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 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 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; - const patternMatch = (routesMatch[1] ?? "").match(/(?:pattern\s*=\s*)?"([^"]+)"/); - 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); - if (!patternMatch) return null; - const domain = cleanDomain(patternMatch[1]); - return domain && !domain.includes("workers.dev") ? domain : null; -} - // ─── Cloudflare API ────────────────────────────────────────────────────────── /** 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-config.ts b/packages/cloudflare/src/wrangler-config.ts new file mode 100644 index 0000000000..30bbd3f47f --- /dev/null +++ b/packages/cloudflare/src/wrangler-config.ts @@ -0,0 +1,344 @@ +/** + * 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: 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"; + +export type WranglerConfig = { + accountId?: string; + cache?: WranglerCacheConfig; + kvNamespaceId?: string; + customDomain?: string; + warmupHosts?: readonly string[]; + hasUnwarmableRoute?: boolean; + name?: string; + legacyEnv?: boolean; + targetEnvironment?: string; + versionMetadataBinding?: string; + env?: Record; +}; + +type WranglerEnvironmentConfig = { + cache?: WranglerCacheConfig; + customDomain?: string; + warmupHosts?: readonly string[]; + hasUnwarmableRoute?: boolean; + 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 rawConfig = readRawWranglerConfig(filepath); + return rawConfig && extractFromJSON(rawConfig); + } + + for (const filename of ["wrangler.jsonc", "wrangler.json", "wrangler.toml"]) { + const filepath = path.join(root, filename); + if (!fs.existsSync(filepath)) continue; + const rawConfig = readRawWranglerConfig(filepath); + if (rawConfig) return extractFromJSON(rawConfig); + } + + return null; +} + +/** + * 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 readRawWranglerConfig(filepath: string): Record | null { + try { + return experimental_readRawConfig({ config: filepath }, {}).rawConfig as Record< + string, + unknown + >; + } catch { + return null; + } +} + +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 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; + + 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.warmupHosts || + 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 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; +} + +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); +} + +/** + * 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) 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)]; +} + +/** + * 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 + * 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; +} + +/** 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; + 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; +} + +/** 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; +} + +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; +} diff --git a/packages/cloudflare/src/wrangler-deployment-target.ts b/packages/cloudflare/src/wrangler-deployment-target.ts new file mode 100644 index 0000000000..fa7a59a6a2 --- /dev/null +++ b/packages/cloudflare/src/wrangler-deployment-target.ts @@ -0,0 +1,93 @@ +/** + * 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. + * + * The env fallback and legacy_env Worker-name suffixing here are CDN-warmup + * resolution rules, not generic Wrangler config fields — they layer on the + * raw projection `wrangler-config.ts` owns. + */ + +import type { WranglerTargetOptions } from "./wrangler-cli.js"; +import { + parseWranglerConfig, + type WranglerCacheConfig, + type WranglerConfig, +} from "./wrangler-config.js"; + +export type WranglerDeploymentTarget = { + cacheEnabled?: boolean; + crossVersionCache?: boolean; + hasProductionRoute: boolean; + workerName?: 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[]; + /** + * 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; +}; + +export function getWranglerTargetEnv( + options: Pick, +): string | undefined { + return options.env || (options.preview ? "preview" : undefined); +} + +export function resolveWranglerDeploymentTarget( + root: string, + options: WranglerTargetOptions, +): WranglerDeploymentTarget | null { + const config = parseWranglerConfig(root, options.config); + if (!config) return null; + const envName = getWranglerTargetEnv(options); + const flattenedEnvConfig = Boolean( + envName && !config.env?.[envName] && config.targetEnvironment === envName, + ); + 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), + productionHosts: selected?.warmupHosts ?? [], + hasUnwarmableProductionRoute: Boolean(selected?.hasUnwarmableRoute), + 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, + 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/packages/vinext/package.json b/packages/vinext/package.json index 7eba92fe57..d0d1c62a0f 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -91,6 +91,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..d81861ef50 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,68 @@ 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), 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, scopeLabel: 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 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 { + 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, + `environment "${envName}"`, + ); + 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 +454,10 @@ export function updateWranglerConfigForCloudflare( } } } + if (options.warmCdnCache) { + output = ensureVersionMetadataInJsonObject(output, "the top level"); + 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 01204cc51a..bbf462e0e7 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"; import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js"; @@ -95,7 +96,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/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/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index e473a896b2..390dc3f03c 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,74 @@ function formatFetchUrl(url: Parameters[0]): string { return url.url; } +function versionedResponse(versionId = UPLOADED_VERSION_ID, cacheStatus = "HIT"): Response { + return new Response("ok", { + status: 200, + headers: { "x-vinext-worker-version": versionId, "cf-cache-status": cacheStatus }, + }); +} + +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, + cache: { enabled: true, ...(config.cache as Record | undefined) }, + version_metadata: { binding: "VINEXT_VERSION_METADATA" }, + ...(configuredEnv ? { env: configuredEnv } : {}), + }); +} + +function currentDeploymentOutput(): string { + return JSON.stringify({ + versions: [{ version_id: PREVIOUS_VERSION_ID, percentage: 100 }], + }); +} + +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%`); +} + +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 +102,1006 @@ 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("rejects a deploy whose Worker cache is disabled before upload", async () => { writeFile( "wrangler.jsonc", JSON.stringify({ name: "my-worker", - custom_domains: ["app.example.com"], + cache: { enabled: false }, + version_metadata: { binding: "VINEXT_VERSION_METADATA" }, }), ); - vi.mocked(fetch).mockImplementation(async (url) => { - events.push(`fetch:${formatFetchUrl(url)}`); - return new Response("ok", { status: 200 }); + 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 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"; + 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( + "wrangler.jsonc", + warmupWranglerConfig({ + name: "my-worker", + cache: { enabled: true }, + routes: [{ pattern: "app.example.com/*", zone_name: "example.com" }], + }), + ); + 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 deploymentStatusOutput(); } - 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"], { + const result = 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(result).toEqual({ url: "https://stable.example.workers.dev", warmed: true }); 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}"`, + "status", "promote", + "triggers", ]); }); - it("uses the env Worker name and env custom domain for version override warmup", async () => { + 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", - JSON.stringify({ + 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", - custom_domains: ["app.example.com"], + 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("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", + warmupWranglerConfig({ + name: "my-worker", + 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 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"; 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 deploymentStatusOutput(); } - 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 result = await deployWithCdnWarmup(tmpDir, ["/cached/intro"], {}); + expect(result).toEqual({ url: "https://workers-cache.vinext.workers.dev", warmed: true }); expect(events).toEqual([ "upload", "status", + "stage", + "fetch:https://workers-cache.vinext.workers.dev/cached/intro", + "status", "promote", "triggers", - "fetch:https://app.example.com/", ]); }); - it("uses the triggers deploy URL for post-promotion fallback warmup", async () => { + it("does not claim workers.dev is warm for a path-scoped production route", async () => { const events: string[] = []; writeFile( "wrangler.jsonc", - JSON.stringify({ - name: "workers-cache", - }), + warmupWranglerConfig({ name: "workers-cache", route: "app.example.com/api/*" }), ); - vi.mocked(fetch).mockImplementation(async (url) => { - events.push(`fetch:${formatFetchUrl(url)}`); - return new Response("ok", { status: 200 }); + 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)) { + 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" })); + 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 deploymentStatusOutput(); } - 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", + "status", "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 () => { - writeFile( - "wrangler.jsonc", - JSON.stringify({ - name: "config-worker", - custom_domains: ["app.example.com"], - }), + 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 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"; + 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 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)) { + 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"); + 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")) { - 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 deploymentStatusOutput(); } - 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\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/deploy.js"); + const { deployWithCdnWarmup } = + await import("../packages/cloudflare/src/cdn-warm-deployment.js"); - await deployWithCdnWarmup(tmpDir, ["/"], { - name: "cli-worker", - warmCdnConcurrency: 1, - }); + const result = await deployWithCdnWarmup(tmpDir, ["/cached/intro"], { warmCdnRetries: 0 }); - 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"', + // 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", + "status", + "promote-uploaded", + "triggers", + ]); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("confirmed 0/1 path(s) served the uploaded version"), ); - for (const [, args] of execFileSyncMock.mock.calls as Array<[string, string[]]>) { - expect(args).toEqual(expect.arrayContaining(["--name", "cli-worker"])); - } }); - it("explains staged version cleanup when strict pre-promotion warmup fails", 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: "my-worker", - custom_domains: ["app.example.com"], - }), + warmupWranglerConfig({ name: "my-worker", route: "app.example.com/*" }), ); - vi.mocked(fetch).mockResolvedValue(new Response("nope", { status: 500 })); + 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 }], - }); + events.push("status"); + return deploymentStatusOutput(); } - if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@0%")) { - return "Staged 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"); + 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( + /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. + expect(events).toEqual(["upload", "status", "stage", "fetch:old-version"]); }); - it("explains staged version cleanup when trigger deployment fails after staging", async () => { + 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", - JSON.stringify({ - name: "my-worker", - custom_domains: ["app.example.com"], - }), + warmupWranglerConfig({ name: "my-worker", route: "app.example.com/*" }), ); + 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"; + 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")) { - return JSON.stringify({ - versions: [{ version_id: "11111111-1111-4111-8111-111111111111", percentage: 100 }], - }); + events.push("status"); + statusReads++; + // 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("deploy") && args.includes("22222222-2222-4222-8222-222222222222@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")) { - throw new Error("trigger deploy failed"); + 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%"); - expect(fetch).not.toHaveBeenCalled(); + ).rejects.toThrow("is staged at 0% and was not promoted"); + + const result = await deployWithCdnWarmup(tmpDir, ["/"], { + warmCdnRetries: 0, + warmCdnStrict: true, + }); + + expect(result).toEqual({ url: "https://stable.example.workers.dev", warmed: true }); + expect(events).toEqual([ + "upload:first", + "status", + "stage", + "fetch:old-version", + "upload:retry", + "status", + "stage", + "fetch:new-version", + "status", + "promote", + "triggers", + ]); + expect(stagingSplits[1]).not.toContain(`${failedVersionId}@0%`); }); - it("explains promoted version state when fallback trigger deployment fails", 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")) { + events.push("status"); + return deploymentStatusOutput(); + } + if (isStage(args)) 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-attempt"); + throw new Error("network blip during promote"); + } + 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 confirm the promotion of Worker version ${UPLOADED_VERSION_ID} succeeded`, + ); + // 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 () => { + 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: "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/cdn-warm-deployment.js"); + + await expect(deployWithCdnWarmup(tmpDir, ["/"], { warmCdnStrict: true })).rejects.toThrow( + `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); + }); + + 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 deploymentStatusOutput(); + 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(3); + expect( + execFileSyncMock.mock.calls.some(([, args]) => + (args as string[]).some((arg) => arg === `${PREVIOUS_VERSION_ID}@0%`), + ), + ).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 deploymentStatusOutput(); + 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" })); + 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 () => { + 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")) { + 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/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, ["/"], {})).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/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts index 6409119332..b26d5834ee 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,259 @@ 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.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 () => + 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("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"); + }); + + 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", diff --git a/tests/deploy-prerender-config.test.ts b/tests/deploy-prerender-config.test.ts index 01443c2552..018a5c851f 100644 --- a/tests/deploy-prerender-config.test.ts +++ b/tests/deploy-prerender-config.test.ts @@ -167,6 +167,18 @@ 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" }, + cache: { enabled: true }, + 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 +188,8 @@ describe("deploy prerender config wiring", () => { }); afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -242,6 +256,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 +315,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 1b2871b726..ae0924fd4e 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -7,19 +7,20 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import { deploy, - buildNodeCliInvocation, buildWranglerKVBulkPutArgs, buildWranglerInvocation, buildWranglerDeployArgs, - getZeroPercentStagingTraffic, parseDeployArgs, - resolveWorkerNameForVersionOverride, - 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, detectPackageManagerName, @@ -62,7 +63,9 @@ 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 ──────────────────────────────────────────────────────────── @@ -725,6 +728,15 @@ describe("parseDeployArgs", () => { expect(parsed.warmCdnIncludeFallbacks).toBe(true); }); + 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"); + expect(help).toContain( + "Verified warmup exposes x-vinext-worker-version on Worker-served responses", + ); + }); + 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".', @@ -3171,6 +3183,7 @@ describe("parseWranglerConfig — custom domain extraction", () => { expect(config?.env?.staging).toEqual({ name: "my-worker-staging", customDomain: "staging.example.com", + warmupHosts: ["staging.example.com"], }); }); @@ -3204,6 +3217,72 @@ 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("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, cross_version_cache = false } # misleading } +version_metadata = { binding = "VINEXT_VERSION_METADATA" } # another misleading } +`, + ); + + 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, @@ -3223,6 +3302,7 @@ describe("parseWranglerConfig — custom domain extraction", () => { expect(config?.env?.staging).toEqual({ name: "my-worker-staging-custom", customDomain: "staging.example.com", + warmupHosts: ["staging.example.com"], }); }); @@ -3261,75 +3341,368 @@ pattern = "staging.example.com/*" expect(config?.env?.staging).toEqual({ name: "my-worker-staging", customDomain: "staging.example.com", + warmupHosts: ["staging.example.com"], }); }); + + it("extracts a top-level TOML version metadata table", () => { + writeFile( + tmpDir, + "wrangler.toml", + `[version_metadata] +binding = "TOP_VERSION" +`, + ); + + const config = parseWranglerConfig(tmpDir); + expect(config?.versionMetadataBinding).toBe("TOP_VERSION"); + }); + + 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"); + }); }); -// ─── CDN warmup Worker version overrides ─────────────────────────────────── +describe("resolveWranglerDeploymentTarget — production hosts", () => { + // The caller (cdn-warm-deployment.ts) falls back to the wrangler-reported + // 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, + "wrangler.jsonc", + JSON.stringify({ + routes: [ + { pattern: "disabled.example.com", custom_domain: true, enabled: false }, + { pattern: "app.example.com", custom_domain: true }, + ], + }), + ); -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"); + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHosts).toEqual([ + "app.example.com", + ]); }); - it("uses the CLI Worker name exactly when provided", () => { - writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ name: "config-worker" })); + it("uses the route pattern host instead of the containing zone", () => { + writeFile( + tmpDir, + "wrangler.jsonc", + JSON.stringify({ + routes: [{ pattern: "app.example.com/*", zone_name: "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, {})?.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, {})?.productionHosts).toEqual([]); + }); + + 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?.productionHosts).toEqual([]); + expect(target?.hasUnwarmableProductionRoute).toBe(true); + }); + + it("uses the pattern from a singular TOML route object", () => { + writeFile( + tmpDir, + "wrangler.toml", + `route = { zone_name = "example.com", pattern = "app.example.com/*" }`, + ); + + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHosts).toEqual([ + "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(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHosts).toEqual([]); + }); + + it("does not inherit a top-level route into an explicit environment", () => { + writeFile( + tmpDir, + "wrangler.jsonc", + JSON.stringify({ + routes: ["app.example.com/*"], + env: { staging: { name: "my-worker-staging" } }, + }), + ); + + expect(resolveWranglerDeploymentTarget(tmpDir, { env: "staging" })?.productionHosts).toEqual( + [], + ); + }); + + 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( - resolveWorkerNameForVersionOverride(parseWranglerConfig(tmpDir), { - name: "cli-worker", + resolveWranglerDeploymentTarget(tmpDir, { env: "staging", + config: "dist/server/wrangler.json", }), - ).toBe("cli-worker"); + ).toEqual({ + cacheEnabled: undefined, + crossVersionCache: undefined, + hasProductionRoute: true, + workerName: "my-worker-staging", + productionHosts: ["staging.example.com"], + hasUnwarmableProductionRoute: false, + versionMetadataBinding: "VINEXT_VERSION_METADATA", + }); }); - 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"); + 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(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHosts).toEqual([ + "app.example.com", + ]); }); - it("uses env-specific Worker names for Wrangler legacy environments", () => { + 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, {})?.productionHosts).toEqual([ + "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, {})?.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({ - name: "my-worker", - env: { staging: { name: "custom-staging-worker" } }, + routes: [ + { pattern: "app.example.com/*", zone_name: "example.com" }, + { pattern: "www.example.com/*", zone_name: "example.com" }, + ], }), ); - expect( - resolveWorkerNameForVersionOverride(parseWranglerConfig(tmpDir), { env: "staging" }), - ).toBe("custom-staging-worker"); + + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHosts).toEqual([ + "app.example.com", + "www.example.com", + ]); }); - it("keeps the service name for Wrangler service environments", () => { + it("unions route and custom-domain origins and dedupes shared hosts", () => { writeFile( tmpDir, "wrangler.jsonc", JSON.stringify({ - name: "my-worker", - legacy_env: false, - env: { staging: {} }, + routes: ["app.example.com/*"], + custom_domains: ["app.example.com", "www.example.com"], }), ); - expect( - resolveWorkerNameForVersionOverride(parseWranglerConfig(tmpDir), { env: "staging" }), - ).toBe("my-worker"); + + expect(resolveWranglerDeploymentTarget(tmpDir, {})?.productionHosts).toEqual([ + "app.example.com", + "www.example.com", + ]); }); -}); -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("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", + ]); + }); + + 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); }); }); diff --git a/tests/init.test.ts b/tests/init.test.ts index 306011364a..2bb6d0484a 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -735,6 +735,7 @@ export default { plugins: [vinext({ cache: { data: customData() } })] }; dataCache: "kv", cdnCache: "workers-cache", imageOptimization: "cloudflare-images", + warmCdnCache: true, }, }); @@ -748,6 +749,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"); }); @@ -874,6 +876,128 @@ 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("adds the fixed binding to named environments and stays idempotent", async () => { + setupProject(tmpDir, { router: "app" }); + writeFile( + tmpDir, + "wrangler.jsonc", + `{ + "version_metadata": { "binding": "VINEXT_VERSION_METADATA" }, + "env": { + "staging": { + // environment-local settings stay intact + "name": "my-worker-staging" + }, + "preview": { + "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"); + // 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 () => { 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); + }); +});