diff --git a/.changeset/new-config-modes.md b/.changeset/new-config-modes.md new file mode 100644 index 00000000000..bc72e3ec6ae --- /dev/null +++ b/.changeset/new-config-modes.md @@ -0,0 +1,31 @@ +--- +"wrangler": minor +--- + +Add `modes` to `cloudflare.config.ts` for declarative per-environment config + +This extends the experimental `--x-new-config` feature. Per-environment config was previously only expressible by branching on `ctx.mode` inside the function form of a config. That works at runtime, but it is opaque to `wrangler types`: the generated `Env` collapses every branch together, so bindings that only exist in one environment cannot be distinguished from ones that always exist. + +`modes` declares those differences statically instead. Each entry is a partial Worker config layered over the base when that mode is selected with `--env` or `CLOUDFLARE_ENV`. `env` and `exports` merge per key so a mode only states what differs; every other field replaces the base value outright. + +```ts +export default defineWorker({ + name: "my-worker", + compatibilityDate: "2026-06-01", + env: { SHARED_KV: bindings.kv({ id: "..." }) }, + modes: { + staging: { env: { API_KEY: bindings.secret() } }, + production: { + name: "my-worker-prod", + env: { + API_KEY: bindings.secret(), + ANALYTICS: bindings.r2({ name: "..." }), + }, + }, + }, +}); +``` + +Because modes are declared rather than computed, `wrangler types` can now aggregate them the same way it does named environments in the Wrangler JSON config. A binding every mode declares is required on `Env`, one that only some declare is optional, and its type is the union of the types the declaring modes give it. Each mode's exact `Env` is also available as `Cloudflare.EnvFor<"production">`, with the declared names as `Cloudflare.Mode`. + +Combined with ordinary `import` statements, this lets a large config be split across files while keeping a single aggregated `Env`. Branching on `ctx.mode` continues to work unchanged for configs that do not declare `modes`. diff --git a/fixtures/experimental-new-config/worker-configuration.d.ts b/fixtures/experimental-new-config/worker-configuration.d.ts index 66d51676d11..c9682948dd2 100644 --- a/fixtures/experimental-new-config/worker-configuration.d.ts +++ b/fixtures/experimental-new-config/worker-configuration.d.ts @@ -1,7 +1,7 @@ /* eslint-disable */ // Generated by @cloudflare/config type __WorkerConfig = import("wrangler/experimental-config").UnwrapConfig; -type __Env = import("wrangler/experimental-config").InferEnv<__WorkerConfig>; +type __Env = import("wrangler/experimental-config").InferAggregatedEnv<__WorkerConfig>; declare namespace Cloudflare { interface GlobalProps { @@ -9,5 +9,9 @@ declare namespace Cloudflare { durableNamespaces: import("wrangler/experimental-config").InferDurableNamespaces<__WorkerConfig>; } interface Env extends __Env {} + /** The modes declared in your config, or `never` if there are none. */ + type Mode = import("wrangler/experimental-config").InferModeNames<__WorkerConfig>; + /** The `Env` for one specific mode, with that mode's overrides applied. */ + type EnvFor = import("wrangler/experimental-config").InferEnvForMode<__WorkerConfig, TMode>; } interface Env extends Cloudflare.Env {} diff --git a/packages/config/src/__tests__/generate.test.ts b/packages/config/src/__tests__/generate.test.ts index 09eef97b2f2..96a84c9300a 100644 --- a/packages/config/src/__tests__/generate.test.ts +++ b/packages/config/src/__tests__/generate.test.ts @@ -33,11 +33,21 @@ describe("generateTypes", () => { }) => { const out = generateTypes({ configPath: "./cloudflare.config.ts" }); expect(out).toContain( - `type __Env = import("@cloudflare/config").InferEnv<__WorkerConfig>;` + `type __Env = import("@cloudflare/config").InferAggregatedEnv<__WorkerConfig>;` ); expect(out).toContain(`interface Env extends __Env {}`); }); + it("exposes the declared modes and their per-mode envs", ({ expect }) => { + const out = generateTypes({ configPath: "./cloudflare.config.ts" }); + expect(out).toContain( + `type Mode = import("@cloudflare/config").InferModeNames<__WorkerConfig>;` + ); + expect(out).toContain( + `type EnvFor = import("@cloudflare/config").InferEnvForMode<__WorkerConfig, TMode>;` + ); + }); + it("emits a global script (no top-level import/export, no declare global)", ({ expect, }) => { diff --git a/packages/config/src/__tests__/modes.test.ts b/packages/config/src/__tests__/modes.test.ts new file mode 100644 index 00000000000..44d8881c193 --- /dev/null +++ b/packages/config/src/__tests__/modes.test.ts @@ -0,0 +1,383 @@ +import { describe, it } from "vitest"; +import { bindings } from "../bindings"; +import { applyMode, UnknownModeError } from "../modes"; +import { ConfigExportsSchema } from "../schema"; +import { defineWorker } from "../worker-definition"; +import type { + InferAggregatedEnv, + InferEnvForMode, + InferModeNames, + UnwrapConfig, +} from "../inference"; +import type { ParsedInputWorkerConfig } from "../schema"; + +const baseConfig = { + type: "worker", + name: "my-worker", + compatibilityDate: "2026-06-01", + env: {}, +} satisfies ParsedInputWorkerConfig; + +describe("applyMode", () => { + it("returns the base config when no mode is selected", ({ expect }) => { + const result = applyMode( + { + ...baseConfig, + env: { SHARED: { type: "kv" } }, + modes: { production: { env: { API_KEY: { type: "secret" } } } }, + }, + undefined + ); + + expect(result).toEqual({ + type: "worker", + name: "my-worker", + compatibilityDate: "2026-06-01", + env: { SHARED: { type: "kv" } }, + }); + }); + + it("strips `modes` from the result", ({ expect }) => { + const result = applyMode( + { ...baseConfig, modes: { production: {} } }, + "production" + ); + + expect(result).not.toHaveProperty("modes"); + }); + + it("is a no-op for a config without modes", ({ expect }) => { + const result = applyMode({ ...baseConfig }, undefined); + + expect(result).toEqual(baseConfig); + }); + + it("merges `env` per binding, keeping bindings the mode does not mention", ({ + expect, + }) => { + const result = applyMode( + { + ...baseConfig, + env: { SHARED: { type: "kv" }, OVERRIDDEN: { type: "kv" } }, + modes: { + production: { + env: { + OVERRIDDEN: { type: "r2", name: "prod-bucket" }, + ADDED: { type: "secret" }, + }, + }, + }, + }, + "production" + ); + + expect(result.env).toEqual({ + SHARED: { type: "kv" }, + OVERRIDDEN: { type: "r2", name: "prod-bucket" }, + ADDED: { type: "secret" }, + }); + }); + + it("merges `exports` per key", ({ expect }) => { + const result = applyMode( + { + ...baseConfig, + exports: { Counter: { type: "durable-object", storage: "sqlite" } }, + modes: { + production: { + exports: { + Sessions: { type: "durable-object", storage: "sqlite" }, + }, + }, + }, + }, + "production" + ); + + expect(result.exports).toEqual({ + Counter: { type: "durable-object", storage: "sqlite" }, + Sessions: { type: "durable-object", storage: "sqlite" }, + }); + }); + + it("replaces scalar fields rather than merging them", ({ expect }) => { + const result = applyMode( + { + ...baseConfig, + name: "my-worker", + logpush: false, + modes: { production: { name: "my-worker-prod", logpush: true } }, + }, + "production" + ); + + expect(result.name).toBe("my-worker-prod"); + expect(result.logpush).toBe(true); + }); + + it("replaces arrays outright so an inherited flag can always be dropped", ({ + expect, + }) => { + const result = applyMode( + { + ...baseConfig, + compatibilityFlags: ["nodejs_compat", "no_global_navigator"], + modes: { production: { compatibilityFlags: ["nodejs_compat"] } }, + }, + "production" + ); + + expect(result.compatibilityFlags).toEqual(["nodejs_compat"]); + }); + + it("leaves base fields the mode does not mention untouched", ({ expect }) => { + const result = applyMode( + { + ...baseConfig, + compatibilityFlags: ["nodejs_compat"], + logpush: true, + modes: { production: { name: "my-worker-prod" } }, + }, + "production" + ); + + expect(result.compatibilityFlags).toEqual(["nodejs_compat"]); + expect(result.logpush).toBe(true); + }); + + it("throws under `strict` for a mode the config does not declare", ({ + expect, + }) => { + expect(() => + applyMode( + { ...baseConfig, modes: { staging: {}, production: {} } }, + "prod", + { strict: true } + ) + ).toThrow(UnknownModeError); + + expect(() => + applyMode( + { ...baseConfig, modes: { staging: {}, production: {} } }, + "prod", + { strict: true } + ) + ).toThrow(`No mode named "prod" is defined in your config.`); + }); + + it("falls back to the base config for an undeclared mode when not strict", ({ + expect, + }) => { + // Vite always supplies a mode ("development" for `vite dev`), and a config + // is not obliged to declare one for it. Erroring here would refuse to start + // the dev server for any config that uses modes at all. + const result = applyMode( + { + ...baseConfig, + env: { SHARED: { type: "kv" } }, + modes: { staging: {}, production: {} }, + }, + "development" + ); + + expect(result.env).toEqual({ SHARED: { type: "kv" } }); + expect(result).not.toHaveProperty("modes"); + }); + + it("lists the available modes on the thrown error", ({ expect }) => { + try { + applyMode( + { ...baseConfig, modes: { staging: {}, production: {} } }, + "prod", + { strict: true } + ); + expect.unreachable("applyMode should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(UnknownModeError); + const error = e as UnknownModeError; + expect(error.mode).toBe("prod"); + expect(error.availableModes).toEqual(["staging", "production"]); + expect(error.message).toContain( + `Available modes: "staging", "production"` + ); + } + }); + + // `modes` is an ordinary object, so a bare `modes[mode]` lookup would find + // these on Object.prototype and mistake them for a declared mode. + for (const inherited of ["toString", "constructor", "valueOf", "__proto__"]) { + it(`does not treat the inherited property ${inherited} as a declared mode`, ({ + expect, + }) => { + expect(() => + applyMode({ ...baseConfig, modes: { staging: {} } }, inherited, { + strict: true, + }) + ).toThrow(UnknownModeError); + + const result = applyMode( + { + ...baseConfig, + env: { SHARED: { type: "kv" } }, + modes: { staging: {} }, + }, + inherited + ); + expect(result.env).toEqual({ SHARED: { type: "kv" } }); + }); + } + + it("passes a config without modes through untouched, whatever the mode", ({ + expect, + }) => { + // The function form of a config receives `ctx.mode` and may branch on it + // itself, so there is nothing to select here and nothing to complain about. + const result = applyMode({ ...baseConfig }, "production"); + + expect(result).toEqual(baseConfig); + }); +}); + +// `loadAndValidateConfig` re-validates after applying a mode. These cover the +// conflicts that only exist once base and mode bindings share one `env`. +describe("validation of the merged config", () => { + it("rejects two singleton bindings that only collide after merging", ({ + expect, + }) => { + const merged = applyMode( + { + ...baseConfig, + env: { SMART: { type: "ai" } }, + modes: { production: { env: { CLEVER: { type: "ai" } } } }, + }, + "production" + ); + + const result = ConfigExportsSchema.safeParse({ default: merged }); + + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toContain( + "can only be defined once" + ); + }); + + it("accepts the same config when the offending mode is not selected", ({ + expect, + }) => { + const base = applyMode( + { + ...baseConfig, + env: { SMART: { type: "ai" } }, + modes: { production: { env: { CLEVER: { type: "ai" } } } }, + }, + undefined + ); + + expect(ConfigExportsSchema.safeParse({ default: base }).success).toBe(true); + }); + + it("survives a second parse after `entrypoint` has been transformed", ({ + expect, + }) => { + // The first parse rewrites `{ default: "..." }` to a plain string. Parsing + // the output again must not reject that already-transformed shape. + const once = ConfigExportsSchema.safeParse({ + default: { ...baseConfig, entrypoint: { default: "./src/index.ts" } }, + }); + expect(once.success).toBe(true); + + const twice = ConfigExportsSchema.safeParse(once.data); + expect(twice.success).toBe(true); + expect( + twice.data?.default && "entrypoint" in twice.data.default + ? twice.data.default.entrypoint + : undefined + ).toBe("./src/index.ts"); + }); +}); + +// Type-level behaviour is the point of this feature, so it is asserted at +// compile time. A regression in the inference helpers fails `tsc`, not vitest. +describe("mode type inference", () => { + const config = defineWorker({ + name: "my-worker", + compatibilityDate: "2026-06-01", + env: { SHARED_KV: bindings.kv() }, + modes: { + staging: { env: { API_KEY: bindings.secret() } }, + production: { + env: { API_KEY: bindings.secret(), ANALYTICS: bindings.r2() }, + }, + }, + }); + + type Config = UnwrapConfig; + + it("infers the declared mode names", ({ expect }) => { + type Modes = InferModeNames; + const modes: Modes[] = ["staging", "production"]; + + expect(modes).toEqual(["staging", "production"]); + }); + + it("layers a mode's bindings over the base bindings", ({ expect }) => { + type StagingEnv = InferEnvForMode; + const staging: StagingEnv = { + SHARED_KV: {} as KVNamespace, + API_KEY: "secret-value", + }; + + expect(staging.API_KEY).toBe("secret-value"); + }); + + it("requires bindings every mode declares and makes the rest optional", ({ + expect, + }) => { + type AggregatedEnv = InferAggregatedEnv; + + // `ANALYTICS` is production-only, so it is optional here. Omitting it must + // still type check. + const aggregated: AggregatedEnv = { + SHARED_KV: {} as KVNamespace, + API_KEY: "secret-value", + }; + + // `SHARED_KV` and `API_KEY` are in every mode, so they are required. + type SharedIsRequired = undefined extends AggregatedEnv["SHARED_KV"] + ? false + : true; + const sharedIsRequired: SharedIsRequired = true; + + type AnalyticsIsOptional = AggregatedEnv extends { ANALYTICS?: unknown } + ? AggregatedEnv extends { ANALYTICS: unknown } + ? false + : true + : false; + const analyticsIsOptional: AnalyticsIsOptional = true; + + expect(aggregated.ANALYTICS).toBeUndefined(); + expect(sharedIsRequired).toBe(true); + expect(analyticsIsOptional).toBe(true); + }); + + it("falls back to the plain env for a config without modes", ({ expect }) => { + const plain = defineWorker({ + name: "my-worker", + compatibilityDate: "2026-06-01", + env: { SHARED_KV: bindings.kv() }, + }); + + type PlainEnv = InferAggregatedEnv>; + const env: PlainEnv = { SHARED_KV: {} as KVNamespace }; + + type HasNoModes = [InferModeNames>] extends [ + never, + ] + ? true + : false; + const hasNoModes: HasNoModes = true; + + expect(env).toBeDefined(); + expect(hasNoModes).toBe(true); + }); +}); diff --git a/packages/config/src/config-loader.ts b/packages/config/src/config-loader.ts index 3bcebbcbd1f..03016dca7c1 100644 --- a/packages/config/src/config-loader.ts +++ b/packages/config/src/config-loader.ts @@ -1,5 +1,6 @@ import { resolveExportDefinition } from "./definition"; import { loadConfig } from "./load"; +import { applyMode } from "./modes"; import { ConfigExportsSchema } from "./schema"; import type { ConfigContext } from "./definition"; import type { ParsedConfigExports } from "./schema"; @@ -17,11 +18,20 @@ export interface LoadAndValidateConfigResult { /** * Load a `cloudflare.config.ts`, resolve all exports, and validate against {@link ConfigExportsSchema}. + * + * Worker exports have their `modes` collapsed against `ctx.mode` after + * validation, so every caller downstream sees a single flat config and never + * has to reason about mode selection itself. + * + * Set `strictModes` when `ctx.mode` is an explicit user selection, so that + * naming a mode the config does not declare raises {@link UnknownModeError} + * rather than silently falling back to the base config. Callers whose mode is + * ambient and always populated should leave it off. See {@link applyMode}. */ export async function loadAndValidateConfig( configPath: string, ctx: ConfigContext, - options?: { include?: string[] } + options?: { include?: string[]; strictModes?: boolean } ): Promise { const { exports, dependencies } = await loadConfig(configPath, options); @@ -32,5 +42,28 @@ export async function loadAndValidateConfig( const result = ConfigExportsSchema.safeParse(resolved); - return { result, dependencies }; + if (!result.success) { + return { result, dependencies }; + } + + // The pass above validates the config as authored, so a bad binding inside a + // mode reports against `modes..env.` rather than a merged path + // the user never wrote. + const withModesApplied: Record = {}; + for (const [name, value] of Object.entries(result.data)) { + withModesApplied[name] = + value.type === "worker" + ? applyMode(value, ctx.mode, { strict: options?.strictModes }) + : value; + } + + // Merging can produce a config that neither the base nor the mode violated on + // its own. Singleton bindings are the motivating case: one `ai` binding in the + // base and another under a different name in a mode are individually fine but + // invalid together, and that is only visible once they share an `env`. Errors + // here are inherently about the merged result, so merged paths are the right + // thing to report. + const mergedResult = ConfigExportsSchema.safeParse(withModesApplied); + + return { result: mergedResult, dependencies }; } diff --git a/packages/config/src/generate.ts b/packages/config/src/generate.ts index 6196fed5ea1..9373f74e407 100644 --- a/packages/config/src/generate.ts +++ b/packages/config/src/generate.ts @@ -41,7 +41,7 @@ export function generateTypes({ /* eslint-disable */ // Generated by @cloudflare/config type __WorkerConfig = import("${packageName}").UnwrapConfig; - type __Env = import("${packageName}").InferEnv<__WorkerConfig>; + type __Env = import("${packageName}").InferAggregatedEnv<__WorkerConfig>; declare namespace Cloudflare { interface GlobalProps { @@ -49,6 +49,10 @@ export function generateTypes({ durableNamespaces: import("${packageName}").InferDurableNamespaces<__WorkerConfig>; } interface Env extends __Env {} + /** The modes declared in your config, or \`never\` if there are none. */ + type Mode = import("${packageName}").InferModeNames<__WorkerConfig>; + /** The \`Env\` for one specific mode, with that mode's overrides applied. */ + type EnvFor = import("${packageName}").InferEnvForMode<__WorkerConfig, TMode>; } interface Env extends Cloudflare.Env {} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index e8f88dad60c..489475ed346 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -9,6 +9,7 @@ export { export { generateTypes } from "./generate"; export { convertToWranglerConfig } from "./convert"; export { loadConfig, registerConfigHooks } from "./load"; +export { applyMode, UnknownModeError } from "./modes"; export { loadAndValidateConfig } from "./config-loader"; export { resolveExportDefinition } from "./definition"; export type { LoadConfigResult } from "./load"; diff --git a/packages/config/src/inference.ts b/packages/config/src/inference.ts index 7068ed2cf22..721821e91d2 100644 --- a/packages/config/src/inference.ts +++ b/packages/config/src/inference.ts @@ -281,6 +281,148 @@ export type InferEnv = TUnwrappedConfig extends { ? { [K in keyof TEnv]: InferBindingType } : never; +/** + * Flatten an intersection into a single object literal so editor hovers and + * error messages show the resolved shape rather than `A & B & C`. + * + * The mapped type is homomorphic, so optional modifiers survive. + */ +type Simplify = { [K in keyof T]: T[K] }; + +/** + * Distribute a union into an intersection. Used below to recover the full set + * of binding names across every mode, since `keyof (A | B)` yields only the + * keys `A` and `B` share. + */ +type UnionToIntersection = ( + TUnion extends unknown ? (arg: TUnion) => void : never +) extends (arg: infer TIntersection) => void + ? TIntersection + : never; + +/** + * The names of the modes a Worker config declares, as a string union. Resolves + * to `never` for a config without `modes`. + * + * @example + * ```typescript + * const config = defineWorker({ + * name: "my-worker", + * modes: { staging: {}, production: {} }, + * }); + * + * type WorkerConfig = UnwrapConfig; + * // Inferred as: "staging" | "production" + * type Modes = InferModeNames; + * ``` + */ +export type InferModeNames = TUnwrappedConfig extends { + modes: infer TModes; +} + ? keyof TModes & string + : never; + +/** A bindings map with no entries, used when a config or mode declares no `env`. */ +type NoBindings = Record; + +/** The raw `env` bindings map declared at the top level of a config. */ +type BaseEnvSource = TUnwrappedConfig extends { + env: infer TEnv extends Record; +} + ? TEnv + : NoBindings; + +/** The raw `env` bindings map declared by a single mode. */ +type ModeEnvSource< + TUnwrappedConfig, + TMode extends string, +> = TUnwrappedConfig extends { modes: infer TModes } + ? TMode extends keyof TModes + ? TModes[TMode] extends { env: infer TEnv extends Record } + ? TEnv + : NoBindings + : NoBindings + : NoBindings; + +/** + * Infer the `Env` interface for one specific mode. + * + * Mirrors what `applyMode` does at runtime: the mode's bindings are layered + * over the base config's, and a name declared in both resolves to the mode's. + * + * @example + * ```typescript + * const config = defineWorker({ + * name: "my-worker", + * env: { SHARED_KV: bindings.kv() }, + * modes: { production: { env: { API_KEY: bindings.secret() } } }, + * }); + * + * type WorkerConfig = UnwrapConfig; + * // Inferred as: { SHARED_KV: KVNamespace; API_KEY: string } + * type ProdEnv = InferEnvForMode; + * ``` + */ +export type InferEnvForMode = Simplify< + InferEnv<{ + env: Omit< + BaseEnvSource, + keyof ModeEnvSource + > & + ModeEnvSource; + }> +>; + +/** Every mode's resolved `Env`, keyed by mode name. */ +type ModeEnvMap = { + [TMode in InferModeNames]: InferEnvForMode< + TUnwrappedConfig, + TMode + >; +}; + +/** Binding names shared by every mode. */ +type CommonKeys = keyof TEnvMap[keyof TEnvMap]; + +/** Binding names present in at least one mode. */ +type AllKeys = keyof UnionToIntersection; + +/** The union of a binding's types across the modes that declare it. */ +type ValueAcrossModes = { + [TMode in keyof TEnvMap]: TKey extends keyof TEnvMap[TMode] + ? TEnvMap[TMode][TKey] + : never; +}[keyof TEnvMap]; + +/** + * Infer a single `Env` covering every mode a config declares. + * + * A binding is required when every mode has it and optional when only some do, + * and its type is the union of the types the declaring modes give it. This + * matches how `wrangler types` aggregates `env.*` for the Wrangler JSON config, + * so code written against `Env` type checks regardless of which mode it is + * deployed with. + * + * Falls back to {@link InferEnv} when the config declares no modes. + */ +export type InferAggregatedEnv = [ + InferModeNames, +] extends [never] + ? InferEnv + : Simplify< + { + [TKey in CommonKeys>]: ValueAcrossModes< + ModeEnvMap, + TKey + >; + } & { + [TKey in Exclude< + AllKeys>, + CommonKeys> + >]?: ValueAcrossModes, TKey>; + } + >; + /** * Infer the Durable Object namespace names from a Worker config's exports. * Returns a union of export names that declare a *live* Durable Object — diff --git a/packages/config/src/modes.ts b/packages/config/src/modes.ts new file mode 100644 index 00000000000..1831d48ba67 --- /dev/null +++ b/packages/config/src/modes.ts @@ -0,0 +1,116 @@ +import type { ParsedInputWorkerConfig } from "./schema"; + +/** + * Thrown when `--env`/`CLOUDFLARE_ENV` names a mode the config does not declare. + * + * Callers are expected to catch this and re-throw it as whatever their own + * user-facing error type is (`UserError` in Wrangler, for example) so the + * message is presented consistently with the rest of their output. + */ +export class UnknownModeError extends Error { + readonly mode: string; + readonly availableModes: string[]; + + constructor(mode: string, availableModes: string[]) { + const available = availableModes.length + ? availableModes.map((name) => `"${name}"`).join(", ") + : "none"; + super( + `No mode named "${mode}" is defined in your config. Available modes: ${available}.` + ); + this.name = "UnknownModeError"; + this.mode = mode; + this.availableModes = availableModes; + } +} + +/** + * Fields that are merged key by key rather than replaced wholesale. + * + * These are the two record-shaped fields on a Worker config. Merging them means + * a mode can add a single binding without having to restate every binding the + * base config already declared, which is the whole point of splitting a large + * config up in the first place. + */ +const MERGED_RECORD_FIELDS = ["env", "exports"] as const; + +/** + * Collapse a config's `modes` down to a single flat Worker config. + * + * The base config supplies the defaults and the selected mode's overrides are + * layered on top: + * + * - `env` and `exports` merge per key, so a mode adding `API_KEY` keeps every + * binding the base declared. A key present in both takes the mode's value. + * - Every other field replaces the base value outright. Arrays in particular + * are not concatenated: a mode that sets `compatibilityFlags` owns that list + * completely, which avoids the surprise of inheriting a flag you cannot drop. + * + * `modes` is always stripped from the result, so nothing downstream of config + * loading needs to know the feature exists. + * + * Passing `undefined` for `mode` selects the base config, which is what happens + * when no `--env` flag and no `CLOUDFLARE_ENV` variable are set. + * + * A config that declares no `modes` is returned untouched whatever the mode is. + * The function form of a config receives `ctx.mode` and may branch on it + * directly, so a mode with nothing to select here is not an error. + * + * `strict` controls what happens when the config declares `modes` but not the + * selected one. Callers where the mode is an explicit user choice should set it + * so a typo is caught: `wrangler deploy --env prodction` is a mistake worth + * reporting. Callers where the mode is ambient should leave it off, because + * they always supply a value and a config is not obliged to name it. Vite is + * the motivating case: `ConfigEnv.mode` defaults to `"development"` for `vite + * dev` and `"production"` for `vite build`, so erroring on an unlisted mode + * would refuse to start the dev server for any config that declares modes at + * all. + * + * @throws {UnknownModeError} If `strict` and the config declares `modes` but + * not this one. + */ +export function applyMode( + config: ParsedInputWorkerConfig, + mode: string | undefined, + options: { strict?: boolean } = {} +): ParsedInputWorkerConfig { + const { modes, ...base } = config; + + if (mode === undefined || modes === undefined) { + return base; + } + + // `modes` comes from `z.record`, which returns an ordinary object, so a mode + // named after something on `Object.prototype` ("toString", "constructor") + // would otherwise resolve to an inherited value and pass for a declared mode. + const override = Object.prototype.hasOwnProperty.call(modes, mode) + ? modes[mode] + : undefined; + + if (override === undefined) { + if (options.strict) { + throw new UnknownModeError(mode, Object.keys(modes)); + } + return base; + } + + const merged: Record = { ...base }; + + for (const [field, value] of Object.entries(override)) { + if (value === undefined) { + continue; + } + merged[field] = value; + } + + for (const field of MERGED_RECORD_FIELDS) { + const baseValue = base[field]; + const overrideValue = override[field]; + if (baseValue === undefined || overrideValue === undefined) { + continue; + } + merged[field] = { ...baseValue, ...overrideValue }; + } + + return merged as ParsedInputWorkerConfig; +} diff --git a/packages/config/src/public.ts b/packages/config/src/public.ts index e5cab52dcf3..a2e76c19c08 100644 --- a/packages/config/src/public.ts +++ b/packages/config/src/public.ts @@ -71,13 +71,16 @@ export type { } from "./exports"; export { exports } from "./exports"; export type { + InferAggregatedEnv, InferEnv, + InferEnvForMode, + InferModeNames, InferDurableNamespaces, InferMainModule, UnwrapConfig, } from "./inference"; export type { ConfigContext } from "./definition"; -export type { SettingsConfig, WorkerConfig } from "./types"; +export type { SettingsConfig, WorkerConfig, WorkerModeConfig } from "./types"; export type { TypedWorkerDefinition, WorkerConfigExport, diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 700b8135850..c92c05f70ab 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -452,16 +452,32 @@ const BaseWorkerSchema = z.strictObject({ exports: z.record(z.string(), ExportSchema).optional(), }); +const EntrypointSchema = z + .union([z.string(), z.strictObject({ default: z.string() })]) + .transform((value) => (typeof value === "string" ? value : value.default)); + +/** + * A single entry under `modes`, a partial Worker config layered over the base + * config when that mode is selected. + * + * Every field is optional, and `type` is omitted because the discriminant is + * fixed by the parent config. `modes` itself is absent too: modes do not nest. + */ +const WorkerModeSchema = BaseWorkerSchema.omit({ type: true }) + .extend({ entrypoint: EntrypointSchema.optional() }) + .partial(); + /** * Input Worker schema — the shape that user-authored `cloudflare.config.ts` - * files are validated against. Adds an optional `entrypoint` field to the - * base schema. + * files are validated against. Adds optional `entrypoint` and `modes` fields to + * the base schema. + * + * `modes` is resolved away by `applyMode` during config loading, so it never + * reaches the Build Output Specification in {@link OutputWorkerSchema}. */ export const InputWorkerSchema = BaseWorkerSchema.extend({ - entrypoint: z - .union([z.string(), z.strictObject({ default: z.string() })]) - .transform((value) => (typeof value === "string" ? value : value.default)) - .optional(), + entrypoint: EntrypointSchema.optional(), + modes: z.record(z.string(), WorkerModeSchema).optional(), }); export type ParsedInputWorkerConfig = z.output; @@ -561,12 +577,18 @@ export type ParsedOutputWorkerConfig = z.output; * only accepts the post-`load.ts` shape (`string` or `{ default: string }`). * * - `env`: see the separate unidirectional drift check below. + * + * - `modes`: each entry is a partial Worker config, so it inherits both of the + * exclusions above. It gets its own unidirectional check below. */ type _ComparableInput = Omit< z.input, - "entrypoint" | "env" + "entrypoint" | "env" | "modes" +>; +type _ComparableWorkerConfig = Omit< + WorkerConfig, + "entrypoint" | "env" | "modes" >; -type _ComparableWorkerConfig = Omit; type _AssertSchemaMatchesWorkerConfig = [ _ComparableInput extends _ComparableWorkerConfig ? true : false, _ComparableWorkerConfig extends _ComparableInput ? true : false, @@ -598,6 +620,25 @@ type _AssertWorkerConfigEnvExtendsSchema = WorkerConfig["env"] extends z.input< const _assertWorkerConfigEnvExtendsSchema: _AssertWorkerConfigEnvExtendsSchema = true; void _assertWorkerConfigEnvExtendsSchema; +/** + * Unidirectional drift check for `modes`, for the same reason as `env` above: + * a mode override is a partial Worker config, so it carries the same phantom + * binding fields the schema cannot validate at runtime. `entrypoint` is + * excluded for the same reason it is excluded from the top-level check. + */ +type _ComparableModeInput = Omit< + NonNullable["modes"]>[string], + "entrypoint" +>; +type _ComparableModeConfig = Omit< + NonNullable[string], + "entrypoint" +>; +type _AssertWorkerConfigModesExtendsSchema = + _ComparableModeConfig extends _ComparableModeInput ? true : false; +const _assertWorkerConfigModesExtendsSchema: _AssertWorkerConfigModesExtendsSchema = true; +void _assertWorkerConfigModesExtendsSchema; + /** * Bidirectional drift check between {@link SettingsSchema} and the public * {@link SettingsConfig} interface. diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 8417d04ff14..6b594f4e7c6 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -404,8 +404,44 @@ export interface WorkerConfig { * For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects. */ exports?: Record; + + /** + * Per-mode overrides, keyed by mode name. + * + * A mode is selected with `--env ` or the `CLOUDFLARE_ENV` environment + * variable, and its overrides are layered over the fields above. `env` and + * `exports` merge per key so a mode only states what differs; every other + * field replaces the base value outright. + * + * Because modes are declared statically, `wrangler types` can generate an + * `Env` for each one. Branching on `ctx.mode` inside a config function + * cannot be analysed this way, so prefer `modes` when the shape of your + * bindings changes between environments. + * + * @example + * ```ts + * export default defineWorker({ + * name: "my-worker", + * env: { SHARED_KV: bindings.kv() }, + * modes: { + * staging: { env: { API: bindings.secret() } }, + * production: { name: "my-worker-prod", env: { API: bindings.secret() } }, + * }, + * }); + * ``` + */ + modes?: Record; } +/** + * A single entry under {@link WorkerConfig.modes}. + * + * Every Worker field is available and optional. `type` is excluded because the + * parent config already fixes it, and `modes` is excluded because modes do not + * nest. + */ +export type WorkerModeConfig = Partial>; + /** * Settings shared by the other exports. * Authored as a named `settings` export via diff --git a/packages/vite-plugin-cloudflare/playground/experimental-build-output/worker-configuration.d.ts b/packages/vite-plugin-cloudflare/playground/experimental-build-output/worker-configuration.d.ts index dea13d05e00..cf03f3854c7 100644 --- a/packages/vite-plugin-cloudflare/playground/experimental-build-output/worker-configuration.d.ts +++ b/packages/vite-plugin-cloudflare/playground/experimental-build-output/worker-configuration.d.ts @@ -1,7 +1,7 @@ /* eslint-disable */ // Generated by @cloudflare/config type __WorkerConfig = import("@cloudflare/vite-plugin/experimental-config").UnwrapConfig; -type __Env = import("@cloudflare/vite-plugin/experimental-config").InferEnv<__WorkerConfig>; +type __Env = import("@cloudflare/vite-plugin/experimental-config").InferAggregatedEnv<__WorkerConfig>; declare namespace Cloudflare { interface GlobalProps { @@ -9,5 +9,9 @@ declare namespace Cloudflare { durableNamespaces: import("@cloudflare/vite-plugin/experimental-config").InferDurableNamespaces<__WorkerConfig>; } interface Env extends __Env {} + /** The modes declared in your config, or `never` if there are none. */ + type Mode = import("@cloudflare/vite-plugin/experimental-config").InferModeNames<__WorkerConfig>; + /** The `Env` for one specific mode, with that mode's overrides applied. */ + type EnvFor = import("@cloudflare/vite-plugin/experimental-config").InferEnvForMode<__WorkerConfig, TMode>; } interface Env extends Cloudflare.Env {} diff --git a/packages/vite-plugin-cloudflare/playground/experimental-config/worker-configuration.d.ts b/packages/vite-plugin-cloudflare/playground/experimental-config/worker-configuration.d.ts index dea13d05e00..cf03f3854c7 100644 --- a/packages/vite-plugin-cloudflare/playground/experimental-config/worker-configuration.d.ts +++ b/packages/vite-plugin-cloudflare/playground/experimental-config/worker-configuration.d.ts @@ -1,7 +1,7 @@ /* eslint-disable */ // Generated by @cloudflare/config type __WorkerConfig = import("@cloudflare/vite-plugin/experimental-config").UnwrapConfig; -type __Env = import("@cloudflare/vite-plugin/experimental-config").InferEnv<__WorkerConfig>; +type __Env = import("@cloudflare/vite-plugin/experimental-config").InferAggregatedEnv<__WorkerConfig>; declare namespace Cloudflare { interface GlobalProps { @@ -9,5 +9,9 @@ declare namespace Cloudflare { durableNamespaces: import("@cloudflare/vite-plugin/experimental-config").InferDurableNamespaces<__WorkerConfig>; } interface Env extends __Env {} + /** The modes declared in your config, or `never` if there are none. */ + type Mode = import("@cloudflare/vite-plugin/experimental-config").InferModeNames<__WorkerConfig>; + /** The `Env` for one specific mode, with that mode's overrides applied. */ + type EnvFor = import("@cloudflare/vite-plugin/experimental-config").InferEnvForMode<__WorkerConfig, TMode>; } interface Env extends Cloudflare.Env {} diff --git a/packages/vite-plugin-cloudflare/src/__tests__/experimental-new-config.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/experimental-new-config.spec.ts index 2435227de2d..3abca7f8ee4 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/experimental-new-config.spec.ts +++ b/packages/vite-plugin-cloudflare/src/__tests__/experimental-new-config.spec.ts @@ -283,6 +283,64 @@ describe("resolvePluginConfig - experimental.newConfig", () => { expect(worker?.config.name).toBe("worker-development"); }); + test("applies a mode whose name matches the Vite mode", async ({ + expect, + }) => { + seedWorkerSource(); + writeWorkerConfig( + [ + "import { defineWorker } from '@cloudflare/config';", + "export default defineWorker({", + " name: 'base-worker',", + " entrypoint: './src/index.ts',", + " compatibilityDate: '2024-12-30',", + " modes: { development: { name: 'dev-worker' } },", + "});", + ].join("\n") + ); + + const result = (await resolvePluginConfig( + { experimental: { newConfig: true } }, + { root: tempDir }, + viteEnv + )) as WorkersResolvedConfig; + + const names = [...result.environmentNameToWorkerMap.values()].map( + (worker) => worker.config.name + ); + expect(names).toContain("dev-worker"); + }); + + test("falls back to the base config when the Vite mode is not a declared mode", async ({ + expect, + }) => { + // Vite always supplies a mode, so a config that only declares deploy-time + // modes must still resolve rather than refusing to start the dev server. + seedWorkerSource(); + writeWorkerConfig( + [ + "import { defineWorker } from '@cloudflare/config';", + "export default defineWorker({", + " name: 'base-worker',", + " entrypoint: './src/index.ts',", + " compatibilityDate: '2024-12-30',", + " modes: { staging: { name: 'staging-worker' } },", + "});", + ].join("\n") + ); + + const result = (await resolvePluginConfig( + { experimental: { newConfig: true } }, + { root: tempDir }, + viteEnv + )) as WorkersResolvedConfig; + + const names = [...result.environmentNameToWorkerMap.values()].map( + (worker) => worker.config.name + ); + expect(names).toContain("base-worker"); + }); + test("adds cloudflare.config.ts to configPaths for watching", async ({ expect, }) => { diff --git a/packages/wrangler/src/__tests__/experimental-config/load.test.ts b/packages/wrangler/src/__tests__/experimental-config/load.test.ts index bc2acacf28a..bdf4e4c4424 100644 --- a/packages/wrangler/src/__tests__/experimental-config/load.test.ts +++ b/packages/wrangler/src/__tests__/experimental-config/load.test.ts @@ -168,6 +168,108 @@ describe("loadNewConfig", () => { }); }); + describe("modes", () => { + const configWithModes = ` + export default { + type: "worker", + name: "my-worker", + compatibilityDate: "2026-05-18", + compatibilityFlags: ["nodejs_compat"], + env: { SHARED_KV: { type: "kv", id: "shared" } }, + modes: { + staging: { + env: { API_KEY: { type: "secret" } }, + }, + production: { + name: "my-worker-prod", + compatibilityFlags: [], + env: { API_KEY: { type: "secret" } }, + }, + }, + }; + `; + + it("uses the base config when no mode is selected", async ({ expect }) => { + await seed({ "cloudflare.config.ts": configWithModes }); + + const result = await loadNewConfig({ cwd: process.cwd(), args: {} }); + + expect(result.rawConfig.name).toBe("my-worker"); + expect(result.rawConfig.compatibility_flags).toEqual(["nodejs_compat"]); + expect(result.rawConfig.kv_namespaces).toEqual([ + { binding: "SHARED_KV", id: "shared" }, + ]); + }); + + it("layers a mode's bindings over the base config", async ({ expect }) => { + await seed({ "cloudflare.config.ts": configWithModes }); + + const result = await loadNewConfig({ + cwd: process.cwd(), + args: { env: "staging" }, + }); + + expect(result.rawConfig.name).toBe("my-worker"); + expect(result.rawConfig.kv_namespaces).toEqual([ + { binding: "SHARED_KV", id: "shared" }, + ]); + expect(result.parsedWorkerConfig.env).toMatchObject({ + SHARED_KV: { type: "kv", id: "shared" }, + API_KEY: { type: "secret" }, + }); + }); + + it("lets a mode override non-binding fields", async ({ expect }) => { + await seed({ "cloudflare.config.ts": configWithModes }); + + const result = await loadNewConfig({ + cwd: process.cwd(), + args: { env: "production" }, + }); + + expect(result.rawConfig.name).toBe("my-worker-prod"); + expect(result.rawConfig.compatibility_flags).toEqual([]); + }); + + it("selects a mode from CLOUDFLARE_ENV", async ({ expect }) => { + vi.stubEnv("CLOUDFLARE_ENV", "production"); + await seed({ "cloudflare.config.ts": configWithModes }); + + const result = await loadNewConfig({ cwd: process.cwd(), args: {} }); + + expect(result.rawConfig.name).toBe("my-worker-prod"); + }); + + it("never leaks `modes` into the converted Wrangler config", async ({ + expect, + }) => { + await seed({ "cloudflare.config.ts": configWithModes }); + + const result = await loadNewConfig({ + cwd: process.cwd(), + args: { env: "staging" }, + }); + + expect(result.rawConfig).not.toHaveProperty("modes"); + expect(result.parsedWorkerConfig).not.toHaveProperty("modes"); + }); + + it("throws a UserError for a mode the config does not declare", async ({ + expect, + }) => { + await seed({ "cloudflare.config.ts": configWithModes }); + + await expect( + loadNewConfig({ cwd: process.cwd(), args: { env: "prod" } }) + ).rejects.toMatchObject({ + message: expect.stringContaining( + `No mode named "prod" is defined in your config. Available modes: "staging", "production".` + ), + telemetryMessage: "new-config unknown mode", + }); + }); + }); + describe("settings export", () => { it("threads accountId and complianceRegion from the settings export", async ({ expect, diff --git a/packages/wrangler/src/__tests__/helpers/mock-new-config.ts b/packages/wrangler/src/__tests__/helpers/mock-new-config.ts index 614c1512a3e..bb8d57fdb38 100644 --- a/packages/wrangler/src/__tests__/helpers/mock-new-config.ts +++ b/packages/wrangler/src/__tests__/helpers/mock-new-config.ts @@ -38,16 +38,41 @@ export async function createConfigMock(importOriginal: () => Promise) { }; } - async function loadAndValidateConfig(configPath: string, ctx: unknown) { + // Mirrors the real `loadAndValidateConfig`, including collapsing `modes` + // against the selected mode. Only the loading step is faked. + async function loadAndValidateConfig( + configPath: string, + ctx: { mode?: string }, + options?: { strictModes?: boolean } + ) { const { exports } = await loadConfig(configPath); const resolved: Record = {}; for (const [name, value] of Object.entries(exports)) { resolved[name] = await actual.resolveExportDefinition(value, ctx); } - return { - result: actual.ConfigExportsSchema.safeParse(resolved), - dependencies: new Set([path.resolve(configPath)]), - }; + + const dependencies = new Set([path.resolve(configPath)]); + const result = actual.ConfigExportsSchema.safeParse(resolved); + + if (!result.success) { + return { result, dependencies }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock plumbing + const withModesApplied: Record = {}; + for (const [name, value] of Object.entries( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock plumbing + result.data as Record + )) { + withModesApplied[name] = + value.type === "worker" + ? actual.applyMode(value, ctx.mode, { + strict: options?.strictModes, + }) + : value; + } + + return { result: { ...result, data: withModesApplied }, dependencies }; } return { diff --git a/packages/wrangler/src/experimental-config/load.ts b/packages/wrangler/src/experimental-config/load.ts index 7125a07250e..901e1ca04b2 100644 --- a/packages/wrangler/src/experimental-config/load.ts +++ b/packages/wrangler/src/experimental-config/load.ts @@ -4,6 +4,7 @@ import { convertToWranglerConfig, loadAndValidateConfig, loadConfig, + UnknownModeError, } from "@cloudflare/config"; import { getCloudflareEnv, UserError } from "@cloudflare/workers-utils"; import { convertToolingConfig } from "./convert"; @@ -75,9 +76,23 @@ export async function loadNewConfig(options: { const mode = options.args.env ?? getCloudflareEnv(); // ── Worker + settings config ──────────────────────────────────────── - const workerConfigResult = await loadAndValidateConfig(cloudflareConfigPath, { - mode, - }); + let workerConfigResult; + try { + workerConfigResult = await loadAndValidateConfig( + cloudflareConfigPath, + { mode }, + // `--env`/`CLOUDFLARE_ENV` is an explicit selection here, so naming a + // mode the config does not declare is a mistake worth reporting. + { strictModes: true } + ); + } catch (e) { + if (e instanceof UnknownModeError) { + throw new UserError(e.message, { + telemetryMessage: "new-config unknown mode", + }); + } + throw e; + } if (!workerConfigResult.result.success) { throw new UserError(