From 6920502bf312b277cbf0f2bf96138a825cd5c001 Mon Sep 17 00:00:00 2001 From: stijnwtf Date: Sat, 1 Aug 2026 02:26:24 +0200 Subject: [PATCH 1/3] [wrangler] Add `modes` to cloudflare.config.ts for per-environment config Under `--x-new-config`, the only way to vary config per environment was to branch on `ctx.mode` inside the function form of a config. That works at runtime, but `wrangler types` cannot see through it. It infers the config's return type, so every branch collapses together and a binding that only exists in one environment looks identical to one that always exists. `modes` declares the differences statically instead. Each entry is a partial Worker config layered over the base when that mode is selected. `env` and `exports` merge per key so a mode only states what differs. Every other field replaces the base value outright, which means an inherited array can always be dropped. The merge happens in `loadAndValidateConfig`, the one place both Wrangler and the Vite plugin load through. It runs after validation so a bad binding inside a mode still reports against the path the user actually wrote, and `modes` is stripped from the result so nothing downstream needs to know the feature exists. Type generation now aggregates modes the same way it aggregates named environments in the Wrangler JSON config. A binding every mode declares is required, one that only some declare is optional, and its type is the union across the modes that declare it. This is done with type level helpers over `typeof import(...)`, so generated types still resolve from source and cannot go stale. Each mode's exact env is available as `Cloudflare.EnvFor<"production">`. Configs without `modes` are passed through untouched whatever the mode is, so branching on `ctx.mode` keeps working as before. --- .changeset/new-config-modes.md | 31 ++ .../config/src/__tests__/generate.test.ts | 12 +- packages/config/src/__tests__/modes.test.ts | 276 ++++++++++++++++++ packages/config/src/config-loader.ts | 20 +- packages/config/src/generate.ts | 6 +- packages/config/src/index.ts | 1 + packages/config/src/inference.ts | 142 +++++++++ packages/config/src/modes.ts | 95 ++++++ packages/config/src/public.ts | 5 +- packages/config/src/schema.ts | 57 +++- packages/config/src/types.ts | 36 +++ .../experimental-config/load.test.ts | 102 +++++++ .../src/__tests__/helpers/mock-new-config.ts | 30 +- .../wrangler/src/experimental-config/load.ts | 17 +- 14 files changed, 810 insertions(+), 20 deletions(-) create mode 100644 .changeset/new-config-modes.md create mode 100644 packages/config/src/__tests__/modes.test.ts create mode 100644 packages/config/src/modes.ts 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/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..3ddc2b4eff3 --- /dev/null +++ b/packages/config/src/__tests__/modes.test.ts @@ -0,0 +1,276 @@ +import { describe, it } from "vitest"; +import { bindings } from "../bindings"; +import { applyMode, UnknownModeError } from "../modes"; +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 for a mode the config does not declare", ({ expect }) => { + expect(() => + applyMode( + { ...baseConfig, modes: { staging: {}, production: {} } }, + "prod" + ) + ).toThrow(UnknownModeError); + + expect(() => + applyMode( + { ...baseConfig, modes: { staging: {}, production: {} } }, + "prod" + ) + ).toThrow(`No mode named "prod" is defined in your config.`); + }); + + it("lists the available modes on the thrown error", ({ expect }) => { + try { + applyMode( + { ...baseConfig, modes: { staging: {}, production: {} } }, + "prod" + ); + 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"` + ); + } + }); + + 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); + }); +}); + +// 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..d4755b33e40 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,6 +18,10 @@ 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. */ export async function loadAndValidateConfig( configPath: string, @@ -32,5 +37,18 @@ export async function loadAndValidateConfig( const result = ConfigExportsSchema.safeParse(resolved); - return { result, dependencies }; + if (!result.success) { + return { result, dependencies }; + } + + // Validation runs against the authored config so that a bad binding inside a + // mode reports against `modes..env.` rather than a merged path + // the user never wrote. + const withModesApplied: ParsedConfigExports = {}; + for (const [name, value] of Object.entries(result.data)) { + withModesApplied[name] = + value.type === "worker" ? applyMode(value, ctx.mode) : value; + } + + return { result: { ...result, data: withModesApplied }, 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..c0898d00cd9 --- /dev/null +++ b/packages/config/src/modes.ts @@ -0,0 +1,95 @@ +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. + * + * @throws {UnknownModeError} If the config declares `modes` but not this one. + */ +export function applyMode( + config: ParsedInputWorkerConfig, + mode: string | undefined +): ParsedInputWorkerConfig { + const { modes, ...base } = config; + + if (mode === undefined || modes === undefined) { + return base; + } + + const override = modes[mode]; + if (override === undefined) { + throw new UnknownModeError(mode, Object.keys(modes)); + } + + 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/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..c9217014f90 100644 --- a/packages/wrangler/src/__tests__/helpers/mock-new-config.ts +++ b/packages/wrangler/src/__tests__/helpers/mock-new-config.ts @@ -38,16 +38,36 @@ 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 } + ) { 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) : 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..aa9a936d452 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,19 @@ 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, + }); + } 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( From ae3f79548389c005680a4d03db812a71afe942ab Mon Sep 17 00:00:00 2001 From: stijnwtf Date: Sat, 1 Aug 2026 02:38:50 +0200 Subject: [PATCH 2/3] Fix unknown mode handling in applyMode Only error on an unknown mode when the caller marks the mode as an explicit selection. Wrangler opts in, the Vite plugin does not, since Vite always supplies a mode and would otherwise fail to start for any config using modes. Also use hasOwnProperty for the lookup so names on Object.prototype are not mistaken for declared modes. --- packages/config/src/__tests__/modes.test.ts | 56 ++++++++++++++++-- packages/config/src/config-loader.ts | 11 +++- packages/config/src/modes.ts | 29 ++++++++-- .../__tests__/experimental-new-config.spec.ts | 58 +++++++++++++++++++ .../src/__tests__/helpers/mock-new-config.ts | 9 ++- .../wrangler/src/experimental-config/load.ts | 10 +++- 6 files changed, 158 insertions(+), 15 deletions(-) diff --git a/packages/config/src/__tests__/modes.test.ts b/packages/config/src/__tests__/modes.test.ts index 3ddc2b4eff3..d7718d52830 100644 --- a/packages/config/src/__tests__/modes.test.ts +++ b/packages/config/src/__tests__/modes.test.ts @@ -144,27 +144,51 @@ describe("applyMode", () => { expect(result.logpush).toBe(true); }); - it("throws for a mode the config does not declare", ({ expect }) => { + it("throws under `strict` for a mode the config does not declare", ({ + expect, + }) => { expect(() => applyMode( { ...baseConfig, modes: { staging: {}, production: {} } }, - "prod" + "prod", + { strict: true } ) ).toThrow(UnknownModeError); expect(() => applyMode( { ...baseConfig, modes: { staging: {}, production: {} } }, - "prod" + "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" + "prod", + { strict: true } ); expect.unreachable("applyMode should have thrown"); } catch (e) { @@ -178,6 +202,30 @@ describe("applyMode", () => { } }); + // `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, }) => { diff --git a/packages/config/src/config-loader.ts b/packages/config/src/config-loader.ts index d4755b33e40..2732fd00c9d 100644 --- a/packages/config/src/config-loader.ts +++ b/packages/config/src/config-loader.ts @@ -22,11 +22,16 @@ export interface LoadAndValidateConfigResult { * 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); @@ -47,7 +52,9 @@ export async function loadAndValidateConfig( const withModesApplied: ParsedConfigExports = {}; for (const [name, value] of Object.entries(result.data)) { withModesApplied[name] = - value.type === "worker" ? applyMode(value, ctx.mode) : value; + value.type === "worker" + ? applyMode(value, ctx.mode, { strict: options?.strictModes }) + : value; } return { result: { ...result, data: withModesApplied }, dependencies }; diff --git a/packages/config/src/modes.ts b/packages/config/src/modes.ts index c0898d00cd9..1831d48ba67 100644 --- a/packages/config/src/modes.ts +++ b/packages/config/src/modes.ts @@ -56,11 +56,23 @@ const MERGED_RECORD_FIELDS = ["env", "exports"] as const; * 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. * - * @throws {UnknownModeError} If the config declares `modes` but not this one. + * `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 + mode: string | undefined, + options: { strict?: boolean } = {} ): ParsedInputWorkerConfig { const { modes, ...base } = config; @@ -68,9 +80,18 @@ export function applyMode( return base; } - const override = modes[mode]; + // `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) { - throw new UnknownModeError(mode, Object.keys(modes)); + if (options.strict) { + throw new UnknownModeError(mode, Object.keys(modes)); + } + return base; } const merged: Record = { ...base }; 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__/helpers/mock-new-config.ts b/packages/wrangler/src/__tests__/helpers/mock-new-config.ts index c9217014f90..bb8d57fdb38 100644 --- a/packages/wrangler/src/__tests__/helpers/mock-new-config.ts +++ b/packages/wrangler/src/__tests__/helpers/mock-new-config.ts @@ -42,7 +42,8 @@ export async function createConfigMock(importOriginal: () => Promise) { // against the selected mode. Only the loading step is faked. async function loadAndValidateConfig( configPath: string, - ctx: { mode?: string } + ctx: { mode?: string }, + options?: { strictModes?: boolean } ) { const { exports } = await loadConfig(configPath); const resolved: Record = {}; @@ -64,7 +65,11 @@ export async function createConfigMock(importOriginal: () => Promise) { result.data as Record )) { withModesApplied[name] = - value.type === "worker" ? actual.applyMode(value, ctx.mode) : value; + value.type === "worker" + ? actual.applyMode(value, ctx.mode, { + strict: options?.strictModes, + }) + : value; } return { result: { ...result, data: withModesApplied }, dependencies }; diff --git a/packages/wrangler/src/experimental-config/load.ts b/packages/wrangler/src/experimental-config/load.ts index aa9a936d452..901e1ca04b2 100644 --- a/packages/wrangler/src/experimental-config/load.ts +++ b/packages/wrangler/src/experimental-config/load.ts @@ -78,9 +78,13 @@ export async function loadNewConfig(options: { // ── Worker + settings config ──────────────────────────────────────── let workerConfigResult; try { - workerConfigResult = await loadAndValidateConfig(cloudflareConfigPath, { - mode, - }); + 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, { From 9a07f5c258118642bc6e8403b7c21aa59fb3307d Mon Sep 17 00:00:00 2001 From: stijnwtf Date: Sat, 1 Aug 2026 02:50:10 +0200 Subject: [PATCH 3/3] Validate the merged config and regenerate checked-in types Re-run validation after a mode is applied. Some rules only fail on the combined result, such as two singleton bindings that are each fine on their own but invalid once base and mode share one env. Regenerate the committed worker-configuration.d.ts files so they match the generator output. --- .../worker-configuration.d.ts | 6 +- packages/config/src/__tests__/modes.test.ts | 59 +++++++++++++++++++ packages/config/src/config-loader.ts | 14 ++++- .../worker-configuration.d.ts | 6 +- .../worker-configuration.d.ts | 6 +- 5 files changed, 85 insertions(+), 6 deletions(-) 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__/modes.test.ts b/packages/config/src/__tests__/modes.test.ts index d7718d52830..44d8881c193 100644 --- a/packages/config/src/__tests__/modes.test.ts +++ b/packages/config/src/__tests__/modes.test.ts @@ -1,6 +1,7 @@ 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, @@ -237,6 +238,64 @@ describe("applyMode", () => { }); }); +// `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", () => { diff --git a/packages/config/src/config-loader.ts b/packages/config/src/config-loader.ts index 2732fd00c9d..03016dca7c1 100644 --- a/packages/config/src/config-loader.ts +++ b/packages/config/src/config-loader.ts @@ -46,10 +46,10 @@ export async function loadAndValidateConfig( return { result, dependencies }; } - // Validation runs against the authored config so that a bad binding inside a + // 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: ParsedConfigExports = {}; + const withModesApplied: Record = {}; for (const [name, value] of Object.entries(result.data)) { withModesApplied[name] = value.type === "worker" @@ -57,5 +57,13 @@ export async function loadAndValidateConfig( : value; } - return { result: { ...result, data: withModesApplied }, dependencies }; + // 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/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 {}