diff --git a/packages/cloudflare/src/cli.ts b/packages/cloudflare/src/cli.ts index 3819d7404..f0265924f 100644 --- a/packages/cloudflare/src/cli.ts +++ b/packages/cloudflare/src/cli.ts @@ -56,6 +56,7 @@ async function deployCommand(): Promise { warmCdnRetries: parsed.warmCdnRetries, warmCdnStrict: parsed.warmCdnStrict, warmCdnPromote: parsed.warmCdnPromote, + warmCdnPromotionDelay: parsed.warmCdnPromotionDelay, warmCdnIncludeFallbacks: parsed.warmCdnIncludeFallbacks, experimentalTPR: parsed.experimentalTPR, tprCoverage: parsed.tprCoverage, diff --git a/packages/cloudflare/src/deploy-help.ts b/packages/cloudflare/src/deploy-help.ts index 84b6d5aa7..c62ca24a8 100644 --- a/packages/cloudflare/src/deploy-help.ts +++ b/packages/cloudflare/src/deploy-help.ts @@ -30,6 +30,8 @@ export function formatDeployHelp(): string { --warm-cdn-retries Retries for transient CDN warmup failures (default: 1) --warm-cdn-strict Fail deploy when any CDN warmup request fails --warm-cdn-no-promote Leave the warmed Worker version staged at 0% traffic + --warm-cdn-promotion-delay + Delay before promotion after warmup (default: 15000) --warm-cdn-include-fallbacks Also warm PPR fallback-shell placeholder paths -h, --help Show this help diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 20927303c..93b327d18 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -67,7 +67,7 @@ import { parseWorkerDeploymentUrl } from "./worker-deployment-url.js"; import { PHASE_PRODUCTION_BUILD } from "vinext/shims/constants"; import { buildPrerenderKVPairs, type KVBulkPair } from "./prerender-kv-populate.js"; -const CDN_WARM_PROPAGATION_DELAY_MS = 15_000; +export const DEFAULT_CDN_WARM_PROMOTION_DELAY_MS = 15_000; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -102,6 +102,8 @@ export type DeployOptions = { warmCdnStrict?: boolean; /** Promote the warmed Worker version to 100% traffic (default: true) */ warmCdnPromote?: boolean; + /** Delay between successful warmup and promotion in milliseconds */ + warmCdnPromotionDelay?: number; /** Include PPR fallback-shell placeholder paths during CDN warmup */ warmCdnIncludeFallbacks?: boolean; /** Enable experimental TPR (Traffic-aware Pre-Rendering) */ @@ -169,6 +171,7 @@ const deployArgOptions = { "warm-cdn-retries": { type: "string" }, "warm-cdn-strict": { type: "boolean", default: false }, "warm-cdn-no-promote": { type: "boolean", default: false }, + "warm-cdn-promotion-delay": { type: "string" }, "warm-cdn-include-fallbacks": { type: "boolean", default: false }, "experimental-tpr": { type: "boolean", default: false }, "tpr-coverage": { type: "string" }, @@ -217,6 +220,13 @@ export function parseDeployArgs(args: string[]) { : parseNonNegativeIntegerArg(values["warm-cdn-retries"], "--warm-cdn-retries"), warmCdnStrict: values["warm-cdn-strict"], warmCdnPromote: !values["warm-cdn-no-promote"], + warmCdnPromotionDelay: + values["warm-cdn-promotion-delay"] === undefined + ? undefined + : parseNonNegativeIntegerArg( + values["warm-cdn-promotion-delay"], + "--warm-cdn-promotion-delay", + ), warmCdnIncludeFallbacks: values["warm-cdn-include-fallbacks"], experimentalTPR: values["experimental-tpr"], tprCoverage: parseIntArg("tpr-coverage", values["tpr-coverage"]), @@ -558,6 +568,7 @@ export async function deployWithCdnWarmup( | "warmCdnRetries" | "warmCdnStrict" | "warmCdnPromote" + | "warmCdnPromotionDelay" > & Pick, ): Promise { @@ -651,8 +662,13 @@ export async function deployWithCdnWarmup( let deployed: ReturnType; try { if (warmedBeforePromotion) { - console.log(" CDN warmup: waiting 15 seconds for cache propagation before promotion..."); - await delay(CDN_WARM_PROPAGATION_DELAY_MS); + const promotionDelay = options.warmCdnPromotionDelay ?? DEFAULT_CDN_WARM_PROMOTION_DELAY_MS; + if (promotionDelay > 0) { + console.log( + ` CDN warmup: waiting ${promotionDelay / 1_000} seconds for cache propagation before promotion...`, + ); + await delay(promotionDelay); + } } deployed = runWranglerVersionDeploy( root, @@ -1017,6 +1033,7 @@ export async function deploy(options: DeployOptions): Promise { warmCdnRetries: options.warmCdnRetries, warmCdnStrict: options.warmCdnStrict, warmCdnPromote: options.warmCdnPromote, + warmCdnPromotionDelay: options.warmCdnPromotionDelay, }); } else { console.log("\n CDN warmup skipped: no build-discovered paths found."); diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index d4e318203..7e6835ccd 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -338,6 +338,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { await deployWithCdnWarmup(tmpDir, ["/"], { env: "staging", warmCdnConcurrency: 1, + warmCdnPromotionDelay: 2_500, }); expect(fetch).toHaveBeenCalledWith(new URL("https://staging.example.com/"), expect.any(Object)); @@ -345,6 +346,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(new Headers(firstInit.headers).get("Cloudflare-Workers-Version-Overrides")).toBe( 'my-worker-staging-custom="22222222-2222-4222-8222-222222222222"', ); + expect(delayMock).toHaveBeenCalledWith(2_500); for (const [, args] of execFileSyncMock.mock.calls as Array<[string, string[]]>) { expect(args).toEqual(expect.arrayContaining(["--env", "staging"])); } diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index f5adff8ed..a43510e36 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -769,6 +769,7 @@ describe("parseDeployArgs", () => { "0", "--warm-cdn-strict", "--warm-cdn-no-promote", + "--warm-cdn-promotion-delay=2500", "--warm-cdn-include-fallbacks", ]); @@ -778,6 +779,7 @@ describe("parseDeployArgs", () => { expect(parsed.warmCdnRetries).toBe(0); expect(parsed.warmCdnStrict).toBe(true); expect(parsed.warmCdnPromote).toBe(false); + expect(parsed.warmCdnPromotionDelay).toBe(2500); expect(parsed.warmCdnIncludeFallbacks).toBe(true); }); @@ -785,6 +787,10 @@ describe("parseDeployArgs", () => { expect(parseDeployArgs([]).warmCdnPromote).toBe(true); }); + it("allows the CDN warmup promotion delay to be set to zero", () => { + expect(parseDeployArgs(["--warm-cdn-promotion-delay=0"]).warmCdnPromotionDelay).toBe(0); + }); + 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".', @@ -792,6 +798,9 @@ describe("parseDeployArgs", () => { expect(() => parseDeployArgs(["--warm-cdn-retries=-1"])).toThrow( '--warm-cdn-retries expects a non-negative integer, but got "-1".', ); + expect(() => parseDeployArgs(["--warm-cdn-promotion-delay=-1"])).toThrow( + '--warm-cdn-promotion-delay expects a non-negative integer, but got "-1".', + ); }); it("trims whitespace from --env value", () => {