diff --git a/packages/cloudflare/src/deploy-help.ts b/packages/cloudflare/src/deploy-help.ts index c1fb92edf3..2bf7404cad 100644 --- a/packages/cloudflare/src/deploy-help.ts +++ b/packages/cloudflare/src/deploy-help.ts @@ -49,8 +49,9 @@ export function formatDeployHelp(): string { a custom domain (zone analytics are unavailable on *.workers.dev) and the CLOUDFLARE_API_TOKEN environment variable with Zone.Analytics read permission. - 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. + Workers Cache automatically uses tiered caching. Warmed entries can therefore + be reused outside the data center reached by the warmup request after cache + propagation, but the deploy does not wait for every edge location to fill. 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 ba5b572fc0..bd10cbe1ac 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -650,11 +650,8 @@ export async function deployWithCdnWarmup( } catch (error) { throw withStagedVersionCleanupNote(error); } - const targetUrl = resolveCdnWarmupTargetUrl( - root, - staged.deployedUrl ?? triggersDeployedUrl, - options, - ); + const targetUrl = + resolveCdnWarmupTargetUrl(root, triggersDeployedUrl, options) ?? staged.deployedUrl; const workerName = resolveWorkerNameForVersionOverride(wranglerConfig, options); const headers = buildVersionOverrideHeaders(workerName, upload.versionId); if (targetUrl && headers) { @@ -741,11 +738,8 @@ export async function deployWithCdnWarmup( remainingWarmPlan.rscPaths.length + remainingWarmPlan.loadingShellPaths.length; if (remainingWarmRequests > 0) { - const targetUrl = resolveCdnWarmupTargetUrl( - root, - deployed.deployedUrl ?? triggersDeployedUrl, - options, - ); + const targetUrl = + resolveCdnWarmupTargetUrl(root, triggersDeployedUrl, options) ?? deployed.deployedUrl; if (targetUrl) { try { await warmUploadedVersion(targetUrl, undefined, true, remainingWarmPlan); @@ -781,16 +775,10 @@ export function resolveCdnWarmupTargetUrl( options: Pick, ): string | null; export function resolveCdnWarmupTargetUrl( - root: string, + _root: string, deployedUrl: string | null, - options?: Pick, + _options?: Pick, ): string | null { - const config = parseWranglerConfig(root, options?.config); - const env = getWranglerTargetEnv(options ?? {}); - const customDomain = env ? config?.env?.[env]?.customDomain : config?.customDomain; - if (customDomain) { - return `https://${customDomain}`; - } return deployedUrl; } diff --git a/packages/cloudflare/src/version-deploy.ts b/packages/cloudflare/src/version-deploy.ts index 0f59f77585..fe7adff36d 100644 --- a/packages/cloudflare/src/version-deploy.ts +++ b/packages/cloudflare/src/version-deploy.ts @@ -5,6 +5,7 @@ import { validateWranglerEnvName, type DeployOptions, } from "./deploy.js"; +import { parseCdnWarmupDeploymentUrl } from "./worker-deployment-url.js"; import { parseWorkersDevUrl } from "./workers-dev-url.js"; export { parseWorkersDevUrl } from "./workers-dev-url.js"; @@ -324,5 +325,5 @@ export function runWranglerTriggersDeploy( console.log("\n Applying Worker triggers..."); } const output = runWranglerCommand(root, args, execute); - return { deployedUrl: parseWorkersDevUrl(output), output }; + return { deployedUrl: parseCdnWarmupDeploymentUrl(output), output }; } diff --git a/packages/cloudflare/src/worker-deployment-url.ts b/packages/cloudflare/src/worker-deployment-url.ts index 12316967d1..ff4788ed36 100644 --- a/packages/cloudflare/src/worker-deployment-url.ts +++ b/packages/cloudflare/src/worker-deployment-url.ts @@ -4,6 +4,43 @@ export function parseWorkerDeploymentUrl(output: string): string | null { return parseWorkersDevUrl(output) ?? parseCustomDomainUrl(output); } +/** + * Parse the concrete hostname that Wrangler reports after applying Worker + * triggers. Catch-all routes are valid warmup targets even though they are not + * canonical deployment URLs for general CLI reporting. + */ +export function parseCdnWarmupDeploymentUrl(output: string): string | null { + return parseWorkerDeploymentUrl(output) ?? parseCatchAllWorkerRouteOrigin(output); +} + +function parseCatchAllWorkerRouteOrigin(output: string): string | null { + for (const line of output.split(/\r?\n/)) { + const route = line.trim().split(/\s+/, 1)[0]; + if (!route || !route.endsWith("/*")) continue; + + try { + const url = new URL(route.includes("://") ? route : `https://${route}`); + if ( + (url.protocol !== "https:" && url.protocol !== "http:") || + url.hostname.includes("*") || + url.username !== "" || + url.password !== "" || + url.port !== "" || + url.pathname !== "/*" || + url.search !== "" || + url.hash !== "" + ) { + continue; + } + return url.origin; + } catch { + continue; + } + } + + return null; +} + function parseCustomDomainUrl(output: string): string | null { // Wrangler adds a scheme only to workers.dev targets. A successful custom-domain // deployment is printed as a bare hostname with this explicit route-type marker. diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 4f1ef458b6..119a28e3b1 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -152,7 +152,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { } if (args.includes("triggers")) { events.push("triggers"); - return "Triggers deployed\n"; + return "Triggers deployed\n app.example.com (custom domain)\n"; } throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); }); @@ -262,6 +262,57 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(delayMock).toHaveBeenCalledWith(15_000); }); + it.each([ + ["singular route", { name: "my-worker", workers_dev: false, route: "sub.example.com/*" }], + [ + "routes object with a zone name", + { + name: "my-worker", + workers_dev: false, + routes: [{ pattern: "sub.example.com/*", zone_name: "example.com" }], + }, + ], + ])("warms the concrete Worker host from a %s", async (_label, config) => { + writeFile("wrangler.jsonc", JSON.stringify(config)); + 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 sub.example.com/*\n"; + } + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + + await deployWithCdnWarmup(tmpDir, ["/about"], { + expectedBuildId: "app-build-a", + warmCdnConcurrency: 1, + warmCdnPromotionDelay: 0, + }); + + expect(fetch).toHaveBeenCalledWith( + new URL("https://sub.example.com/about"), + expect.any(Object), + ); + expect( + vi + .mocked(fetch) + .mock.calls.every(([url]) => formatFetchUrl(url).startsWith("https://sub.example.com/")), + ).toBe(true); + }); + it("does not replay successful staged fills after a non-strict partial failure", async () => { const events: string[] = []; writeFile( @@ -314,7 +365,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { } if (args.includes("triggers")) { events.push("triggers"); - return "Triggers deployed\n"; + return "Triggers deployed\n app.example.com (custom domain)\n"; } throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); }); @@ -336,7 +387,6 @@ describe("Cloudflare CDN warmup deploy flow", () => { "fetch:staged:rsc", "fetch:staged:loading", "fetch:staged:html", - "fetch:staged:loading", "promote", "fetch:promoted:loading", ]); @@ -383,7 +433,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { } if (args.includes("triggers")) { events.push("triggers"); - return "Triggers deployed\n"; + return "Triggers deployed\n app.example.com (custom domain)\n"; } throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); }); @@ -437,7 +487,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { return "Deployed version\nhttps://stable.example.workers.dev\n"; } if (args.includes("triggers")) { - return "Triggers deployed\n"; + return "Triggers deployed\n staging.example.com (custom domain)\n"; } throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); }); @@ -479,6 +529,20 @@ describe("Cloudflare CDN warmup deploy flow", () => { ).toBe("https://my-worker-staging.example.workers.dev"); }); + it("does not infer a warmup target from zone-oriented Wrangler config", async () => { + writeFile( + "wrangler.jsonc", + JSON.stringify({ + name: "my-worker", + workers_dev: false, + routes: [{ pattern: "sub.example.com/*", zone_name: "example.com" }], + }), + ); + const { resolveCdnWarmupTargetUrl } = await import("../packages/cloudflare/src/deploy.js"); + + expect(resolveCdnWarmupTargetUrl(tmpDir, null)).toBeNull(); + }); + it("skips unverifiable HTML warmup for adapters without build identity", async () => { const events: string[] = []; writeFile( @@ -510,7 +574,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { } if (args.includes("triggers")) { events.push("triggers"); - return "Triggers deployed\n"; + return "Triggers deployed\n app.example.com (custom domain)\n"; } throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); }); @@ -555,7 +619,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { } if (args.includes("triggers")) { events.push("triggers"); - return "Triggers deployed\n"; + return "Triggers deployed\n app.example.com (custom domain)\n"; } throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); }); @@ -606,7 +670,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { } if (args.includes("triggers")) { events.push("triggers"); - return "Triggers deployed\n"; + return "Triggers deployed\n app.example.com (custom domain)\n"; } throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); }); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index a43510e363..dbc4a00c9f 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -64,7 +64,10 @@ import { resolveStaticAssetSignal, } from "../packages/vinext/src/server/worker-utils.js"; import { domainCandidates, parseWranglerConfig, runTPR } from "../packages/cloudflare/src/tpr.js"; -import { parseWorkerDeploymentUrl } from "../packages/cloudflare/src/worker-deployment-url.js"; +import { + parseCdnWarmupDeploymentUrl, + parseWorkerDeploymentUrl, +} from "../packages/cloudflare/src/worker-deployment-url.js"; // ─── Test Helpers ──────────────────────────────────────────────────────────── @@ -561,6 +564,21 @@ describe("parseWorkerDeploymentUrl", () => { expect(parseWorkerDeploymentUrl("Deployed app triggers\n app.example.com/*\n")).toBeNull(); }); + it.each([ + ["app.example.com/*", "https://app.example.com"], + ["https://app.example.com/*", "https://app.example.com"], + ["http://app.example.com/*", "http://app.example.com"], + ])("uses a concrete catch-all Worker route for CDN warmup: %s", (route, expected) => { + expect(parseCdnWarmupDeploymentUrl(`Deployed app triggers\n ${route}\n`)).toBe(expected); + }); + + it.each(["*.example.com/*", "app.example.com/api/*", "app.example.com/"])( + "rejects a non-canonical Worker route for CDN warmup: %s", + (route) => { + expect(parseCdnWarmupDeploymentUrl(`Deployed app triggers\n ${route}\n`)).toBeNull(); + }, + ); + it("does not report a disabled custom domain", () => { expect( parseWorkerDeploymentUrl(