diff --git a/.changeset/new-config-settings-export.md b/.changeset/new-config-settings-export.md new file mode 100644 index 0000000000..03d0c9e139 --- /dev/null +++ b/.changeset/new-config-settings-export.md @@ -0,0 +1,27 @@ +--- +"@cloudflare/config": minor +"@cloudflare/vite-plugin": minor +"wrangler": minor +--- + +Add a `settings` export to the experimental `cloudflare.config.ts` config + +Account-level settings (`accountId`, `complianceRegion`) now live in a dedicated, named `settings` export authored via `defineSettings`, rather than on the Worker config. A `cloudflare.config.ts` can export at most one `settings` object; the Worker itself is the `default` export. + +```ts +// cloudflare.config.ts +import { defineSettings, defineWorker } from "wrangler/experimental-config"; +import * as entrypoint from "./src/index.ts" with { type: "cf-worker" }; + +export const settings = defineSettings({ + accountId: "", +}); + +export default defineWorker({ + name: "my-worker", + entrypoint, + compatibilityDate: "2026-05-18", +}); +``` + +This is only used behind the experimental new-config path (`wrangler --experimental-new-config` and the `@cloudflare/vite-plugin` `experimental.newConfig` option). diff --git a/fixtures/experimental-new-config/cloudflare.config.ts b/fixtures/experimental-new-config/cloudflare.config.ts index 936521a489..fbec075211 100644 --- a/fixtures/experimental-new-config/cloudflare.config.ts +++ b/fixtures/experimental-new-config/cloudflare.config.ts @@ -1,6 +1,14 @@ -import { bindings, defineWorker } from "wrangler/experimental-config"; +import { + bindings, + defineSettings, + defineWorker, +} from "wrangler/experimental-config"; import * as entrypoint from "./src/index.ts" with { type: "cf-worker" }; +export const settings = defineSettings({ + complianceRegion: "public", +}); + export default defineWorker((ctx) => ({ name: "experimental-new-config", entrypoint, diff --git a/packages/config/src/__tests__/convert.test.ts b/packages/config/src/__tests__/convert.test.ts index 7a81337d03..2af0071a51 100644 --- a/packages/config/src/__tests__/convert.test.ts +++ b/packages/config/src/__tests__/convert.test.ts @@ -3,15 +3,19 @@ import { bindings } from "../bindings"; import { convertToWranglerConfig } from "../convert"; import { exports as exportConfig } from "../exports"; -const baseConfig = { name: "worker", compatibilityDate: "2026-06-01" } as const; +const baseConfig = { + type: "worker", + name: "my-worker", + compatibilityDate: "2026-06-01", +} as const; describe("convertToWranglerConfig", () => { describe("top-level fields", () => { it("maps primitive top-level fields", ({ expect }) => { const result = convertToWranglerConfig({ + type: "worker", name: "my-worker", entrypoint: "./src/index.ts", - accountId: "acc-123", compatibilityDate: "2026-01-01", compatibilityFlags: ["nodejs_compat"], workersDev: true, @@ -22,7 +26,6 @@ describe("convertToWranglerConfig", () => { expect(result).toEqual({ name: "my-worker", main: "./src/index.ts", - account_id: "acc-123", compatibility_date: "2026-01-01", compatibility_flags: ["nodejs_compat"], workers_dev: true, @@ -32,24 +35,6 @@ describe("convertToWranglerConfig", () => { }); }); - it("maps complianceRegion: 'fedramp-high' to 'fedramp_high'", ({ - expect, - }) => { - const result = convertToWranglerConfig({ - ...baseConfig, - complianceRegion: "fedramp-high", - }); - expect(result.compliance_region).toBe("fedramp_high"); - }); - - it("passes complianceRegion: 'public' through unchanged", ({ expect }) => { - const result = convertToWranglerConfig({ - ...baseConfig, - complianceRegion: "public", - }); - expect(result.compliance_region).toBe("public"); - }); - it("passes placement through unchanged", ({ expect }) => { const result = convertToWranglerConfig({ ...baseConfig, @@ -1072,4 +1057,46 @@ describe("convertToWranglerConfig", () => { expect(result.streaming_tail_consumers).toEqual([{ service: "b" }]); }); }); + + describe("settings", () => { + it("maps accountId to account_id", ({ expect }) => { + const result = convertToWranglerConfig(baseConfig, { + type: "settings", + accountId: "acc-123", + }); + expect(result.account_id).toBe("acc-123"); + }); + + it("maps complianceRegion: 'fedramp-high' to 'fedramp_high'", ({ + expect, + }) => { + const result = convertToWranglerConfig(baseConfig, { + type: "settings", + complianceRegion: "fedramp-high", + }); + expect(result.compliance_region).toBe("fedramp_high"); + }); + + it("passes complianceRegion: 'public' through unchanged", ({ expect }) => { + const result = convertToWranglerConfig(baseConfig, { + type: "settings", + complianceRegion: "public", + }); + expect(result.compliance_region).toBe("public"); + }); + + it("sets no settings fields when none are provided", ({ expect }) => { + const result = convertToWranglerConfig(baseConfig, { type: "settings" }); + expect(result.account_id).toBeUndefined(); + expect(result.compliance_region).toBeUndefined(); + }); + + it("sets no settings fields when settings config is omitted", ({ + expect, + }) => { + const result = convertToWranglerConfig(baseConfig); + expect(result.account_id).toBeUndefined(); + expect(result.compliance_region).toBeUndefined(); + }); + }); }); diff --git a/packages/config/src/__tests__/load.test.ts b/packages/config/src/__tests__/load.test.ts index e7407179e7..d20d39734d 100644 --- a/packages/config/src/__tests__/load.test.ts +++ b/packages/config/src/__tests__/load.test.ts @@ -10,19 +10,28 @@ import { InputWorkerSchema } from "../schema"; // inside a test directly. Instead, we run a small Node program in a // subprocess that calls `loadConfig`, serialises the result as JSON, and // prints it to stdout for the test to consume. -function runLoadConfigInSubprocess(args: { cwd: string; configPath: string }): { +function runLoadConfigInSubprocess(args: { + cwd: string; + configPath: string; + include?: string[]; +}): { config: unknown; + exports: Record; dependencies: string[]; } { // Use a file:// URL rather than a raw filesystem path so the embedded // `import` specifier is valid on Windows (where absolute paths like // `C:\...` are not accepted as ESM specifiers). const sourceEntry = pathToFileURL(path.resolve(__dirname, "../load.ts")).href; + const options = args.include + ? `, { include: ${JSON.stringify(args.include)} }` + : ""; const script = ` import { loadConfig } from ${JSON.stringify(sourceEntry)}; - const result = await loadConfig(${JSON.stringify(args.configPath)}); + const result = await loadConfig(${JSON.stringify(args.configPath)}${options}); const serialisable = { - config: result.config, + config: result.exports.default, + exports: result.exports, dependencies: [...result.dependencies], }; process.stdout.write(JSON.stringify(serialisable, (_, v) => { @@ -65,6 +74,43 @@ describe("loadConfig", () => { expect(result.config).toEqual({ name: "my-worker" }); }); + it("returns all named exports keyed by name", async ({ expect }) => { + await seed({ + "cloudflare.config.ts": ` + export default { type: "worker", name: "w" }; + export const settings = { type: "settings", accountId: "acc-123" }; + `, + }); + + const result = runLoadConfigInSubprocess({ + cwd: process.cwd(), + configPath: "./cloudflare.config.ts", + }); + + expect(result.exports.default).toEqual({ type: "worker", name: "w" }); + expect(result.exports.settings).toEqual({ + type: "settings", + accountId: "acc-123", + }); + }); + + it("filters exports by `include` before resolution", async ({ expect }) => { + await seed({ + "cloudflare.config.ts": ` + export default { type: "worker", name: "w" }; + export const settings = { type: "settings" }; + `, + }); + + const result = runLoadConfigInSubprocess({ + cwd: process.cwd(), + configPath: "./cloudflare.config.ts", + include: ["settings"], + }); + + expect(Object.keys(result.exports)).toEqual(["settings"]); + }); + it("anchors relative cf-worker specifiers to an absolute path without executing them", async ({ expect, }) => { @@ -152,7 +198,7 @@ describe("loadConfig", () => { "src/index.ts": `// not executed`, "cloudflare.config.ts": ` import * as entrypoint from "./src/index.ts" with { type: "cf-worker" }; - export default { name: "worker", compatibilityDate: "2026-06-01", entrypoint }; + export default { type: "worker", name: "worker", compatibilityDate: "2026-06-01", entrypoint }; `, }); @@ -182,8 +228,8 @@ describe("loadConfig", () => { writeFileSync("./cloudflare.config.ts", 'export default { name: "second" };'); const second = await loadConfig("./cloudflare.config.ts"); process.stdout.write(JSON.stringify({ - first: first.config, - second: second.config, + first: first.exports.default, + second: second.exports.default, })); `; const sub = spawnSync( diff --git a/packages/config/src/__tests__/schema.test.ts b/packages/config/src/__tests__/schema.test.ts index 5b5fc0a2b8..a1c9798d90 100644 --- a/packages/config/src/__tests__/schema.test.ts +++ b/packages/config/src/__tests__/schema.test.ts @@ -1,7 +1,16 @@ import { describe, it } from "vitest"; -import { InputWorkerSchema, OutputWorkerSchema } from "../schema"; - -const baseConfig = { name: "worker", compatibilityDate: "2026-06-01" } as const; +import { + ConfigExportsSchema, + InputWorkerSchema, + OutputWorkerSchema, + SettingsSchema, +} from "../schema"; + +const baseConfig = { + type: "worker", + name: "my-worker", + compatibilityDate: "2026-06-01", +} as const; describe("InputWorkerSchema", () => { describe("env singleton bindings", () => { @@ -589,3 +598,129 @@ describe("OutputWorkerSchema", () => { expect(result.success).toBe(false); }); }); + +describe("InputWorkerSchema type discriminant", () => { + it("requires type: 'worker'", ({ expect }) => { + const { type: _type, ...withoutType } = baseConfig; + const result = InputWorkerSchema.safeParse(withoutType); + + expect(result.success).toBe(false); + }); +}); + +describe("SettingsSchema", () => { + it("accepts a minimal settings config", ({ expect }) => { + const result = SettingsSchema.safeParse({ type: "settings" }); + + expect(result.success).toBe(true); + }); + + it("accepts accountId and complianceRegion", ({ expect }) => { + const result = SettingsSchema.safeParse({ + type: "settings", + accountId: "acc-123", + complianceRegion: "fedramp-high", + }); + + expect(result.success).toBe(true); + }); + + it("rejects unknown fields", ({ expect }) => { + const result = SettingsSchema.safeParse({ + type: "settings", + name: "my-worker", + }); + + expect(result.success).toBe(false); + }); +}); + +describe("ConfigExportsSchema", () => { + it("discriminates worker and settings exports by type", ({ expect }) => { + const result = ConfigExportsSchema.safeParse({ + default: baseConfig, + settings: { type: "settings", accountId: "acc-123" }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.default?.type).toBe("worker"); + expect(result.data.settings?.type).toBe("settings"); + } + }); + + it("reports an invalid-discriminator issue keyed by export name", ({ + expect, + }) => { + const result = ConfigExportsSchema.safeParse({ + default: { name: "my-worker", compatibilityDate: "2026-06-01" }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.path).toEqual(["default", "type"]); + } + }); + + it("rejects a settings config on a non-`settings` export", ({ expect }) => { + const result = ConfigExportsSchema.safeParse({ + default: baseConfig, + settings: { type: "settings" }, + extraSettings: { type: "settings" }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => + i.message.includes( + "A `settings` config is only allowed on the `settings` export" + ) + ); + expect(issue?.path).toEqual(["extraSettings"]); + } + }); + + it("rejects a settings config on the `default` export", ({ expect }) => { + const result = ConfigExportsSchema.safeParse({ + default: { type: "settings" }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => + i.message.includes( + "A `settings` config is only allowed on the `settings` export" + ) + ); + expect(issue?.path).toEqual(["default"]); + } + }); + + it("rejects a worker config on the reserved `settings` export", ({ + expect, + }) => { + const result = ConfigExportsSchema.safeParse({ + default: baseConfig, + settings: { ...baseConfig, name: "settings" }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => + i.message.includes( + "The `settings` export is reserved for a `settings` config" + ) + ); + expect(issue?.path).toEqual(["settings"]); + } + }); + + it("allows multiple worker exports", ({ expect }) => { + const result = ConfigExportsSchema.safeParse({ + default: baseConfig, + api: { ...baseConfig, name: "api" }, + }); + + expect(result.success).toBe(true); + }); +}); diff --git a/packages/config/src/build-output.ts b/packages/config/src/build-output.ts index 6ab391a8d2..4593b4d47e 100644 --- a/packages/config/src/build-output.ts +++ b/packages/config/src/build-output.ts @@ -4,6 +4,7 @@ import { removeDir } from "@cloudflare/workers-utils"; import type { ParsedInputWorkerConfig, ParsedOutputWorkerConfig, + ParsedSettingsConfig, } from "./schema"; /** @@ -18,6 +19,11 @@ export const BUILD_OUTPUT_VERSION = "v0"; */ export const BUILD_OUTPUT_ROOT = ".cloudflare/output"; +/** + * Filename of the top-level (root) config holding shared settings. + */ +export const ROOT_CONFIG_FILENAME = "config.json"; + /** * Filename of the per-Worker config. */ @@ -30,6 +36,17 @@ function getBuildOutputDir(root: string): string { return path.resolve(root, BUILD_OUTPUT_ROOT); } +/** + * Absolute path to the top-level `config.json` for the current project. + */ +export function getRootConfigPath(root: string): string { + return path.join( + getBuildOutputDir(root), + BUILD_OUTPUT_VERSION, + ROOT_CONFIG_FILENAME + ); +} + /** * Clean the build output directory. */ @@ -84,3 +101,16 @@ export async function writeOutputWorkerConfig( const configPath = getWorkerConfigPath(root, outputConfig.name); await fsp.writeFile(configPath, JSON.stringify(outputConfig)); } + +/** + * Write the top-level `config.json` holding shared settings to the Build + * Output API tree. + */ +export async function writeRootOutputConfig( + root: string, + settings: ParsedSettingsConfig +): Promise { + const configPath = getRootConfigPath(root); + await fsp.mkdir(path.dirname(configPath), { recursive: true }); + await fsp.writeFile(configPath, JSON.stringify(settings)); +} diff --git a/packages/config/src/config-loader.ts b/packages/config/src/config-loader.ts new file mode 100644 index 0000000000..3bcebbcbd1 --- /dev/null +++ b/packages/config/src/config-loader.ts @@ -0,0 +1,36 @@ +import { resolveExportDefinition } from "./definition"; +import { loadConfig } from "./load"; +import { ConfigExportsSchema } from "./schema"; +import type { ConfigContext } from "./definition"; +import type { ParsedConfigExports } from "./schema"; +import type * as z from "zod"; + +export interface LoadAndValidateConfigResult { + /** + * Zod result for the validated exports record, keyed by JS export name. + * Consumers format `result.error` themselves. + */ + result: z.ZodSafeParseResult; + /** Transitive deps imported while resolving the config (node_modules excluded). */ + dependencies: Set; +} + +/** + * Load a `cloudflare.config.ts`, resolve all exports, and validate against {@link ConfigExportsSchema}. + */ +export async function loadAndValidateConfig( + configPath: string, + ctx: ConfigContext, + options?: { include?: string[] } +): Promise { + const { exports, dependencies } = await loadConfig(configPath, options); + + const resolved: Record = {}; + for (const [name, value] of Object.entries(exports)) { + resolved[name] = await resolveExportDefinition(value, ctx); + } + + const result = ConfigExportsSchema.safeParse(resolved); + + return { result, dependencies }; +} diff --git a/packages/config/src/convert.ts b/packages/config/src/convert.ts index 5862a10c9b..6e44eead87 100644 --- a/packages/config/src/convert.ts +++ b/packages/config/src/convert.ts @@ -4,7 +4,7 @@ import { type Exports, } from "@cloudflare/workers-utils"; import { isParsedUnsafeBinding } from "./schema"; -import type { ParsedInputWorkerConfig } from "./schema"; +import type { ParsedInputWorkerConfig, ParsedSettingsConfig } from "./schema"; import type { Json } from "./utils"; /** @@ -13,24 +13,48 @@ import type { Json } from "./utils"; * The caller is responsible for unwrapping any function/promise wrapper around * the config and validating it against `InputWorkerSchema` before passing it in. * - * @param config The parsed (post-validation) config. + * @param workerConfig The parsed (post-validation) Worker config. + * @param settingsConfig The optional parsed settings config, whose fields + * are merged onto the result. * @returns The corresponding Wrangler `RawConfig`. */ export function convertToWranglerConfig( - config: ParsedInputWorkerConfig + workerConfig: ParsedInputWorkerConfig, + settingsConfig?: ParsedSettingsConfig ): RawConfig { const result: RawConfig = {}; - convertTopLevel(config, result); - convertBindingsAndAssets(config, result); - convertExports(config, result); - convertDomains(config, result); - convertTriggers(config, result); - convertTailConsumers(config, result); + convertTopLevel(workerConfig, result); + convertBindingsAndAssets(workerConfig, result); + convertExports(workerConfig, result); + convertDomains(workerConfig, result); + convertTriggers(workerConfig, result); + convertTailConsumers(workerConfig, result); + + if (settingsConfig !== undefined) { + convertSettings(settingsConfig, result); + } return result; } +/** + * Merge a parsed settings config's fields (`account_id`, `compliance_region`) + * onto an existing Wrangler `RawConfig`. + */ +function convertSettings( + settings: ParsedSettingsConfig, + result: RawConfig +): void { + if (settings.accountId !== undefined) { + result.account_id = settings.accountId; + } + if (settings.complianceRegion !== undefined) { + result.compliance_region = + settings.complianceRegion === "fedramp-high" ? "fedramp_high" : "public"; + } +} + // ═══════════════════════════════════════════════════════════════════════════ // TOP-LEVEL FIELDS // ═══════════════════════════════════════════════════════════════════════════ @@ -45,9 +69,6 @@ function convertTopLevel( if (typeof config.entrypoint === "string") { result.main = config.entrypoint; } - if (config.accountId !== undefined) { - result.account_id = config.accountId; - } if (config.compatibilityDate !== undefined) { result.compatibility_date = config.compatibilityDate; } @@ -63,10 +84,6 @@ function convertTopLevel( if (config.logpush !== undefined) { result.logpush = config.logpush; } - if (config.complianceRegion !== undefined) { - result.compliance_region = - config.complianceRegion === "fedramp-high" ? "fedramp_high" : "public"; - } if (config.firstPartyWorker !== undefined) { result.first_party_worker = config.firstPartyWorker; } diff --git a/packages/config/src/definition.ts b/packages/config/src/definition.ts new file mode 100644 index 0000000000..4f505722c5 --- /dev/null +++ b/packages/config/src/definition.ts @@ -0,0 +1,56 @@ +export interface ConfigContext { + /** + * The mode the config is being evaluated in. + * Set via the `--mode` CLI flag. + * In Vite the mode defaults to `development` in `vite dev` and `production` in `vite build` ([more info](https://vite.dev/guide/env-and-mode.html#modes)). + * In Wrangler the mode defaults to `undefined`. + */ + mode: string | undefined; +} + +// We currently use Symbol.for rather than Symbol so that the symbol matches if duplicated across bundles +// This wouldn't be necessary if @cloudflare/config was published and included as a dependency +export const DEFINITION = Symbol.for("@cloudflare/config:definition"); + +/** + * The authored config in any of its supported shapes: a plain value, a promise, + * or a function of {@link ConfigContext}. + */ +export type ConfigInput = + | T + | Promise + | ((ctx: ConfigContext) => T | Promise); + +/** + * Unwrap an authored config from its value / promise / function shape, awaiting + * the result. A function config is invoked with {@link ConfigContext}. + */ +async function unwrap(config: unknown, ctx: ConfigContext): Promise { + return typeof config === "function" + ? await (config as (ctx: ConfigContext) => unknown)(ctx) + : await config; +} + +/** + * Resolve any `cloudflare.config.ts` export to its plain config value. + * + * A `define*` helper stores its authored config plus `type` under the + * {@link DEFINITION} symbol; here we unwrap the config and stamp `type` back on. + * Every other export — a raw object/promise/function — is unwrapped as-is and + * already carries its own `type`. Discrimination happens afterwards via `type`. + */ +export async function resolveExportDefinition( + def: unknown, + ctx: ConfigContext +): Promise { + if (typeof def === "object" && def !== null && DEFINITION in def) { + const { config, type } = (def as Record)[DEFINITION] as { + config: unknown; + type: string; + }; + const resolved = await unwrap(config, ctx); + return { ...(resolved as object), type }; + } + + return await unwrap(def, ctx); +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 1b9b48cf7e..3e51a432f9 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -3,25 +3,34 @@ export { BUILD_OUTPUT_ROOT, BUILD_OUTPUT_VERSION, cleanBuildOutputDir, + getRootConfigPath, getWorkerAssetsDir, getWorkerBundleDir, getWorkerConfigPath, getWorkersDir, + ROOT_CONFIG_FILENAME, WORKER_CONFIG_FILENAME, writeOutputWorkerConfig, + writeRootOutputConfig, } from "./build-output"; export { + ConfigExportsSchema, InputWorkerSchema, OutputWorkerSchema, ModuleTypeSchema, + SettingsSchema, } from "./schema"; export { generateTypes } from "./generate"; export { convertToWranglerConfig } from "./convert"; export { loadConfig, registerConfigHooks } from "./load"; -export { resolveWorkerDefinition } from "./worker-definition"; +export { loadAndValidateConfig } from "./config-loader"; +export { resolveExportDefinition } from "./definition"; export type { LoadConfigResult } from "./load"; +export type { LoadAndValidateConfigResult } from "./config-loader"; export type { + ParsedConfigExports, ParsedInputWorkerConfig, ParsedOutputWorkerConfig, + ParsedSettingsConfig, ModuleType, } from "./schema"; diff --git a/packages/config/src/inference.ts b/packages/config/src/inference.ts index 7f990c3c85..7068ed2cf2 100644 --- a/packages/config/src/inference.ts +++ b/packages/config/src/inference.ts @@ -11,7 +11,7 @@ import type { TypedWorkerBinding, TypedWorkflowBinding, } from "./bindings"; -import type { UserConfigExport, WorkerDefinition } from "./worker-definition"; +import type { WorkerConfigExport, WorkerDefinition } from "./worker-definition"; import type { Pipeline } from "cloudflare:pipelines"; // ═══════════════════════════════════════════════════════════════════════════ @@ -248,7 +248,7 @@ export type InferWorkerEntrypointExports = Exclude< export type UnwrapConfig = TConfig extends WorkerDefinition ? TUnwrappedConfig - : TConfig extends UserConfigExport + : TConfig extends WorkerConfigExport ? TUnwrappedConfig : never; diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts index 30f8208cb7..56ac6754f3 100644 --- a/packages/config/src/load.ts +++ b/packages/config/src/load.ts @@ -25,16 +25,20 @@ const depsStore = new AsyncLocalStorage>(); /** * Dynamically import a worker config file from disk. * - * Returns the module's default export, plus the set of file paths - * imported during resolution. Callers are responsible for unwrapping - * function/promise wrappers around the returned value and validating it - * against `InputWorkerSchema`. + * Returns all of the module's exports (keyed by export name, including + * `default`), plus the set of file paths imported during resolution. Callers + * are responsible for unwrapping function/promise/definition wrappers around + * each export and validating them. * * @param configPath Filesystem path to the config file. Relative paths are * resolved against `process.cwd()`. + * @param options.include When provided, only exports whose names are listed + * survive into the returned `exports` map. Filtering happens before any + * resolution or validation. */ export async function loadConfig( - configPath: string + configPath: string, + options?: { include?: string[] } ): Promise { registerConfigHooks(); const url = pathToFileURL(configPath).href; @@ -43,12 +47,24 @@ export async function loadConfig( dependencies, () => import(url, { with: { [CF_ATTR]: CF_NO_CACHE_VALUE } }) ); - return { config: mod.default, dependencies }; + + const exports: Record = {}; + for (const [name, value] of Object.entries(mod as Record)) { + if (options?.include && !options.include.includes(name)) { + continue; + } + exports[name] = value; + } + + return { exports, dependencies }; } export interface LoadConfigResult { - /** The default export of the config module */ - config: unknown; + /** + * All exports of the config module, keyed by export name (including + * `default`). Filtered by `options.include` when provided. + */ + exports: Record; /** * Absolute file paths imported while resolving the config. * `cf-worker` entrypoints are NOT included — they are diff --git a/packages/config/src/public.ts b/packages/config/src/public.ts index 777a939b29..e5cab52dcf 100644 --- a/packages/config/src/public.ts +++ b/packages/config/src/public.ts @@ -76,10 +76,13 @@ export type { InferMainModule, UnwrapConfig, } from "./inference"; -export type { UserConfig } from "./types"; +export type { ConfigContext } from "./definition"; +export type { SettingsConfig, WorkerConfig } from "./types"; export type { - ConfigContext, TypedWorkerDefinition, - UserConfigExport, + WorkerConfigExport, + WorkerConfigInput, } from "./worker-definition"; export { defineWorker } from "./worker-definition"; +export type { SettingsConfigInput } from "./settings-definition"; +export { defineSettings } from "./settings-definition"; diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index f9729eabb8..541ff6e644 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -1,5 +1,5 @@ import * as z from "zod"; -import type { UserConfig } from "./types"; +import type { SettingsConfig, WorkerConfig } from "./types"; const AssetsSchema = z.strictObject({ htmlHandling: z @@ -430,8 +430,8 @@ const UnsafeSchema = z.strictObject({ * (user-authored) and output (on-disk) Worker configs. */ const BaseWorkerSchema = z.strictObject({ + type: z.literal("worker"), name: z.string(), - accountId: z.string().optional(), compatibilityDate: z.string(), compatibilityFlags: z.array(z.string()).optional(), assets: AssetsSchema.optional(), @@ -445,7 +445,6 @@ const BaseWorkerSchema = z.strictObject({ observability: ObservabilitySchema.optional(), workersDev: z.boolean().optional(), previewUrls: z.boolean().optional(), - complianceRegion: z.enum(["public", "fedramp-high"]).optional(), firstPartyWorker: z.boolean().optional(), unsafe: UnsafeSchema.optional(), // TODO: support previews @@ -467,6 +466,61 @@ export const InputWorkerSchema = BaseWorkerSchema.extend({ export type ParsedInputWorkerConfig = z.output; +/** + * Settings schema — validates the named `settings` export of a + * `cloudflare.config.ts`. Holds account/deployment settings shared by the other exports. + */ +export const SettingsSchema = z.strictObject({ + type: z.literal("settings"), + accountId: z.string().optional(), + complianceRegion: z.enum(["public", "fedramp-high"]).optional(), +}); + +export type ParsedSettingsConfig = z.output; + +/** + * Discriminated union of the config kinds a single export may resolve to. + */ +const ConfigExportSchema = z.discriminatedUnion("type", [ + InputWorkerSchema, + SettingsSchema, +]); + +const SETTINGS_EXPORT_NAME = "settings"; + +/** + * Schema for the resolved config exports, keyed by export + * name. Each value is discriminated on its `type` field. Reserves the + * `settings` export name exclusively for settings configs: a `settings` + * config must live on the `settings` export, and the `settings` export + * may only hold a `settings` config. + */ +export const ConfigExportsSchema = z + .record(z.string(), ConfigExportSchema) + .check((ctx) => { + for (const [key, value] of Object.entries(ctx.value)) { + const isSettingsName = key === SETTINGS_EXPORT_NAME; + const isSettingsType = value.type === "settings"; + if (isSettingsType && !isSettingsName) { + ctx.issues.push({ + code: "custom", + input: value, + path: [key], + message: `A \`settings\` config is only allowed on the \`${SETTINGS_EXPORT_NAME}\` export; found one on the \`${key}\` export.`, + }); + } else if (isSettingsName && !isSettingsType) { + ctx.issues.push({ + code: "custom", + input: value, + path: [key], + message: `The \`${SETTINGS_EXPORT_NAME}\` export is reserved for a \`settings\` config; found a \`${value.type}\` config.`, + }); + } + } + }); + +export type ParsedConfigExports = z.output; + export const ModuleTypeSchema = z.enum([ "esm", "cjs", @@ -499,7 +553,7 @@ export type ParsedOutputWorkerConfig = z.output; /** * Bidirectional drift check between {@link InputWorkerSchema} and the - * public {@link UserConfig} interface. Excludes `entrypoint` and `env`, + * public {@link WorkerConfig} interface. Excludes `entrypoint` and `env`, * which deliberately differ: * * - `entrypoint`: the public type accepts a `WorkerModule` namespace @@ -512,16 +566,16 @@ type _ComparableInput = Omit< z.input, "entrypoint" | "env" >; -type _ComparableUserConfig = Omit; -type _AssertSchemaMatchesUserConfig = [ - _ComparableInput extends _ComparableUserConfig ? true : false, - _ComparableUserConfig extends _ComparableInput ? true : false, +type _ComparableWorkerConfig = Omit; +type _AssertSchemaMatchesWorkerConfig = [ + _ComparableInput extends _ComparableWorkerConfig ? true : false, + _ComparableWorkerConfig extends _ComparableInput ? true : false, ]; -const _assertSchemaMatchesUserConfig: _AssertSchemaMatchesUserConfig = [ +const _assertSchemaMatchesWorkerConfig: _AssertSchemaMatchesWorkerConfig = [ true, true, ]; -void _assertSchemaMatchesUserConfig; +void _assertSchemaMatchesWorkerConfig; /** * Unidirectional drift check for `env`. The public binding return types @@ -529,17 +583,31 @@ void _assertSchemaMatchesUserConfig; * inference helpers that the schema does not (and cannot) validate at * runtime, so a bidirectional check would always fail in that direction. * - * We therefore only assert that `UserConfig['env']` is assignable to + * We therefore only assert that `WorkerConfig['env']` is assignable to * `z.input['env']` — i.e. every binding shape * the public type accepts is something the schema is willing to parse. * This catches drift where the public type drops a field the schema * still requires, renames a field, changes a field's type to one the * schema rejects, or adds a binding the schema doesn't know about. */ -type _AssertUserConfigEnvExtendsSchema = UserConfig["env"] extends z.input< +type _AssertWorkerConfigEnvExtendsSchema = WorkerConfig["env"] extends z.input< typeof InputWorkerSchema >["env"] ? true : false; -const _assertUserConfigEnvExtendsSchema: _AssertUserConfigEnvExtendsSchema = true; -void _assertUserConfigEnvExtendsSchema; +const _assertWorkerConfigEnvExtendsSchema: _AssertWorkerConfigEnvExtendsSchema = true; +void _assertWorkerConfigEnvExtendsSchema; + +/** + * Bidirectional drift check between {@link SettingsSchema} and the public + * {@link SettingsConfig} interface. + */ +type _AssertSettingsSchemaMatchesConfig = [ + z.input extends SettingsConfig ? true : false, + SettingsConfig extends z.input ? true : false, +]; +const _assertSettingsSchemaMatchesConfig: _AssertSettingsSchemaMatchesConfig = [ + true, + true, +]; +void _assertSettingsSchemaMatchesConfig; diff --git a/packages/config/src/settings-definition.ts b/packages/config/src/settings-definition.ts new file mode 100644 index 0000000000..ed59c99ef3 --- /dev/null +++ b/packages/config/src/settings-definition.ts @@ -0,0 +1,17 @@ +import { DEFINITION } from "./definition"; +import type { ConfigInput } from "./definition"; +import type { SettingsConfig } from "./types"; + +/** + * Authored settings config shape — {@link SettingsConfig} without the `type` + * discriminant, which `defineSettings` injects. + */ +export type SettingsConfigInput = Omit; + +/** + * Declare shared settings. + * Authored as a named `settings` export. + */ +export function defineSettings(config: ConfigInput) { + return { [DEFINITION]: { config, type: "settings" } }; +} diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 9922a14f6d..8417d04ff1 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -135,20 +135,19 @@ type Export = * Fields are validated at runtime by `InputWorkerSchema` and normalised before * being passed to downstream tooling. */ -export interface UserConfig { +export interface WorkerConfig { /** - * The name of your Worker. + * Discriminates this config as a Worker config. + * + * Injected automatically by `defineWorker`; only needs to be written by + * hand when authoring a raw config object without the helper. */ - name: string; + type: "worker"; /** - * This is the ID of the account associated with your zone. - * You might have more than one account, so make sure to use - * the ID of the account associated with the zone/route you - * provide, if you provide one. It can also be specified through - * the CLOUDFLARE_ACCOUNT_ID environment variable. + * The name of your Worker. */ - accountId?: string; + name: string; /** * A date in the form yyyy-mm-dd, which will be used to determine @@ -350,14 +349,6 @@ export interface UserConfig { */ previewUrls?: boolean; - /** - * Specify the compliance region mode of the Worker. - * - * Although if the user does not specify a compliance region, the default is `public`, - * it can be set to `undefined` in configuration to delegate to the CLOUDFLARE_COMPLIANCE_REGION environment variable. - */ - complianceRegion?: "public" | "fedramp-high"; - /** * Designates this Worker as an internal-only "first-party" Worker. * @@ -414,3 +405,35 @@ export interface UserConfig { */ exports?: Record; } + +/** + * Settings shared by the other exports. + * Authored as a named `settings` export via + * `defineSettings`. + */ +export interface SettingsConfig { + /** + * Discriminates this config as a settings config. + * + * Injected automatically by `defineSettings`; only needs to be written by + * hand when authoring a raw config object without the helper. + */ + type: "settings"; + + /** + * This is the ID of the account associated with your zone. + * You might have more than one account, so make sure to use + * the ID of the account associated with the zone/route you + * provide, if you provide one. It can also be specified through + * the CLOUDFLARE_ACCOUNT_ID environment variable. + */ + accountId?: string; + + /** + * Specify the compliance region mode of the Worker. + * + * Although if the user does not specify a compliance region, the default is `public`, + * it can be set to `undefined` in configuration to delegate to the CLOUDFLARE_COMPLIANCE_REGION environment variable. + */ + complianceRegion?: "public" | "fedramp-high"; +} diff --git a/packages/config/src/worker-definition.ts b/packages/config/src/worker-definition.ts index ac982ecf6c..5b046939bc 100644 --- a/packages/config/src/worker-definition.ts +++ b/packages/config/src/worker-definition.ts @@ -1,48 +1,35 @@ +import { DEFINITION } from "./definition"; import type { Bindings, TypedDurableObjectBinding, TypedWorkerBinding, } from "./bindings"; +import type { ConfigContext, ConfigInput } from "./definition"; import type { InferDurableNamespaces, InferWorkerName, InferWorkerEntrypointExports, } from "./inference"; -import type { UserConfig } from "./types"; - -// TODO: Use declaration merging in the consuming package once this package is published -export interface ConfigContext { - /** - * The mode the config is being evaluated in. - * Set via the `--mode` CLI flag. - * In Vite the mode defaults to `development` in `vite dev` and `production` in `vite build` ([more info](https://vite.dev/guide/env-and-mode.html#modes)). - * In Wrangler the mode defaults to `undefined`. - */ - mode: string | undefined; -} - -// We currently use Symbol.for rather than Symbol so that the symbol matches if duplicated across bundles -// This wouldn't be necessary if @cloudflare/config was published and included as a dependency -const CONFIG = Symbol.for("@cloudflare/config:worker-config"); +import type { WorkerConfig } from "./types"; /** - * Base shape of a Worker definition. Carries the resolved config and the - * untyped cross-worker binding helpers. + * Base shape of a Worker definition. Carries the authored config (under + * {@link DEFINITION}) and the untyped cross-worker binding helpers. */ export interface WorkerDefinition< - TConfig extends UserConfig = UserConfig, + TConfig extends WorkerConfig = WorkerConfig, > extends Pick { - [CONFIG]: - | TConfig - | Promise - | ((ctx: ConfigContext) => TConfig | Promise); + // The authored config is stored without its `type` discriminant (the helper + // omits it); `type` sits alongside it and is stamped back on during + // resolution. `TConfig` is still referenced so `UnwrapConfig` can recover it. + [DEFINITION]: { config: ConfigInput>; type: "worker" }; } /** * Worker definition with typed cross-worker binding helpers. */ export interface TypedWorkerDefinition< - TConfig extends UserConfig, + TConfig extends WorkerConfig, TWorkerName extends string = InferWorkerName, > extends WorkerDefinition { /** @@ -84,20 +71,32 @@ export interface TypedWorkerDefinition< // }): TypedWorkflowBinding; } -export type UserConfigExport = - | T - | Promise - | ((ctx: ConfigContext) => T | Promise); +export type WorkerConfigExport = + ConfigInput; -export function defineWorker( - config: (ctx: ConfigContext) => (UserConfig & T) | Promise -): TypedWorkerDefinition; -export function defineWorker( - config: (UserConfig & T) | Promise -): TypedWorkerDefinition; -export function defineWorker(config: UserConfigExport): WorkerDefinition { +/** + * Authored Worker config shape — {@link WorkerConfig} without the `type` + * discriminant, which `defineWorker` injects. + */ +export type WorkerConfigInput = Omit; + +export type WorkerConfigInputExport< + T extends WorkerConfigInput = WorkerConfigInput, +> = ConfigInput; + +export function defineWorker( + config: ( + ctx: ConfigContext + ) => (WorkerConfigInput & T) | Promise +): TypedWorkerDefinition; +export function defineWorker( + config: (WorkerConfigInput & T) | Promise +): TypedWorkerDefinition; +export function defineWorker( + config: WorkerConfigInputExport +): WorkerDefinition { return { - [CONFIG]: config, + [DEFINITION]: { config, type: "worker" }, durableObject(options) { return { type: "durable-object", ...options }; }, @@ -110,20 +109,3 @@ export function defineWorker(config: UserConfigExport): WorkerDefinition { // }, }; } - -export async function resolveWorkerDefinition( - def: unknown, - ctx: ConfigContext -): Promise { - const raw = - typeof def === "object" && def !== null && CONFIG in def - ? (def as Record)[CONFIG] - : def; - - const value = - typeof raw === "function" - ? (raw as (ctx: ConfigContext) => unknown)(ctx) - : raw; - - return await value; -} diff --git a/packages/vite-plugin-cloudflare/src/build-output-preview.ts b/packages/vite-plugin-cloudflare/src/build-output-preview.ts index 8b6e9960e8..2d82bb2ac6 100644 --- a/packages/vite-plugin-cloudflare/src/build-output-preview.ts +++ b/packages/vite-plugin-cloudflare/src/build-output-preview.ts @@ -2,11 +2,13 @@ import assert from "node:assert"; import * as fs from "node:fs"; import { convertToWranglerConfig, + getRootConfigPath, getWorkerAssetsDir, getWorkerBundleDir, getWorkerConfigPath, getWorkersDir, OutputWorkerSchema, + SettingsSchema, WORKER_CONFIG_FILENAME, } from "@cloudflare/config"; import { normalizeAndValidateConfig } from "@cloudflare/workers-utils"; @@ -49,6 +51,13 @@ export async function readBuildOutputWorkers( ); } + // Read the optional top-level `config.json` holding project-level + // settings (`account_id`, `compliance_region`) shared by every Worker. + const rootConfigPath = getRootConfigPath(root); + const settings = fs.existsSync(rootConfigPath) + ? SettingsSchema.parse(JSON.parse(fs.readFileSync(rootConfigPath, "utf-8"))) + : undefined; + return workerNames.map((workerName) => { const configPath = getWorkerConfigPath(root, workerName); assert( @@ -59,7 +68,7 @@ export async function readBuildOutputWorkers( JSON.parse(fs.readFileSync(configPath, "utf-8")) ); const { manifest, ...inputShape } = outputConfig; - const rawConfig = convertToWranglerConfig(inputShape); + const rawConfig = convertToWranglerConfig(inputShape, settings); const { config, diagnostics } = normalizeAndValidateConfig( rawConfig, diff --git a/packages/vite-plugin-cloudflare/src/context.ts b/packages/vite-plugin-cloudflare/src/context.ts index 6c5d02bc1e..e2f07eb6c6 100644 --- a/packages/vite-plugin-cloudflare/src/context.ts +++ b/packages/vite-plugin-cloudflare/src/context.ts @@ -210,7 +210,7 @@ export class PluginContext { getWorkerNewConfig( environmentName: string ): ParsedInputWorkerConfig | undefined { - return this.#getWorker(environmentName)?.parsedNewConfig; + return this.#getWorker(environmentName)?.parsedNewWorkerConfig; } get allWorkerConfigs(): Unstable_Config[] { diff --git a/packages/vite-plugin-cloudflare/src/plugin-config.ts b/packages/vite-plugin-cloudflare/src/plugin-config.ts index 59602b0d45..34b0162c27 100644 --- a/packages/vite-plugin-cloudflare/src/plugin-config.ts +++ b/packages/vite-plugin-cloudflare/src/plugin-config.ts @@ -4,9 +4,7 @@ import * as path from "node:path"; import { convertToWranglerConfig, generateTypes, - InputWorkerSchema, - loadConfig, - resolveWorkerDefinition, + loadAndValidateConfig, } from "@cloudflare/config"; import { generateRuntimeTypes, @@ -37,7 +35,10 @@ import type { WorkerResolvedConfig, WorkerWithServerLogicResolvedConfig, } from "./workers-configs"; -import type { ParsedInputWorkerConfig } from "@cloudflare/config"; +import type { + ParsedConfigExports, + ParsedInputWorkerConfig, +} from "@cloudflare/config"; import type { StaticRouting } from "@cloudflare/workers-shared/utils/types"; import type { RawConfig } from "@cloudflare/workers-utils"; import type { Unstable_Config } from "wrangler"; @@ -223,7 +224,7 @@ export interface Worker { config: ResolvedWorkerConfig; nodeJsCompat: NodeJsCompat | undefined; devOnly: DevOnly | undefined; - parsedNewConfig: ParsedInputWorkerConfig | undefined; + parsedNewWorkerConfig: ParsedInputWorkerConfig | undefined; } interface BaseResolvedConfig { @@ -242,12 +243,15 @@ interface NonPreviewResolvedConfig extends BaseResolvedConfig { environmentNameToWorkerMap: Map; environmentNameToChildEnvironmentNamesMap: Map; prerenderWorkerEnvironmentName: string | undefined; + // The full parsed `cloudflare.config.ts` exports (every worker export plus + // the optional `settings` export), keyed by export name. Undefined when + // new-config is not in use. + parsedNewConfig: ParsedConfigExports | undefined; } export interface AssetsOnlyResolvedConfig extends NonPreviewResolvedConfig { type: "assets-only"; config: ResolvedAssetsOnlyConfig; - parsedNewConfig: ParsedInputWorkerConfig | undefined; rawConfigs: { entryWorker: AssetsOnlyWorkerResolvedConfig; }; @@ -483,7 +487,7 @@ export async function resolvePluginConfig( let configPath: string | undefined; let rawConfigOverride: RawConfig | undefined; - let parsedNewConfig: ParsedInputWorkerConfig | undefined; + let parsedNewConfig: ParsedConfigExports | undefined; if (resolvedNewConfig) { if (pluginConfig.configPath) { @@ -637,12 +641,17 @@ export async function resolvePluginConfig( validateAndAddEnvironmentName(entryWorkerEnvironmentName); + const entryWorkerNewConfig = + parsedNewConfig?.default?.type === "worker" + ? parsedNewConfig.default + : undefined; + environmentNameToWorkerMap.set( entryWorkerEnvironmentName, resolveWorker( entryWorkerResolvedConfig.config, pluginConfig.assetsOnly, - parsedNewConfig + entryWorkerNewConfig ) ); @@ -723,6 +732,7 @@ export async function resolvePluginConfig( environmentNameToWorkerMap, environmentNameToChildEnvironmentNamesMap, prerenderWorkerEnvironmentName, + parsedNewConfig, entryWorkerEnvironmentName, staticRouting, remoteBindings, @@ -769,7 +779,7 @@ export function resolveDevOnly(devOnly: DevOnly | undefined): boolean { function resolveWorker( workerConfig: ResolvedWorkerConfig, devOnly: DevOnly | undefined, - parsedNewConfig?: ParsedInputWorkerConfig + parsedNewWorkerConfig?: ParsedInputWorkerConfig ): Worker { return { config: workerConfig, @@ -777,7 +787,7 @@ function resolveWorker( ? new NodeJsCompat(workerConfig) : undefined, devOnly, - parsedNewConfig, + parsedNewWorkerConfig, }; } @@ -803,7 +813,7 @@ async function loadNewConfig(options: { types: { generate: boolean; includeRuntime: boolean }; }): Promise<{ rawConfig: RawConfig; - parsedConfig: ParsedInputWorkerConfig; + parsedConfig: ParsedConfigExports; configPath: string; dependencies: Set; }> { @@ -815,34 +825,45 @@ async function loadNewConfig(options: { ); } - const { config: rawExport, dependencies } = await loadConfig(configPath); - - const resolved = await resolveWorkerDefinition(rawExport, { + const { result, dependencies } = await loadAndValidateConfig(configPath, { mode: options.mode, }); - const parsed = InputWorkerSchema.safeParse(resolved); - if (!parsed.success) { + if (!result.success) { throw new Error( - `Invalid \`${NEW_CONFIG_FILENAME}\`:\n${parsed.error.message}` + `Invalid \`${NEW_CONFIG_FILENAME}\`:\n${result.error.message}` ); } - const rawConfig = convertToWranglerConfig(parsed.data); + const worker = + result.data.default?.type === "worker" ? result.data.default : undefined; + + if (worker === undefined) { + throw new Error( + `\`${NEW_CONFIG_FILENAME}\` must have a default worker export.` + ); + } + + const settings = + result.data.settings?.type === "settings" + ? result.data.settings + : undefined; + + const rawConfig: RawConfig = convertToWranglerConfig(worker, settings); if (options.command === "serve" && options.types.generate) { await writeWorkerConfigurationDts({ root: options.root, configPath, includeRuntime: options.types.includeRuntime, - compatibilityDate: parsed.data.compatibilityDate, - compatibilityFlags: parsed.data.compatibilityFlags ?? [], + compatibilityDate: worker.compatibilityDate, + compatibilityFlags: worker.compatibilityFlags ?? [], }); } return { rawConfig, - parsedConfig: parsed.data, + parsedConfig: result.data, configPath, dependencies, }; diff --git a/packages/vite-plugin-cloudflare/src/plugins/build-output.ts b/packages/vite-plugin-cloudflare/src/plugins/build-output.ts index aa6f8a9fe5..0ec1aaec7a 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/build-output.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/build-output.ts @@ -1,6 +1,9 @@ import assert from "node:assert"; import * as path from "node:path"; -import { writeOutputWorkerConfig } from "@cloudflare/config"; +import { + writeOutputWorkerConfig, + writeRootOutputConfig, +} from "@cloudflare/config"; import { MAIN_ENTRY_NAME } from "../cloudflare-environment"; import { createPlugin } from "../utils"; import type { ModuleType } from "@cloudflare/config"; @@ -20,15 +23,18 @@ export const buildOutputPlugin = createPlugin("build-output", (ctx) => { ctx.resolvedPluginConfig.type === "assets-only" && this.environment.name === "client" ) { - const workerNewConfig = ctx.resolvedPluginConfig.parsedNewConfig; + const defaultExport = ctx.resolvedPluginConfig.parsedNewConfig?.default; + const workerNewConfig = + defaultExport?.type === "worker" ? defaultExport : undefined; assert( workerNewConfig, - "Expected parsedNewConfig on assets-only resolved config" + "Expected a default worker export on assets-only resolved config" ); await writeOutputWorkerConfig( ctx.resolvedViteConfig.root, workerNewConfig ); + await writeRootSettings(); return; } @@ -77,8 +83,22 @@ export const buildOutputPlugin = createPlugin("build-output", (ctx) => { modules, } ); + await writeRootSettings(); }, }; + + async function writeRootSettings(): Promise { + if (ctx.resolvedPluginConfig.type === "preview") { + return; + } + const settingsExport = ctx.resolvedPluginConfig.parsedNewConfig?.settings; + const settings = + settingsExport?.type === "settings" ? settingsExport : undefined; + if (!settings) { + return; + } + await writeRootOutputConfig(ctx.resolvedViteConfig.root, settings); + } }); /** diff --git a/packages/vite-plugin-cloudflare/src/plugins/config.ts b/packages/vite-plugin-cloudflare/src/plugins/config.ts index 91f1ed8ff9..59c53941a0 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/config.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/config.ts @@ -382,10 +382,13 @@ function forceBuildOutputDirs( if (!environment) { continue; } - assert(worker.parsedNewConfig, "Expected parsedNewConfig to be defined"); + assert( + worker.parsedNewWorkerConfig, + "Expected parsedNewWorkerConfig to be defined" + ); environment.build.outDir = getWorkerBundleDir( root, - worker.parsedNewConfig.name + worker.parsedNewWorkerConfig.name ); } @@ -394,16 +397,21 @@ function forceBuildOutputDirs( resolvedPluginConfig.environmentNameToWorkerMap.get(entryName); assert(entryWorker, `Expected entry worker for environment "${entryName}"`); assert( - entryWorker.parsedNewConfig, - "Expected parsedNewConfig to be defined" + entryWorker.parsedNewWorkerConfig, + "Expected parsedNewWorkerConfig to be defined" ); - clientWorkerName = entryWorker.parsedNewConfig.name; + clientWorkerName = entryWorker.parsedNewWorkerConfig.name; } else { assert( resolvedPluginConfig.parsedNewConfig, "Expected parsedNewConfig to be defined" ); - clientWorkerName = resolvedPluginConfig.parsedNewConfig.name; + const defaultExport = resolvedPluginConfig.parsedNewConfig.default; + assert( + defaultExport?.type === "worker", + "Expected a default worker export" + ); + clientWorkerName = defaultExport.name; } const clientEnvironment = resolvedViteConfig.environments.client; diff --git a/packages/workers-utils/src/config/environment.ts b/packages/workers-utils/src/config/environment.ts index 1e87bd94fc..f11298c460 100644 --- a/packages/workers-utils/src/config/environment.ts +++ b/packages/workers-utils/src/config/environment.ts @@ -1,7 +1,7 @@ /** * Wrangler configuration types. The JSDoc on these fields is also the source * of truth for the equivalent fields in `@cloudflare/config` - * (`packages/config/src/types.ts` — `UserConfig` — and the binding option + * (`packages/config/src/types.ts` — `WorkerConfig` — and the binding option * interfaces in `packages/config/src/config.ts`). When editing prose here, * mirror the changes there. */ diff --git a/packages/wrangler/src/__tests__/build-output.test.ts b/packages/wrangler/src/__tests__/build-output.test.ts index 2a1f9494fd..000788c19c 100644 --- a/packages/wrangler/src/__tests__/build-output.test.ts +++ b/packages/wrangler/src/__tests__/build-output.test.ts @@ -5,34 +5,9 @@ import { describe, it, vi } from "vitest"; import { mockConsoleMethods } from "./helpers/mock-console"; import { runWrangler } from "./helpers/run-wrangler"; -// ───────────────────────────────────────────────────────────────────────────── -// Mock `@cloudflare/config`'s `loadConfig` -// ───────────────────────────────────────────────────────────────────────────── -// -// `loadNewConfig` calls into `@cloudflare/config`'s `loadConfig`, which uses -// `module.registerHooks` to register hooks for `.ts` files. That mechanism -// does not run inside vitest's module runner, so we mock the loader and -// `import()` the seeded file via a `data:` URL to keep ESM semantics. -// ───────────────────────────────────────────────────────────────────────────── - vi.mock("@cloudflare/config", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - - async function loadConfig(configPath: string) { - const source = await fs.promises.readFile(configPath, "utf8"); - const mod = (await import( - `data:text/javascript;base64,${Buffer.from(source).toString("base64")}` - )) as { default: unknown }; - return { - config: mod.default, - dependencies: new Set([path.resolve(configPath)]), - }; - } - - return { - ...actual, - loadConfig, - }; + const { createConfigMock } = await import("./helpers/mock-new-config"); + return createConfigMock(importOriginal); }); const WORKER_NAME = "build-output-test-worker"; @@ -74,6 +49,7 @@ describe("wrangler build --experimental-cf-build-output", () => { it("emits the Build Output API tree for a Worker", async ({ expect }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "${WORKER_NAME}", compatibilityDate: "2026-05-18", entrypoint: "./src/index.js", @@ -107,6 +83,7 @@ describe("wrangler build --experimental-cf-build-output", () => { }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "${WORKER_NAME}", compatibilityDate: "2026-05-18", entrypoint: "./src/index.ts", @@ -136,6 +113,7 @@ describe("wrangler build --experimental-cf-build-output", () => { }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "${WORKER_NAME}", compatibilityDate: "2026-05-18", entrypoint: "./src/index.js", @@ -174,6 +152,7 @@ describe("wrangler build --experimental-cf-build-output", () => { }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "${WORKER_NAME}", compatibilityDate: "2026-05-18", entrypoint: "./src/index.js", @@ -208,6 +187,7 @@ describe("wrangler build --experimental-cf-build-output", () => { it("copies the assets directory", async ({ expect }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "${WORKER_NAME}", compatibilityDate: "2026-05-18", entrypoint: "./src/index.js", @@ -247,6 +227,7 @@ describe("wrangler build --experimental-cf-build-output", () => { }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "${WORKER_NAME}", compatibilityDate: "2026-05-18", };`, @@ -273,6 +254,7 @@ describe("wrangler build --experimental-cf-build-output", () => { }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "${WORKER_NAME}", compatibilityDate: "2026-05-18", entrypoint: "./src/index.js", @@ -301,6 +283,7 @@ describe("wrangler build --experimental-cf-build-output", () => { }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "${WORKER_NAME}", compatibilityDate: "2026-05-18", entrypoint: "./src/index.js", @@ -320,6 +303,7 @@ describe("wrangler build --experimental-cf-build-output", () => { }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "${WORKER_NAME}", compatibilityDate: "2026-05-18", };`, diff --git a/packages/wrangler/src/__tests__/cf-wrangler/build.test.ts b/packages/wrangler/src/__tests__/cf-wrangler/build.test.ts index 04ce890036..dc465eb53d 100644 --- a/packages/wrangler/src/__tests__/cf-wrangler/build.test.ts +++ b/packages/wrangler/src/__tests__/cf-wrangler/build.test.ts @@ -6,23 +6,8 @@ import { runCfWranglerBuild } from "../../cf-wrangler/build"; import { mockConsoleMethods } from "../helpers/mock-console"; vi.mock("@cloudflare/config", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - - async function loadConfig(configPath: string) { - const source = await fs.promises.readFile(configPath, "utf8"); - const mod = (await import( - `data:text/javascript;base64,${Buffer.from(source).toString("base64")}` - )) as { default: unknown }; - return { - config: mod.default, - dependencies: new Set([path.resolve(configPath)]), - }; - } - - return { - ...actual, - loadConfig, - }; + const { createConfigMock } = await import("../helpers/mock-new-config"); + return createConfigMock(importOriginal); }); describe("cf-wrangler build", () => { @@ -32,6 +17,7 @@ describe("cf-wrangler build", () => { it("emits the Build Output API tree", async ({ expect }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "cf-wrangler-build-worker", compatibilityDate: "2026-05-18", entrypoint: "./src/index.js", diff --git a/packages/wrangler/src/__tests__/experimental-config/load.test.ts b/packages/wrangler/src/__tests__/experimental-config/load.test.ts index 714db81527..bc2acacf28 100644 --- a/packages/wrangler/src/__tests__/experimental-config/load.test.ts +++ b/packages/wrangler/src/__tests__/experimental-config/load.test.ts @@ -1,43 +1,11 @@ -import * as fs from "node:fs"; import * as path from "node:path"; import { runInTempDir, seed } from "@cloudflare/workers-utils/test-helpers"; import { describe, it, vi } from "vitest"; import { loadNewConfig } from "../../experimental-config/load"; -// ───────────────────────────────────────────────────────────────────────────── -// Mock `@cloudflare/config` -// ───────────────────────────────────────────────────────────────────────────── -// -// `loadNewConfig` calls into `@cloudflare/config`'s `loadConfig`, which uses -// `module.registerHooks` to register hooks for `.ts` files and `cf-worker` -// import attributes. That mechanism does not run inside vitest's module -// runner, so we cannot invoke the real loader here. -// -// Mocking `loadConfig` lets us "load" arbitrary seeded files inside the temp -// dir without going through Node's module hooks. We use `import("data:...")` -// to evaluate the file as ESM so the function-form configs work naturally. -// ───────────────────────────────────────────────────────────────────────────── - vi.mock("@cloudflare/config", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - - async function loadConfig(configPath: string) { - const source = await fs.promises.readFile(configPath, "utf8"); - // Evaluate via a data URL — preserves ESM semantics (top-level await, - // default export, etc.) without triggering Node's `.ts` hooks. - const mod = (await import( - `data:text/javascript;base64,${Buffer.from(source).toString("base64")}` - )) as { default: unknown }; - return { - config: mod.default, - dependencies: new Set([path.resolve(configPath)]), - }; - } - - return { - ...actual, - loadConfig, - }; + const { createConfigMock } = await import("../helpers/mock-new-config"); + return createConfigMock(importOriginal); }); describe("loadNewConfig", () => { @@ -62,7 +30,7 @@ describe("loadNewConfig", () => { }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "my-worker", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "my-worker", compatibilityDate: "2026-05-18" };', }); const result = await loadNewConfig({ @@ -89,7 +57,7 @@ describe("loadNewConfig", () => { }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "merged-worker", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "merged-worker", compatibilityDate: "2026-05-18" };', "wrangler.config.ts": 'export default { minify: true, assetsDirectory: "./public" };', }); @@ -118,6 +86,7 @@ describe("loadNewConfig", () => { await seed({ "cloudflare.config.ts": ` export default (ctx) => ({ + type: "worker", name: \`worker-\${ctx.mode}\`, compatibilityDate: "2026-05-18", }); @@ -139,6 +108,7 @@ describe("loadNewConfig", () => { await seed({ "cloudflare.config.ts": ` export default (ctx) => ({ + type: "worker", name: \`worker-\${ctx.mode}\`, compatibilityDate: "2026-05-18", }); @@ -159,6 +129,7 @@ describe("loadNewConfig", () => { await seed({ "cloudflare.config.ts": ` export default (ctx) => ({ + type: "worker", name: \`worker[\${ctx.mode}]\`, compatibilityDate: "2026-05-18", }); @@ -178,7 +149,7 @@ describe("loadNewConfig", () => { }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', "wrangler.config.ts": ` export default (ctx) => ({ assetsDirectory: \`./\${ctx.mode}-public\`, @@ -197,6 +168,87 @@ describe("loadNewConfig", () => { }); }); + describe("settings export", () => { + it("threads accountId and complianceRegion from the settings export", async ({ + expect, + }) => { + await seed({ + "cloudflare.config.ts": ` + export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" }; + export const settings = { type: "settings", accountId: "acc-123", complianceRegion: "fedramp-high" }; + `, + }); + + const result = await loadNewConfig({ cwd: process.cwd(), args: {} }); + + expect(result.rawConfig.account_id).toBe("acc-123"); + expect(result.rawConfig.compliance_region).toBe("fedramp_high"); + expect(result.parsedSettingsConfig).toEqual({ + type: "settings", + accountId: "acc-123", + complianceRegion: "fedramp-high", + }); + }); + + it("leaves settings undefined when there is no settings export", async ({ + expect, + }) => { + await seed({ + "cloudflare.config.ts": + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', + }); + + const result = await loadNewConfig({ cwd: process.cwd(), args: {} }); + + expect(result.parsedSettingsConfig).toBeUndefined(); + expect(result.rawConfig.account_id).toBeUndefined(); + }); + }); + + describe("default worker selection", () => { + it("consumes the default export and validates-then-ignores other workers", async ({ + expect, + }) => { + await seed({ + "cloudflare.config.ts": ` + export default { type: "worker", name: "primary", compatibilityDate: "2026-05-18" }; + export const other = { type: "worker", name: "other", compatibilityDate: "2026-05-18" }; + `, + }); + + const result = await loadNewConfig({ cwd: process.cwd(), args: {} }); + + expect(result.rawConfig.name).toBe("primary"); + expect(result.parsedWorkerConfig.name).toBe("primary"); + }); + + it("still validates non-default worker exports", async ({ expect }) => { + await seed({ + "cloudflare.config.ts": ` + export default { type: "worker", name: "primary", compatibilityDate: "2026-05-18" }; + export const other = { type: "worker", name: 42, compatibilityDate: "2026-05-18" }; + `, + }); + + await expect( + loadNewConfig({ cwd: process.cwd(), args: {} }) + ).rejects.toThrow(/other\.name/); + }); + + it("throws when there is no default worker export", async ({ expect }) => { + await seed({ + "cloudflare.config.ts": 'export const settings = { type: "settings" };', + }); + + await expect( + loadNewConfig({ cwd: process.cwd(), args: {} }) + ).rejects.toMatchObject({ + message: expect.stringContaining("must have a default worker export"), + telemetryMessage: "new-config worker default export missing", + }); + }); + }); + describe("worker schema validation", () => { it("throws when cloudflare.config.ts has invalid types", async ({ expect, @@ -204,7 +256,7 @@ describe("loadNewConfig", () => { // `compatibilityDate` must be a string — number triggers a Zod error. await seed({ "cloudflare.config.ts": - 'export default { name: "bad", compatibilityDate: 12345 };', + 'export default { type: "worker", name: "bad", compatibilityDate: 12345 };', }); await expect( @@ -220,12 +272,12 @@ describe("loadNewConfig", () => { }) => { await seed({ "cloudflare.config.ts": - 'export default { name: 42, compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: 42, compatibilityDate: "2026-05-18" };', }); await expect( loadNewConfig({ cwd: process.cwd(), args: {} }) - ).rejects.toThrow(/\s*•\s+name:/); + ).rejects.toThrow(/\s*•\s+default\.name:/); }); }); @@ -235,7 +287,7 @@ describe("loadNewConfig", () => { }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', // `name` is a worker-runtime field, not a tooling field; the // `WORKER_CONFIG_FIELD_HINTS` set turns this into a hint. "wrangler.config.ts": @@ -257,7 +309,7 @@ describe("loadNewConfig", () => { }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', "wrangler.config.ts": "export default { bogusField: true };", }); @@ -271,7 +323,7 @@ describe("loadNewConfig", () => { it("rejects wrong types for tooling fields", async ({ expect }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', "wrangler.config.ts": 'export default { minify: "yes-please" };', }); @@ -291,6 +343,7 @@ describe("loadNewConfig", () => { await seed({ "cloudflare.config.ts": ` export default { + type: "worker", name: "w", compatibilityDate: "2026-05-18", env: { ASSETS: { type: "assets" } }, @@ -322,6 +375,7 @@ describe("loadNewConfig", () => { await seed({ "cloudflare.config.ts": ` export default { + type: "worker", name: "email-worker", compatibilityDate: "2026-05-18", triggers: [ @@ -352,7 +406,7 @@ describe("loadNewConfig", () => { }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', }); const result = await loadNewConfig({ @@ -368,7 +422,7 @@ describe("loadNewConfig", () => { }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', "wrangler.config.ts": "export default { minify: true };", }); @@ -383,7 +437,7 @@ describe("loadNewConfig", () => { it("honors `dev.types.generate: false`", async ({ expect }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', "wrangler.config.ts": "export default { dev: { types: { generate: false } } };", }); @@ -399,7 +453,7 @@ describe("loadNewConfig", () => { it("honors `dev.types.includeRuntime: false`", async ({ expect }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', "wrangler.config.ts": "export default { dev: { types: { includeRuntime: false } } };", }); @@ -415,7 +469,7 @@ describe("loadNewConfig", () => { it("is not threaded into the merged rawConfig.dev", async ({ expect }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', "wrangler.config.ts": "export default { dev: { types: { generate: false }, port: 1234 } };", }); @@ -437,7 +491,7 @@ describe("loadNewConfig", () => { it("is the union of dependencies from both files", async ({ expect }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', "wrangler.config.ts": "export default { minify: true };", }); diff --git a/packages/wrangler/src/__tests__/helpers/mock-new-config.ts b/packages/wrangler/src/__tests__/helpers/mock-new-config.ts new file mode 100644 index 0000000000..614c1512a3 --- /dev/null +++ b/packages/wrangler/src/__tests__/helpers/mock-new-config.ts @@ -0,0 +1,58 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared `@cloudflare/config` mock +// ───────────────────────────────────────────────────────────────────────────── +// +// `loadNewConfig` calls into `@cloudflare/config`'s `loadConfig`, which uses +// `module.registerHooks` to register hooks for `.ts` files and `cf-worker` +// import attributes. That mechanism does not run inside vitest's module +// runner, so we cannot invoke the real loader here. +// +// Mocking `loadConfig` lets us "load" arbitrary seeded files inside the temp +// dir without going through Node's module hooks. We `import("data:...")` to +// evaluate the file as ESM so the function-form configs work naturally. +// ───────────────────────────────────────────────────────────────────────────── + +export async function createConfigMock(importOriginal: () => Promise) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock plumbing + const actual = (await importOriginal()) as Record; + + async function importSeeded( + configPath: string + ): Promise> { + const source = await fs.promises.readFile(configPath, "utf8"); + // Evaluate via a data URL — preserves ESM semantics (top-level await, + // default export, etc.) without triggering Node's `.ts` hooks. + return (await import( + `data:text/javascript;base64,${Buffer.from(source).toString("base64")}` + )) as Record; + } + + async function loadConfig(configPath: string) { + const exports = await importSeeded(configPath); + return { + exports, + dependencies: new Set([path.resolve(configPath)]), + }; + } + + async function loadAndValidateConfig(configPath: string, ctx: unknown) { + 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)]), + }; + } + + return { + ...actual, + loadConfig, + loadAndValidateConfig, + }; +} diff --git a/packages/wrangler/src/build/run-build-output.ts b/packages/wrangler/src/build/run-build-output.ts index 60d88cd358..b782825ace 100644 --- a/packages/wrangler/src/build/run-build-output.ts +++ b/packages/wrangler/src/build/run-build-output.ts @@ -15,9 +15,10 @@ import type { WorkerBuildResult } from "@cloudflare/deploy-helpers"; export async function runBuildOutput(buildArgs: { env?: string; }): Promise { - const { config, parsedWorkerConfig } = await readNewConfig({ - env: buildArgs.env, - }); + const { config, parsedWorkerConfig, parsedSettingsConfig } = + await readNewConfig({ + env: buildArgs.env, + }); const { buildProps, assetsOptions } = await mergeBuildOutputProps(config); const root = process.cwd(); @@ -30,6 +31,7 @@ export async function runBuildOutput(buildArgs: { await writeBuildOutput({ root, parsedWorkerConfig, + parsedSettingsConfig, buildResult, assetsOptions, }); diff --git a/packages/wrangler/src/config/index.ts b/packages/wrangler/src/config/index.ts index 2bfea9d54b..72e94aea16 100644 --- a/packages/wrangler/src/config/index.ts +++ b/packages/wrangler/src/config/index.ts @@ -16,7 +16,10 @@ import { logger } from "../logger"; import { EXIT_CODE_INVALID_PAGES_CONFIG } from "../pages/errors"; import { updateCheck } from "../update-check"; import type { NormalizedTypes } from "../experimental-config/load"; -import type { ParsedInputWorkerConfig } from "@cloudflare/config"; +import type { + ParsedInputWorkerConfig, + ParsedSettingsConfig, +} from "@cloudflare/config"; import type { Config, ConfigBindingOptions, @@ -72,6 +75,7 @@ async function logWarningsWithUpgradeHint( export interface NewConfig { config: Config; parsedWorkerConfig: ParsedInputWorkerConfig; + parsedSettingsConfig: ParsedSettingsConfig | undefined; dependencies: Set; types: NormalizedTypes; } @@ -128,6 +132,7 @@ export async function readNewConfig( return { config, parsedWorkerConfig: loaded.parsedWorkerConfig, + parsedSettingsConfig: loaded.parsedSettingsConfig, dependencies: loaded.dependencies, types: loaded.types, }; diff --git a/packages/wrangler/src/deployment-bundle/build-output.ts b/packages/wrangler/src/deployment-bundle/build-output.ts index c7466edf90..97fd1e22e8 100644 --- a/packages/wrangler/src/deployment-bundle/build-output.ts +++ b/packages/wrangler/src/deployment-bundle/build-output.ts @@ -5,12 +5,14 @@ import { getWorkerAssetsDir, getWorkerBundleDir, writeOutputWorkerConfig, + writeRootOutputConfig, } from "@cloudflare/config"; import { UserError } from "@cloudflare/workers-utils"; import type { ModuleType, ParsedInputWorkerConfig, ParsedOutputWorkerConfig, + ParsedSettingsConfig, } from "@cloudflare/config"; import type { WorkerBuildResult } from "@cloudflare/deploy-helpers"; import type { AssetsOptions, CfModuleType } from "@cloudflare/workers-utils"; @@ -18,6 +20,7 @@ import type { AssetsOptions, CfModuleType } from "@cloudflare/workers-utils"; interface WriteBuildOutputArgs { root: string; parsedWorkerConfig: ParsedInputWorkerConfig; + parsedSettingsConfig: ParsedSettingsConfig | undefined; buildResult: WorkerBuildResult | undefined; assetsOptions: AssetsOptions | undefined; } @@ -29,6 +32,7 @@ interface WriteBuildOutputArgs { export async function writeBuildOutput({ root, parsedWorkerConfig, + parsedSettingsConfig, buildResult, assetsOptions, }: WriteBuildOutputArgs): Promise { @@ -58,6 +62,10 @@ export async function writeBuildOutput({ ]); await writeOutputWorkerConfig(root, parsedWorkerConfig, manifest); + + if (parsedSettingsConfig !== undefined) { + await writeRootOutputConfig(root, parsedSettingsConfig); + } } async function writeBundle({ diff --git a/packages/wrangler/src/experimental-config/load.ts b/packages/wrangler/src/experimental-config/load.ts index 45768e472e..7125a07250 100644 --- a/packages/wrangler/src/experimental-config/load.ts +++ b/packages/wrangler/src/experimental-config/load.ts @@ -1,10 +1,9 @@ import { existsSync } from "node:fs"; import path from "node:path"; import { - InputWorkerSchema, convertToWranglerConfig, + loadAndValidateConfig, loadConfig, - resolveWorkerDefinition, } from "@cloudflare/config"; import { getCloudflareEnv, UserError } from "@cloudflare/workers-utils"; import { convertToolingConfig } from "./convert"; @@ -15,7 +14,10 @@ import { } from "./schema"; import { resolveWranglerConfig } from "./wrangler-definition"; import type { ParsedWranglerConfig } from "./schema"; -import type { ParsedInputWorkerConfig } from "@cloudflare/config"; +import type { + ParsedInputWorkerConfig, + ParsedSettingsConfig, +} from "@cloudflare/config"; import type { RawConfig } from "@cloudflare/workers-utils"; export const CLOUDFLARE_CONFIG_FILENAME = "cloudflare.config.ts"; @@ -29,8 +31,10 @@ export interface NormalizedTypes { export interface LoadNewConfigResult { /** Merged result: `cloudflare.config.ts` runtime + `wrangler.config.ts` tooling. */ rawConfig: Omit; - /** The validated `cloudflare.config.ts` shape. */ + /** The validated `cloudflare.config.ts` worker shape (default export). */ parsedWorkerConfig: ParsedInputWorkerConfig; + /** The validated `settings` export, if present. */ + parsedSettingsConfig: ParsedSettingsConfig | undefined; /** Resolved absolute path to `cloudflare.config.ts`. */ cloudflareConfigPath: string; /** Resolved absolute path to `wrangler.config.ts`, if present. */ @@ -70,25 +74,38 @@ export async function loadNewConfig(options: { const mode = options.args.env ?? getCloudflareEnv(); - // ── Worker config ─────────────────────────────────────────────────── - const workerConfigResult = await loadConfig(cloudflareConfigPath); - - const resolvedWorkerConfig = await resolveWorkerDefinition( - workerConfigResult.config, - { mode } - ); + // ── Worker + settings config ──────────────────────────────────────── + const workerConfigResult = await loadAndValidateConfig(cloudflareConfigPath, { + mode, + }); - const parsedWorkerConfig = InputWorkerSchema.safeParse(resolvedWorkerConfig); - if (!parsedWorkerConfig.success) { + if (!workerConfigResult.result.success) { throw new UserError( - `Invalid \`${CLOUDFLARE_CONFIG_FILENAME}\`:\n${formatZodError(parsedWorkerConfig.error)}`, + `Invalid \`${CLOUDFLARE_CONFIG_FILENAME}\`:\n${formatZodError(workerConfigResult.result.error)}`, { telemetryMessage: "new-config worker validation failed" } ); } + const worker = + workerConfigResult.result.data.default?.type === "worker" + ? workerConfigResult.result.data.default + : undefined; + + if (worker === undefined) { + throw new UserError( + `\`${CLOUDFLARE_CONFIG_FILENAME}\` must have a default worker export.`, + { telemetryMessage: "new-config worker default export missing" } + ); + } + + const settings = + workerConfigResult.result.data.settings?.type === "settings" + ? workerConfigResult.result.data.settings + : undefined; + // ── Wrangler (tooling) config ─────────────────────────────────────── let wranglerConfigResult: - | { config: unknown; dependencies: Set } + | { exports: Record; dependencies: Set } | undefined; let parsedWranglerConfig: { data: ParsedWranglerConfig } | undefined; @@ -96,7 +113,7 @@ export async function loadNewConfig(options: { wranglerConfigResult = await loadConfig(wranglerConfigPath); const resolvedWranglerConfig = await resolveWranglerConfig( - wranglerConfigResult.config, + wranglerConfigResult.exports.default, { mode } ); @@ -111,7 +128,7 @@ export async function loadNewConfig(options: { } // ── Conversion + merge ────────────────────────────────────────────── - const rawWorkerConfig = convertToWranglerConfig(parsedWorkerConfig.data); + const rawWorkerConfig: RawConfig = convertToWranglerConfig(worker, settings); const rawWranglerConfig = convertToolingConfig( parsedWranglerConfig?.data ?? {} @@ -136,7 +153,8 @@ export async function loadNewConfig(options: { return { rawConfig, - parsedWorkerConfig: parsedWorkerConfig.data, + parsedWorkerConfig: worker, + parsedSettingsConfig: settings, cloudflareConfigPath, wranglerConfigPath, dependencies,