diff --git a/.changeset/olive-donkeys-shave.md b/.changeset/olive-donkeys-shave.md new file mode 100644 index 00000000000..ad39df97b1e --- /dev/null +++ b/.changeset/olive-donkeys-shave.md @@ -0,0 +1,39 @@ +--- +"@cloudflare/config": minor +"@cloudflare/workers-utils": minor +"@cloudflare/vite-plugin": minor +"wrangler": minor +--- + +Add an `optional` option to `bindings.secret()` and a `secrets.optional` field to the Wrangler configuration + +Secrets declared with `bindings.secret()` are inferred as `string` and enforced at deploy time. That is wrong for secrets that can legitimately be unset — a freshly created Worker has none set, and a rolled-back Worker can lose one — so there was no way to declare such a secret without lying to the type system. + +Passing `{ optional: true }` infers the binding as `string | undefined` and emits it as `secrets.optional` rather than `secrets.required`: + +```ts +import { bindings, defineWorker } from "wrangler/experimental-config"; + +export default defineWorker({ + name: "my-worker", + env: { + API_TOKEN: bindings.secret(), // string + SENTRY_TOKEN: bindings.secret({ optional: true }), // string | undefined + }, +}); +``` + +The equivalent field is also available directly in `wrangler.json`: + +```json +{ + "secrets": { + "required": ["API_TOKEN"], + "optional": ["SENTRY_TOKEN"] + } +} +``` + +Optional secrets are still loaded from `.dev.vars`/`.env`/`process.env` in local dev and are emitted by `wrangler types` as `string | undefined`, but no "Missing required secrets" warning is logged when they are absent and deploying a Worker that does not have them set no longer fails. + +Secrets remain required by default — `bindings.secret()` is unchanged. diff --git a/packages/config/src/__tests__/convert.test.ts b/packages/config/src/__tests__/convert.test.ts index c49497ac1ed..fe8727fbf26 100644 --- a/packages/config/src/__tests__/convert.test.ts +++ b/packages/config/src/__tests__/convert.test.ts @@ -668,6 +668,43 @@ describe("convertToWranglerConfig", () => { }); expect(result.secrets).toEqual({ required: ["A", "B"] }); }); + + it("splits secret bindings across secrets.required and secrets.optional", ({ + expect, + }) => { + const result = convertToWranglerConfig({ + ...baseConfig, + env: { + A: bindings.secret(), + B: bindings.secret({ optional: true }), + C: bindings.secret({ optional: false }), + }, + }); + expect(result.secrets).toEqual({ + required: ["A", "C"], + optional: ["B"], + }); + }); + + it("omits `required` when every secret is optional", ({ expect }) => { + const result = convertToWranglerConfig({ + ...baseConfig, + env: { + A: bindings.secret({ optional: true }), + }, + }); + expect(result.secrets).toEqual({ optional: ["A"] }); + }); + + it("omits `optional` when every secret is required", ({ expect }) => { + const result = convertToWranglerConfig({ + ...baseConfig, + env: { + A: bindings.secret(), + }, + }); + expect(result.secrets).toEqual({ required: ["A"] }); + }); }); describe("exports", () => { diff --git a/packages/config/src/__tests__/inference.test.ts b/packages/config/src/__tests__/inference.test.ts new file mode 100644 index 00000000000..06711aa302f --- /dev/null +++ b/packages/config/src/__tests__/inference.test.ts @@ -0,0 +1,52 @@ +import { describe, expectTypeOf, it } from "vitest"; +import { bindings } from "../bindings"; +import { defineWorker } from "../worker-definition"; +import type { InferEnv, UnwrapConfig } from "../inference"; + +const config = defineWorker({ + name: "my-worker", + compatibilityDate: "2026-06-01", + env: { + REQUIRED_SECRET: bindings.secret(), + EXPLICITLY_REQUIRED_SECRET: bindings.secret({ optional: false }), + OPTIONAL_SECRET: bindings.secret({ optional: true }), + }, +}); + +type Env = InferEnv>; + +describe("secret bindings", () => { + it("infers `bindings.secret()` as a required string", ({ expect }) => { + expectTypeOf().toEqualTypeOf(); + expect(bindings.secret()).toEqual({ type: "secret" }); + }); + + it("infers `bindings.secret({ optional: false })` as a required string", ({ + expect, + }) => { + expectTypeOf().toEqualTypeOf(); + expect(bindings.secret({ optional: false })).toEqual({ + type: "secret", + optional: false, + }); + }); + + it("infers `bindings.secret({ optional: true })` as `string | undefined`", ({ + expect, + }) => { + expectTypeOf().toEqualTypeOf(); + expect(bindings.secret({ optional: true })).toEqual({ + type: "secret", + optional: true, + }); + }); + + it("keeps every secret key present on the inferred env", ({ expect }) => { + // Optionality is expressed as `| undefined`, not as an optional key, so + // `keyof Env` is unaffected. + expectTypeOf().toEqualTypeOf< + "REQUIRED_SECRET" | "EXPLICITLY_REQUIRED_SECRET" | "OPTIONAL_SECRET" + >(); + expect(Object.keys(bindings.secret())).toEqual(["type"]); + }); +}); diff --git a/packages/config/src/bindings.ts b/packages/config/src/bindings.ts index f6275c2e7c3..a4be55dc99e 100644 --- a/packages/config/src/bindings.ts +++ b/packages/config/src/bindings.ts @@ -413,6 +413,22 @@ export interface RateLimitBinding extends RateLimitBindingOptions { type: "rate-limit"; } +interface SecretBindingOptions { + /** + * Whether the secret may legitimately be absent at runtime. + * + * Defaults to `false` — the secret is required, is inferred as `string`, and + * is enforced when deploying. + * + * When `true`, the secret is inferred as `string | undefined` and is not + * enforced when deploying. Use this for secrets that are expected to be + * unset in some deployments — for example a Worker that is deployed fresh + * before its secrets are populated, or a rolled-back Worker that has lost + * one. + */ + optional?: boolean; +} + /** * Declares a secret that is required by your Worker, exposed on `env` under * the binding name. @@ -423,10 +439,25 @@ export interface RateLimitBinding extends RateLimitBindingOptions { * * For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#secrets-configuration-property */ -export interface SecretBinding { +export interface SecretBinding extends SecretBindingOptions { type: "secret"; } +/** + * Declares a secret that may legitimately be absent at runtime, exposed on + * `env` under the binding name as `string | undefined`. + * + * Produced by `bindings.secret({ optional: true })`. Unlike a required secret, + * an optional secret is emitted as `secrets.optional` rather than + * `secrets.required` in the generated Wrangler configuration: it is still + * loaded from `.dev.vars`/`.env` in local dev, but no warning is emitted when + * it is missing and deploying a Worker that does not have it set will not + * fail. + */ +export interface OptionalSecretBinding extends SecretBinding { + optional: true; +} + interface SecretsStoreSecretBindingOptions { /** ID of the secret store. */ storeId: string; @@ -777,17 +808,33 @@ export interface Bindings { r2(options?: R2BindingOptions): R2Binding; /** Binding to a rate limiter. */ rateLimit(options: RateLimitBindingOptions): RateLimitBinding; + /** + * Declares a secret that may legitimately be absent at runtime, exposed on + * `env` under the binding name as `string | undefined`. + * + * Optional secrets are emitted as `secrets.optional` rather than + * `secrets.required`, so they are still loaded in local dev but do not warn + * when missing and do not fail a deploy when unset. + * + * For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#secrets-configuration-property + */ + secret( + options: SecretBindingOptions & { optional: true } + ): OptionalSecretBinding; /** * Declares a secret that is required by your Worker, exposed on `env` under - * the binding name. + * the binding name as `string`. * * When defined, this binding: * - Replaces .dev.vars/.env/process.env inference for type generation * - Enables local dev validation with warnings for missing secrets * + * Pass `{ optional: true }` for a secret that may legitimately be unset, to + * infer it as `string | undefined`. + * * For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#secrets-configuration-property */ - secret(): SecretBinding; + secret(options?: SecretBindingOptions): SecretBinding; /** Binding to a Secrets Store secret. */ secretsStoreSecret( options: SecretsStoreSecretBindingOptions @@ -876,7 +923,7 @@ export const bindings = { queue: (options) => ({ type: "queue", ...options }), rateLimit: (options) => ({ type: "rate-limit", ...options }), r2: (options) => ({ type: "r2", ...options }), - secret: () => ({ type: "secret" }), + secret: (options) => ({ type: "secret", ...options }), secretsStoreSecret: (options) => ({ type: "secrets-store-secret", ...options, diff --git a/packages/config/src/convert.ts b/packages/config/src/convert.ts index 6d244a13445..d70bcfda9d5 100644 --- a/packages/config/src/convert.ts +++ b/packages/config/src/convert.ts @@ -244,6 +244,7 @@ function convertBindingsAndAssets( > = []; const vars: Record = {}; const secretsRequired: string[] = []; + const secretsOptional: string[] = []; for (const [name, binding] of Object.entries(env)) { if (isParsedUnsafeBinding(binding)) { @@ -464,7 +465,15 @@ function convertBindingsAndAssets( break; } case "secret": { - secretsRequired.push(name); + // Optional secrets go in `secrets.optional` rather than + // `secrets.required` — they are still loaded in local dev and + // included in type generation, but they do not trigger the + // missing-secret warning and do not fail a deploy when unset. + if (binding.optional) { + secretsOptional.push(name); + } else { + secretsRequired.push(name); + } break; } case "secrets-store-secret": { @@ -665,8 +674,11 @@ function convertBindingsAndAssets( if (Object.keys(vars).length) { result.vars = vars; } - if (secretsRequired.length) { - result.secrets = { required: secretsRequired }; + if (secretsRequired.length || secretsOptional.length) { + result.secrets = { + ...(secretsRequired.length ? { required: secretsRequired } : {}), + ...(secretsOptional.length ? { optional: secretsOptional } : {}), + }; } // Merge top-level `assets` config with the assets binding name. diff --git a/packages/config/src/inference.ts b/packages/config/src/inference.ts index 7068ed2cf22..e59f979578b 100644 --- a/packages/config/src/inference.ts +++ b/packages/config/src/inference.ts @@ -2,6 +2,7 @@ import type { JsonBinding, + OptionalSecretBinding, TextBinding, TypedAiBinding, TypedDurableObjectBinding, @@ -62,11 +63,11 @@ type ExtractInstance = * Mapping from binding type literals to Cloudflare runtime types. * * Entries fall into two groups: - * - Parameterized bindings (ai, json, kv, pipeline, queue, text) refine their - * runtime type from the binding instance via nominal matches against the - * `Typed*Binding` / `JsonBinding` / `TextBinding` interfaces from - * `./bindings`. When `TBinding` does not match, the entry falls back to the - * unparameterized runtime type. + * - Parameterized bindings (ai, json, kv, pipeline, queue, secret, text) refine + * their runtime type from the binding instance via nominal matches against + * the `Typed*Binding` / `JsonBinding` / `TextBinding` / + * `OptionalSecretBinding` interfaces from `./bindings`. When `TBinding` does + * not match, the entry falls back to the unparameterized runtime type. * - Non-parameterized bindings map their type literal directly to a runtime * type and ignore `TBinding`. * @@ -76,9 +77,10 @@ type ExtractInstance = * binding interfaces in `./bindings.ts` (`ImagesBinding`, `MediaBinding`, * `StreamBinding`) share names with ambient globals — importing those local * types into this file silently shadows the globals and breaks `InferEnv`. - * Only import the `Typed*Binding`, `JsonBinding`, and `TextBinding` interfaces - * from `./bindings` (their names do not collide with ambient globals); never - * widen the import to a wildcard or to the plain `*Binding` interfaces. + * Only import the `Typed*Binding`, `JsonBinding`, `TextBinding`, and + * `OptionalSecretBinding` interfaces from `./bindings` (their names do not + * collide with ambient globals); never widen the import to a wildcard or to the + * plain `*Binding` interfaces. */ interface BindingTypeMap { // Parameterized bindings - refine via nominal match on the binding instance @@ -89,6 +91,7 @@ interface BindingTypeMap { ? Pipeline : Pipeline; queue: TBinding extends TypedQueueBinding ? Queue : Queue; + secret: TBinding extends OptionalSecretBinding ? string | undefined : string; text: TBinding extends TextBinding ? T : never; // Non-parameterized bindings @@ -110,7 +113,6 @@ interface BindingTypeMap { "mtls-certificate": Fetcher; "rate-limit": RateLimit; r2: R2Bucket; - secret: string; "secrets-store-secret": SecretsStoreSecret; "send-email": SendEmail; stream: StreamBinding; diff --git a/packages/config/src/public.ts b/packages/config/src/public.ts index b022faed23e..2406783f3db 100644 --- a/packages/config/src/public.ts +++ b/packages/config/src/public.ts @@ -24,6 +24,7 @@ export type { LogfwdrBinding, MediaBinding, MtlsCertificateBinding, + OptionalSecretBinding, PipelineBinding, QueueBinding, RateLimitBinding, diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index bb1abb53e99..4c269677ab9 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -161,7 +161,10 @@ export const KnownBindingSchema = z.discriminatedUnion("type", [ }), }), R2BindingSchema, - z.strictObject({ type: z.literal("secret") }), + z.strictObject({ + type: z.literal("secret"), + optional: z.boolean().optional(), + }), z.strictObject({ type: z.literal("secrets-store-secret"), storeId: z.string(), diff --git a/packages/workers-utils/src/config/environment.ts b/packages/workers-utils/src/config/environment.ts index eae4b7c8f37..afd1e5d317e 100644 --- a/packages/workers-utils/src/config/environment.ts +++ b/packages/workers-utils/src/config/environment.ts @@ -931,6 +931,18 @@ export interface EnvironmentNonInheritable { * - Enables local dev validation with warnings for missing secrets */ required?: string[]; + + /** + * List of secret names that your Worker uses but that may legitimately be + * unset — for example on a freshly created Worker whose secrets have not + * been populated yet, or on a Worker that was rolled back. + * + * Optional secrets are loaded from .dev.vars/.env/process.env in local dev + * and are included in type generation as `string | undefined`, but no + * warning is emitted when they are missing and deploying without them + * does not fail. + */ + optional?: string[]; }; /** diff --git a/packages/workers-utils/src/config/validation.ts b/packages/workers-utils/src/config/validation.ts index 46a2ce1b1e7..7a2903e91e0 100644 --- a/packages/workers-utils/src/config/validation.ts +++ b/packages/workers-utils/src/config/validation.ts @@ -2629,6 +2629,7 @@ const validateSecrets = // Warn about unexpected properties validateAdditionalProperties(diagnostics, fieldPath, Object.keys(value), [ "required", + "optional", ]); // Validate 'required' property if present @@ -2640,6 +2641,15 @@ const validateSecrets = "string" ) && isValid; + // Validate 'optional' property if present + isValid = + validateOptionalTypedArray( + diagnostics, + `${fieldPath}.optional`, + (value as Record).optional, + "string" + ) && isValid; + return isValid; }; @@ -4806,7 +4816,10 @@ const validateBindingsHaveUniqueNames = ( // Add secrets to binding name validation (secrets is not a CfWorkerInit binding type, // but we want to validate that secret names don't conflict with other bindings) - bindingsGroupedByType["Secret"] = config.secrets?.required ?? []; + bindingsGroupedByType["Secret"] = [ + ...(config.secrets?.required ?? []), + ...(config.secrets?.optional ?? []), + ]; const bindingsGroupedByName: Record = {}; diff --git a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts index 2e447db7e25..99146a88b3b 100644 --- a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts +++ b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts @@ -10167,6 +10167,103 @@ describe("normalizeAndValidateConfig()", () => { ); }); + it("should accept secrets.optional alongside secrets.required", ({ + expect, + }) => { + const rawConfig: RawConfig = { + secrets: { + required: ["API_KEY"], + optional: ["ANALYTICS_TOKEN"], + }, + }; + const { config, diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(config.secrets).toEqual({ + required: ["API_KEY"], + optional: ["ANALYTICS_TOKEN"], + }); + expect(diagnostics.hasErrors()).toBe(false); + expect(diagnostics.hasWarnings()).toBe(false); + }); + + it("should accept secrets.optional on its own", ({ expect }) => { + const rawConfig: RawConfig = { + secrets: { optional: ["ANALYTICS_TOKEN"] }, + }; + const { config, diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(config.secrets).toEqual({ optional: ["ANALYTICS_TOKEN"] }); + expect(diagnostics.hasErrors()).toBe(false); + expect(diagnostics.hasWarnings()).toBe(false); + }); + + it("should error if secrets.optional is not an array", ({ expect }) => { + const rawConfig: RawConfig = { + // @ts-expect-error purposely using an invalid value + secrets: { optional: "API_KEY" }, + }; + const { diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.renderErrors()).toContain( + 'Expected "secrets.optional" to be an array of strings' + ); + }); + + it("should error if secrets.optional contains non-strings", ({ + expect, + }) => { + const rawConfig: RawConfig = { + // @ts-expect-error purposely using an invalid value + secrets: { optional: ["VALID_KEY", 123] }, + }; + const { diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.renderErrors()).toContain( + 'Expected "secrets.optional.[1]" to be of type string' + ); + }); + + it("should error when a name appears in both required and optional", ({ + expect, + }) => { + const rawConfig: RawConfig = { + secrets: { required: ["API_KEY"], optional: ["API_KEY"] }, + }; + const { diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.renderErrors()).toContain( + "API_KEY assigned to multiple Secret bindings" + ); + }); + it("should error if secrets.required is not an array", ({ expect }) => { const rawConfig: RawConfig = { // @ts-expect-error purposely using an invalid value diff --git a/packages/wrangler/src/__tests__/dev/dev-vars.test.ts b/packages/wrangler/src/__tests__/dev/dev-vars.test.ts index 8bc24491c37..22590f84087 100644 --- a/packages/wrangler/src/__tests__/dev/dev-vars.test.ts +++ b/packages/wrangler/src/__tests__/dev/dev-vars.test.ts @@ -152,4 +152,134 @@ describe("getVarsForDev", () => { MY_VARIABLE_A: { type: "plain_text", value: "100900" }, }); }); + + describe("optional secrets", () => { + it("loads both required and optional secrets when both are present", ({ + expect, + }) => { + fs.writeFileSync( + ".dev.vars", + "REQUIRED_SECRET=rainbow\nOPTIONAL_SECRET=moon\nEXTRA=ignored" + ); + + const secrets = { + required: ["REQUIRED_SECRET"], + optional: ["OPTIONAL_SECRET"], + }; + + const result = getVarsForDev( + path.resolve("wrangler.jsonc"), + undefined, + {}, + undefined, + false, + secrets + ); + + expect(result).toEqual({ + REQUIRED_SECRET: { type: "secret_text", value: "rainbow" }, + OPTIONAL_SECRET: { type: "secret_text", value: "moon" }, + }); + expect(std.warn).toBe(""); + }); + + it("does not warn when an optional secret is missing", ({ expect }) => { + fs.writeFileSync(".dev.vars", "REQUIRED_SECRET=rainbow"); + + const secrets = { + required: ["REQUIRED_SECRET"], + optional: ["OPTIONAL_SECRET"], + }; + + const result = getVarsForDev( + path.resolve("wrangler.jsonc"), + undefined, + {}, + undefined, + false, + secrets + ); + + expect(result).toEqual({ + REQUIRED_SECRET: { type: "secret_text", value: "rainbow" }, + }); + expect(result).not.toHaveProperty("OPTIONAL_SECRET"); + expect(std.warn).toBe(""); + }); + + it("still warns about a missing required secret alongside optional ones", ({ + expect, + }) => { + fs.writeFileSync(".dev.vars", "OPTIONAL_SECRET=moon"); + + const secrets = { + required: ["MY_MISSING_SECRET"], + optional: ["OPTIONAL_SECRET"], + }; + + const result = getVarsForDev( + path.resolve("wrangler.jsonc"), + undefined, + {}, + undefined, + false, + secrets + ); + + expect(result).toEqual({ + OPTIONAL_SECRET: { type: "secret_text", value: "moon" }, + }); + expect(std.warn).toContain("Missing required secrets: MY_MISSING_SECRET"); + expect(std.warn).not.toContain("OPTIONAL_SECRET"); + }); + + it("loads optional secrets from .env files", ({ expect }) => { + vi.stubEnv("CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV", "true"); + try { + fs.writeFileSync(".env", "OPTIONAL_SECRET=moon"); + + const secrets = { + required: ["REQUIRED_SECRET"], + optional: ["OPTIONAL_SECRET"], + }; + + const result = getVarsForDev( + path.resolve("wrangler.jsonc"), + undefined, + {}, + undefined, + true, + secrets + ); + + expect(result).toEqual({ + OPTIONAL_SECRET: { type: "secret_text", value: "moon" }, + }); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("excludes undeclared keys when only optional secrets are declared", ({ + expect, + }) => { + fs.writeFileSync(".dev.vars", "OPTIONAL_SECRET=moon\nEXTRA=ignored"); + + const secrets = { optional: ["OPTIONAL_SECRET"] }; + + const result = getVarsForDev( + path.resolve("wrangler.jsonc"), + undefined, + {}, + undefined, + false, + secrets + ); + + expect(result).toEqual({ + OPTIONAL_SECRET: { type: "secret_text", value: "moon" }, + }); + expect(std.warn).toBe(""); + }); + }); }); diff --git a/packages/wrangler/src/__tests__/type-generation.test.ts b/packages/wrangler/src/__tests__/type-generation.test.ts index 11582c9114e..b38b361fa06 100644 --- a/packages/wrangler/src/__tests__/type-generation.test.ts +++ b/packages/wrangler/src/__tests__/type-generation.test.ts @@ -1977,6 +1977,45 @@ describe("generate types - CLI", () => { `); }); + it("should generate types from config `secrets.optional` as `string | undefined`", async ({ + expect, + }) => { + fs.writeFileSync( + "./wrangler.jsonc", + JSON.stringify({ + secrets: { + required: ["API_KEY"], + optional: ["ANALYTICS_TOKEN"], + }, + }), + "utf-8" + ); + + await runWrangler("types --include-runtime=false"); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + Generating project types... + + interface __BaseEnv_Env { + API_KEY: string; + ANALYTICS_TOKEN: string | undefined; + } + declare namespace Cloudflare { + interface Env extends __BaseEnv_Env {} + } + interface Env extends __BaseEnv_Env {} + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📣 Remember to rerun 'wrangler types' after you change your wrangler.jsonc file. + " + `); + }); + it("should ignore .dev.vars when config `secrets` is defined", async ({ expect, }) => { diff --git a/packages/wrangler/src/dev.ts b/packages/wrangler/src/dev.ts index 90cef8db540..43cb3209974 100644 --- a/packages/wrangler/src/dev.ts +++ b/packages/wrangler/src/dev.ts @@ -536,13 +536,17 @@ export function getBindings( }); // createTestHarness() can override secrets through inputBindings. - // This filters out those required secrets so the logic doesn't consider them missing + // This filters out those declared secrets so the logic doesn't consider them + // missing, and doesn't reload them from .dev.vars over the harness value. const secrets = configParam.secrets ? { ...configParam.secrets, required: configParam.secrets?.required?.filter( (secret) => inputBindings?.[secret]?.type !== "secret_text" ), + optional: configParam.secrets?.optional?.filter( + (secret) => inputBindings?.[secret]?.type !== "secret_text" + ), } : undefined; // Override vars with .dev.vars (dev-specific) diff --git a/packages/wrangler/src/dev/dev-vars.ts b/packages/wrangler/src/dev/dev-vars.ts index c9e63c2f476..fd1cbbf2605 100644 --- a/packages/wrangler/src/dev/dev-vars.ts +++ b/packages/wrangler/src/dev/dev-vars.ts @@ -42,10 +42,11 @@ export type VarBinding = Extract< * * When `secrets` is defined in the config, `.dev.vars` values will still * override existing config `vars` (so that local development overrides always - * take effect). Additionally, any `required` secret keys that are not already - * in the config vars will also be loaded from `.dev.vars`. Keys in `.dev.vars` - * that are neither existing config vars nor declared required secrets are - * excluded. A warning is emitted for any required secrets that are missing. + * take effect). Additionally, any `required` or `optional` secret keys that are + * not already in the config vars will also be loaded from `.dev.vars`. Keys in + * `.dev.vars` that are neither existing config vars nor declared secrets are + * excluded. A warning is emitted for any *required* secrets that are missing; + * missing optional secrets are expected and stay silent. * * @param configPath - The path to the Wrangler configuration file, if defined. * @param envFiles - An array of paths to .env files to load; if `undefined` the default .env files will be used (see `getDefaultEnvFiles()`). @@ -53,7 +54,7 @@ export type VarBinding = Extract< * @param vars - The existing `vars` bindings from the Wrangler configuration. * @param env - The specific environment name (e.g., "staging") or `undefined` if no specific environment is set. * @param silent - If true, will not log any messages about the loaded .dev.vars files or .env files. - * @param secrets - If defined, only the declared secret keys are loaded from `.dev.vars` or `.env`/`process.env`. + * @param secrets - If defined, only the declared secret keys (`required` and `optional`) are loaded from `.dev.vars` or `.env`/`process.env`. * @returns The merged `vars` as typed bindings. Config vars are `plain_text`/`json`, while `.dev.vars`/`.env` vars are `secret_text`. */ export function getVarsForDev( @@ -107,18 +108,21 @@ export function getVarsForDev( // in .dev.vars (these remain as secret_text since they come from dev vars). // Then, additionally load declared secret keys from .dev.vars. // Keys in .dev.vars that are neither in the config vars nor declared as - // required secrets are excluded. + // required or optional secrets are excluded. const requiredSecrets = secrets.required ?? []; + const optionalSecrets = secrets.optional ?? []; + const declaredSecrets = [...requiredSecrets, ...optionalSecrets]; if (loadedSecrets !== undefined) { for (const [key, value] of Object.entries(loadedSecrets)) { // Always override if the key was already a config var (plain_text/json), - // or if it is a declared required secret. - if (key in result || requiredSecrets.includes(key)) { + // or if it is a declared secret. + if (key in result || declaredSecrets.includes(key)) { result[key] = { type: "secret_text", value }; } } } - // Warn about missing required secrets + // Warn about missing required secrets. Optional secrets are expected to + // be absent, so they are deliberately excluded from this check. if (!silent) { const missing = requiredSecrets.filter( (key) => loadedSecrets === undefined || !(key in loadedSecrets) diff --git a/packages/wrangler/src/type-generation/index.ts b/packages/wrangler/src/type-generation/index.ts index 8f95738cbfd..98b09ad8b02 100644 --- a/packages/wrangler/src/type-generation/index.ts +++ b/packages/wrangler/src/type-generation/index.ts @@ -783,6 +783,15 @@ export function generateImportSpecifier(from: string, to: string) { } } +/** The TypeScript type emitted for a required secret. */ +const SECRET_TYPE = "string"; + +/** + * The TypeScript type emitted for a secret declared in `secrets.optional`, + * which may legitimately be unset at runtime. + */ +const OPTIONAL_SECRET_TYPE = "string | undefined"; + /** * Checks whether any config level (top-level or any named environment) declares * `secrets`. Used to determine if the project has opted into config-based @@ -845,7 +854,10 @@ export async function generateEnvTypes( // Top-level secrets const topLevelKeys: Record = {}; for (const key of rawConfig.secrets?.required ?? []) { - topLevelKeys[key] = ""; + topLevelKeys[key] = SECRET_TYPE; + } + for (const key of rawConfig.secrets?.optional ?? []) { + topLevelKeys[key] = OPTIONAL_SECRET_TYPE; } perEnvSecrets.set(TOP_LEVEL_ENV_NAME, topLevelKeys); @@ -853,7 +865,10 @@ export async function generateEnvTypes( for (const [envName, envConfig] of Object.entries(rawConfig.env ?? {})) { const envKeys: Record = {}; for (const key of envConfig.secrets?.required ?? []) { - envKeys[key] = ""; + envKeys[key] = SECRET_TYPE; + } + for (const key of envConfig.secrets?.optional ?? []) { + envKeys[key] = OPTIONAL_SECRET_TYPE; } perEnvSecrets.set(envName, envKeys); } @@ -872,9 +887,9 @@ export async function generateEnvTypes( true ); // Extract just the keys as a Record for compatibility - // (type generation only needs the names, not the values) + // (type generation only needs the names and the type to emit, not the values) for (const key of Object.keys(secretBindings)) { - secrets[key] = ""; + secrets[key] = SECRET_TYPE; } } @@ -940,7 +955,7 @@ export async function generateEnvTypes( * @param outputPath - The file path where the generated types will be written * @param entrypoint - Optional entry point information for the Worker * @param serviceEntries - Optional map of service names to their entry points for cross-worker type generation - * @param secrets - Record of secret variable names to their values + * @param secrets - Record of secret variable names to the TypeScript type to emit for them (`string`, or `string | undefined` for optional secrets) * @param command - Optional command string used in the generated env header. * @param log - Whether to log output to the console (default: true) * @@ -1021,7 +1036,7 @@ async function generateSimpleEnvTypes( for (const secretName in secrets) { envTypeStructure.push({ key: constructTypeKey(secretName), - type: "string", + type: secrets[secretName] || SECRET_TYPE, }); stringKeys.push(secretName); } @@ -1263,7 +1278,7 @@ async function generateSimpleEnvTypes( * @param outputPath - The file path where the generated types will be written * @param entrypoint - Optional entry point information for the Worker * @param serviceEntries - Optional map of service names to their entry points for cross-worker type generation - * @param secrets - Record of secret variable names (fallback for all envs when perEnvSecrets is not provided) + * @param secrets - Record of secret variable names to the TypeScript type to emit for them (fallback for all envs when perEnvSecrets is not provided) * @param perEnvSecrets - Optional per-environment secrets map. When provided, each env uses its own secrets instead of the shared fallback. * @param command - Optional command string used in the generated env header. * @param log - Whether to log output to the console (default: true) @@ -1449,8 +1464,12 @@ async function generatePerEnvironmentTypes( } for (const secretName in envSecrets) { - envBindings.push({ key: constructTypeKey(secretName), value: "string" }); - trackBinding(secretName, "string", envName); + const secretType = envSecrets[secretName] || SECRET_TYPE; + envBindings.push({ + key: constructTypeKey(secretName), + value: secretType, + }); + trackBinding(secretName, secretType, envName); if (!stringKeys.includes(secretName)) { stringKeys.push(secretName); } @@ -1550,7 +1569,11 @@ async function generatePerEnvironmentTypes( } for (const secretName in topLevelSecrets) { - trackBinding(secretName, "string", TOP_LEVEL_ENV_NAME); + trackBinding( + secretName, + topLevelSecrets[secretName] || SECRET_TYPE, + TOP_LEVEL_ENV_NAME + ); if (!stringKeys.includes(secretName)) { stringKeys.push(secretName); }