Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/preview-provision-parent-worker.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions packages/deploy-helpers/src/preview/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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,
Expand Down
59 changes: 58 additions & 1 deletion packages/deploy-helpers/src/preview/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
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.
Expand Down Expand Up @@ -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;
}
}
Expand Down
204 changes: 204 additions & 0 deletions packages/wrangler/src/__tests__/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
}) => {
Expand Down
Loading