[wrangler] Add modes to cloudflare.config.ts for per-environment config - #14958
[wrangler] Add modes to cloudflare.config.ts for per-environment config#14958stijnwtf wants to merge 3 commits into
modes to cloudflare.config.ts for per-environment config#14958Conversation
…nfig 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 detectedLatest commit: 9a07f5c The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Codeowners approval required for this PR:
Show detailed file reviewers
|
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.
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.
| export type InferAggregatedEnv<TUnwrappedConfig> = [ | ||
| InferModeNames<TUnwrappedConfig>, | ||
| ] extends [never] | ||
| ? InferEnv<TUnwrappedConfig> | ||
| : Simplify< | ||
| { | ||
| [TKey in CommonKeys<ModeEnvMap<TUnwrappedConfig>>]: ValueAcrossModes< | ||
| ModeEnvMap<TUnwrappedConfig>, | ||
| TKey | ||
| >; | ||
| } & { | ||
| [TKey in Exclude< | ||
| AllKeys<ModeEnvMap<TUnwrappedConfig>>, | ||
| CommonKeys<ModeEnvMap<TUnwrappedConfig>> | ||
| >]?: ValueAcrossModes<ModeEnvMap<TUnwrappedConfig>, TKey>; | ||
| } | ||
| >; |
There was a problem hiding this comment.
🟡 Generated types promise bindings that are missing when no environment is selected
Bindings that every declared environment provides are marked as always-present ([TKey in CommonKeys<ModeEnvMap<...>>] at packages/config/src/inference.ts:414) even though running with no environment selected uses only the base config, so code can read a binding the type system guarantees but that is actually absent at runtime.
Impact: Running wrangler dev/deploy without --env, or vite dev (whose mode usually isn't a declared one), yields an env object missing bindings the generated types say are required, producing runtime undefined errors with no type error.
Aggregation omits the base (no-mode) configuration as one of the possible shapes
InferAggregatedEnv builds ModeEnvMap from InferModeNames only (packages/config/src/inference.ts:377-382), i.e. the aggregation universe is the set of declared modes. The base configuration on its own is never one of the entries, yet it is a reachable runtime shape:
applyModereturns the base config whenmode === undefined(packages/config/src/modes.ts:79-81), which is whatwranglerdoes when neither--envnorCLOUDFLARE_ENVis set (packages/wrangler/src/experimental-config/load.ts:75).- The Vite plugin loads non-strict, so any Vite mode that is not a declared mode (e.g. the default
"development") also falls back to the base config (packages/config/src/modes.ts:90-95,packages/vite-plugin-cloudflare/src/plugin-config.ts:828-830).
For the documented example (base: { SHARED_KV }, staging: { API_KEY }, production: { API_KEY, ANALYTICS }), CommonKeys is SHARED_KV | API_KEY, so API_KEY is required on Env even though a base run has no API_KEY at all.
This also diverges from the JSON-config rule the doc comment claims parity with: generatePerEnvironmentTypes includes the top-level environment in the aggregation set (allEnvNames = [TOP_LEVEL_ENV_NAME, ...envNames], packages/wrangler/src/type-generation/index.ts:1312), which is exactly what makes named-env-only bindings optional there.
Prompt for agents
InferAggregatedEnv in packages/config/src/inference.ts aggregates only over the declared modes (ModeEnvMap keyed by InferModeNames). The base config with no mode applied is also a reachable runtime shape: applyMode returns the base when mode is undefined (wrangler with no --env/CLOUDFLARE_ENV) and, non-strictly, whenever the selected mode is not declared (the Vite plugin's default "development" mode). As a result, a binding declared by every mode but absent from the base is typed as required on Env, so user code can dereference something that is undefined at runtime with no type error. Wrangler's JSON-config equivalent, generatePerEnvironmentTypes, avoids this by including the top-level environment in the aggregation set (allEnvNames = [TOP_LEVEL_ENV_NAME, ...envNames]). Consider including the base env (InferEnv of the config itself) as an additional entry in ModeEnvMap so mode-only bindings become optional, or alternatively make the no-mode fallback impossible/explicit. Update the tests in packages/config/src/__tests__/modes.test.ts ("requires bindings every mode declares and makes the rest optional") accordingly.
Was this helpful? React with 👍 or 👎 to provide feedback.
Adds a
modesfield tocloudflare.config.ts, so per-environment config can be declared statically instead of computed at runtime.Why
Under
--x-new-configthe only way to vary config per environment is to branch onctx.modeinside the function form of a config:That is fine at runtime, but
wrangler typescannot see through it.generateTypesemitsUnwrapConfig<typeof import("./cloudflare.config").default>, anddefineWorkerinfers its type parameter from the function's return type, so every branch collapses into one. A binding that only exists in production ends up indistinguishable from one that always exists.The result is that the new config format has no equivalent of the per-environment type aggregation the Wrangler JSON config gets from
env.*. This is also the thing that makes it awkward to split a large config into several files: composition itself already works through ordinaryimportstatements, sincecloudflare.config.tsis a real TypeScript module andload.tsalready tracks transitive imports for reload. It was only the types that did not keep up.What this adds
Each entry is a partial Worker config layered over the base when that mode is selected with
--envorCLOUDFLARE_ENV.envandexportsmerge 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 rather than being stuck with it.Because the modes are declared rather than computed,
wrangler typescan aggregate them:Envwhich is the same rule
generatePerEnvironmentTypesalready applies toenv.*for the JSON config. Each mode's exact env is available asCloudflare.EnvFor<"production">, and the declared names asCloudflare.Mode.Implementation notes
loadAndValidateConfig, the single choke point both Wrangler and the Vite plugin load through, so neither needed a change beyond error mapping.modes.production.env.FOOrather than a merged path the user never wrote.modesis stripped from the result, so nothing downstream of config loading knows the feature exists. It never reachesOutputWorkerSchemaorconvertToWranglerConfig.typeof import(...)rather than by emitting concrete literal types. That keeps the existing property that generated types resolve from source and cannot go stale.modesis passed through untouched whatever the mode is. Branching onctx.modeis still valid and still works, so this is purely additive.Verification
Beyond the unit tests, I generated a
worker-configuration.d.tsfrom a config that splits its bindings across two files and type checked a Worker against it. The positive cases pass and the negative cases fail with the errors you would want:--x-new-config, which is experimental and opt-in and not yet covered in the public docs. Docs for the config format, includingmodes, should land together when the feature stabilises.