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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/olive-donkeys-shave.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 37 additions & 0 deletions packages/config/src/__tests__/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
52 changes: 52 additions & 0 deletions packages/config/src/__tests__/inference.test.ts
Original file line number Diff line number Diff line change
@@ -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<UnwrapConfig<typeof config>>;

describe("secret bindings", () => {
it("infers `bindings.secret()` as a required string", ({ expect }) => {
expectTypeOf<Env["REQUIRED_SECRET"]>().toEqualTypeOf<string>();
expect(bindings.secret()).toEqual({ type: "secret" });
});

it("infers `bindings.secret({ optional: false })` as a required string", ({
expect,
}) => {
expectTypeOf<Env["EXPLICITLY_REQUIRED_SECRET"]>().toEqualTypeOf<string>();
expect(bindings.secret({ optional: false })).toEqual({
type: "secret",
optional: false,
});
});

it("infers `bindings.secret({ optional: true })` as `string | undefined`", ({
expect,
}) => {
expectTypeOf<Env["OPTIONAL_SECRET"]>().toEqualTypeOf<string | undefined>();
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<keyof Env>().toEqualTypeOf<
"REQUIRED_SECRET" | "EXPLICITLY_REQUIRED_SECRET" | "OPTIONAL_SECRET"
>();
expect(Object.keys(bindings.secret())).toEqual(["type"]);
});
});
55 changes: 51 additions & 4 deletions packages/config/src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 15 additions & 3 deletions packages/config/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ function convertBindingsAndAssets(
> = [];
const vars: Record<string, string | Json> = {};
const secretsRequired: string[] = [];
const secretsOptional: string[] = [];

for (const [name, binding] of Object.entries(env)) {
if (isParsedUnsafeBinding(binding)) {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 11 additions & 9 deletions packages/config/src/inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import type {
JsonBinding,
OptionalSecretBinding,
TextBinding,
TypedAiBinding,
TypedDurableObjectBinding,
Expand Down Expand Up @@ -62,11 +63,11 @@ type ExtractInstance<T, TInstance> =
* 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`.
*
Expand All @@ -76,9 +77,10 @@ type ExtractInstance<T, TInstance> =
* 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<TBinding> {
// Parameterized bindings - refine via nominal match on the binding instance
Expand All @@ -89,6 +91,7 @@ interface BindingTypeMap<TBinding> {
? Pipeline<T>
: Pipeline;
queue: TBinding extends TypedQueueBinding<infer T> ? Queue<T> : Queue;
secret: TBinding extends OptionalSecretBinding ? string | undefined : string;
text: TBinding extends TextBinding<infer T> ? T : never;

// Non-parameterized bindings
Expand All @@ -110,7 +113,6 @@ interface BindingTypeMap<TBinding> {
"mtls-certificate": Fetcher;
"rate-limit": RateLimit;
r2: R2Bucket;
secret: string;
"secrets-store-secret": SecretsStoreSecret;
"send-email": SendEmail;
stream: StreamBinding;
Expand Down
1 change: 1 addition & 0 deletions packages/config/src/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type {
LogfwdrBinding,
MediaBinding,
MtlsCertificateBinding,
OptionalSecretBinding,
PipelineBinding,
QueueBinding,
RateLimitBinding,
Expand Down
5 changes: 4 additions & 1 deletion packages/config/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
12 changes: 12 additions & 0 deletions packages/workers-utils/src/config/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
};

/**
Expand Down
Loading
Loading