diff --git a/.changeset/smart-workers-reference.md b/.changeset/smart-workers-reference.md new file mode 100644 index 00000000000..ac12240a688 --- /dev/null +++ b/.changeset/smart-workers-reference.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/config": minor +--- + +Enable referencing Worker configs directly in cross-Worker bindings + +`defineWorker` and `defineSettings` now return ordinary config objects, functions, or promises. Cross-Worker bindings can use a `defineWorker` result as their `worker`, preserving type inference while resolving and parsing the referenced config only once. diff --git a/packages/config/src/__tests__/config-loader.test.ts b/packages/config/src/__tests__/config-loader.test.ts new file mode 100644 index 00000000000..26737bf70a4 --- /dev/null +++ b/packages/config/src/__tests__/config-loader.test.ts @@ -0,0 +1,362 @@ +import { describe, it, vi } from "vitest"; +import { bindings } from "../bindings"; +import { resolveAndValidateConfigExports } from "../config-loader"; +import { defineWorker } from "../worker-definition"; +import type { ConfigContext } from "../definition"; +import type { + WorkerConfigExport, + WorkerConfigInput, +} from "../worker-definition"; + +const compatibilityDate = "2026-09-02"; +const baseConfig = { + type: "worker", + name: "my-worker", + compatibilityDate, +} as const; + +describe("resolveAndValidateConfigExports", () => { + it("parses Worker and settings exports", async ({ expect }) => { + const result = await resolveAndValidateConfigExports( + { + default: baseConfig, + api: { ...baseConfig, name: "api" }, + settings: { type: "settings", accountId: "acc-123" }, + }, + { mode: undefined } + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.default?.name).toBe("my-worker"); + expect(result.data.api?.name).toBe("api"); + expect(result.data.settings?.accountId).toBe("acc-123"); + } + }); + + it("collects settings and Worker validation errors", async ({ expect }) => { + const result = await resolveAndValidateConfigExports( + { + default: { ...baseConfig, compatibilityDate: 42 }, + settings: { type: "settings", accountId: 42 }, + }, + { mode: undefined } + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.map((issue) => issue.path)).toEqual( + expect.arrayContaining([ + ["settings", "accountId"], + ["default", "compatibilityDate"], + ]) + ); + } + }); + + it("reports an invalid-discriminator issue keyed by export name", async ({ + expect, + }) => { + const result = await resolveAndValidateConfigExports( + { + default: { name: "my-worker", compatibilityDate }, + }, + { mode: undefined } + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.path).toEqual(["default", "type"]); + } + }); + + it.for([ + { + description: "object", + value: { staging: "staging-worker", production: "production-worker" }, + path: ["WORKER_NAMES", "type"], + }, + { + description: "primitive", + value: 42, + path: ["WORKER_NAMES"], + }, + ])( + "reports an actionable error for an unknown $description export", + async ({ value, path }, { expect }) => { + const result = await resolveAndValidateConfigExports( + { default: baseConfig, WORKER_NAMES: value }, + { mode: undefined } + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]).toMatchObject({ + path, + message: + "The `WORKER_NAMES` export is not a supported export type. Move constants, helper functions, and other unsupported exports to a separate module.", + }); + } + } + ); + + it("rejects a settings config on a non-settings export", async ({ + expect, + }) => { + const result = await resolveAndValidateConfigExports( + { + default: baseConfig, + settings: { type: "settings" }, + extraSettings: { type: "settings" }, + }, + { mode: undefined } + ); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((candidate) => + candidate.message.includes( + "A `settings` config is only allowed on the `settings` export" + ) + ); + expect(issue?.path).toEqual(["extraSettings"]); + } + }); + + it("rejects a Worker config on the settings export", async ({ expect }) => { + const result = await resolveAndValidateConfigExports( + { + default: baseConfig, + settings: { ...baseConfig, name: "settings" }, + }, + { mode: undefined } + ); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((candidate) => + candidate.message.includes( + "The `settings` export is reserved for a `settings` config" + ) + ); + expect(issue?.path).toEqual(["settings"]); + } + }); + + it("resolves object Worker references to names", async ({ expect }) => { + const auxiliary = defineWorker({ + name: "auxiliary", + compatibilityDate, + }); + const entry = defineWorker({ + name: "entry", + compatibilityDate, + env: { + AUXILIARY: bindings.worker({ worker: auxiliary }), + }, + }); + + const result = await resolveAndValidateConfigExports( + { default: entry, auxiliary }, + { mode: "development" } + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.default?.env?.AUXILIARY).toMatchObject({ + type: "worker", + worker: "auxiliary", + }); + } + }); + + it("resolves repeated Worker references once using the config context", async ({ + expect, + }) => { + const auxiliaryFactory = vi.fn((ctx: ConfigContext) => ({ + name: `auxiliary-${ctx.mode}`, + compatibilityDate, + exports: { + Counter: { + type: "durable-object" as const, + storage: "sqlite" as const, + }, + }, + })); + const auxiliary = defineWorker(auxiliaryFactory); + const entry = defineWorker({ + name: "entry", + compatibilityDate, + env: { + FIRST: bindings.worker({ worker: auxiliary }), + SECOND: bindings.worker({ worker: auxiliary }), + COUNTER: bindings.durableObject({ + worker: auxiliary, + exportName: "Counter", + }), + }, + }); + + const ctx = { mode: "test" }; + const result = await resolveAndValidateConfigExports( + { default: entry }, + ctx + ); + + expect(result.success).toBe(true); + expect(auxiliaryFactory).toHaveBeenCalledOnce(); + expect(auxiliaryFactory).toHaveBeenCalledWith(ctx); + if (result.success) { + expect(result.data.default?.env).toMatchObject({ + FIRST: { worker: "auxiliary-test" }, + SECOND: { worker: "auxiliary-test" }, + COUNTER: { worker: "auxiliary-test" }, + }); + } + }); + + it("does not parse unexported Worker references", async ({ expect }) => { + const invalidFactory = vi.fn(() => ({ + name: "referenced-only", + compatibilityDate, + unexpected: true, + })); + const referencedOnly = defineWorker( + invalidFactory as unknown as () => WorkerConfigInput + ); + const entry = defineWorker({ + name: "entry", + compatibilityDate, + env: { + FIRST: bindings.worker({ worker: referencedOnly }), + SECOND: bindings.worker({ worker: referencedOnly }), + }, + }); + + const result = await resolveAndValidateConfigExports( + { default: entry }, + { mode: "development" } + ); + + expect(result.success).toBe(true); + expect(invalidFactory).toHaveBeenCalledOnce(); + if (result.success) { + expect(result.data.default?.env).toMatchObject({ + FIRST: { worker: "referenced-only" }, + SECOND: { worker: "referenced-only" }, + }); + } + }); + + it("leaves string Worker references unchanged", async ({ expect }) => { + const entry = defineWorker({ + name: "entry", + compatibilityDate, + env: { + EXTERNAL: bindings.worker({ worker: "external-worker" }), + }, + }); + + const result = await resolveAndValidateConfigExports( + { default: entry }, + { mode: undefined } + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.default?.env?.EXTERNAL).toMatchObject({ + worker: "external-worker", + }); + } + }); + + it("stops after top-level export type errors", async ({ expect }) => { + const auxiliaryFactory = vi.fn(() => ({ + name: "auxiliary", + compatibilityDate, + })); + const auxiliary = defineWorker(auxiliaryFactory); + const entry = defineWorker({ + name: "entry", + compatibilityDate, + env: { + AUXILIARY: bindings.worker({ worker: auxiliary }), + }, + }); + + const result = await resolveAndValidateConfigExports( + { default: entry, UNSUPPORTED: 42 }, + { mode: undefined } + ); + + expect(result.success).toBe(false); + expect(auxiliaryFactory).not.toHaveBeenCalled(); + if (!result.success) { + expect(result.error.issues).toHaveLength(1); + expect(result.error.issues[0]?.path).toEqual(["UNSUPPORTED"]); + } + }); + + it("supports mutually-referencing Worker factories", async ({ expect }) => { + const workers = {} as Record<"first" | "second", WorkerConfigExport>; + + workers.first = defineWorker(() => ({ + name: "first", + compatibilityDate, + env: { SECOND: bindings.worker({ worker: workers.second }) }, + })); + workers.second = defineWorker(() => ({ + name: "second", + compatibilityDate, + env: { FIRST: bindings.worker({ worker: workers.first }) }, + })); + + const result = await resolveAndValidateConfigExports( + { default: workers.first, second: workers.second }, + { mode: "development" } + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.default?.env?.SECOND).toMatchObject({ + worker: "second", + }); + expect(result.data.second?.env?.FIRST).toMatchObject({ + worker: "first", + }); + } + }); + + it("parses an exported referenced Worker once", async ({ expect }) => { + const invalidFactory = vi.fn(() => ({ + name: "invalid", + compatibilityDate: 42, + })); + const invalid = defineWorker( + invalidFactory as unknown as () => WorkerConfigInput + ); + const entry = defineWorker({ + name: "entry", + compatibilityDate, + env: { + FIRST: bindings.worker({ worker: invalid }), + SECOND: bindings.worker({ worker: invalid }), + }, + }); + + const result = await resolveAndValidateConfigExports( + { default: entry, invalid }, + { mode: "development" } + ); + + expect(result.success).toBe(false); + expect(invalidFactory).toHaveBeenCalledOnce(); + if (!result.success) { + expect( + result.error.issues.filter( + (issue) => issue.path.join(".") === "invalid.compatibilityDate" + ) + ).toHaveLength(1); + } + }); +}); diff --git a/packages/config/src/__tests__/schema.test.ts b/packages/config/src/__tests__/schema.test.ts index 423c6ff1f84..c0db935dc15 100644 --- a/packages/config/src/__tests__/schema.test.ts +++ b/packages/config/src/__tests__/schema.test.ts @@ -2,7 +2,6 @@ import { describe, it } from "vitest"; import { exports as exportConfig } from "../exports"; import { BindingSchema, - ConfigExportsSchema, InputSettingsSchema, InputWorkerSchema, OutputSettingsSchema, @@ -912,126 +911,6 @@ describe("OutputSettingsSchema", () => { }); }); -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?.name).toBe("my-worker"); - expect(result.data.settings?.accountId).toBe("acc-123"); - } - }); - - 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.for([ - { - description: "object", - value: { staging: "staging-worker", production: "production-worker" }, - path: ["WORKER_NAMES", "type"], - }, - { - description: "primitive", - value: 42, - path: ["WORKER_NAMES"], - }, - ])( - "reports an actionable error for an unknown $description export", - ({ value, path }, { expect }) => { - const result = ConfigExportsSchema.safeParse({ - default: baseConfig, - WORKER_NAMES: value, - }); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues[0]).toMatchObject({ - path, - message: - "The `WORKER_NAMES` export is not a supported export type. Move constants, helper functions, and other unsupported exports to a separate module.", - }); - } - } - ); - - 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); - }); -}); - describe("ExportSchema", () => { function parseExports(exports: unknown) { return InputWorkerSchema.safeParse({ ...baseConfig, exports }); diff --git a/packages/config/src/__tests__/worker-references.test-d.ts b/packages/config/src/__tests__/worker-references.test-d.ts new file mode 100644 index 00000000000..478878b5d00 --- /dev/null +++ b/packages/config/src/__tests__/worker-references.test-d.ts @@ -0,0 +1,112 @@ +import { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; +import { bindings } from "../bindings"; +import { exports as workerExports } from "../exports"; +import { defineWorker } from "../worker-definition"; +import type { DurableObjectBinding, WorkerBinding } from "../bindings"; +import type { InferEnv, UnwrapConfig } from "../inference"; + +class Admin extends WorkerEntrypoint { + adminMethod(): string { + return "admin"; + } +} + +class Counter extends DurableObject { + increment(): number { + return 1; + } +} + +const entrypoint = { + default: { fetch: () => new Response() }, + Admin, + Counter, +}; + +const auxiliary = defineWorker({ + name: "auxiliary", + compatibilityDate: "2026-09-02", + entrypoint, + exports: { + Counter: workerExports.durableObject({ storage: "sqlite" }), + }, +}); + +const auxiliaryFactory = defineWorker(() => ({ + name: "auxiliary-factory", + compatibilityDate: "2026-09-02", + entrypoint, + /* + * Deliberately omit `exports`: `entrypoint` alone provides enough + * information to infer WorkerEntrypoint service bindings. + */ +})); + +const config = defineWorker({ + name: "entry", + compatibilityDate: "2026-09-02", + env: { + ADMIN: bindings.worker({ worker: auxiliary, exportName: "Admin" }), + DEFAULT: bindings.worker({ worker: auxiliary }), + FACTORY_ADMIN: bindings.worker({ + worker: auxiliaryFactory, + exportName: "Admin", + }), + COUNTER: bindings.durableObject({ + worker: auxiliary, + exportName: "Counter", + }), + DIRECT_ADMIN: { + type: "worker", + worker: auxiliary, + exportName: "Admin", + }, + EXTERNAL: bindings.worker({ + worker: "external-worker", + exportName: "AnyEntrypoint", + }), + }, +}); + +bindings.worker({ + worker: auxiliary, + // @ts-expect-error Only WorkerEntrypoint exports are accepted. + exportName: "Counter", +}); + +bindings.durableObject({ + worker: auxiliary, + // @ts-expect-error Only configured Durable Object exports are accepted. + exportName: "Admin", +}); + +type Equal = + (() => V extends T ? 1 : 2) extends () => V extends U ? 1 : 2 + ? true + : false; +type Assert = T; +type Env = InferEnv>; +type Auxiliary = typeof auxiliary; + +export type WorkerExportNameTest = Assert< + Equal["exportName"], "Admin" | undefined> +>; +export type DurableObjectExportNameTest = Assert< + Equal["exportName"], "Counter"> +>; +// @ts-expect-error Worker binding export names come from the referenced Worker. +export type InvalidWorkerExportNameTest = WorkerBinding; +// @ts-expect-error Durable Object export names come from the referenced Worker. +export type InvalidDoExportNameTest = DurableObjectBinding; +export type AdminBindingTest = Assert>>; +export type DefaultBindingTest = Assert>; +export type FactoryAdminBindingTest = Assert< + Equal> +>; +export type DirectAdminBindingTest = Assert< + Equal> +>; +export type CounterBindingTest = Assert< + Equal> +>; +export type ExternalBindingTest = Assert>; diff --git a/packages/config/src/bindings.ts b/packages/config/src/bindings.ts index fb18c1ace29..269607a7a8d 100644 --- a/packages/config/src/bindings.ts +++ b/packages/config/src/bindings.ts @@ -1,4 +1,11 @@ +import type { + InferDurableNamespaces, + InferExportsByType, + InferWorkerEntrypointExports, + UnwrapConfig, +} from "./inference"; import type { Json } from "./utils"; +import type { WorkerConfigExport } from "./worker-definition"; import type { PipelineRecord } from "cloudflare:pipelines"; // JSDoc is derived from `packages/workers-utils/src/config/environment.ts` — keep both in sync. @@ -177,39 +184,51 @@ export interface DispatchNamespaceBinding extends DispatchNamespaceBindingOption type: "dispatch-namespace"; } -interface DurableObjectBindingOptions { - /** The name of the Worker that defines the Durable Object class. */ - worker: string; +export type WorkerReference = string | WorkerConfigExport; + +type ReferencedWorkerConfig = + TWorker extends string ? never : UnwrapConfig; + +type DurableObjectExportName = + TWorker extends string + ? string + : InferDurableNamespaces>; + +type WorkerEntrypointExportName = + TWorker extends string + ? string + : InferWorkerEntrypointExports>; + +type WorkflowExportName = + TWorker extends string + ? string + : InferExportsByType, "workflow">; + +interface DurableObjectBindingOptions< + TWorker extends WorkerReference = WorkerReference, + TExportName extends DurableObjectExportName = + DurableObjectExportName, +> { + /** The name or config of the Worker that defines the Durable Object class. */ + worker: TWorker; /** The exported class name of the Durable Object. */ - exportName: string; + exportName: TExportName; } /** - * Binding to a Durable Object class. `worker` is the name of the Worker - * that defines the class; `exportName` is the exported class name. + * Binding to a Durable Object class. `worker` is the name or config of the + * Worker that defines the class; `exportName` is the exported class name. * * For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects */ -export interface DurableObjectBinding extends DurableObjectBindingOptions { +export interface DurableObjectBinding< + TWorker extends WorkerReference = WorkerReference, + TExportName extends DurableObjectExportName = + DurableObjectExportName, +> extends DurableObjectBindingOptions { type: "durable-object"; } -/** - * Binding to a Durable Object class. `worker` is the name of the Worker - * that defines the class; `exportName` is the exported class name. - * - * For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects - */ -export interface TypedDurableObjectBinding< - TConfig, - TExportName extends string, -> extends DurableObjectBinding { - worker: string; - exportName: TExportName; - /** @internal Carries the config type for inference */ - __config: TConfig; -} - interface FlagshipBindingOptions { /** The Flagship app ID to bind to. */ id?: string; @@ -590,11 +609,16 @@ export interface WebSearchBinding extends WebSearchBindingOptions { type: "web-search"; } -interface WorkerBindingOptions { - /** The name of the bound Worker. */ - worker: string; +interface WorkerBindingOptions< + TWorker extends WorkerReference = WorkerReference, + TExportName extends WorkerEntrypointExportName | undefined = + | WorkerEntrypointExportName + | undefined, +> { + /** The name or config of the bound Worker. */ + worker: TWorker; /** The named export to bind to (defaults to the default export). */ - exportName?: string; + exportName?: TExportName; /** Optional properties that will be made available to the service via `ctx.props`. */ props?: Record; /** Options that only apply during local development. */ @@ -602,67 +626,48 @@ interface WorkerBindingOptions { } /** - * Service binding (Worker-to-Worker). `worker` is the name of the bound - * Worker; `exportName` selects a named `WorkerEntrypoint` export (defaults to - * the default export). + * Service binding (Worker-to-Worker). `worker` is the name or config of the + * bound Worker; `exportName` selects a named `WorkerEntrypoint` export + * (defaults to the default export). * * For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings */ -export interface WorkerBinding extends WorkerBindingOptions { +export interface WorkerBinding< + TWorker extends WorkerReference = WorkerReference, + TExportName extends WorkerEntrypointExportName | undefined = + | WorkerEntrypointExportName + | undefined, +> extends WorkerBindingOptions { type: "worker"; } -/** - * Service binding (Worker-to-Worker). `worker` is the name of the bound - * Worker; `exportName` selects a named `WorkerEntrypoint` export (defaults to - * the default export). - * - * For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings - */ -export interface TypedWorkerBinding< - TConfig, - TExportName extends string, -> extends WorkerBinding { - worker: string; - exportName: TExportName; - /** @internal Carries the config type for inference */ - __config: TConfig; -} - /** Binding to a Worker Loader. */ export interface WorkerLoaderBinding { type: "worker-loader"; } -interface WorkflowBindingOptions { - /** The name of the Worker that defines the Workflow. */ - worker: string; +interface WorkflowBindingOptions< + TWorker extends WorkerReference = WorkerReference, + TExportName extends WorkflowExportName = WorkflowExportName, +> { + /** The name or config of the Worker that defines the Workflow. */ + worker: TWorker; /** The exported class name of the Workflow. */ - exportName: string; + exportName: TExportName; } /** - * Binding to a Workflow. `worker` is the name of the Worker that defines - * the Workflow; `exportName` is the exported `WorkflowEntrypoint` class name. + * Binding to a Workflow. `worker` is the name or config of the Worker that + * defines the Workflow; `exportName` is the exported `WorkflowEntrypoint` + * class name. */ -export interface WorkflowBinding extends WorkflowBindingOptions { +export interface WorkflowBinding< + TWorker extends WorkerReference = WorkerReference, + TExportName extends WorkflowExportName = WorkflowExportName, +> extends WorkflowBindingOptions { type: "workflow"; } -/** - * Binding to a Workflow. `worker` is the name of the Worker that defines - * the Workflow; `exportName` is the exported `WorkflowEntrypoint` class name. - */ -export interface TypedWorkflowBinding< - TConfig, - TExportName extends string, -> extends WorkflowBinding { - worker: string; - exportName: TExportName; - /** @internal Carries the config type for inference */ - __config: TConfig; -} - // ═══════════════════════════════════════════════════════════════════════════ // BINDINGS API // ═══════════════════════════════════════════════════════════════════════════ @@ -733,12 +738,17 @@ export interface Bindings { options?: DispatchNamespaceBindingOptions ): DispatchNamespaceBinding; /** - * Binding to a Durable Object class. `worker` is the name of the Worker - * that defines the class; `exportName` is the exported class name. + * Binding to a Durable Object class. `worker` is the name or config of the + * Worker that defines the class; `exportName` is the exported class name. * * For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects */ - durableObject(options: DurableObjectBindingOptions): DurableObjectBinding; + durableObject< + TWorker extends WorkerReference, + TExportName extends DurableObjectExportName, + >( + options: DurableObjectBindingOptions + ): DurableObjectBinding; /** Binding to a Flagship feature-flag service. */ flagship(options?: FlagshipBindingOptions): FlagshipBinding; /** @@ -846,19 +856,25 @@ export interface Bindings { */ webSearch(options?: WebSearchBindingOptions): WebSearchBinding; /** - * Service binding (Worker-to-Worker). `worker` is the name of the bound - * Worker; `exportName` selects a named `WorkerEntrypoint` export (defaults to - * the default export). + * Service binding (Worker-to-Worker). `worker` is the name or config of the + * bound Worker; `exportName` selects a named `WorkerEntrypoint` export + * (defaults to the default export). * * For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings */ - worker(options: WorkerBindingOptions): WorkerBinding; + worker< + TWorker extends WorkerReference, + TExportName extends WorkerEntrypointExportName | undefined = + undefined, + >( + options: WorkerBindingOptions + ): WorkerBinding>; /** Binding to a Worker Loader. */ workerLoader(): WorkerLoaderBinding; // TODO: re-enable when workflow bindings return. // /** // * Create a Workflow binding. - // * `worker` must match a known config's name (or any `string` for untyped bindings). + // * `worker` may be a Worker config reference or a Worker name. // * `exportName` must be a valid `WorkflowEntrypoint` export for the given Worker. // */ // workflow(options: WorkflowBindingOptions): WorkflowBinding; diff --git a/packages/config/src/config-loader.ts b/packages/config/src/config-loader.ts index 3bcebbcbd1f..f5dd58b21c3 100644 --- a/packages/config/src/config-loader.ts +++ b/packages/config/src/config-loader.ts @@ -1,22 +1,190 @@ -import { resolveExportDefinition } from "./definition"; +import * as z from "zod"; import { loadConfig } from "./load"; -import { ConfigExportsSchema } from "./schema"; +import { + ConfigExportsTypeSchema, + InputSettingsSchema, + InputWorkerSchema, +} from "./schema"; import type { ConfigContext } from "./definition"; -import type { ParsedConfigExports } from "./schema"; -import type * as z from "zod"; +import type { + ParsedInputSettingsConfig, + ParsedInputWorkerConfig, +} from "./schema"; + +const CROSS_WORKER_BINDING_TYPES = new Set([ + "durable-object", + "worker", + "workflow", +]); + +type ResolveDefinition = (input: unknown) => Promise; + +export type ParsedConfigExports = { + settings?: ParsedInputSettingsConfig; +} & Record; + +export type ConfigParseResult = + | z.ZodSafeParseSuccess + | z.ZodSafeParseError; export interface LoadAndValidateConfigResult { /** - * Zod result for the validated exports record, keyed by JS export name. + * Zod result for the parsed exports record, keyed by JS export name. * Consumers format `result.error` themselves. */ - result: z.ZodSafeParseResult; + result: ConfigParseResult; /** Transitive deps imported while resolving the config (node_modules excluded). */ dependencies: Set; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isConfigReference(value: unknown): boolean { + return typeof value === "function" || isRecord(value); +} + +function createDefinitionResolver(ctx: ConfigContext): ResolveDefinition { + const resolvedDefinitions = new Map>(); + + return (input) => { + const cached = resolvedDefinitions.get(input); + if (cached) { + return cached; + } + + const resolved = (async () => { + const value = + typeof input === "function" + ? (input as (ctx: ConfigContext) => unknown)(ctx) + : input; + return await value; + })(); + resolvedDefinitions.set(input, resolved); + return resolved; + }; +} + +async function normalizeWorkerReferences( + resolved: unknown, + resolveDefinition: ResolveDefinition +): Promise { + if (!isRecord(resolved) || !isRecord(resolved.env)) { + return resolved; + } + + const env: Record = { ...resolved.env }; + for (const [bindingName, binding] of Object.entries(env)) { + if ( + !isRecord(binding) || + typeof binding.type !== "string" || + !CROSS_WORKER_BINDING_TYPES.has(binding.type) || + !isConfigReference(binding.worker) + ) { + continue; + } + + const target = await resolveDefinition(binding.worker); + env[bindingName] = { + ...binding, + worker: isRecord(target) ? target.name : undefined, + }; + } + + return { ...resolved, env }; +} + +function prefixIssues( + issues: z.core.$ZodIssue[], + prefix: PropertyKey +): z.core.$ZodIssue[] { + return issues.map((issue) => ({ + ...issue, + path: [prefix, ...issue.path], + })); +} + +/** + * Resolve and validate loaded `cloudflare.config.ts` exports. + * + * Config inputs are resolved once by identity. Top-level Worker exports are + * also parsed once by identity; unexported references are resolved only far + * enough to replace the reference with the Worker's name. + */ +export async function resolveAndValidateConfigExports( + exports: Record, + ctx: ConfigContext +): Promise { + const resolveDefinition = createDefinitionResolver(ctx); + const parsedWorkers = new Map< + unknown, + z.ZodSafeParseResult + >(); + const resolvedExports: Record = {}; + + for (const [name, input] of Object.entries(exports)) { + resolvedExports[name] = await resolveDefinition(input); + } + + const typeResult = ConfigExportsTypeSchema.safeParse(resolvedExports); + if (!typeResult.success) { + return typeResult; + } + + const issues: z.core.$ZodIssue[] = []; + const data: ParsedConfigExports = {}; + + const resolvedSettings = resolvedExports.settings; + const settingsResult = resolvedSettings + ? InputSettingsSchema.safeParse(resolvedSettings) + : undefined; + if (settingsResult) { + if (settingsResult.success) { + data.settings = settingsResult.data; + } else { + issues.push(...prefixIssues(settingsResult.error.issues, "settings")); + } + } + + for (const [name, input] of Object.entries(exports)) { + const resolved = resolvedExports[name]; + if (!isRecord(resolved)) { + continue; + } + + if (resolved.type === "worker") { + let result = parsedWorkers.get(input); + if (!result) { + result = InputWorkerSchema.safeParse( + await normalizeWorkerReferences(resolved, resolveDefinition) + ); + parsedWorkers.set(input, result); + if (!result.success) { + issues.push(...prefixIssues(result.error.issues, name)); + } + } + + if (result.success) { + data[name] = result.data; + } + continue; + } + } + + return issues.length > 0 + ? { + success: false, + error: new z.ZodError(issues), + } + : { + success: true, + data, + }; +} + /** - * Load a `cloudflare.config.ts`, resolve all exports, and validate against {@link ConfigExportsSchema}. + * Load a `cloudflare.config.ts`, resolve all exports, and validate them. */ export async function loadAndValidateConfig( configPath: string, @@ -24,13 +192,7 @@ export async function loadAndValidateConfig( 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); + const result = await resolveAndValidateConfigExports(exports, ctx); return { result, dependencies }; } diff --git a/packages/config/src/definition.ts b/packages/config/src/definition.ts index 4f505722c5a..38963c2a180 100644 --- a/packages/config/src/definition.ts +++ b/packages/config/src/definition.ts @@ -8,10 +8,6 @@ export interface ConfigContext { 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}. @@ -21,36 +17,60 @@ export type ConfigInput = | 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; -} +type ConfigObject = Record; -/** - * 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, +export type ConfigWithType = T & { + type: TType; +}; + +type DefinedConfigValue = + TValue extends Promise + ? Promise> + : TValue extends ConfigObject + ? ConfigWithType + : never; + +type DefinedConfig = TInput extends ( ctx: ConfigContext -): Promise { - if (typeof def === "object" && def !== null && DEFINITION in def) { - const { config, type } = (def as Record)[DEFINITION] as { - config: unknown; - type: string; +) => infer TResult + ? (ctx: ConfigContext) => DefinedConfigValue + : DefinedConfigValue; + +/** Add a config type while preserving its value, promise, or function shape. */ +function addConfigType< + TConfig extends ConfigObject, + const TType extends string, +>( + config: ConfigInput, + type: TType +): ConfigInput> { + function addType(value: TConfig): ConfigWithType { + return { ...value, type }; + } + + if (typeof config === "function") { + return (ctx) => { + const result = config(ctx); + return result instanceof Promise ? result.then(addType) : addType(result); }; - const resolved = await unwrap(config, ctx); - return { ...(resolved as object), type }; } - return await unwrap(def, ctx); + return config instanceof Promise ? config.then(addType) : addType(config); +} + +/** Create a type-safe config helper for a particular export type. */ +export function createConfigDefiner< + TConfigInput extends ConfigObject, + const TType extends string, +>(type: TType) { + function define>( + config: TInput + ): DefinedConfig; + function define( + config: ConfigInput + ): ConfigInput> { + return addConfigType(config, type); + } + + return define; } diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 295f90844b1..120f2321a49 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -4,7 +4,6 @@ export { AssetsSchema, BindingSchema, BrowserBindingSchema, - ConfigExportsSchema, D1BindingSchema, DurableObjectCreatedExportSchema, DurableObjectDeletedExportSchema, @@ -32,12 +31,17 @@ export { export { generateTypes } from "./generate"; export { convertToWranglerConfig } from "./convert"; export { loadConfig, registerConfigHooks } from "./load"; -export { loadAndValidateConfig } from "./config-loader"; -export { resolveExportDefinition } from "./definition"; +export { + loadAndValidateConfig, + resolveAndValidateConfigExports, +} from "./config-loader"; export type { LoadConfigResult } from "./load"; -export type { LoadAndValidateConfigResult } from "./config-loader"; export type { + ConfigParseResult, + LoadAndValidateConfigResult, ParsedConfigExports, +} from "./config-loader"; +export type { ParsedInputSettingsConfig, ParsedInputWorkerConfig, ParsedOutputSettingsConfig, diff --git a/packages/config/src/inference.ts b/packages/config/src/inference.ts index 7068ed2cf22..b65004c0433 100644 --- a/packages/config/src/inference.ts +++ b/packages/config/src/inference.ts @@ -4,14 +4,11 @@ import type { JsonBinding, TextBinding, TypedAiBinding, - TypedDurableObjectBinding, TypedKvBinding, TypedPipelineBinding, TypedQueueBinding, - TypedWorkerBinding, - TypedWorkflowBinding, + WorkerReference, } from "./bindings"; -import type { WorkerConfigExport, WorkerDefinition } from "./worker-definition"; import type { Pipeline } from "cloudflare:pipelines"; // ═══════════════════════════════════════════════════════════════════════════ @@ -124,52 +121,76 @@ interface BindingTypeMap { workflow: Workflow; } +type SelectedWorkerExportName = TBinding extends { + exportName?: infer TExportName; +} + ? TExportName extends string + ? TExportName + : "default" + : "default"; + type InferBindingType = // Worker binding - TBinding extends TypedWorkerBinding< - infer TConfig, - infer TExportName extends string - > - ? InferMainModule extends infer TModule extends WorkerModule - ? TExportName extends keyof TModule - ? TModule[TExportName] extends Constructor - ? Fetcher< - ExtractInstance - > - : Fetcher + TBinding extends { + type: "worker"; + worker: infer TWorker extends WorkerReference; + } + ? TWorker extends string + ? Fetcher + : InferMainModule> extends infer TModule extends + WorkerModule + ? SelectedWorkerExportName extends infer TExportName + ? TExportName extends keyof TModule + ? TModule[TExportName] extends Constructor + ? Fetcher< + ExtractInstance< + TModule[TExportName], + Rpc.WorkerEntrypointBranded + > + > + : Fetcher + : never + : never : never - : never : // Durable Object binding - TBinding extends TypedDurableObjectBinding< - infer TConfig, - infer TExportName extends string - > - ? InferMainModule extends infer TModule extends WorkerModule - ? TExportName extends keyof TModule - ? DurableObjectNamespace< - ExtractInstance - > + TBinding extends { + type: "durable-object"; + worker: infer TWorker extends WorkerReference; + exportName: infer TExportName extends string; + } + ? TWorker extends string + ? DurableObjectNamespace + : InferMainModule> extends infer TModule extends + WorkerModule + ? TExportName extends keyof TModule + ? DurableObjectNamespace< + ExtractInstance + > + : never : never - : never : // Workflow binding - TBinding extends TypedWorkflowBinding< - infer TConfig, - infer TExportName extends string - > - ? InferMainModule extends infer TModule extends WorkerModule - ? TExportName extends keyof TModule - ? ExtractInstance< - TModule[TExportName], - Rpc.WorkflowEntrypointBranded - > extends infer TWorkflow - ? TWorkflow extends { - run(event: { payload: infer P }, step: any): any; - } - ? Workflow

