From 412b7763af1cfc0c2308d62cf5656d431ff00fb4 Mon Sep 17 00:00:00 2001 From: Patrick O'Donnell Date: Mon, 21 Sep 2026 07:45:41 -0400 Subject: [PATCH 1/2] WC-6046 [wrangler] previews: normalize D1 metadata bindings --- .../helpers/create-worker-upload-form.ts | 2 +- .../src/deploy/helpers/provision-bindings.ts | 9 ++- packages/deploy-helpers/src/preview/api.ts | 61 +++---------------- packages/deploy-helpers/src/preview/shared.ts | 8 ++- .../deploy-helpers/src/startup-profile.ts | 6 +- .../src/map-worker-metadata-bindings.ts | 8 ++- packages/workers-utils/src/types.ts | 13 ++++ .../map-worker-metadata-bindings.test.ts | 26 +++++--- .../bindings.test.ts | 2 +- .../__tests__/pages/functions-build.test.ts | 2 +- .../wrangler/src/__tests__/provision.test.ts | 16 ++--- .../versions/versions.upload.test.ts | 2 +- .../wrangler/src/api/startDevWorker/utils.ts | 4 +- 13 files changed, 81 insertions(+), 78 deletions(-) diff --git a/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts b/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts index 58e305f8214..1f329167f51 100644 --- a/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts +++ b/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts @@ -338,7 +338,7 @@ export function createWorkerUploadForm( metadataBindings.push({ name: binding, type: "d1", - id: database_id, + database_id, internalEnv: database_internal_env, raw, }); diff --git a/packages/deploy-helpers/src/deploy/helpers/provision-bindings.ts b/packages/deploy-helpers/src/deploy/helpers/provision-bindings.ts index dc067fe513e..b5083616907 100644 --- a/packages/deploy-helpers/src/deploy/helpers/provision-bindings.ts +++ b/packages/deploy-helpers/src/deploy/helpers/provision-bindings.ts @@ -653,10 +653,17 @@ class D1Handler extends ProvisionResourceHandler< // ...and the user HAS specified a name in their config, so we need to check if the database_name they provided // matches the database_name of the existing binding (which isn't present in settings, so we'll need to make an API call to check). + let databaseId: string; + if ("database_id" in maybeInherited) { + databaseId = maybeInherited.database_id; + } else { + // oxlint-disable-next-line typescript/no-deprecated -- intentional support of deprecated binding style + databaseId = maybeInherited.id; + } const dbFromId = await getDatabaseInfoFromIdOrName( this.complianceConfig, this.accountId, - maybeInherited.id + databaseId ); if (this.binding.database_name === dbFromId.name) { return true; diff --git a/packages/deploy-helpers/src/preview/api.ts b/packages/deploy-helpers/src/preview/api.ts index 76db24f0db5..4c7ae991825 100644 --- a/packages/deploy-helpers/src/preview/api.ts +++ b/packages/deploy-helpers/src/preview/api.ts @@ -6,62 +6,17 @@ import type { CfPlacement, CfUserLimits, Config, - Json, Observability, + WorkerMetadataBinding, } from "@cloudflare/workers-utils"; -export interface Binding { - type: string; - text?: string; - json?: Json; - namespace_id?: string; - workflow_name?: string; - destination_address?: string; - allowed_destination_addresses?: string[]; - allowed_sender_addresses?: string[]; - queue_name?: string; - delivery_delay?: number; - database_id?: string; - database_name?: string; - bucket_name?: string; - jurisdiction?: string; - index_name?: string; - instance_name?: string; - id?: string; - service?: string; - environment?: string; - // Props supplied to a Worker service binding. - props?: Record; - cross_account_grant?: string; - dataset?: string; - namespace?: string; - outbound?: { - worker: { - service: string; - environment?: string; - }; - params?: Array<{ name: string }>; - }; - certificate_id?: string; - pipeline?: string; - stream?: string; - store_id?: string; - secret_name?: string; - simple?: { - limit: number; - period: 10 | 60; - }; - service_id?: string; - tunnel_id?: string; - network_id?: string; - destination?: string; - staging?: boolean; - enable_timer?: boolean; - app_id?: string; - entrypoint?: string; - class_name?: string; - script_name?: string; -} +type PreviewBinding = + Metadata extends WorkerMetadataBinding + ? { type: Metadata["type"] } & Partial> + : never; + +/** A canonical Preview binding, before its record key becomes `name`. */ +export type Binding = PreviewBinding; export type EnvBindings = Record; diff --git a/packages/deploy-helpers/src/preview/shared.ts b/packages/deploy-helpers/src/preview/shared.ts index 92ae0fdf64a..7b9ad6c0160 100644 --- a/packages/deploy-helpers/src/preview/shared.ts +++ b/packages/deploy-helpers/src/preview/shared.ts @@ -377,7 +377,13 @@ export function getBindingValue(binding: Binding): string { case "kv_namespace": return String(binding.namespace_id ?? ""); case "d1": - return binding.database_name ?? String(binding.database_id ?? ""); + return ( + binding.database_name ?? + String( + // oxlint-disable-next-line typescript/no-deprecated -- intentional support of deprecated binding style + "database_id" in binding ? binding.database_id : (binding.id ?? "") + ) + ); case "r2_bucket": return String(binding.bucket_name ?? ""); case "service": diff --git a/packages/deploy-helpers/src/startup-profile.ts b/packages/deploy-helpers/src/startup-profile.ts index 08f67dd5836..ee486fb67ff 100644 --- a/packages/deploy-helpers/src/startup-profile.ts +++ b/packages/deploy-helpers/src/startup-profile.ts @@ -413,7 +413,11 @@ async function convertWorkerBundleBindings( kvNamespaces[binding.name] = { id: binding.namespace_id }; break; case "d1": - d1Databases[binding.name] = { id: binding.id }; + d1Databases[binding.name] = { + id: + // oxlint-disable-next-line typescript/no-deprecated -- intentional support of deprecated binding style + "database_id" in binding ? binding.database_id : binding.id, + }; break; case "r2_bucket": r2Buckets[binding.name] = { id: binding.bucket_name }; diff --git a/packages/workers-utils/src/map-worker-metadata-bindings.ts b/packages/workers-utils/src/map-worker-metadata-bindings.ts index e215983c81f..213efce4d57 100644 --- a/packages/workers-utils/src/map-worker-metadata-bindings.ts +++ b/packages/workers-utils/src/map-worker-metadata-bindings.ts @@ -63,11 +63,14 @@ export function mapWorkerMetadataBindings( break; case "d1": { + const databaseId = + // oxlint-disable-next-line typescript/no-deprecated -- intentional support of deprecated binding style + "database_id" in binding ? binding.database_id : binding.id; configObj.d1_databases = [ ...(configObj.d1_databases ?? []), { binding: binding.name, - database_id: binding.id, + database_id: databaseId, }, ]; } @@ -83,6 +86,9 @@ export function mapWorkerMetadataBindings( { configObj.ai = { binding: binding.name, + ...(binding.staging !== undefined && { + staging: binding.staging, + }), }; } break; diff --git a/packages/workers-utils/src/types.ts b/packages/workers-utils/src/types.ts index 3ebba928bd4..46c30e2fefb 100644 --- a/packages/workers-utils/src/types.ts +++ b/packages/workers-utils/src/types.ts @@ -128,6 +128,18 @@ export type WorkerMetadataBinding = | { type: "d1"; name: string; + database_id: string; + database_name?: string; + /** @deprecated The API may return this for legacy D1 bindings. Use `database_id` instead. */ + id?: string; + internalEnv?: string; + raw?: boolean; + } + | { + type: "d1"; + name: string; + database_name?: string; + /** @deprecated The API may return this for legacy D1 bindings. Use `database_id` instead. */ id: string; internalEnv?: string; raw?: boolean; @@ -146,6 +158,7 @@ export type WorkerMetadataBinding = service: string; environment?: string; entrypoint?: string; + props?: Record; cross_account_grant?: string; } | { type: "analytics_engine"; name: string; dataset?: string } diff --git a/packages/workers-utils/tests/map-worker-metadata-bindings.test.ts b/packages/workers-utils/tests/map-worker-metadata-bindings.test.ts index a71e89d9053..6a89fa678b1 100644 --- a/packages/workers-utils/tests/map-worker-metadata-bindings.test.ts +++ b/packages/workers-utils/tests/map-worker-metadata-bindings.test.ts @@ -117,14 +117,17 @@ describe("mapWorkerMetadataBindings", () => { }); describe("d1_databases", () => { - it("maps d1 binding", ({ expect }) => { - const bindings: WorkerMetadataBinding[] = [ + it("maps canonical and legacy d1 bindings", ({ expect }) => { + const bindings = [ + { type: "d1", name: "MY_DB", database_id: "db-456" }, { type: "d1", name: "MY_DB", id: "db-456" }, - ]; - const result = mapWorkerMetadataBindings(bindings); - expect(result.d1_databases).toEqual([ - { binding: "MY_DB", database_id: "db-456" }, - ]); + ] satisfies WorkerMetadataBinding[]; + for (const binding of bindings) { + const result = mapWorkerMetadataBindings([binding]); + expect(result.d1_databases).toEqual([ + { binding: "MY_DB", database_id: "db-456" }, + ]); + } }); }); @@ -159,8 +162,15 @@ describe("mapWorkerMetadataBindings", () => { }); it("maps ai binding", ({ expect }) => { - const bindings: WorkerMetadataBinding[] = [{ type: "ai", name: "AI" }]; + const bindings: WorkerMetadataBinding[] = [ + { type: "ai", name: "AI", staging: true }, + ]; const result = mapWorkerMetadataBindings(bindings); + expect(result.ai).toEqual({ binding: "AI", staging: true }); + }); + + it("omits absent ai staging", ({ expect }) => { + const result = mapWorkerMetadataBindings([{ type: "ai", name: "AI" }]); expect(result.ai).toEqual({ binding: "AI" }); }); diff --git a/packages/wrangler/src/__tests__/create-worker-upload-form/bindings.test.ts b/packages/wrangler/src/__tests__/create-worker-upload-form/bindings.test.ts index 4e2e0140b4b..04c12c97db8 100644 --- a/packages/wrangler/src/__tests__/create-worker-upload-form/bindings.test.ts +++ b/packages/wrangler/src/__tests__/create-worker-upload-form/bindings.test.ts @@ -145,7 +145,7 @@ describe("createWorkerUploadForm — bindings", () => { expect(getBindings(form)).toContainEqual({ name: "MY_DB", type: "d1", - id: "db-123", + database_id: "db-123", }); }); diff --git a/packages/wrangler/src/__tests__/pages/functions-build.test.ts b/packages/wrangler/src/__tests__/pages/functions-build.test.ts index a757e84fd66..8833de13070 100644 --- a/packages/wrangler/src/__tests__/pages/functions-build.test.ts +++ b/packages/wrangler/src/__tests__/pages/functions-build.test.ts @@ -806,7 +806,7 @@ export default { "------formdata-undici-0.test Content-Disposition: form-data; name="metadata" - {"main_module":"functionsWorker-0.test.js","bindings":[{"name":"TEST_JSON_PREVIEW","type":"plain_text","text":"{\\njson: \\"value\\"\\n}"},{"name":"TEST_PLAINTEXT_PREVIEW","type":"plain_text","text":"PLAINTEXT"},{"name":"KV_PREVIEW","type":"kv_namespace","namespace_id":"kv-id"},{"name":"KV_PREVIEW2","type":"kv_namespace","namespace_id":"kv-id"},{"name":"DO_PREVIEW","type":"durable_object_namespace","class_name":"some-class-do-id","script_name":"some-script-do-id","environment":"some-environment-do-id"},{"name":"DO_PREVIEW2","type":"durable_object_namespace","class_name":"some-class-do-id","script_name":"some-script-do-id","environment":"some-environment-do-id"},{"name":"DO_PREVIEW3","type":"durable_object_namespace","class_name":"do-class","script_name":"do-s","environment":"do-e"},{"type":"queue","name":"QUEUE_PREVIEW","queue_name":"q-id"},{"type":"queue","name":"QUEUE_PREVIEW2","queue_name":"q-id"},{"name":"R2_PREVIEW","type":"r2_bucket","bucket_name":"r2-name"},{"name":"R2_PREVIEW2","type":"r2_bucket","bucket_name":"r2-name"},{"name":"D1_PREVIEW","type":"d1","id":"d1-id"},{"name":"D1_PREVIEW2","type":"d1","id":"d1-id"},{"name":"SERVICE_PREVIEW","type":"service","service":"service","environment":"production"},{"name":"SERVICE_PREVIEW2","type":"service","service":"service","environment":"production"},{"name":"AE_PREVIEW","type":"analytics_engine","dataset":"data"},{"name":"AE_PREVIEW2","type":"analytics_engine","dataset":"data"},{"name":"AI_PREVIEW","type":"ai"}],"compatibility_date":"2023-02-14","compatibility_flags":[],"placement":{"mode":"smart"},"limits":{"cpu_ms":50}} + {"main_module":"functionsWorker-0.test.js","bindings":[{"name":"TEST_JSON_PREVIEW","type":"plain_text","text":"{\\njson: \\"value\\"\\n}"},{"name":"TEST_PLAINTEXT_PREVIEW","type":"plain_text","text":"PLAINTEXT"},{"name":"KV_PREVIEW","type":"kv_namespace","namespace_id":"kv-id"},{"name":"KV_PREVIEW2","type":"kv_namespace","namespace_id":"kv-id"},{"name":"DO_PREVIEW","type":"durable_object_namespace","class_name":"some-class-do-id","script_name":"some-script-do-id","environment":"some-environment-do-id"},{"name":"DO_PREVIEW2","type":"durable_object_namespace","class_name":"some-class-do-id","script_name":"some-script-do-id","environment":"some-environment-do-id"},{"name":"DO_PREVIEW3","type":"durable_object_namespace","class_name":"do-class","script_name":"do-s","environment":"do-e"},{"type":"queue","name":"QUEUE_PREVIEW","queue_name":"q-id"},{"type":"queue","name":"QUEUE_PREVIEW2","queue_name":"q-id"},{"name":"R2_PREVIEW","type":"r2_bucket","bucket_name":"r2-name"},{"name":"R2_PREVIEW2","type":"r2_bucket","bucket_name":"r2-name"},{"name":"D1_PREVIEW","type":"d1","database_id":"d1-id"},{"name":"D1_PREVIEW2","type":"d1","database_id":"d1-id"},{"name":"SERVICE_PREVIEW","type":"service","service":"service","environment":"production"},{"name":"SERVICE_PREVIEW2","type":"service","service":"service","environment":"production"},{"name":"AE_PREVIEW","type":"analytics_engine","dataset":"data"},{"name":"AE_PREVIEW2","type":"analytics_engine","dataset":"data"},{"name":"AI_PREVIEW","type":"ai"}],"compatibility_date":"2023-02-14","compatibility_flags":[],"placement":{"mode":"smart"},"limits":{"cpu_ms":50}} ------formdata-undici-0.test Content-Disposition: form-data; name="functionsWorker-0.test.js"; filename="functionsWorker-0.test.js" Content-Type: application/javascript+module diff --git a/packages/wrangler/src/__tests__/provision.test.ts b/packages/wrangler/src/__tests__/provision.test.ts index d8b3ee5a7ba..89d07f4e250 100644 --- a/packages/wrangler/src/__tests__/provision.test.ts +++ b/packages/wrangler/src/__tests__/provision.test.ts @@ -749,7 +749,7 @@ describe("resource provisioning", () => { { name: "D1", type: "d1", - id: "existing-d1-id", + database_id: "existing-d1-id", }, ], }); @@ -872,7 +872,7 @@ describe("resource provisioning", () => { { name: "D1", type: "d1", - id: "existing-d1-id-1", + database_id: "existing-d1-id-1", }, ], }); @@ -1005,7 +1005,7 @@ describe("resource provisioning", () => { { name: "D1", type: "d1", - id: "new-d1-id", + database_id: "new-d1-id", }, ], }); @@ -1168,7 +1168,7 @@ describe("resource provisioning", () => { { name: "D1", type: "d1", - id: "new-d1-id", + database_id: "new-d1-id", }, ], }); @@ -1318,7 +1318,7 @@ describe("resource provisioning", () => { { name: "D1", type: "d1", - id: "test-name-d1-id", + database_id: "test-name-d1-id", }, ], }); @@ -1477,7 +1477,7 @@ describe("resource provisioning", () => { { name: "D1", type: "d1", - id: "new-d1-id", + database_id: "new-d1-id", }, ], }); @@ -1610,7 +1610,7 @@ describe("resource provisioning", () => { { name: "D1", type: "d1", - id: "new-d1-id", + database_id: "new-d1-id", }, ], }); @@ -1815,7 +1815,7 @@ describe("resource provisioning", () => { { name: "DB_NAME", type: "d1", - id: "existing-d1-id", + database_id: "existing-d1-id", }, ], }); diff --git a/packages/wrangler/src/__tests__/versions/versions.upload.test.ts b/packages/wrangler/src/__tests__/versions/versions.upload.test.ts index 220a5bf05bb..85633fc097e 100644 --- a/packages/wrangler/src/__tests__/versions/versions.upload.test.ts +++ b/packages/wrangler/src/__tests__/versions/versions.upload.test.ts @@ -2035,7 +2035,7 @@ describe("versions upload", () => { expect.objectContaining({ type: "d1", name: "MY_DB", - id: "d1-db-id-1", + database_id: "d1-db-id-1", }), expect.objectContaining({ type: "service", diff --git a/packages/wrangler/src/api/startDevWorker/utils.ts b/packages/wrangler/src/api/startDevWorker/utils.ts index 1544b4cdb39..56f94e0ce3e 100644 --- a/packages/wrangler/src/api/startDevWorker/utils.ts +++ b/packages/wrangler/src/api/startDevWorker/utils.ts @@ -189,7 +189,9 @@ export function convertWorkerMetadataBindingsToFlatBindings( const b = binding as Extract; output[name] = { type: "d1", - database_id: b.id, + database_id: + // oxlint-disable-next-line typescript/no-deprecated -- intentional support of deprecated binding style + "database_id" in b ? b.database_id : b.id, database_internal_env: b.internalEnv, raw: b.raw, }; From 85d62a9b787f175b3b1688b45037e877264634d4 Mon Sep 17 00:00:00 2001 From: Patrick O'Donnell Date: Mon, 21 Sep 2026 07:47:49 -0400 Subject: [PATCH 2/2] WC-6046 [wrangler] previews: simplify configuration onboarding --- .changeset/calm-previews-onboard.md | 7 + .../src/__tests__/preview-config.test.ts | 446 ++------ .../wrangler/src/preview/ensure-config.ts | 18 +- .../wrangler/src/preview/preview-config.ts | 969 ++++++------------ packages/wrangler/src/preview/preview.ts | 15 +- 5 files changed, 406 insertions(+), 1049 deletions(-) create mode 100644 .changeset/calm-previews-onboard.md diff --git a/.changeset/calm-previews-onboard.md b/.changeset/calm-previews-onboard.md new file mode 100644 index 00000000000..59f0ec68be9 --- /dev/null +++ b/.changeset/calm-previews-onboard.md @@ -0,0 +1,7 @@ +--- +"wrangler": minor +--- + +Improve beta Preview configuration onboarding + +Wrangler now builds suggested Preview configuration from shared binding metadata, retains safe settings, and recursively redacts production string values that need Preview-specific replacements. diff --git a/packages/wrangler/src/__tests__/preview-config.test.ts b/packages/wrangler/src/__tests__/preview-config.test.ts index a660ac41038..abea75ae98e 100644 --- a/packages/wrangler/src/__tests__/preview-config.test.ts +++ b/packages/wrangler/src/__tests__/preview-config.test.ts @@ -1,15 +1,10 @@ -import { UserError } from "@cloudflare/workers-utils"; import { describe, test } from "vitest"; import { - convertBinding, - convertPreviewBaseToPreviewsConfig, - convertPreviewSettings, - convertProductionToPreviewsConfig, - convertTopLevelSetting, - getProductionBindingsExpectedInPreview, + buildPreviewConfigProposal, + createPreviewConfigProposal, } from "../preview/preview-config"; import type { PreviewTopLevelSettings } from "../preview/preview-config"; -import type { Binding } from "@cloudflare/deploy-helpers"; +import type { Binding, PreviewBaseConfig } from "@cloudflare/deploy-helpers"; import type { Config } from "@cloudflare/workers-utils"; function topLevelSettings( @@ -32,112 +27,51 @@ function topLevelSettings( } describe("Preview configuration conversion", () => { - test("converts a broad supported configuration without production values", ({ - expect, - }) => { - const result = convertPreviewSettings( + test("redacts local values while retaining safe fields", ({ expect }) => { + const result = buildPreviewConfigProposal( { TEXT: { type: "plain_text", text: "production-text" }, - JSON: { type: "json", json: { source: "production-json" } }, - BROWSER: { type: "browser" }, + JSON: { + type: "json", + json: { nested: ["production-json", null] }, + }, AI: { type: "ai", staging: true }, - IMAGES: { type: "images" }, - STREAM: { type: "stream" }, - VERSION: { type: "version_metadata" }, - KV: { type: "kv_namespace", namespace_id: "production-kv" }, - MEDIA: { type: "media" }, - EMAIL: { - type: "send_email", - destination_address: "production@example.com", + D1: { + type: "d1", + database_id: "production-database", + internalEnv: "production-internal-env", }, - QUEUE: { type: "queue", queue_name: "production-queue" }, R2: { type: "r2_bucket", bucket_name: "production-bucket", jurisdiction: "eu", }, - D1: { type: "d1", database_id: "production-database" }, - VECTOR: { type: "vectorize", index_name: "production-index" }, - AI_SEARCH_NAMESPACE: { - type: "ai_search_namespace", - namespace: "production-search-namespace", - }, - AI_SEARCH: { - type: "ai_search", - instance_name: "production-search-instance", - }, - HYPERDRIVE: { type: "hyperdrive", id: "production-hyperdrive" }, - ANALYTICS: { type: "analytics_engine", dataset: "production-dataset" }, - DISPATCH: { - type: "dispatch_namespace", - namespace: "production-namespace", - }, - MTLS: { type: "mtls_certificate", certificate_id: "production-cert" }, - PIPELINE: { type: "pipelines", stream: "production-stream" }, - SECRET: { - type: "secrets_store_secret", - store_id: "production-store", - secret_name: "production-secret", - }, - ARTIFACT: { type: "artifacts", namespace: "production-artifact" }, - FLAGS: { type: "flagship", app_id: "production-app" }, - LIMITER: { - type: "ratelimit", - namespace_id: "production-limiter", - simple: { limit: 10, period: 60 }, - }, - VPC: { type: "vpc_service", service_id: "production-vpc" }, - LOADER: { type: "worker_loader" }, + QUEUE: { type: "queue", queue_name: "production-queue" }, }, topLevelSettings({ define: { API_URL: "production-url" }, observability: { enabled: true, - logs: { destinations: ["production-log-destination"] }, + logs: { destinations: ["production-destination"] }, }, + logpush: true, + limits: { cpu_ms: 50, subrequests: 100 }, placement: { mode: "smart", hint: "production-hint" }, - tail_consumers: [{ service: "production-tail" }], + cache: { enabled: true, cross_version_cache: true }, + tail_consumers: [ + { service: "production-tail", environment: "production-env" }, + ], }), - true + "localConfig" ); - expect(result.messages).toEqual([]); - expect(result.blockingDeploymentMessages).toEqual([]); - expect(Object.keys(result.config)).toEqual([ - "vars", - "browser", - "ai", - "images", - "stream", - "version_metadata", - "kv_namespaces", - "media", - "send_email", - "queues", - "r2_buckets", - "d1_databases", - "vectorize", - "ai_search_namespaces", - "ai_search", - "hyperdrive", - "analytics_engine_datasets", - "dispatch_namespaces", - "mtls_certificates", - "pipelines", - "secrets_store_secrets", - "artifacts", - "flagship", - "ratelimits", - "vpc_services", - "worker_loaders", - "define", - "placement", - "tail_consumers", - ]); - expect(result.config).toMatchObject({ - vars: { TEXT: "", JSON: "" }, + expect(result.config).toEqual({ + vars: { + TEXT: "", + JSON: { nested: ["", null] }, + }, ai: { binding: "AI", staging: true }, - kv_namespaces: [{ binding: "KV", id: "" }], + d1_databases: [{ binding: "D1", database_id: "" }], r2_buckets: [ { binding: "R2", @@ -148,101 +82,60 @@ describe("Preview configuration conversion", () => { queues: { producers: [{ binding: "QUEUE", queue: "" }], }, - ai_search_namespaces: [ - { binding: "AI_SEARCH_NAMESPACE", namespace: "" }, - ], - ai_search: [{ binding: "AI_SEARCH", instance_name: "" }], define: { API_URL: "" }, + observability: { + enabled: true, + logs: { destinations: [""] }, + }, + logpush: true, + limits: { cpu_ms: 50, subrequests: 100 }, placement: { mode: "smart", hint: "" }, - tail_consumers: [{ service: "" }], + cache: { enabled: true, cross_version_cache: true }, + tail_consumers: [ + { service: "", environment: "" }, + ], }); + expect(result.messages).toEqual([ + "These settings have limitations in Worker Previews: Tail consumer environments.\nWrangler did not add them automatically. Review the limitations, then decide how you want to configure them for your Preview.\nLearn more: https://developers.cloudflare.com/workers/previews/limitations/", + ]); expect(JSON.stringify(result)).not.toContain("production"); }); - test("copies supported Preview Base configuration and blocks Durable Objects", ({ - expect, - }) => { - expect( - convertPreviewBaseToPreviewsConfig({ + test("preserves Preview Base values", ({ expect }) => { + const result = createPreviewConfigProposal({ + kind: "previewBase", + config: { observability: { enabled: true, - issues: { enabled: true }, - logs: { enabled: false }, + logs: { destinations: ["preview-destination"] }, }, - logpush: false, - limits: { subrequests: 100 }, - placement: { mode: "smart" }, - cache: { enabled: false }, + placement: { mode: "smart", hint: "preview-hint" }, tail_consumers: [{ name: "preview-tail" }], env: { - TEXT: { type: "plain_text", text: "preview-text" }, + JSON: { type: "json", json: null }, D1: { type: "d1", database_id: "preview-database" }, - QUEUE: { type: "queue", queue_name: "preview-queue" }, - DO: { type: "durable_object_namespace", class_name: "PreviewDO" }, - }, - } as Parameters[0]) - ).toEqual({ - config: { - observability: { - enabled: true, - issues: { enabled: true }, - logs: { enabled: false }, - }, - logpush: false, - limits: { subrequests: 100 }, - placement: { mode: "smart" }, - cache: { enabled: false }, - tail_consumers: [{ service: "preview-tail" }], - vars: { TEXT: "preview-text" }, - d1_databases: [{ binding: "D1", database_id: "preview-database" }], - queues: { - producers: [{ binding: "QUEUE", queue: "preview-queue" }], }, - }, - messages: [], - blockingDeploymentMessages: [ - "This Worker uses Durable Objects. They are not included in the suggested Preview configuration.\nFollow the setup instructions so each Preview automatically gets a new, isolated Durable Object namespace:\nhttps://developers.cloudflare.com/workers/previews/resources/#durable-objects", - ], + } as PreviewBaseConfig, }); - }); - test("copies an Issues-only Preview Base observability configuration", ({ - expect, - }) => { - expect( - convertPreviewBaseToPreviewsConfig({ - observability: { issues: { enabled: true } }, - } as Parameters[0]) - ).toEqual({ - config: { - observability: { issues: { enabled: true } }, + expect(result.config).toEqual({ + vars: { JSON: null }, + d1_databases: [{ binding: "D1", database_id: "preview-database" }], + observability: { + enabled: true, + logs: { destinations: ["preview-destination"] }, }, - messages: [], - blockingDeploymentMessages: [], + placement: { mode: "smart", hint: "preview-hint" }, + tail_consumers: [{ service: "preview-tail" }], }); + expect(result.messages).toEqual([]); + expect(result.blockingDeploymentMessages).toEqual([]); }); - test("returns one no-op outcome for ignored and incomplete bindings", ({ + test("ignores excluded bindings without reading their values", ({ expect, }) => { - const ignoredBindings: Binding[] = [ - { type: "inherit" }, - { type: "secret_text" }, - { type: "wasm_module" }, - { type: "text_blob" }, - { type: "data_blob" }, - { type: "assets" }, - { type: "unknown" }, - { type: "kv_namespace" }, - ]; - - for (const binding of ignoredBindings) { - expect(convertBinding("IGNORED", binding, true)).toEqual({}); - } - }); - - test("does not read ignored binding payloads", ({ expect }) => { - const binding = Object.defineProperty( + const secret = Object.defineProperty( { type: "secret_text" } as Binding, "text", { @@ -252,120 +145,45 @@ describe("Preview configuration conversion", () => { }, } ); + const ignored: Binding[] = [ + secret, + { type: "inherit" }, + { type: "wasm_module" }, + { type: "assets" }, + ]; - expect(convertBinding("SECRET", binding, false)).toEqual({}); - }); - - test("reports unsupported binding messages", ({ expect }) => { - expect(convertBinding("WORKFLOW", { type: "workflow" }, true)).toEqual({ - bindingLimitationName: "Workflows", - }); - expect(convertBinding("SERVICE", { type: "service" }, true)).toEqual({ - bindingLimitationName: "Service Bindings", - }); - }); - - test("reports top-level setting messages", ({ expect }) => { - const settings = topLevelSettings({ - containers: [{ class_name: "ContainerDO" }], - queues: { consumers: [{ queue: "production-queue" }] }, - triggers: { crons: ["0 * * * *"] }, - }); - - expect(convertTopLevelSetting(settings, "containers", true)).toEqual({ - blockDeploymentMessage: - "This Worker uses Containers. They are not included in the suggested Preview configuration.\nFollow the setup instructions so each Preview automatically gets a new, isolated Container app and state:\nhttps://developers.cloudflare.com/workers/previews/resources/#containers", - }); - expect(convertTopLevelSetting(settings, "queues", true)).toEqual({ - bindingLimitationName: "Queue consumers", - }); - expect(convertTopLevelSetting(settings, "triggers", true)).toEqual({ - bindingLimitationName: "Cron triggers", - }); - expect( - convertTopLevelSetting( - topLevelSettings({ - triggers: { - events: [ - { - type: "cf.artifacts.repo.created", - targets: [ - { - type: "workflow", - workflow_name: "production-workflow", - }, - ], - }, - ], - }, - }), - "triggers", - true - ) - ).toEqual({ bindingLimitationName: "Artifacts event triggers" }); - }); - - test("omits tail consumer environments and reports their limitation", ({ - expect, - }) => { - const result = convertPreviewSettings( - {}, - topLevelSettings({ - tail_consumers: [ - { service: "production-tail", environment: "staging" }, - ], - }), - true + const result = buildPreviewConfigProposal( + Object.fromEntries( + ignored.map((binding, i) => [`IGNORED_${i}`, binding]) + ), + topLevelSettings(), + "previewBase" ); - - expect(result.config).toEqual({ - tail_consumers: [{ service: "" }], + expect(result).toEqual({ + config: {}, + messages: [], + blockingDeploymentMessages: [], }); - expect(result.messages).toEqual([ - "These settings have limitations in Worker Previews: Tail consumer environments.\nWrangler did not add them automatically. Review the limitations, then decide how you want to configure them for your Preview.\nLearn more: https://developers.cloudflare.com/workers/previews/limitations/", - ]); - expect(result.blockingDeploymentMessages).toEqual([]); }); - test("inherits production observability", ({ expect }) => { - expect( - convertTopLevelSetting( - topLevelSettings({ - observability: { - enabled: true, - logs: { destinations: ["production-log-destination"] }, - }, - }), - "observability", - true - ) - ).toEqual({}); - }); - - test("aggregates config, deduplicates messages, and preserves blocking", ({ - expect, - }) => { - const result = convertPreviewSettings( + test("deduplicates limitations and blocking messages", ({ expect }) => { + const result = buildPreviewConfigProposal( { - FIRST_WORKFLOW: { type: "workflow" }, - SECOND_WORKFLOW: { type: "workflow" }, + WORKFLOW_1: { type: "workflow" }, + WORKFLOW_2: { type: "workflow" }, SERVICE: { type: "service" }, - QUEUE: { type: "queue", queue_name: "production-queue" }, - DO: { type: "durable_object_namespace", class_name: "ProductionDO" }, + DO_1: { type: "durable_object_namespace" }, + DO_2: { type: "durable_object_namespace" }, }, topLevelSettings({ containers: [{ class_name: "ContainerDO" }], queues: { consumers: [{ queue: "production-queue" }] }, triggers: { crons: ["0 * * * *"] }, }), - true + "localConfig" ); - expect(result.config).toEqual({ - queues: { - producers: [{ binding: "QUEUE", queue: "" }], - }, - }); + expect(result.config).toEqual({}); expect(result.messages).toEqual([ "These settings have limitations in Worker Previews: Workflows, Service Bindings, Queue consumers, Cron triggers.\nWrangler did not add them automatically. Review the limitations, then decide how you want to configure them for your Preview.\nLearn more: https://developers.cloudflare.com/workers/previews/limitations/", ]); @@ -375,96 +193,18 @@ describe("Preview configuration conversion", () => { ]); }); - test("returns an empty nonblocking aggregate", ({ expect }) => { - expect(convertPreviewSettings({}, topLevelSettings(), true)).toEqual({ - config: {}, - messages: [], - blockingDeploymentMessages: [], - }); - }); - - test("uses conversion to select production bindings expected in Preview", ({ + test("adapts normalized local config and preserves empty values", ({ expect, }) => { expect( - getProductionBindingsExpectedInPreview({ - ai: { binding: "AI", staging: false }, - kv_namespaces: [{ binding: "KV", id: "production-kv" }], - services: [{ binding: "SERVICE", service: "production-service" }], - durable_objects: { - bindings: [{ name: "DO", class_name: "ProductionDO" }], - }, - assets: { binding: "ASSETS", directory: "public" }, - } as Config) - ).toEqual({ - AI: { type: "ai", staging: false }, - KV: { type: "kv_namespace", namespace_id: "production-kv" }, - }); - }); - - test("preserves empty Preview Base values", ({ expect }) => { - expect( - convertPreviewBaseToPreviewsConfig({ - limits: {}, - tail_consumers: [], + createPreviewConfigProposal({ + kind: "localConfig", + config: { + define: {}, + limits: {}, + tail_consumers: [], + } as unknown as Config, }).config ).toEqual({ limits: {}, tail_consumers: [] }); }); - - test("omits null Preview Base settings without removing JSON null", ({ - expect, - }) => { - expect( - convertPreviewBaseToPreviewsConfig({ - observability: { - enabled: false, - head_sampling_rate: null, - }, - } as unknown as Parameters[0]) - .config - ).toEqual({ observability: { enabled: false } }); - - expect( - convertBinding("NULL_VALUE", { type: "json", json: null }, false) - ).toEqual({ - config: { vars: { NULL_VALUE: null } }, - }); - }); - - test("rejects duplicate singleton bindings", ({ expect }) => { - const convertDuplicates = () => - convertPreviewSettings( - { - FIRST_BROWSER: { type: "browser" }, - SECOND_BROWSER: { type: "browser" }, - }, - topLevelSettings(), - false - ); - - expect(convertDuplicates).toThrow(UserError); - expect(convertDuplicates).toThrow( - "Preview browser binding is defined more than once. Rename one of the bindings so each Preview setting is produced only once." - ); - }); - - test("production conversion never serializes unsupported values", ({ - expect, - }) => { - const result = convertProductionToPreviewsConfig({ - services: [ - { - binding: "SERVICE", - service: "production-service", - environment: "production", - }, - ], - } as Config); - - expect(result.config).toEqual({}); - expect(result.messages).toEqual([ - "These settings have limitations in Worker Previews: Service Bindings.\nWrangler did not add them automatically. Review the limitations, then decide how you want to configure them for your Preview.\nLearn more: https://developers.cloudflare.com/workers/previews/limitations/", - ]); - expect(JSON.stringify(result)).not.toContain("production"); - }); }); diff --git a/packages/wrangler/src/preview/ensure-config.ts b/packages/wrangler/src/preview/ensure-config.ts index fd74bbc0989..8e62bbcd97a 100644 --- a/packages/wrangler/src/preview/ensure-config.ts +++ b/packages/wrangler/src/preview/ensure-config.ts @@ -15,11 +15,7 @@ import { } from "@cloudflare/workers-utils"; import { confirm } from "../dialogs"; import { logger } from "../logger"; -import { - convertPreviewBaseToPreviewsConfig, - convertProductionToPreviewsConfig, - REPLACE_ME, -} from "./preview-config"; +import { createPreviewConfigProposal, REPLACE_ME } from "./preview-config"; import type { ProposedPreviewsConfig } from "./preview-config"; import type { PreviewBaseConfig } from "@cloudflare/deploy-helpers"; import type { @@ -119,16 +115,22 @@ export async function ensurePreviewsConfig( } } } - const productionConversion = convertProductionToPreviewsConfig(config); + const localConfigConversion = createPreviewConfigProposal({ + kind: "localConfig", + config, + }); const baseConversion: ProposedPreviewsConfig = baseConfig - ? convertPreviewBaseToPreviewsConfig(baseConfig) + ? createPreviewConfigProposal({ + kind: "previewBase", + config: baseConfig, + }) : { config: {}, messages: [], blockingDeploymentMessages: [] }; const hasPreviewBase = hasConfiguredFields(baseConfig); // preview base configuration takes precedence over local config for warnings, printed output, etc const selectedConversion = hasPreviewBase ? baseConversion - : productionConversion; + : localConfigConversion; const proposedConfig = selectedConversion.config; const proposedConfigPatch: RawConfig = config.targetEnvironment ? { env: { [config.targetEnvironment]: { previews: proposedConfig } } } diff --git a/packages/wrangler/src/preview/preview-config.ts b/packages/wrangler/src/preview/preview-config.ts index 2bd09df8e07..e4b53db21c4 100644 --- a/packages/wrangler/src/preview/preview-config.ts +++ b/packages/wrangler/src/preview/preview-config.ts @@ -1,6 +1,5 @@ import { extractConfigBindings } from "@cloudflare/deploy-helpers"; -import { UserError } from "@cloudflare/workers-utils"; -import { omitNullish } from "../utils/omit-nullish"; +import { mapWorkerMetadataBindings } from "@cloudflare/workers-utils"; import type { Binding, EnvBindings, @@ -10,10 +9,28 @@ import type { Config, Environment, PreviewsConfig, + WorkerMetadataBinding, } from "@cloudflare/workers-utils"; export const REPLACE_ME = ""; +type PreviewConfigSource = + | { kind: "localConfig"; config: Config } + | { kind: "previewBase"; config: PreviewBaseConfig }; + +type PreviewPolicyMessage = + | string + | ((value: Value) => string | undefined); + +type PreviewPolicy = { + exclude?: true; + limitationLabel?: PreviewPolicyMessage; + keepLocalValuesUnredacted?: string[]; + blockingMessage?: PreviewPolicyMessage; +}; + +type BindingPolicy = PreviewPolicy; + export type PreviewTopLevelSettings = { [ K in keyof Pick< @@ -33,17 +50,12 @@ export type PreviewTopLevelSettings = { ]: Environment[K] | undefined; }; -export type PreviewSettingConversion = - | { - config?: PreviewsConfig; - bindingLimitationName?: string; - blockDeploymentMessage?: never; - } - | { - config?: never; - bindingLimitationName?: never; - blockDeploymentMessage: string; - }; +type SettingPolicy = + PreviewPolicy>; + +type SettingPolicies = { + [Setting in keyof PreviewTopLevelSettings]: SettingPolicy; +}; export type ProposedPreviewsConfig = { config: PreviewsConfig; @@ -51,703 +63,292 @@ export type ProposedPreviewsConfig = { blockingDeploymentMessages: string[]; }; -/** Converts Preview Base API data into local Preview configuration. */ -export function convertPreviewBaseToPreviewsConfig( - baseConfig: PreviewBaseConfig -): ProposedPreviewsConfig { - const convertedSettings = convertPreviewSettings( - baseConfig.env ?? {}, - { - define: undefined, - observability: baseConfig.observability, - logpush: baseConfig.logpush, - limits: baseConfig.limits, - placement: baseConfig.placement, - cache: baseConfig.cache, - containers: undefined, - tail_consumers: baseConfig.tail_consumers?.map(({ name }) => ({ - service: name, - })), - streaming_tail_consumers: undefined, - queues: {}, - triggers: {}, - }, - false - ); - return convertedSettings; -} +const DURABLE_OBJECTS_MESSAGE = + "This Worker uses Durable Objects. They are not included in the suggested Preview configuration.\nFollow the setup instructions so each Preview automatically gets a new, isolated Durable Object namespace:\nhttps://developers.cloudflare.com/workers/previews/resources/#durable-objects"; +const CONTAINERS_MESSAGE = + "This Worker uses Containers. They are not included in the suggested Preview configuration.\nFollow the setup instructions so each Preview automatically gets a new, isolated Container app and state:\nhttps://developers.cloudflare.com/workers/previews/resources/#containers"; +const previewLimitationsMessage = ( + limitationLabels: string[] +): string | undefined => + limitationLabels.length === 0 + ? undefined + : `These settings have limitations in Worker Previews: ${limitationLabels.join( + ", " + )}.\nWrangler did not add them automatically. Review the limitations, then decide how you want to configure them for your Preview.\nLearn more: https://developers.cloudflare.com/workers/previews/limitations/`; -/** Creates local Preview configuration without copying production values. */ -export function convertProductionToPreviewsConfig( - config: Config -): ProposedPreviewsConfig { - const convertedSettings = convertPreviewSettings( - extractConfigBindings({ ...config, previews: config }), - { - define: config.define, - observability: config.observability, - logpush: config.logpush, - limits: config.limits, - placement: config.placement, - cache: config.cache, - containers: config.containers, - tail_consumers: config.tail_consumers, - streaming_tail_consumers: config.streaming_tail_consumers, - queues: config.queues, - triggers: config.triggers, +const bindingPolicies: Record = { + inherit: { exclude: true }, + plain_text: {}, + secret_text: { exclude: true }, + json: {}, + wasm_module: { exclude: true }, + text_blob: { exclude: true }, + browser: {}, + ai: {}, + images: {}, + stream: {}, + version_metadata: {}, + data_blob: { exclude: true }, + ai_search_namespace: {}, + ai_search: {}, + agent_memory: { exclude: true }, + kv_namespace: {}, + media: {}, + send_email: {}, + durable_object_namespace: { + exclude: true, + blockingMessage: DURABLE_OBJECTS_MESSAGE, + }, + workflow: { + exclude: true, + limitationLabel: "Workflows", + }, + queue: {}, + r2_bucket: { keepLocalValuesUnredacted: ["jurisdiction"] }, + d1: {}, + vectorize: {}, + hyperdrive: {}, + service: { + exclude: true, + limitationLabel: "Service Bindings", + }, + analytics_engine: {}, + dispatch_namespace: {}, + mtls_certificate: {}, + pipelines: {}, + secrets_store_secret: {}, + artifacts: {}, + unsafe_hello_world: { exclude: true }, + flagship: {}, + ratelimit: {}, + worker_loader: {}, + vpc_service: {}, + vpc_network: { exclude: true }, + logfwdr: { exclude: true }, + assets: { exclude: true }, +}; + +const settingPolicies: SettingPolicies = { + define: {}, + observability: {}, + logpush: {}, + limits: {}, + placement: { keepLocalValuesUnredacted: ["mode"] }, + cache: {}, + containers: { + exclude: true, + blockingMessage: (value) => + value.length > 0 ? CONTAINERS_MESSAGE : undefined, + }, + tail_consumers: { + limitationLabel: (value) => + value.some(({ environment }) => environment !== undefined) + ? "Tail consumer environments" + : undefined, + }, + streaming_tail_consumers: { exclude: true }, + queues: { + exclude: true, + limitationLabel: (value) => + (value.consumers?.length ?? 0) > 0 ? "Queue consumers" : undefined, + }, + triggers: { + exclude: true, + limitationLabel: (value) => { + const hasCrons = (value.crons?.length ?? 0) > 0; + const hasEvents = (value.events?.length ?? 0) > 0; + if (!hasCrons && !hasEvents) { + return undefined; + } + return hasCrons && hasEvents + ? "Cron and Artifacts event triggers" + : hasCrons + ? "Cron triggers" + : "Artifacts event triggers"; }, - true - ); - return convertedSettings; -} + }, +}; -/** - * `getBindings` uses a different shape than `convertBinding` accepts, so project - * production config into `previews` to extract API-shaped bindings. - * TODO: Unify the binding representations and remove this projection. - */ -export function getProductionBindingsExpectedInPreview( - config: Config -): EnvBindings { - const bindings = extractConfigBindings({ ...config, previews: config }); - return Object.fromEntries( - Object.entries(bindings).filter( - ([name, binding]) => - convertBinding(name, binding, true).config !== undefined - ) - ); +// for each potential preview setting or binding we: +// - redact all values by default unless specified +// - show a .limitationLabel for an unsupported by previews message +// - allow explicitly excluding the key from previews copy paste block +function applyPolicy( + value: Value, + policy: PreviewPolicy, + source: PreviewConfigSource["kind"] +): { + value: Value | undefined; + limitationLabel: string | undefined; + blockingMessage: string | undefined; +} { + const blockingMessage = + typeof policy.blockingMessage === "function" + ? policy.blockingMessage(value) + : policy.blockingMessage; + const limitationLabel = + typeof policy.limitationLabel === "function" + ? policy.limitationLabel(value) + : policy.limitationLabel; + + if (policy.exclude === true) { + return { value: undefined, limitationLabel, blockingMessage }; + } + + if (source === "previewBase") { + return { value, limitationLabel, blockingMessage }; + } + + return { + value: redactLocalValues(value, policy.keepLocalValuesUnredacted) as Value, + limitationLabel, + blockingMessage, + }; } -export function convertTopLevelSetting( - settings: PreviewTopLevelSettings, - setting: keyof PreviewTopLevelSettings, - usePlaceholderValue: boolean -): PreviewSettingConversion { - const converted: PreviewsConfig = {}; - switch (setting) { - case "define": { - const define = settings.define; - if ( - define === undefined || - (usePlaceholderValue && Object.keys(define).length === 0) - ) { - break; - } - converted.define = Object.fromEntries( - Object.keys(define).map((name) => [ - name, - usePlaceholderValue ? REPLACE_ME : define[name], - ]) - ); - break; - } - case "observability": { - if (usePlaceholderValue) { - break; - } - const observability = settings.observability; - if (observability === undefined) { - break; - } - converted.observability = omitNullish({ - enabled: observability.enabled, - head_sampling_rate: observability.head_sampling_rate, - redact_query_string: observability.redact_query_string, - issues: - observability.issues === undefined - ? undefined - : omitNullish({ - enabled: observability.issues.enabled, - }), - logs: - observability.logs === undefined - ? undefined - : omitNullish({ - enabled: observability.logs.enabled, - head_sampling_rate: observability.logs.head_sampling_rate, - invocation_logs: observability.logs.invocation_logs, - persist: observability.logs.persist, - destinations: - observability.logs.destinations === undefined - ? undefined - : usePlaceholderValue - ? observability.logs.destinations.map(() => REPLACE_ME) - : observability.logs.destinations, - }), - traces: - observability.traces === undefined - ? undefined - : omitNullish({ - enabled: observability.traces.enabled, - head_sampling_rate: observability.traces.head_sampling_rate, - persist: observability.traces.persist, - destinations: - observability.traces.destinations === undefined - ? undefined - : usePlaceholderValue - ? observability.traces.destinations.map(() => REPLACE_ME) - : observability.traces.destinations, - }), - }); - break; +function redactLocalValues( + value: unknown, + keepOriginalValues: string[] = [] +): unknown { + const keep = new Set(keepOriginalValues); + + function redact(current: unknown, path = ""): unknown { + if (typeof current === "string") { + return keep.has(path) ? current : REPLACE_ME; } - case "logpush": - if (!usePlaceholderValue && settings.logpush !== undefined) { - converted.logpush = settings.logpush; - } - break; - case "limits": - if (!usePlaceholderValue && settings.limits !== undefined) { - converted.limits = omitNullish({ - cpu_ms: settings.limits.cpu_ms, - subrequests: settings.limits.subrequests, - }); - } - break; - case "placement": { - const placement = settings.placement; - if (placement === undefined) { - break; - } - if (placement.mode === "smart") { - converted.placement = { - mode: placement.mode, - ...(placement.hint !== undefined && { - hint: usePlaceholderValue ? REPLACE_ME : placement.hint, - }), - }; - } else if (placement.mode === "off") { - converted.placement = { mode: placement.mode }; - } else if ("region" in placement) { - converted.placement = omitNullish({ - mode: placement.mode, - region: usePlaceholderValue ? REPLACE_ME : placement.region, - }); - } else if ("host" in placement) { - converted.placement = omitNullish({ - mode: placement.mode, - host: usePlaceholderValue ? REPLACE_ME : placement.host, - }); - } else if ("hostname" in placement) { - converted.placement = omitNullish({ - mode: placement.mode, - hostname: usePlaceholderValue ? REPLACE_ME : placement.hostname, - }); - } - break; + if (Array.isArray(current)) { + return current.map((item) => redact(item, path)); } - case "cache": - if (!usePlaceholderValue && settings.cache !== undefined) { - converted.cache = omitNullish({ - enabled: settings.cache.enabled, - cross_version_cache: settings.cache.cross_version_cache, - }); - } - break; - case "tail_consumers": - if (settings.tail_consumers !== undefined) { - converted.tail_consumers = settings.tail_consumers.map( - ({ service }) => ({ - service: usePlaceholderValue ? REPLACE_ME : service, - }) - ); - if ( - settings.tail_consumers.some( - ({ environment }) => environment !== undefined - ) - ) { - return { - config: converted, - bindingLimitationName: "Tail consumer environments", - }; + if (typeof current === "object" && current !== null) { + const redacted: Record = {}; + for (const [key, child] of Object.entries(current)) { + if (child === undefined) { + continue; } + const childPath = path === "" ? key : `${path}.${key}`; + redacted[key] = redact(child, childPath); } - break; - case "containers": - if (settings.containers !== undefined && settings.containers.length > 0) { - return { - blockDeploymentMessage: - "This Worker uses Containers. They are not included in the suggested Preview configuration.\nFollow the setup instructions so each Preview automatically gets a new, isolated Container app and state:\nhttps://developers.cloudflare.com/workers/previews/resources/#containers", - }; - } - break; - case "streaming_tail_consumers": - break; - case "queues": - if ((settings.queues?.consumers?.length ?? 0) > 0) { - return { bindingLimitationName: "Queue consumers" }; - } - break; - case "triggers": { - const hasCrons = (settings.triggers?.crons?.length ?? 0) > 0; - const hasEvents = (settings.triggers?.events?.length ?? 0) > 0; - if (hasCrons || hasEvents) { - return { - bindingLimitationName: - hasCrons && hasEvents - ? "Cron and Artifacts event triggers" - : hasCrons - ? "Cron triggers" - : "Artifacts event triggers", - }; - } - break; - } - default: { - const exhaustiveCheck: never = setting; - throw new Error(`Unexpected Preview setting: ${exhaustiveCheck}`); + return redacted; } + return current; } - return Object.keys(converted).length > 0 ? { config: converted } : {}; + + return redact(value); } -export function convertPreviewSettings( +export function buildPreviewConfigProposal( bindings: EnvBindings, settings: PreviewTopLevelSettings, - usePlaceholderValue: boolean + configSource: PreviewConfigSource["kind"] ): ProposedPreviewsConfig { - const config = new Map(); - const bindingLimitationNames = new Set(); - const blockingDeploymentMessages = new Set(); + const includedBindings: WorkerMetadataBinding[] = []; + const includedSettings: Record = {}; + const limitationLabels = new Set(); + const blockingMessages = new Set(); - const conversions = [ - ...Object.entries(bindings).map(([name, binding]) => - convertBinding(name, binding, usePlaceholderValue) - ), - ...(Object.keys(settings) as Array).map( - (setting) => - convertTopLevelSetting(settings, setting, usePlaceholderValue) - ), - ]; + for (const [name, binding] of Object.entries(bindings)) { + const policy: BindingPolicy | undefined = bindingPolicies[binding.type]; + if (policy === undefined) { + continue; + } + const { value, limitationLabel, blockingMessage } = applyPolicy( + binding, + policy, + configSource + ); + if (limitationLabel !== undefined) { + limitationLabels.add(limitationLabel); + } + if (blockingMessage !== undefined) { + blockingMessages.add(blockingMessage); + } + if (value !== undefined) { + includedBindings.push({ + ...value, + name, + type: binding.type, + } as WorkerMetadataBinding); + } + } - for (const converted of conversions) { - if (converted.bindingLimitationName !== undefined) { - bindingLimitationNames.add(converted.bindingLimitationName); + for (const setting of Object.keys(settingPolicies) as Array< + keyof PreviewTopLevelSettings + >) { + const current = settings[setting]; + if (current === undefined) { + continue; } - if (converted.blockDeploymentMessage !== undefined) { - blockingDeploymentMessages.add(converted.blockDeploymentMessage); + const policy = settingPolicies[setting] as SettingPolicy< + keyof PreviewTopLevelSettings + >; + const { value, limitationLabel, blockingMessage } = applyPolicy( + current, + policy, + configSource + ); + if (limitationLabel !== undefined) { + limitationLabels.add(limitationLabel); } - if (converted.config !== undefined) { - for (const [configField, configValue] of Object.entries( - converted.config - )) { - const current = config.get(configField); - if (current === undefined) { - config.set(configField, configValue); - continue; - } - // Flat binding lists append because EnvBindings already has unique names - if (Array.isArray(current) && Array.isArray(configValue)) { - config.set(configField, [...current, ...configValue]); - continue; - } - // Merge maps by key and append nested binding lists. - // Other overlaps are duplicate singletons. - if ( - typeof current === "object" && - current !== null && - !Array.isArray(current) && - typeof configValue === "object" && - configValue !== null && - !Array.isArray(configValue) - ) { - const merged = new Map(Object.entries(current)); - for (const [key, next] of Object.entries(configValue)) { - const existing = merged.get(key); - if (existing === undefined) { - merged.set(key, next); - } else if (Array.isArray(existing) && Array.isArray(next)) { - merged.set(key, [...existing, ...next]); - } else { - throw new UserError( - `Preview ${configField} binding is defined more than once. Rename one of the bindings so each Preview setting is produced only once.`, - { - telemetryMessage: - "preview command duplicate previews binding", - } - ); - } - } - config.set(configField, Object.fromEntries(merged)); - continue; - } - throw new UserError( - `Preview ${configField} binding is defined more than once. Rename one of the bindings so each Preview setting is produced only once.`, - { - telemetryMessage: "preview command duplicate previews binding", - } - ); - } + if (blockingMessage !== undefined) { + blockingMessages.add(blockingMessage); + } + if (value === undefined || value === null) { + continue; } + + includedSettings[setting] = value; } + const bindingConfig = mapWorkerMetadataBindings( + includedBindings + ) as PreviewsConfig; return { - config: Object.fromEntries(config) as PreviewsConfig, - messages: [ - ...(bindingLimitationNames.size === 0 - ? [] - : [ - `These settings have limitations in Worker Previews: ${[ - ...bindingLimitationNames, - ].join( - ", " - )}.\nWrangler did not add them automatically. Review the limitations, then decide how you want to configure them for your Preview.\nLearn more: https://developers.cloudflare.com/workers/previews/limitations/`, - ]), - ], - blockingDeploymentMessages: [...blockingDeploymentMessages], + config: { ...bindingConfig, ...includedSettings } as PreviewsConfig, + messages: [previewLimitationsMessage([...limitationLabels])].filter( + (message): message is string => message !== undefined + ), + blockingDeploymentMessages: [...blockingMessages], }; } -/** Converts one binding into Preview configuration or a policy outcome. */ -export function convertBinding( - name: string, - binding: Binding, - usePlaceholderValue: boolean -): PreviewSettingConversion { - let config: PreviewsConfig | undefined; - switch (binding.type) { - case "plain_text": - if (binding.text === undefined) { - break; - } - config = { - vars: { - [name]: usePlaceholderValue ? REPLACE_ME : binding.text, - }, - }; - break; - case "json": - if (binding.json === undefined) { - break; - } - config = { - vars: { - [name]: usePlaceholderValue ? REPLACE_ME : binding.json, - }, - }; - break; - case "browser": - config = { browser: { binding: name } }; - break; - case "ai": - config = { - ai: { - binding: name, - ...(binding.staging !== undefined && { - staging: binding.staging, - }), - }, - }; - break; - case "images": - config = { images: { binding: name } }; - break; - case "stream": - config = { stream: { binding: name } }; - break; - case "version_metadata": - config = { version_metadata: { binding: name } }; - break; - case "kv_namespace": - if (binding.namespace_id === undefined) { - break; - } - config = { - kv_namespaces: [ - { - binding: name, - id: usePlaceholderValue ? REPLACE_ME : binding.namespace_id, - }, - ], - }; - break; - case "media": - config = { media: { binding: name } }; - break; - case "send_email": - config = { - send_email: [ - { - name, - ...(binding.destination_address !== undefined && { - destination_address: usePlaceholderValue - ? REPLACE_ME - : binding.destination_address, - }), - ...(binding.allowed_destination_addresses !== undefined && { - allowed_destination_addresses: usePlaceholderValue - ? binding.allowed_destination_addresses.map(() => REPLACE_ME) - : binding.allowed_destination_addresses, - }), - ...(binding.allowed_sender_addresses !== undefined && { - allowed_sender_addresses: usePlaceholderValue - ? binding.allowed_sender_addresses.map(() => REPLACE_ME) - : binding.allowed_sender_addresses, - }), - }, - ], - }; - break; - case "durable_object_namespace": - return { - blockDeploymentMessage: - "This Worker uses Durable Objects. They are not included in the suggested Preview configuration.\nFollow the setup instructions so each Preview automatically gets a new, isolated Durable Object namespace:\nhttps://developers.cloudflare.com/workers/previews/resources/#durable-objects", - }; - case "workflow": - return { bindingLimitationName: "Workflows" }; - case "queue": - if (binding.queue_name === undefined) { - break; - } - config = { - queues: { - producers: [ - omitNullish({ - binding: name, - queue: usePlaceholderValue ? REPLACE_ME : binding.queue_name, - delivery_delay: binding.delivery_delay, - }), - ], - }, - }; - break; - case "r2_bucket": - if (binding.bucket_name === undefined) { - break; - } - config = { - r2_buckets: [ - { - binding: name, - bucket_name: usePlaceholderValue ? REPLACE_ME : binding.bucket_name, - jurisdiction: binding.jurisdiction, - }, - ], - }; - break; - case "d1": { - const databaseId = binding.database_id ?? binding.id; - if (databaseId === undefined) { - break; - } - config = { - d1_databases: [ - { - binding: name, - database_id: usePlaceholderValue ? REPLACE_ME : databaseId, - }, - ], - }; - break; - } - case "vectorize": - if (binding.index_name === undefined) { - break; - } - config = { - vectorize: [ - { - binding: name, - index_name: usePlaceholderValue ? REPLACE_ME : binding.index_name, - }, - ], - }; - break; - case "ai_search_namespace": - if (binding.namespace === undefined) { - break; - } - config = { - ai_search_namespaces: [ - { - binding: name, - namespace: usePlaceholderValue ? REPLACE_ME : binding.namespace, - }, - ], - }; - break; - case "ai_search": - if (binding.instance_name === undefined) { - break; - } - config = { - ai_search: [ - { - binding: name, - instance_name: usePlaceholderValue - ? REPLACE_ME - : binding.instance_name, - }, - ], - }; - break; - case "hyperdrive": - if (binding.id === undefined) { - break; - } - config = { - hyperdrive: [ - { - binding: name, - id: usePlaceholderValue ? REPLACE_ME : binding.id, - }, - ], - }; - break; - case "service": - return { bindingLimitationName: "Service Bindings" }; - case "analytics_engine": - config = { - analytics_engine_datasets: [ - { - binding: name, - ...(binding.dataset !== undefined && { - dataset: usePlaceholderValue ? REPLACE_ME : binding.dataset, - }), - }, - ], - }; - break; - case "dispatch_namespace": - if (binding.namespace === undefined) { - break; - } - config = { - dispatch_namespaces: [ - { - binding: name, - namespace: usePlaceholderValue ? REPLACE_ME : binding.namespace, - ...(binding.outbound !== undefined && { - outbound: { - service: usePlaceholderValue - ? REPLACE_ME - : binding.outbound.worker.service, - ...(binding.outbound.worker.environment !== undefined && { - environment: usePlaceholderValue - ? REPLACE_ME - : binding.outbound.worker.environment, - }), - parameters: usePlaceholderValue - ? Array.from( - { length: binding.outbound.params?.length ?? 0 }, - () => REPLACE_ME - ) - : (binding.outbound.params?.map( - ({ name: parameterName }) => parameterName - ) ?? []), - }, - }), - }, - ], - }; - break; - case "mtls_certificate": - if (binding.certificate_id === undefined) { - break; - } - config = { - mtls_certificates: [ - { - binding: name, - certificate_id: usePlaceholderValue - ? REPLACE_ME - : binding.certificate_id, - }, - ], - }; - break; - case "pipelines": - if (binding.stream === undefined && binding.pipeline === undefined) { - break; - } - config = { - pipelines: [ - { - binding: name, - ...(binding.stream !== undefined && { - stream: usePlaceholderValue ? REPLACE_ME : binding.stream, - }), - ...(binding.pipeline !== undefined && { - pipeline: usePlaceholderValue ? REPLACE_ME : binding.pipeline, - }), - }, - ], - }; - break; - case "secrets_store_secret": - if (binding.store_id === undefined || binding.secret_name === undefined) { - break; - } - config = { - secrets_store_secrets: [ - { - binding: name, - store_id: usePlaceholderValue ? REPLACE_ME : binding.store_id, - secret_name: usePlaceholderValue ? REPLACE_ME : binding.secret_name, - }, - ], - }; - break; - case "artifacts": - if (binding.namespace === undefined) { - break; - } - config = { - artifacts: [ - { - binding: name, - namespace: usePlaceholderValue ? REPLACE_ME : binding.namespace, - }, - ], - }; - break; - case "flagship": - if (binding.app_id === undefined) { - break; - } - config = { - flagship: [ - { - binding: name, - app_id: usePlaceholderValue ? REPLACE_ME : binding.app_id, - }, - ], - }; - break; - case "ratelimit": - if (binding.namespace_id === undefined || binding.simple === undefined) { - break; - } - config = { - ratelimits: [ - { - name, - namespace_id: usePlaceholderValue - ? REPLACE_ME - : binding.namespace_id, - simple: binding.simple, - }, - ], - }; - break; - case "vpc_service": - if (binding.service_id === undefined) { - break; - } - config = { - vpc_services: [ - { - binding: name, - service_id: usePlaceholderValue ? REPLACE_ME : binding.service_id, - }, - ], - }; - break; - case "worker_loader": - config = { worker_loaders: [{ binding: name }] }; - break; - default: - break; +export function createPreviewConfigProposal( + source: PreviewConfigSource +): ProposedPreviewsConfig { + if (source.kind === "previewBase") { + const config = source.config; + const bindings = config.env ?? {}; + const settings: PreviewTopLevelSettings = { + define: undefined, + observability: config.observability, + logpush: config.logpush, + limits: config.limits, + placement: config.placement, + cache: config.cache, + containers: undefined, + tail_consumers: config.tail_consumers?.map(({ name }) => ({ + service: name, + })), + streaming_tail_consumers: undefined, + queues: undefined, + triggers: undefined, + }; + return buildPreviewConfigProposal(bindings, settings, "previewBase"); } - return config === undefined ? {} : { config }; + + const config = source.config; + const bindings = extractConfigBindings({ ...config, previews: config }); + const settings: PreviewTopLevelSettings = { + define: Object.keys(config.define).length === 0 ? undefined : config.define, + observability: config.observability, + logpush: config.logpush, + limits: config.limits, + placement: config.placement, + cache: config.cache, + containers: config.containers, + tail_consumers: config.tail_consumers, + streaming_tail_consumers: config.streaming_tail_consumers, + queues: config.queues, + triggers: config.triggers, + }; + return buildPreviewConfigProposal(bindings, settings, "localConfig"); } diff --git a/packages/wrangler/src/preview/preview.ts b/packages/wrangler/src/preview/preview.ts index 60280e4ce33..d6b3e60187a 100644 --- a/packages/wrangler/src/preview/preview.ts +++ b/packages/wrangler/src/preview/preview.ts @@ -1,4 +1,8 @@ -import { preview, resolveWorkerName } from "@cloudflare/deploy-helpers"; +import { + extractConfigBindings, + preview, + resolveWorkerName, +} from "@cloudflare/deploy-helpers"; import { getWranglerTmpDir } from "@cloudflare/workers-utils"; import { getAssetsOptions } from "../assets"; import { getNormalizedContainerOptions } from "../containers/config"; @@ -11,7 +15,7 @@ import { requireAuth } from "../user"; import { collectKeyValues } from "../utils/collectKeyValues"; import { deployPreviewContainers, verifyContainersScope } from "./containers"; import { ensurePreviewsConfig } from "./ensure-config"; -import { getProductionBindingsExpectedInPreview } from "./preview-config"; +import { createPreviewConfigProposal } from "./preview-config"; export const previewCommand = createCommand({ metadata: { @@ -79,8 +83,11 @@ export const previewCommand = createCommand({ }, handler: async function previewHandler(args, { config }) { const accountId = await requireAuth(config); - const productionBindingsExpectedInPreview = - getProductionBindingsExpectedInPreview(config); + const productionBindingsExpectedInPreview = extractConfigBindings({ + ...config, + previews: createPreviewConfigProposal({ kind: "localConfig", config }) + .config, + }); const previewConfig = await ensurePreviewsConfig(accountId, args, config); const entry = await getEntry(