From 649f667bd871061da945881ce953ef8f81caea1a Mon Sep 17 00:00:00 2001 From: WillTaylorDev Date: Tue, 18 Aug 2026 16:28:44 -0400 Subject: [PATCH] WC-5764 Create parent Worker for Previews (#15174) --- .changeset/preview-provision-parent-worker.md | 8 + packages/deploy-helpers/src/preview/api.ts | 21 ++ .../deploy-helpers/src/preview/preview.ts | 59 ++++- .../wrangler/src/__tests__/preview.test.ts | 204 ++++++++++++++++++ 4 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 .changeset/preview-provision-parent-worker.md diff --git a/.changeset/preview-provision-parent-worker.md b/.changeset/preview-provision-parent-worker.md new file mode 100644 index 00000000000..97f983a36b6 --- /dev/null +++ b/.changeset/preview-provision-parent-worker.md @@ -0,0 +1,8 @@ +--- +"@cloudflare/deploy-helpers": minor +"wrangler": minor +--- + +[private beta]: Create the parent Worker automatically when `wrangler preview` targets one that doesn't exist yet + +Previews hang off a parent Worker, so running `wrangler preview` before the Worker had ever been deployed failed with a raw API error naming the Preview endpoint. Wrangler now offers to create an empty parent Worker and then carries on creating the Preview. The parent uses the same workers.dev and Preview URL settings that `wrangler deploy` would resolve, without applying routes or cron triggers. In non-interactive environments, Wrangler creates the Worker without asking. diff --git a/packages/deploy-helpers/src/preview/api.ts b/packages/deploy-helpers/src/preview/api.ts index 54a62c7e0a3..43eda127a0e 100644 --- a/packages/deploy-helpers/src/preview/api.ts +++ b/packages/deploy-helpers/src/preview/api.ts @@ -163,6 +163,27 @@ type WorkerPreviewBaseConfigResource = { previews_base_config?: PreviewBaseConfig; }; +/** Create an undeployed Worker that can own Preview resources. */ +export async function createPreviewParentWorker( + config: Config, + accountId: string, + workerName: string, + workersDevEnabled: boolean, + previewsEnabled: boolean +): Promise { + await fetchResult(config, `/accounts/${accountId}/workers/workers`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: workerName, + subdomain: { + enabled: workersDevEnabled, + previews_enabled: previewsEnabled, + }, + }), + }); +} + export async function getPreview( config: Config, accountId: string, diff --git a/packages/deploy-helpers/src/preview/preview.ts b/packages/deploy-helpers/src/preview/preview.ts index 54874476d71..da383420437 100644 --- a/packages/deploy-helpers/src/preview/preview.ts +++ b/packages/deploy-helpers/src/preview/preview.ts @@ -9,10 +9,13 @@ import { syncAssets } from "../deploy/helpers/assets"; import { getBindings } from "../deploy/helpers/binding-utils"; import { moduleTypeMimeType } from "../deploy/helpers/create-worker-upload-form"; import { parseConfigPlacement } from "../deploy/helpers/placement"; +import { isWorkerNotFoundError } from "../deploy/helpers/worker-not-found-error"; import { confirm, logger } from "../shared/context"; +import { getSubdomainValues } from "../triggers/deploy"; import { createPreview, createPreviewDeployment, + createPreviewParentWorker, deletePreview, editPreview, getPreview, @@ -349,6 +352,53 @@ ${Object.entries(missingBindings) Either include these bindings in the ${chalk.cyan(`"previews"`)} field of your Wrangler config or update the Previews settings of your Worker in the Cloudflare dashboard.`); } +/** + * Creates the parent Worker required for a Preview, prompting when interactive. + * + * @param config The resolved Wrangler config. + * @param accountId The Cloudflare account ID. + * @param workerName The parent Worker name. + * @param json Whether to suppress human-readable output. + * @returns A promise that resolves when the parent Worker has been created. + */ +async function provisionParentWorker( + config: Config, + accountId: string, + workerName: string, + json: boolean +): Promise { + const confirmed = + json || + (await confirm( + `Worker "${workerName}" does not exist yet. Would you like to create it for this Preview?`, + // Default to true so CI and Workers Builds can create Previews unattended. + { defaultValue: true, fallbackValue: true } + )); + if (!confirmed) { + throw new UserError( + `Cannot create a Preview because the Worker "${workerName}" does not exist.`, + { telemetryMessage: "preview command parent worker not created" } + ); + } + + if (!json) { + logger.log(`🌀 Creating new Worker "${workerName}"...`); + } + const routes = config.routes ?? (config.route ? [config.route] : []); + const { workers_dev, preview_urls } = getSubdomainValues( + config.workers_dev, + config.preview_urls, + routes + ); + await createPreviewParentWorker( + config, + accountId, + workerName, + workers_dev, + preview_urls ?? workers_dev + ); +} + /** * Full preview create/update + deployment orchestration. * The wrangler handler calls this after auth + build. @@ -392,7 +442,14 @@ export async function preview( previewIdentifier ); } catch (e) { - if (!(e instanceof Error && "code" in e && e.code === 10025)) { + if (isWorkerNotFoundError(e)) { + await provisionParentWorker( + config, + accountId, + workerName, + args.json ?? false + ); + } else if (!(e instanceof Error && "code" in e && e.code === 10025)) { throw e; } } diff --git a/packages/wrangler/src/__tests__/preview.test.ts b/packages/wrangler/src/__tests__/preview.test.ts index 1c28fbcf95f..8b2ab68b01a 100644 --- a/packages/wrangler/src/__tests__/preview.test.ts +++ b/packages/wrangler/src/__tests__/preview.test.ts @@ -12,6 +12,8 @@ import { afterAll, afterEach, beforeEach, describe, test, vi } from "vitest"; import { clearOutputFilePath } from "../output"; import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; import { mockConsoleMethods } from "./helpers/mock-console"; +import { mockConfirm } from "./helpers/mock-dialogs"; +import { useMockIsTTY } from "./helpers/mock-istty"; import { msw } from "./helpers/msw"; import { runWrangler } from "./helpers/run-wrangler"; import { @@ -420,6 +422,208 @@ describe("wrangler preview", () => { ); }); + describe("when the parent Worker does not exist", () => { + const { setIsTTY } = useMockIsTTY(); + + function mockParentWorkerNotFound() { + const createWorkerRequests: unknown[] = []; + msw.use( + http.get( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId`, + () => + HttpResponse.json( + { + success: false, + result: null, + errors: [ + { + code: 10007, + message: "This Worker does not exist on your account.", + }, + ], + }, + { status: 404 } + ) + ), + http.post( + `*/accounts/:accountId/workers/workers`, + async ({ request }) => { + createWorkerRequests.push(await request.json()); + return HttpResponse.json({ + success: true, + result: { id: "worker-id-123", name: "test-worker" }, + }); + } + ), + http.post( + `*/accounts/:accountId/workers/workers/:workerId/previews`, + () => + HttpResponse.json({ + success: true, + result: { + id: "preview-id-provisioned", + name: "test-preview", + slug: "test-preview", + urls: ["https://test-preview-test-worker.workers.dev"], + worker_name: "test-worker", + created_on: new Date().toISOString(), + }, + }) + ), + http.post( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId/deployments`, + () => + HttpResponse.json({ + success: true, + result: { + id: "deployment-id-provisioned", + preview_id: "preview-id-provisioned", + preview_name: "test-preview", + compatibility_date: "2025-01-01", + env: {}, + created_on: new Date().toISOString(), + }, + }) + ) + ); + return createWorkerRequests; + } + + test("creates the parent Worker with Preview URLs enabled, then creates the Preview", async ({ + expect, + }) => { + writeWranglerConfig( + { + name: "test-worker", + main: "src/index.ts", + workers_dev: true, + }, + "wrangler.json" + ); + setIsTTY(false); + const createWorkerRequests = mockParentWorkerNotFound(); + + await runWrangler("preview --name test-preview"); + + expect(createWorkerRequests).toEqual([ + { + name: "test-worker", + subdomain: { + enabled: true, + previews_enabled: true, + }, + }, + ]); + expect(std.out).toContain( + `Worker "test-worker" does not exist yet. Would you like to create it for this Preview?` + ); + expect(std.out).toContain(`Creating new Worker "test-worker"...`); + expect(std.out).toContain("Preview: test-preview (new)"); + }); + + describe.each([ + { + name: "defaults Preview URLs to workers.dev", + previewUrls: undefined, + workersDev: undefined, + expectedWorkersDev: false, + expectedPreviewUrls: false, + }, + { + name: "defaults Preview URLs to explicit workers.dev", + previewUrls: undefined, + workersDev: true, + expectedWorkersDev: true, + expectedPreviewUrls: true, + }, + { + name: "respects enabled Preview URLs", + previewUrls: true, + workersDev: undefined, + expectedWorkersDev: false, + expectedPreviewUrls: true, + }, + { + name: "respects disabled Preview URLs", + previewUrls: false, + workersDev: undefined, + expectedWorkersDev: false, + expectedPreviewUrls: false, + }, + ])( + "$name", + ({ + previewUrls, + workersDev, + expectedWorkersDev, + expectedPreviewUrls, + }) => { + test("resolves subdomain settings without applying production triggers", async ({ + expect, + }) => { + writeWranglerConfig( + { + name: "test-worker", + main: "src/index.ts", + preview_urls: previewUrls, + workers_dev: workersDev, + route: "example.com/*", + triggers: { crons: ["0 * * * *"] }, + }, + "wrangler.json" + ); + setIsTTY(false); + const createWorkerRequests = mockParentWorkerNotFound(); + + await runWrangler("preview --name test-preview"); + + expect(createWorkerRequests).toEqual([ + { + name: "test-worker", + subdomain: { + enabled: expectedWorkersDev, + previews_enabled: expectedPreviewUrls, + }, + }, + ]); + }); + } + ); + + test("keeps JSON output parseable when creating the parent Worker", async ({ + expect, + }) => { + setIsTTY(false); + mockParentWorkerNotFound(); + + await runWrangler("preview --name test-preview --json"); + + expect(JSON.parse(std.out)).toMatchObject({ + preview: { id: "preview-id-provisioned" }, + deployment: { id: "deployment-id-provisioned" }, + }); + }); + + test("aborts without creating the parent Worker when the user declines", async ({ + expect, + }) => { + setIsTTY(true); + const createWorkerRequests = mockParentWorkerNotFound(); + mockConfirm({ + text: `Worker "test-worker" does not exist yet. Would you like to create it for this Preview?`, + result: false, + }); + + await expect( + runWrangler("preview --name test-preview") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Cannot create a Preview because the Worker "test-worker" does not exist.]` + ); + + expect(createWorkerRequests).toEqual([]); + }); + }); + test("should warn about top-level bindings missing from preview settings", async ({ expect, }) => {