+ TBinding extends { + type: "workflow"; + worker: infer TWorker extends WorkerReference; + exportName: infer TExportName extends string; + } + ? TWorker extends string + ? Workflow + : InferMainModule> extends infer TModule extends + WorkerModule + ? TExportName extends keyof TModule + ? ExtractInstance< + TModule[TExportName], + Rpc.WorkflowEntrypointBranded + > extends infer TWorkflow + ? TWorkflow extends { + run(event: { payload: infer P }, step: any): any; + } + ? Workflow

+ : Workflow : Workflow - : Workflow + : never : never - : never : // Unsafe bindings TBinding extends { type: `unsafe:${string}` } ? any @@ -185,27 +206,6 @@ type InferBindingType = // Types used by the Bindings interface for type-safe cross-worker bindings. // ═══════════════════════════════════════════════════════════════════════════ -/** - * Infer the Worker name from a config. - * - * @example - * ```typescript - * import { defineWorker } from "@cloudflare/config"; - * import type { InferDurableNamespaces, UnwrapConfig } from "@cloudflare/config"; - * - * const config = defineWorker({ name: "my-worker", ... }); - * - * type WorkerConfig = UnwrapConfig; - * // Inferred as: "my-worker" - * type Name = InferWorkerName; - * ``` - */ -export type InferWorkerName = TUnwrappedConfig extends { - name: infer TName extends string; -} - ? TName - : never; - /** * Infer export names from a config's exports, optionally filtered by type. * When TExportType is `string` (default), returns all export names. @@ -245,12 +245,13 @@ export type InferWorkerEntrypointExports = Exclude< * Unwrap function and promise types to get the underlying config. * Use this to normalize a config before passing it to other inference utilities. */ -export type UnwrapConfig = - TConfig extends WorkerDefinition - ? TUnwrappedConfig - : TConfig extends WorkerConfigExport - ? TUnwrappedConfig - : never; +export type UnwrapConfig = TConfig extends ( + ...args: any[] +) => infer TReturn + ? UnwrapConfig + : TConfig extends Promise + ? UnwrapConfig + : TConfig; /** * Infer the `Env` interface type from a Worker config. diff --git a/packages/config/src/public.ts b/packages/config/src/public.ts index ac888bd5062..77f1ed2907d 100644 --- a/packages/config/src/public.ts +++ b/packages/config/src/public.ts @@ -35,12 +35,9 @@ export type { StreamBinding, TextBinding, TypedAiBinding, - TypedDurableObjectBinding, TypedKvBinding, TypedPipelineBinding, TypedQueueBinding, - TypedWorkerBinding, - TypedWorkflowBinding, UnsafeBinding, VectorizeBinding, VersionMetadataBinding, @@ -49,6 +46,7 @@ export type { WebSearchBinding, WorkerBinding, WorkerLoaderBinding, + WorkerReference, WorkflowBinding, } from "./bindings"; export { bindings } from "./bindings"; @@ -81,13 +79,12 @@ export type { export type { ConfigContext } from "./definition"; export type { SettingsConfig, WorkerConfig } from "./types"; export type { - TypedWorkerDefinition, WorkerConfigExport, WorkerConfigInput, } from "./worker-definition"; export { defineWorker } from "./worker-definition"; export type { + SettingsConfigExport, SettingsConfigInput, - SettingsDefinition, } from "./settings-definition"; export { defineSettings } from "./settings-definition"; diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 09b26b6f9e3..9334691dad0 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -591,7 +591,7 @@ function invalidConfigExportMessage(exportName: string): string { return `The \`${exportName}\` export is not a supported export type. Move constants, helper functions, and other unsupported exports to a separate module.`; } -const ConfigExportsTypeSchema = z +export const ConfigExportsTypeSchema = z .record(z.string(), z.unknown()) .check((ctx) => { for (const [key, value] of Object.entries(ctx.value)) { @@ -627,25 +627,6 @@ const ConfigExportsTypeSchema = z } }); -const ConfigExportsObjectSchema = z - .object({ - settings: InputSettingsSchema.optional(), - }) - .catchall(InputWorkerSchema); - -/** - * 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 = ConfigExportsTypeSchema.pipe( - ConfigExportsObjectSchema -); - -export type ParsedConfigExports = z.output; - export const ModuleTypeSchema = z.enum([ "esm", "cjs", @@ -726,23 +707,34 @@ const _assertSchemaMatchesWorkerConfig: _AssertSchemaMatchesWorkerConfig = [ ]; void _assertSchemaMatchesWorkerConfig; +type _ResolvedBinding = TBinding extends { + type: "durable-object" | "worker" | "workflow"; + worker: unknown; +} + ? Omit & { worker: string } + : TBinding; + +type _ResolvedWorkerConfigEnv = + | Record[string]>> + | undefined; + /** - * Drift checks between the schema and public `env` types. Schema input is - * intentionally broader for bindings with cross-field validation, so only - * assert that every public binding is accepted as input. After parsing, the - * schema output and public types should match bidirectionally. + * Drift checks between the schema and resolved public `env` types. Authored + * cross-Worker bindings may contain a Worker config reference; the config + * loader replaces those references with names before parsing. Schema input is + * otherwise intentionally broader for bindings with cross-field validation. * * These checks catch fields or bindings that are missing, renamed, or typed * differently between the public definitions and the schema. */ type _AssertSchemaEnvMatchesWorkerConfig = [ - WorkerConfig["env"] extends z.input["env"] + _ResolvedWorkerConfigEnv extends z.input["env"] ? true : false, - z.output["env"] extends WorkerConfig["env"] + z.output["env"] extends _ResolvedWorkerConfigEnv ? true : false, - WorkerConfig["env"] extends z.output["env"] + _ResolvedWorkerConfigEnv extends z.output["env"] ? true : false, ]; diff --git a/packages/config/src/settings-definition.ts b/packages/config/src/settings-definition.ts index 4078b1cb0d7..00c88e058ae 100644 --- a/packages/config/src/settings-definition.ts +++ b/packages/config/src/settings-definition.ts @@ -1,29 +1,21 @@ -import { DEFINITION } from "./definition"; +import { createConfigDefiner } from "./definition"; import type { ConfigInput } from "./definition"; import type { SettingsConfig } from "./types"; +export type SettingsConfigExport = + ConfigInput; + /** * Authored settings config shape — {@link SettingsConfig} without the `type` * discriminant, which `defineSettings` injects. */ export type SettingsConfigInput = Omit; -/** - * A settings definition created by {@link defineSettings}. - */ -export interface SettingsDefinition { - [DEFINITION]: { - config: ConfigInput; - type: "settings"; - }; -} - /** * Declare shared settings. * Authored as a named `settings` export. */ -export function defineSettings( - config: ConfigInput -): SettingsDefinition { - return { [DEFINITION]: { config, type: "settings" } }; -} +export const defineSettings = createConfigDefiner< + SettingsConfigInput, + "settings" +>("settings"); diff --git a/packages/config/src/worker-definition.ts b/packages/config/src/worker-definition.ts index 883d28d30a5..d1925e68f64 100644 --- a/packages/config/src/worker-definition.ts +++ b/packages/config/src/worker-definition.ts @@ -1,76 +1,7 @@ -import { DEFINITION } from "./definition"; -import type { - BindingDevOptions, - Bindings, - TypedDurableObjectBinding, - TypedWorkerBinding, -} from "./bindings"; -import type { ConfigContext, ConfigInput } from "./definition"; -import type { - InferDurableNamespaces, - InferWorkerName, - InferWorkerEntrypointExports, -} from "./inference"; +import { createConfigDefiner } from "./definition"; +import type { ConfigInput } from "./definition"; import type { WorkerConfig } from "./types"; -/** - * 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 WorkerConfig = WorkerConfig, -> extends Pick { - // 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 WorkerConfig, - TWorkerName extends string = InferWorkerName, -> extends WorkerDefinition { - /** - * Binding to a Durable Object class. `worker` is the name of the Worker - * that defines the class; `exportName` is the exported class name. - * - * For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects - */ - durableObject>(options: { - worker: TWorkerName; - exportName: TExportName; - }): TypedDurableObjectBinding; - /** - * Service binding (Worker-to-Worker). `worker` is the name of the bound - * Worker; `exportName` selects a named `WorkerEntrypoint` export (defaults to - * the default export). - * - * For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings - */ - worker< - TExportName extends InferWorkerEntrypointExports | undefined = - undefined, - >(options: { - worker: TWorkerName; - exportName?: TExportName; - props?: Record; - dev?: BindingDevOptions; - }): TypedWorkerBinding< - TConfig, - TExportName extends string ? TExportName : "default" - >; - // TODO: re-enable when workflow bindings return. - // workflow< - // TExportName extends InferExportsByType, - // >(options: { - // worker: TWorkerName; - // exportName: TExportName; - // }): TypedWorkflowBinding; -} - export type WorkerConfigExport = ConfigInput; @@ -80,32 +11,6 @@ export type WorkerConfigExport = */ 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 { - [DEFINITION]: { config, type: "worker" }, - durableObject(options) { - return { type: "durable-object", ...options }; - }, - worker(options) { - return { type: "worker", ...options }; - }, - // TODO: re-enable when workflow bindings return. - // workflow(options) { - // return { type: "workflow", ...options }; - // }, - }; -} +export const defineWorker = createConfigDefiner( + "worker" +); diff --git a/packages/wrangler/src/__tests__/helpers/mock-new-config.ts b/packages/wrangler/src/__tests__/helpers/mock-new-config.ts index 614c1512a3e..0cdc9dbef30 100644 --- a/packages/wrangler/src/__tests__/helpers/mock-new-config.ts +++ b/packages/wrangler/src/__tests__/helpers/mock-new-config.ts @@ -40,12 +40,8 @@ export async function createConfigMock(importOriginal: () => Promise) { 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), + result: await actual.resolveAndValidateConfigExports(exports, ctx), dependencies: new Set([path.resolve(configPath)]), }; }