diff --git a/packages/deploy-helpers/src/preview/preview.ts b/packages/deploy-helpers/src/preview/preview.ts index 7f6c6cf511..3d82b59125 100644 --- a/packages/deploy-helpers/src/preview/preview.ts +++ b/packages/deploy-helpers/src/preview/preview.ts @@ -293,7 +293,7 @@ async function prepareContainersForPreview( } export const NO_ACTIVE_PREVIEW_URLS_MESSAGE = - "Note: This Preview deployment has no active URLs. To get one, enable Preview Deployments on workers.dev or a custom domain. See https://developers.cloudflare.com/workers/previews/custom-domains/ for more information"; + "Note: This Preview deployment was created, but it has no active URLs.\n\nEnable at least one Preview URL, then run `wrangler deploy` to apply routing:\n\n- workers.dev: set `preview_urls` to `true` in your Wrangler config. If `preview_urls` is omitted, it follows `workers_dev`.\n- Custom domain: set `custom_domain` and `previews_enabled` to `true` on a route.\n\nYou can also enable Preview URLs in the Cloudflare dashboard. Choose one source of truth for routes. If Wrangler manages this Worker, keep these settings in your Wrangler config so your next deploy does not turn them off.\n\nDocs:\n- workers.dev Previews: https://developers.cloudflare.com/workers/previews/custom-domains/#enable-workersdev-previews\n- Custom domain Previews: https://developers.cloudflare.com/workers/previews/custom-domains/#enable-custom-domain-previews"; function getPreviewMigrationsToUpload( workerName: string, @@ -570,17 +570,23 @@ function formatPreviewDeploymentSummary( previewResource: PreviewResource, deployment: DeploymentResource, isNew: boolean, + hasWarnings = false, pullRequest?: PullRequestMetadata ): string { - const statusLabel = isNew ? chalk.green("(new)") : chalk.dim("(updated)"); + const hasActiveUrls = + (previewResource.urls?.length ?? 0) > 0 || + (deployment.urls?.length ?? 0) > 0; + const statusLabel = + hasWarnings || !hasActiveUrls + ? chalk.yellow(`(${isNew ? "new" : "updated"} with warnings)`) + : isNew + ? chalk.green("(new)") + : chalk.dim("(updated)"); const pullRequestUrl = deployment.annotations?.["workers/pull_request_url"] ?? pullRequest?.url; const pullRequestNumber = deployment.annotations?.["workers/pull_request_number"] ?? pullRequest?.number; - const hasActiveUrls = - (previewResource.urls?.length ?? 0) > 0 || - (deployment.urls?.length ?? 0) > 0; return [ `${chalk.bold("Preview:")} ${previewResource.name} ${statusLabel}`, @@ -599,11 +605,48 @@ function formatPreviewDeploymentSummary( ].join("\n"); } +const previewProductionResourceBindingTypes = new Set([ + "kv_namespace", + "d1", + "r2_bucket", + "queue", + "workflow", + "hyperdrive", + "vectorize", + "service", +]); + +const listFormatter = new Intl.ListFormat("en-US"); + +function formatPreviewResourceWarning( + missingBindings: Record +): string { + const missingResourceTypes = [ + ...new Set( + Object.values(missingBindings) + .map((binding) => binding.type) + .filter((type) => previewProductionResourceBindingTypes.has(type)) + .map((type) => + getBindingTypeFriendlyName( + type as Parameters[0] + ) + ) + ), + ]; + + if (missingResourceTypes.length === 0) { + return "Use Preview-safe values instead of production values."; + } + + return `Do not reuse production resources for ${listFormatter.format(missingResourceTypes)} unless you intentionally want Preview traffic to share production data.`; +} + function logMissingPreviewsBindingsWarning( topLevelBindings: Record, remotePreviewDefaultBindings: Record | undefined, - localPreviewBindings: Record -) { + localPreviewBindings: Record, + hasActiveUrls: boolean +): boolean { const availableBindingNames = new Set([ ...Object.keys(remotePreviewDefaultBindings ?? {}), ...Object.keys(localPreviewBindings), @@ -615,11 +658,14 @@ function logMissingPreviewsBindingsWarning( ); if (Object.keys(missingBindings).length === 0) { - return; + return false; } - logger.warn(`Your configuration has diverged. -The following bindings are configured at the top level of your Wrangler config file, but are missing from the Previews settings of your Worker. + logger.warn(`Preview deployment created, but its runtime configuration is incomplete. + +${hasActiveUrls ? "Your Preview URL is live, but requests may fail or behave differently from production." : "Requests may fail or behave differently from production when this Preview has an active URL."} + +These bindings exist in your top-level Wrangler config, but are missing from this Worker's Preview configuration: ${Object.entries(missingBindings) .map( @@ -628,7 +674,13 @@ ${Object.entries(missingBindings) ) .join("\n")} -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.`); +Fix: add these bindings to the ${chalk.cyan(`"previews"`)} field in your Wrangler config using Preview-safe values. + +${formatPreviewResourceWarning(missingBindings)} + +Docs: https://developers.cloudflare.com/workers/previews/configuration/#wrangler-configuration-file`); + + return true; } /** @@ -822,15 +874,10 @@ export async function preview( JSON.stringify({ preview: previewResource, deployment }, null, 2) ); } else { - logger.log( - formatPreviewDeploymentSummary( - previewResource, - deployment, - isNewPreview, - pullRequest - ) - ); - + let hasWarnings = false; + const hasActiveUrls = + (previewResource.urls?.length ?? 0) > 0 || + (deployment.urls?.length ?? 0) > 0; const topLevelBindings = getBindings(config); if (Object.keys(topLevelBindings).length > 0) { const previewDefaults = await getWorkerPreviewDefaults( @@ -838,12 +885,23 @@ export async function preview( accountId, workerName ); - logMissingPreviewsBindingsWarning( + hasWarnings = logMissingPreviewsBindingsWarning( topLevelBindings, previewDefaults.env, - extractConfigBindings(config) + extractConfigBindings(config), + hasActiveUrls ); } + + logger.log( + formatPreviewDeploymentSummary( + previewResource, + deployment, + isNewPreview, + hasWarnings, + pullRequest + ) + ); } return { preview: previewResource, deployment, isNewPreview }; diff --git a/packages/wrangler/src/__tests__/preview-version-warning.test.ts b/packages/wrangler/src/__tests__/preview-version-warning.test.ts new file mode 100644 index 0000000000..3cdfdf60d9 --- /dev/null +++ b/packages/wrangler/src/__tests__/preview-version-warning.test.ts @@ -0,0 +1,45 @@ +import { describe, test } from "vitest"; +import { + formatWranglerPreviewVersionWarning, + isWranglerPreviewVersionUnsupported, + warnIfWranglerPreviewVersionUnsupported, +} from "../preview/version-warning"; +import { mockConsoleMethods } from "./helpers/mock-console"; + +describe("wrangler preview version warning", () => { + const std = mockConsoleMethods(); + + test("requires Wrangler 4.127.1 or later", ({ expect }) => { + expect(isWranglerPreviewVersionUnsupported("4.127.0")).toBe(true); + expect(isWranglerPreviewVersionUnsupported("4.127.1")).toBe(false); + expect(isWranglerPreviewVersionUnsupported("4.128.0")).toBe(false); + }); + + test("points users at the project-local Wrangler install", ({ expect }) => { + expect(formatWranglerPreviewVersionWarning("4.123.0")) + .toMatchInlineSnapshot(` + "Workers Previews require Wrangler 4.127.1 or later. This project is using Wrangler 4.123.0. + + \`npx wrangler preview\` uses the Wrangler installed in this project, not your global Wrangler. + + Update this project: + npm install -D wrangler@latest @cloudflare/workers-types@latest + + Or run once: + npx wrangler@latest preview" + `); + }); + + test("warns when the Wrangler version is unsupported", ({ expect }) => { + expect(warnIfWranglerPreviewVersionUnsupported("4.123.0")).toBe(true); + expect(std.warn).toContain( + "Workers Previews require Wrangler 4.127.1 or later." + ); + expect(std.warn).toContain("npm install -D wrangler@latest"); + }); + + test("does not warn when the Wrangler version is supported", ({ expect }) => { + expect(warnIfWranglerPreviewVersionUnsupported("4.127.1")).toBe(false); + expect(std.warn).toBe(""); + }); +}); diff --git a/packages/wrangler/src/__tests__/preview.secret.test.ts b/packages/wrangler/src/__tests__/preview.secret.test.ts index 9c29c2ce0b..1cd1b1e585 100644 --- a/packages/wrangler/src/__tests__/preview.secret.test.ts +++ b/packages/wrangler/src/__tests__/preview.secret.test.ts @@ -21,7 +21,7 @@ const BRANCH_ENV_VARS = [ "CI_COMMIT_REF_NAME", ] as const; const NO_ACTIVE_PREVIEW_URLS_MESSAGE = - "Note: This Preview deployment has no active URLs. To get one, enable Preview Deployments on workers.dev or a custom domain. See https://developers.cloudflare.com/workers/previews/custom-domains/ for more information"; + "Note: This Preview deployment was created, but it has no active URLs.\n\nEnable at least one Preview URL, then run `wrangler deploy` to apply routing:\n\n- workers.dev: set `preview_urls` to `true` in your Wrangler config. If `preview_urls` is omitted, it follows `workers_dev`.\n- Custom domain: set `custom_domain` and `previews_enabled` to `true` on a route.\n\nYou can also enable Preview URLs in the Cloudflare dashboard. Choose one source of truth for routes. If Wrangler manages this Worker, keep these settings in your Wrangler config so your next deploy does not turn them off.\n\nDocs:\n- workers.dev Previews: https://developers.cloudflare.com/workers/previews/custom-domains/#enable-workersdev-previews\n- Custom domain Previews: https://developers.cloudflare.com/workers/previews/custom-domains/#enable-custom-domain-previews"; async function withoutBranchEnvVars(callback: () => Promise): Promise { const originalBranchEnv = Object.fromEntries( diff --git a/packages/wrangler/src/__tests__/preview.test.ts b/packages/wrangler/src/__tests__/preview.test.ts index 889db87326..e79e9e8f84 100644 --- a/packages/wrangler/src/__tests__/preview.test.ts +++ b/packages/wrangler/src/__tests__/preview.test.ts @@ -1138,6 +1138,16 @@ describe("wrangler preview", () => { main: "src/index.ts", compatibility_date: "2025-01-01", kv_namespaces: [{ binding: "IMPORTANT_BINDING", id: "kv-id-123" }], + d1_databases: [ + { + binding: "DB", + database_name: "production-db", + database_id: "production-db-id", + }, + ], + hyperdrive: [{ binding: "HYPERDRIVE", id: "production-db" }], + vectorize: [{ binding: "VECTORIZE", index_name: "production-index" }], + services: [{ binding: "API", service: "production-api" }], }) ); @@ -1201,18 +1211,33 @@ describe("wrangler preview", () => { const normalizedWarningOutput = warningOutput.replace(/\s+/g, " "); expect(normalizedWarningOutput).toContain( - "Your configuration has diverged." + "Preview deployment created, but its runtime configuration is incomplete." + ); + expect(normalizedWarningOutput).toContain( + "Your Preview URL is live, but requests may fail or behave differently from production." + ); + expect(normalizedWarningOutput).toContain( + "These bindings exist in your top-level Wrangler config, but are missing from this Worker's Preview configuration:" + ); + expect(normalizedWarningOutput).toContain( + "IMPORTANT_BINDING KV Namespace" ); + expect(normalizedWarningOutput).toContain("DB D1 Database"); + expect(normalizedWarningOutput).toContain("HYPERDRIVE Hyperdrive Config"); + expect(normalizedWarningOutput).toContain("VECTORIZE Vectorize Index"); + expect(normalizedWarningOutput).toContain("API Worker"); expect(normalizedWarningOutput).toContain( - "The following bindings are configured at the top level of your Wrangler config file, but are missing from the Previews settings of your Worker." + 'Fix: add these bindings to the "previews" field in your Wrangler config using Preview-safe values.' ); - expect(warningOutput).toContain("IMPORTANT_BINDING"); - expect(warningOutput).toContain("KV Namespace"); expect(normalizedWarningOutput).toContain( - 'Either include these bindings in the "previews" field of your Wrangler config' + "Do not reuse production resources for KV Namespace, D1 Database, Hyperdrive Config, Vectorize Index, and Worker unless you intentionally want Preview traffic to share production data." ); expect(normalizedWarningOutput).toContain( - "or update the Previews settings of your Worker in the Cloudflare dashboard." + "Docs: https://developers.cloudflare.com/workers/previews/configuration/#wrangler-configuration-file" + ); + expect(std.out).toContain("Preview: test-preview (new with warnings)"); + expect(std.out).toContain( + "Preview URL: https://test-preview.test-worker.cloudflare.app" ); }); @@ -1941,11 +1966,29 @@ describe("wrangler preview", () => { (line) => line.startsWith("Preview") || line.startsWith("Deployment") ); expect(summaryLines).toEqual([ - "Preview: empty-urls-preview (new)", + "Preview: empty-urls-preview (new with warnings)", "Deployment ID: deployment-id-empty-urls", ]); expect(std.out).toContain( - "Note: This Preview deployment has no active URLs. To get one, enable Preview Deployments on workers.dev or a custom domain. See https://developers.cloudflare.com/workers/previews/custom-domains/ for more information" + "Note: This Preview deployment was created, but it has no active URLs." + ); + expect(std.out).toContain( + "Enable at least one Preview URL, then run `wrangler deploy` to apply routing:" + ); + expect(std.out).toContain( + "workers.dev: set `preview_urls` to `true` in your Wrangler config. If `preview_urls` is omitted, it follows `workers_dev`." + ); + expect(std.out).toContain( + "Custom domain: set `custom_domain` and `previews_enabled` to `true` on a route." + ); + expect(std.out).toContain( + "Choose one source of truth for routes. If Wrangler manages this Worker, keep these settings in your Wrangler config so your next deploy does not turn them off." + ); + expect(std.out).toContain( + "workers.dev Previews: https://developers.cloudflare.com/workers/previews/custom-domains/#enable-workersdev-previews" + ); + expect(std.out).toContain( + "Custom domain Previews: https://developers.cloudflare.com/workers/previews/custom-domains/#enable-custom-domain-previews" ); }); diff --git a/packages/wrangler/src/preview/preview.ts b/packages/wrangler/src/preview/preview.ts index b60194c711..0c40036d75 100644 --- a/packages/wrangler/src/preview/preview.ts +++ b/packages/wrangler/src/preview/preview.ts @@ -9,6 +9,7 @@ import { cleanupDestination } from "../deployment-bundle/merge-config-args"; import { writeOutput } from "../output"; import { requireAuth } from "../user"; import { deployPreviewContainers, verifyContainersScope } from "./containers"; +import { warnIfWranglerPreviewVersionUnsupported } from "./version-warning"; export const previewCommand = createCommand({ metadata: { @@ -63,6 +64,8 @@ export const previewCommand = createCommand({ suggestSkillsAfterHandler: (args) => args.json !== true, }, handler: async function previewHandler(args, { config }) { + warnIfWranglerPreviewVersionUnsupported(); + const accountId = await requireAuth(config); const entry = await getEntry({ script: args.script }, config, "deploy"); diff --git a/packages/wrangler/src/preview/version-warning.ts b/packages/wrangler/src/preview/version-warning.ts new file mode 100644 index 0000000000..f20f6bb0f2 --- /dev/null +++ b/packages/wrangler/src/preview/version-warning.ts @@ -0,0 +1,36 @@ +import semiver from "semiver"; +import { version as wranglerVersion } from "../../package.json"; +import { logger } from "../logger"; + +export const MIN_WRANGLER_PREVIEW_VERSION = "4.127.1"; + +export function isWranglerPreviewVersionUnsupported( + version = wranglerVersion +): boolean { + return semiver(version, MIN_WRANGLER_PREVIEW_VERSION) < 0; +} + +export function formatWranglerPreviewVersionWarning( + version = wranglerVersion +): string { + return `Workers Previews require Wrangler ${MIN_WRANGLER_PREVIEW_VERSION} or later. This project is using Wrangler ${version}. + +\`npx wrangler preview\` uses the Wrangler installed in this project, not your global Wrangler. + +Update this project: + npm install -D wrangler@latest @cloudflare/workers-types@latest + +Or run once: + npx wrangler@latest preview`; +} + +export function warnIfWranglerPreviewVersionUnsupported( + version = wranglerVersion +): boolean { + if (!isWranglerPreviewVersionUnsupported(version)) { + return false; + } + + logger.warn(formatWranglerPreviewVersionWarning(version)); + return true; +}