From eccd0149000a689d37dfaacdfa6db0989b24bae6 Mon Sep 17 00:00:00 2001 From: James Opstad <13586373+jamesopstad@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:08:48 +0000 Subject: [PATCH 1/2] Add experimental `secrets` property to config validation (#12677) --- .changeset/nine-baths-take.md | 5 + .../workers-utils/src/config/environment.ts | 20 +++ .../workers-utils/src/config/validation.ts | 51 ++++++ .../normalize-and-validate-config.test.ts | 163 ++++++++++++++++++ 4 files changed, 239 insertions(+) create mode 100644 .changeset/nine-baths-take.md diff --git a/.changeset/nine-baths-take.md b/.changeset/nine-baths-take.md new file mode 100644 index 000000000000..f0e8bea712de --- /dev/null +++ b/.changeset/nine-baths-take.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/workers-utils": minor +--- + +Add experimental `secrets` property to config validation diff --git a/packages/workers-utils/src/config/environment.ts b/packages/workers-utils/src/config/environment.ts index 6ae7ce719626..c5d3ba4724cd 100644 --- a/packages/workers-utils/src/config/environment.ts +++ b/packages/workers-utils/src/config/environment.ts @@ -708,6 +708,26 @@ export interface EnvironmentNonInheritable { */ vars: Record; + /** + * Secrets configuration. + * + * NOTE: This field is not automatically inherited from the top level environment, + * and so must be specified in every named environment. + * + * @default undefined + * @nonInheritable + */ + secrets?: { + /** + * List of secret names that are required by your Worker. + * When defined, this property: + * - Replaces .dev.vars/.env inference for type generation + * - Enables deploy-time validation to ensure secrets are configured + * - Enables local dev validation with warnings for missing secrets + */ + required?: string[]; + }; + /** * A list of durable objects that your Worker should be bound to. * diff --git a/packages/workers-utils/src/config/validation.ts b/packages/workers-utils/src/config/validation.ts index 747187f96c0b..f791c7ef5394 100644 --- a/packages/workers-utils/src/config/validation.ts +++ b/packages/workers-utils/src/config/validation.ts @@ -1391,6 +1391,7 @@ function normalizeAndValidateEnvironment( ); experimental(diagnostics, rawEnv, "unsafe"); + experimental(diagnostics, rawEnv, "secrets"); const route = normalizeAndValidateRoute(diagnostics, topLevelEnv, rawEnv); @@ -1558,6 +1559,16 @@ function normalizeAndValidateEnvironment( validateVars(envName), {} ), + secrets: notInheritable( + diagnostics, + topLevelEnv, + rawConfig, + rawEnv, + envName, + "secrets", + validateSecrets(envName), + undefined + ), define: notInheritable( diagnostics, topLevelEnv, @@ -2235,6 +2246,42 @@ const validateVars = return isValid; }; +const validateSecrets = + (envName: string): ValidatorFn => + (diagnostics, field, value, config) => { + const fieldPath = + config === undefined ? `${field}` : `env.${envName}.${field}`; + + if (value === undefined) { + return true; + } + + if (typeof value !== "object" || value === null || Array.isArray(value)) { + diagnostics.errors.push( + `The field "${fieldPath}" should be an object but got ${JSON.stringify(value)}.` + ); + return false; + } + + let isValid = true; + + // Warn about unexpected properties + validateAdditionalProperties(diagnostics, fieldPath, Object.keys(value), [ + "required", + ]); + + // Validate 'required' property if present + isValid = + validateOptionalTypedArray( + diagnostics, + `${fieldPath}.required`, + (value as Record).required, + "string" + ) && isValid; + + return isValid; + }; + const validateBindingsProperty = (envName: string, validateBinding: ValidatorFn): ValidatorFn => (diagnostics, field, value, config) => { @@ -3921,6 +3968,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 ?? []; + const bindingsGroupedByName: Record = {}; for (const bindingType in bindingsGroupedByType) { 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 3d332710a5c4..b5ee03aa0b34 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 @@ -6090,6 +6090,169 @@ describe("normalizeAndValidateConfig()", () => { }); }); + describe("[secrets]", () => { + it("should accept valid secrets config", ({ expect }) => { + const rawConfig: RawConfig = { + secrets: { + required: ["API_KEY", "DATABASE_PASSWORD"], + }, + }; + const { config, diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(config.secrets).toEqual({ + required: ["API_KEY", "DATABASE_PASSWORD"], + }); + expect(diagnostics.hasErrors()).toBe(false); + // Expect experimental warning + expect(diagnostics.hasWarnings()).toBe(true); + expect(diagnostics.renderWarnings()).toContain( + '"secrets" fields are experimental' + ); + }); + + it("should error if secrets is not an object", ({ expect }) => { + const rawConfig: RawConfig = { + // @ts-expect-error purposely using an invalid value + secrets: "invalid", + }; + const { diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.renderErrors()).toContain( + 'The field "secrets" should be an object' + ); + }); + + it("should error if secrets.required is not an array", ({ expect }) => { + const rawConfig: RawConfig = { + // @ts-expect-error purposely using an invalid value + secrets: { required: "API_KEY" }, + }; + const { diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.renderErrors()).toContain( + 'Expected "secrets.required" to be an array of strings' + ); + }); + + it("should error if secrets.required contains non-strings", ({ + expect, + }) => { + const rawConfig: RawConfig = { + // @ts-expect-error purposely using an invalid value + secrets: { required: ["VALID_KEY", 123, true] }, + }; + const { diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.renderErrors()).toContain( + 'Expected "secrets.required.[1]" to be of type string' + ); + expect(diagnostics.renderErrors()).toContain( + 'Expected "secrets.required.[2]" to be of type string' + ); + }); + + it("should error on duplicate secret names", ({ expect }) => { + const rawConfig: RawConfig = { + secrets: { required: ["API_KEY", "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 on secret name conflicting with var", ({ expect }) => { + const rawConfig: RawConfig = { + vars: { API_KEY: "not-a-secret" }, + secrets: { required: ["API_KEY"] }, + }; + const { diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.renderErrors()).toContain( + "API_KEY assigned to Environment Variable and Secret bindings" + ); + }); + + describe("per-environment overrides", () => { + it("should accept valid secrets in environment", ({ expect }) => { + const rawConfig: RawConfig = { + env: { + production: { + secrets: { required: ["PROD_API_KEY"] }, + }, + }, + }; + const { config, diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + undefined, + { env: "production" } + ); + + expect(config.secrets).toEqual({ required: ["PROD_API_KEY"] }); + expect(diagnostics.hasErrors()).toBe(false); + }); + + it("should warn when secrets exists at top level but not in environment", ({ + expect, + }) => { + const rawConfig: RawConfig = { + secrets: { required: ["API_KEY"] }, + env: { + production: {}, + }, + }; + const { diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + undefined, + { env: "production" } + ); + + expect(diagnostics.hasErrors()).toBe(false); + expect(diagnostics.renderWarnings()).toContain( + '"secrets" exists at the top level, but not on "env.production"' + ); + }); + }); + }); + describe("[durable_objects]", () => { it("should error if durable_objects is an array", ({ expect }) => { const { diagnostics } = normalizeAndValidateConfig( From 53025f96ce307aa97c4efc1948dff6701dc526df Mon Sep 17 00:00:00 2001 From: James Opstad <13586373+jamesopstad@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:57:39 +0000 Subject: [PATCH 2/2] Fix Miniflare being incorrectly disposed during rapid dev server restarts (#12684) --- .changeset/proud-items-switch.md | 5 +++++ .../bindings/__tests__/shortcuts.spec.ts | 6 +++--- packages/vite-plugin-cloudflare/src/context.ts | 14 +++++++++----- packages/vite-plugin-cloudflare/src/index.ts | 6 +++--- 4 files changed, 20 insertions(+), 11 deletions(-) create mode 100644 .changeset/proud-items-switch.md diff --git a/.changeset/proud-items-switch.md b/.changeset/proud-items-switch.md new file mode 100644 index 000000000000..2feccd037803 --- /dev/null +++ b/.changeset/proud-items-switch.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/vite-plugin": patch +--- + +Fix Miniflare being incorrectly disposed during rapid dev server restarts diff --git a/packages/vite-plugin-cloudflare/playground/bindings/__tests__/shortcuts.spec.ts b/packages/vite-plugin-cloudflare/playground/bindings/__tests__/shortcuts.spec.ts index 706b406bd770..823526dd7e92 100644 --- a/packages/vite-plugin-cloudflare/playground/bindings/__tests__/shortcuts.spec.ts +++ b/packages/vite-plugin-cloudflare/playground/bindings/__tests__/shortcuts.spec.ts @@ -32,7 +32,7 @@ describe.skipIf(!satisfiesViteVersion("7.2.7"))("shortcuts", () => { // Set up the shortcut wrapper (after stubs are in place from beforeAll) const mockContext = new PluginContext({ hasShownWorkerConfigWarnings: false, - isRestartingDevServer: false, + restartingDevServerCount: 0, }); mockContext.setResolvedPluginConfig( resolvePluginConfig( @@ -69,7 +69,7 @@ describe.skipIf(!satisfiesViteVersion("7.2.7"))("shortcuts", () => { // Create mock plugin context const mockContext = new PluginContext({ hasShownWorkerConfigWarnings: false, - isRestartingDevServer: false, + restartingDevServerCount: 0, }); mockContext.setResolvedPluginConfig( @@ -125,7 +125,7 @@ describe.skipIf(!satisfiesViteVersion("7.2.7"))("shortcuts", () => { // Create mock plugin context const mockContext = new PluginContext({ hasShownWorkerConfigWarnings: false, - isRestartingDevServer: false, + restartingDevServerCount: 0, }); mockContext.setResolvedPluginConfig( diff --git a/packages/vite-plugin-cloudflare/src/context.ts b/packages/vite-plugin-cloudflare/src/context.ts index 927992d9222a..71f700194bfd 100644 --- a/packages/vite-plugin-cloudflare/src/context.ts +++ b/packages/vite-plugin-cloudflare/src/context.ts @@ -24,8 +24,8 @@ export interface SharedContext { miniflare?: Miniflare; workerNameToExportTypesMap?: Map; hasShownWorkerConfigWarnings: boolean; - /** Used to track whether hooks are being called because of a server restart or a server close event */ - isRestartingDevServer: boolean; + /** Tracks the number of in-flight dev server restarts (0 means no restart in progress) */ + restartingDevServerCount: number; } /** @@ -108,12 +108,16 @@ export class PluginContext { return this.#sharedContext.hasShownWorkerConfigWarnings; } - setIsRestartingDevServer(isRestartingDevServer: boolean): void { - this.#sharedContext.isRestartingDevServer = isRestartingDevServer; + beginRestartingDevServer(): void { + this.#sharedContext.restartingDevServerCount++; + } + + endRestartingDevServer(): void { + this.#sharedContext.restartingDevServerCount--; } get isRestartingDevServer(): boolean { - return this.#sharedContext.isRestartingDevServer; + return this.#sharedContext.restartingDevServerCount > 0; } setResolvedPluginConfig(resolvedPluginConfig: ResolvedPluginConfig): void { diff --git a/packages/vite-plugin-cloudflare/src/index.ts b/packages/vite-plugin-cloudflare/src/index.ts index db85954ad7f8..9a07b9c47542 100644 --- a/packages/vite-plugin-cloudflare/src/index.ts +++ b/packages/vite-plugin-cloudflare/src/index.ts @@ -32,7 +32,7 @@ export type { WorkerConfig } from "./workers-configs"; const sharedContext: SharedContext = { hasShownWorkerConfigWarnings: false, - isRestartingDevServer: false, + restartingDevServerCount: 0, }; await assertWranglerVersion(); @@ -65,12 +65,12 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] { const restartServer = viteDevServer.restart.bind(viteDevServer); viteDevServer.restart = async () => { try { - ctx.setIsRestartingDevServer(true); + ctx.beginRestartingDevServer(); debuglog("From server.restart(): Restarting server..."); await restartServer(); debuglog("From server.restart(): Restarted server..."); } finally { - ctx.setIsRestartingDevServer(false); + ctx.endRestartingDevServer(); } }; },