From 711a134f171772537e979799314b77d62be7c952 Mon Sep 17 00:00:00 2001 From: Eric Clemmons Date: Tue, 14 Jul 2026 18:37:02 -0500 Subject: [PATCH 1/5] Add Workflow exports to experimental config --- .../experimental-config-workflow-exports.md | 8 ++ packages/config/src/__tests__/convert.test.ts | 68 +++++++++- packages/config/src/__tests__/schema.test.ts | 42 +++++++ packages/config/src/bindings.ts | 16 ++- packages/config/src/convert.ts | 27 ++-- packages/config/src/exports.ts | 25 ++++ packages/config/src/public.ts | 2 + packages/config/src/schema.ts | 27 ++-- packages/config/src/types.ts | 13 +- packages/config/src/worker-definition.ts | 26 ++-- .../helpers/check-workflow-conflicts.ts | 17 ++- .../helpers/create-worker-upload-form.ts | 2 +- .../src/deploy/helpers/exports.ts | 1 + .../deploy/helpers/validate-worker-props.ts | 3 +- .../deploy-helpers/src/triggers/deploy.ts | 46 +++++++ .../src/__tests__/export-types.spec.ts | 36 ++++++ .../src/export-types.ts | 10 +- .../workers-utils/src/config/environment.ts | 22 +++- packages/workers-utils/src/config/exports.ts | 3 + packages/workers-utils/src/config/index.ts | 1 + .../workers-utils/src/config/validation.ts | 118 +++++++++++++++++- .../src/config/workflow-exports.ts | 16 +++ packages/workers-utils/src/index.ts | 4 + .../src/map-worker-metadata-bindings.ts | 4 +- packages/workers-utils/src/types.ts | 2 +- packages/workers-utils/src/worker.ts | 3 +- .../tests/config/exports.test.ts | 10 +- .../normalize-and-validate-config.test.ts | 6 +- .../map-worker-metadata-bindings.test.ts | 19 +++ .../bindings.test.ts | 19 +++ .../deploy/check-workflow-conflicts.test.ts | 30 +++++ .../src/__tests__/deploy/workflows.test.ts | 61 +++++++++ ...table-get-miniflare-worker-options.test.ts | 33 +++++ .../wrangler/src/api/startDevWorker/utils.ts | 2 +- packages/wrangler/src/dev/miniflare/index.ts | 18 ++- .../wrangler/src/type-generation/index.ts | 14 +-- 36 files changed, 664 insertions(+), 90 deletions(-) create mode 100644 .changeset/experimental-config-workflow-exports.md create mode 100644 packages/vite-plugin-cloudflare/src/__tests__/export-types.spec.ts create mode 100644 packages/workers-utils/src/config/workflow-exports.ts diff --git a/.changeset/experimental-config-workflow-exports.md b/.changeset/experimental-config-workflow-exports.md new file mode 100644 index 0000000000..16dc771a6b --- /dev/null +++ b/.changeset/experimental-config-workflow-exports.md @@ -0,0 +1,8 @@ +--- +"wrangler": minor +"@cloudflare/vite-plugin": minor +--- + +Add experimental config support for declarative Workflow exports and cross-Worker Workflow bindings + +`cloudflare.config.ts` can now declare owned Workflows with `exports.workflow()`, including step limits and schedules, and create typed external bindings with a referenced Worker's `workflow()` helper. Wrangler deploys and locally simulates these exports, while the Vite plugin recognizes them as `WorkflowEntrypoint` exports. diff --git a/packages/config/src/__tests__/convert.test.ts b/packages/config/src/__tests__/convert.test.ts index 1d9ab0fc29..d3b7d2db4b 100644 --- a/packages/config/src/__tests__/convert.test.ts +++ b/packages/config/src/__tests__/convert.test.ts @@ -1,6 +1,8 @@ import { describe, it } from "vitest"; +import { bindings as bindingConfig } from "../bindings"; import { convertToWranglerConfig } from "../convert"; import { exports as exportConfig } from "../exports"; +import { defineWorker } from "../worker-definition"; const baseConfig = { name: "worker", compatibilityDate: "2026-06-01" } as const; @@ -782,6 +784,28 @@ describe("convertToWranglerConfig", () => { }); }); + it("passes Workflow exports through", ({ expect }) => { + const result = convertToWranglerConfig({ + ...baseConfig, + exports: { + MyWorkflow: exportConfig.workflow({ + name: "my-workflow", + limits: { steps: 25_000 }, + schedules: ["0 * * * *"], + }), + }, + }); + + expect(result.exports).toEqual({ + MyWorkflow: { + type: "workflow", + name: "my-workflow", + limits: { steps: 25_000 }, + schedules: ["0 * * * *"], + }, + }); + }); + it("emits no exports key when the map is empty", ({ expect }) => { const result = convertToWranglerConfig({ ...baseConfig, exports: {} }); expect("exports" in (result as object)).toBe(false); @@ -791,16 +815,56 @@ describe("convertToWranglerConfig", () => { const config = { ...baseConfig, exports: { - FutureExport: { type: "workflow" }, + FutureExport: { type: "container" }, }, } as unknown as Parameters[0]; expect(() => convertToWranglerConfig(config)).toThrow( - /Unknown export types found: - FutureExport : workflow/ + /Unknown export types found: - FutureExport : container/ ); }); }); + describe("Workflow bindings", () => { + it("maps an export-based Workflow binding", ({ expect }) => { + const workflowWorker = defineWorker({ + ...baseConfig, + name: "workflow-worker", + exports: { + MyWorkflow: exportConfig.workflow({ name: "my-workflow" }), + }, + }); + const result = convertToWranglerConfig({ + ...baseConfig, + env: { + MY_WORKFLOW: workflowWorker.workflow({ + workerName: "workflow-worker", + exportName: "MyWorkflow", + remote: true, + }), + UNTYPED_WORKFLOW: bindingConfig.workflow({ + workerName: "other-worker", + exportName: "OtherWorkflow", + }), + }, + }); + + expect(result.workflows).toEqual([ + { + binding: "MY_WORKFLOW", + class_name: "MyWorkflow", + script_name: "workflow-worker", + remote: true, + }, + { + binding: "UNTYPED_WORKFLOW", + class_name: "OtherWorkflow", + script_name: "other-worker", + }, + ]); + }); + }); + describe("triggers", () => { it("maps scheduled triggers to triggers.crons", ({ expect }) => { const result = convertToWranglerConfig({ diff --git a/packages/config/src/__tests__/schema.test.ts b/packages/config/src/__tests__/schema.test.ts index 5b5fc0a2b8..52c42d3f48 100644 --- a/packages/config/src/__tests__/schema.test.ts +++ b/packages/config/src/__tests__/schema.test.ts @@ -237,6 +237,48 @@ describe("InputWorkerSchema", () => { }); }); + describe("Workflows", () => { + it("accepts Workflow exports and bindings", ({ expect }) => { + const result = InputWorkerSchema.safeParse({ + ...baseConfig, + env: { + MY_WORKFLOW: { + type: "workflow", + workerName: "workflow-worker", + exportName: "MyWorkflow", + remote: true, + }, + }, + exports: { + MyWorkflow: { + type: "workflow", + name: "my-workflow", + limits: { steps: 25_000 }, + schedules: ["0 * * * *"], + }, + }, + }); + + expect(result.success).toBe(true); + }); + + it("rejects invalid Workflow export configuration", ({ expect }) => { + const result = InputWorkerSchema.safeParse({ + ...baseConfig, + exports: { + MyWorkflow: { + type: "workflow", + name: "", + limits: { steps: 25_001 }, + schedules: [], + }, + }, + }); + + expect(result.success).toBe(false); + }); + }); + describe("unknown property rejection", () => { it("rejects unknown top-level keys (typo)", ({ expect }) => { const result = InputWorkerSchema.safeParse({ diff --git a/packages/config/src/bindings.ts b/packages/config/src/bindings.ts index b7021e4fe6..196ca4e401 100644 --- a/packages/config/src/bindings.ts +++ b/packages/config/src/bindings.ts @@ -828,13 +828,12 @@ export interface Bindings { worker(options: WorkerBindingOptions): WorkerBinding; /** Binding to a Worker Loader. */ workerLoader(): WorkerLoaderBinding; - // TODO: re-enable when workflow bindings return. - // /** - // * Create a Workflow binding. - // * `workerName` must match a known config's name (or any `string` for untyped bindings). - // * `exportName` must be a valid `WorkflowEntrypoint` export for the given Worker. - // */ - // workflow(options: WorkflowBindingOptions): WorkflowBinding; + /** + * Create a Workflow binding. + * `workerName` must match a known config's name (or any `string` for untyped bindings). + * `exportName` must be a valid `WorkflowEntrypoint` export for the given Worker. + */ + workflow(options: WorkflowBindingOptions): WorkflowBinding; } export const bindings = { @@ -885,6 +884,5 @@ export const bindings = { webSearch: (options) => ({ type: "web-search", ...options }), worker: (options) => ({ type: "worker", ...options }), workerLoader: () => ({ type: "worker-loader" }), - // TODO: re-enable when workflow bindings return. - // workflow: (options) => ({ type: "workflow", ...options }), + workflow: (options) => ({ type: "workflow", ...options }), } as Bindings; diff --git a/packages/config/src/convert.ts b/packages/config/src/convert.ts index 7dd044955f..32b91b0fd6 100644 --- a/packages/config/src/convert.ts +++ b/packages/config/src/convert.ts @@ -543,18 +543,17 @@ function convertBindingsAndAssets( workerLoaders.push({ binding: name }); break; } - // TODO: re-enable when workflow bindings return. - // case "workflow": { - // workflows.push( - // omitUndefined({ - // binding: name, - // class_name: binding.exportName, - // script_name: binding.workerName, - // remote: binding.remote, - // }) - // ); - // break; - // } + case "workflow": { + workflows.push( + omitUndefined({ + binding: name, + class_name: binding.exportName, + script_name: binding.workerName, + remote: binding.remote, + }) + ); + break; + } } } @@ -683,6 +682,10 @@ function convertExports( converted[exportName] = value; continue; } + if (value.type === "workflow") { + converted[exportName] = value; + continue; + } if (value.type !== "durable-object") { unknownExports[exportName] = value; diff --git a/packages/config/src/exports.ts b/packages/config/src/exports.ts index c01f4393fb..7410be036f 100644 --- a/packages/config/src/exports.ts +++ b/packages/config/src/exports.ts @@ -125,6 +125,23 @@ export interface WorkerEntrypointExport extends WorkerEntrypointExportOptions { type: "worker"; } +export interface WorkflowExportOptions { + /** The account-unique name of the Workflow. */ + name: string; + /** Optional limits for the Workflow. */ + limits?: { + /** Maximum number of steps a Workflow instance can execute. */ + steps?: number; + }; + /** Optional cron schedules for automatically triggering Workflow instances. */ + schedules?: string[]; +} + +/** Declares a Workflow defined by this Worker. */ +export interface WorkflowExport extends WorkflowExportOptions { + type: "workflow"; +} + /** * Configuration for named exports declared by the Worker. Each entry's * key is the exported class name; the value configures the export. @@ -179,6 +196,9 @@ export interface Exports { /** Declares a WorkerEntrypoint export defined by this Worker. */ worker(options?: WorkerEntrypointExportOptions): WorkerEntrypointExport; + + /** Declares a WorkflowEntrypoint export defined by this Worker. */ + workflow(options: WorkflowExportOptions): WorkflowExport; } function durableObject( @@ -213,6 +233,10 @@ function worker( return { type: "worker", ...options }; } +function workflow(options: WorkflowExportOptions): WorkflowExport { + return { type: "workflow", ...options }; +} + /** * Exports builder for configuring Worker exports. * @@ -234,4 +258,5 @@ function worker( export const exports: Exports = { durableObject, worker, + workflow, }; diff --git a/packages/config/src/public.ts b/packages/config/src/public.ts index d8ca780a5b..ea4dc84d6f 100644 --- a/packages/config/src/public.ts +++ b/packages/config/src/public.ts @@ -67,6 +67,8 @@ export type { DurableObjectExpectingTransferExport, WorkerEntrypointExport, WorkerEntrypointExportOptions, + WorkflowExport, + WorkflowExportOptions, } from "./exports"; export { exports } from "./exports"; export type { diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index fb4fcf298c..a550f6d8d1 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -186,13 +186,12 @@ const KnownBindingSchema = z.discriminatedUnion("type", [ remote: z.boolean().optional(), }), z.strictObject({ type: z.literal("worker-loader") }), - // TODO: support Workflows - // z.strictObject({ - // type: z.literal("workflow"), - // workerName: z.string(), - // exportName: z.string(), - // remote: z.boolean().optional(), - // }), + z.strictObject({ + type: z.literal("workflow"), + workerName: z.string(), + exportName: z.string(), + remote: z.boolean().optional(), + }), ]); const UnsafeBindingSchema = z.looseObject({ @@ -321,12 +320,14 @@ const ExportSchema = z.union([ type: z.literal("worker"), cache: z.strictObject({ enabled: z.boolean() }).optional(), }), - // TODO: support Workflows - // z.strictObject({ - // type: z.literal("workflow"), - // name: z.string(), - // limits: z.strictObject({ steps: z.number().optional() }).optional(), - // }), + z.strictObject({ + type: z.literal("workflow"), + name: z.string().min(1), + limits: z + .strictObject({ steps: z.number().int().min(1).max(25_000).optional() }) + .optional(), + schedules: z.array(z.string().min(1)).min(1).max(100).optional(), + }), ]); const LimitsSchema = z.strictObject({ diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index fe8e9ec225..e7d246681d 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -43,8 +43,7 @@ import type { WebSearchBinding, WorkerBinding, WorkerLoaderBinding, - // TODO: re-enable when workflow bindings return. - // WorkflowBinding, + WorkflowBinding, } from "./bindings"; import type { DurableObjectDeletedExport, @@ -53,6 +52,7 @@ import type { DurableObjectRenamedExport, DurableObjectTransferredExport, WorkerEntrypointExport, + WorkflowExport, } from "./exports"; import type { WorkerModule } from "./inference"; import type { @@ -100,9 +100,8 @@ type Binding = | VpcServiceBinding | WebSearchBinding | WorkerBinding - | WorkerLoaderBinding; -// TODO: re-enable when workflow bindings return. -// | WorkflowBinding; + | WorkerLoaderBinding + | WorkflowBinding; /** * Union of all trigger definitions accepted in `triggers`. @@ -120,8 +119,8 @@ type Export = | DurableObjectRenamedExport | DurableObjectTransferredExport | DurableObjectExpectingTransferExport - | WorkerEntrypointExport; -// TODO: support Workflows + | WorkerEntrypointExport + | WorkflowExport; /** * Worker configuration. This is the input shape passed to diff --git a/packages/config/src/worker-definition.ts b/packages/config/src/worker-definition.ts index ac982ecf6c..659add97fd 100644 --- a/packages/config/src/worker-definition.ts +++ b/packages/config/src/worker-definition.ts @@ -2,9 +2,11 @@ import type { Bindings, TypedDurableObjectBinding, TypedWorkerBinding, + TypedWorkflowBinding, } from "./bindings"; import type { InferDurableNamespaces, + InferExportsByType, InferWorkerName, InferWorkerEntrypointExports, } from "./inference"; @@ -31,7 +33,7 @@ const CONFIG = Symbol.for("@cloudflare/config:worker-config"); */ export interface WorkerDefinition< TConfig extends UserConfig = UserConfig, -> extends Pick { +> extends Pick { [CONFIG]: | TConfig | Promise @@ -74,14 +76,13 @@ export interface TypedWorkerDefinition< TConfig, TExportName extends string ? TExportName : "default" >; - // TODO: re-enable when workflow bindings return. - // workflow< - // TExportName extends InferExportsByType, - // >(options: { - // workerName: TWorkerName; - // exportName: TExportName; - // remote?: boolean; - // }): TypedWorkflowBinding; + workflow< + TExportName extends InferExportsByType, + >(options: { + workerName: TWorkerName; + exportName: TExportName; + remote?: boolean; + }): TypedWorkflowBinding; } export type UserConfigExport = @@ -104,10 +105,9 @@ export function defineWorker(config: UserConfigExport): WorkerDefinition { worker(options) { return { type: "worker", ...options }; }, - // TODO: re-enable when workflow bindings return. - // workflow(options) { - // return { type: "workflow", ...options }; - // }, + workflow(options) { + return { type: "workflow", ...options }; + }, }; } diff --git a/packages/deploy-helpers/src/deploy/helpers/check-workflow-conflicts.ts b/packages/deploy-helpers/src/deploy/helpers/check-workflow-conflicts.ts index c0724e5c97..1230d67d11 100644 --- a/packages/deploy-helpers/src/deploy/helpers/check-workflow-conflicts.ts +++ b/packages/deploy-helpers/src/deploy/helpers/check-workflow-conflicts.ts @@ -1,4 +1,4 @@ -import { APIError } from "@cloudflare/workers-utils"; +import { APIError, getWorkflowExports } from "@cloudflare/workers-utils"; import { fetchResult } from "../../shared/context"; import type { Config } from "@cloudflare/workers-utils"; @@ -53,9 +53,18 @@ export async function checkWorkflowConflicts( | { hasConflicts: false } | { hasConflicts: true; conflicts: WorkflowConflict[]; message: string } > { - const workflowsToDeploy = config.workflows?.filter( - (w) => w.script_name === undefined || w.script_name === scriptName - ); + const workflowsToDeploy = [ + ...(config.workflows ?? []).flatMap((workflow) => + (workflow.script_name === undefined || + workflow.script_name === scriptName) && + workflow.name !== undefined + ? [{ name: workflow.name }] + : [] + ), + ...Object.values(getWorkflowExports(config.exports)).map(({ name }) => ({ + name, + })), + ]; if (!workflowsToDeploy?.length) { return { hasConflicts: false }; diff --git a/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts b/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts index 911680c705..6601c69f8b 100644 --- a/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts +++ b/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts @@ -254,7 +254,7 @@ export function createWorkerUploadForm( metadataBindings.push({ type: "workflow", name: binding, - workflow_name: name, + ...(name !== undefined && { workflow_name: name }), class_name, script_name, raw, diff --git a/packages/deploy-helpers/src/deploy/helpers/exports.ts b/packages/deploy-helpers/src/deploy/helpers/exports.ts index 4037d6d266..15b48fa72c 100644 --- a/packages/deploy-helpers/src/deploy/helpers/exports.ts +++ b/packages/deploy-helpers/src/deploy/helpers/exports.ts @@ -19,6 +19,7 @@ export async function resolveExportsUploadPayload( // either path, but not both. const exports = { ...partitionedExports.worker, + ...partitionedExports.workflow, ...(durableObjectExports ?? {}), }; diff --git a/packages/deploy-helpers/src/deploy/helpers/validate-worker-props.ts b/packages/deploy-helpers/src/deploy/helpers/validate-worker-props.ts index 9b9a8b79e2..5cfdb4dbf1 100644 --- a/packages/deploy-helpers/src/deploy/helpers/validate-worker-props.ts +++ b/packages/deploy-helpers/src/deploy/helpers/validate-worker-props.ts @@ -4,6 +4,7 @@ import { experimental_patchConfig, formatConfigSnippet, getTodaysCompatDate, + hasWorkflowExports, isNonInteractiveOrCI, UserError, } from "@cloudflare/workers-utils"; @@ -289,7 +290,7 @@ export async function preUploadApiChecks( } } - if (config.workflows?.length) { + if (config.workflows?.length || hasWorkflowExports(config.exports)) { const workflowCheck = await checkWorkflowConflicts(config, accountId, name); if (workflowCheck.hasConflicts) { diff --git a/packages/deploy-helpers/src/triggers/deploy.ts b/packages/deploy-helpers/src/triggers/deploy.ts index ecd5ae7464..faec4d80dc 100644 --- a/packages/deploy-helpers/src/triggers/deploy.ts +++ b/packages/deploy-helpers/src/triggers/deploy.ts @@ -2,6 +2,7 @@ import { APIError, formatTime, getSubdomainMixedStateCheckDisabled, + getWorkflowExports, isNonInteractiveOrCI, retryOnAPIFailure, UserError, @@ -264,6 +265,9 @@ export async function triggersDeploy( } continue; } + if (workflow.name === undefined) { + continue; + } deployments.push( fetchResult( @@ -315,6 +319,48 @@ export async function triggersDeploy( } } + for (const [className, workflow] of Object.entries( + getWorkflowExports(config.exports) + )) { + deployments.push( + fetchResult(config, `/accounts/${accountId}/workflows/${workflow.name}`, { + method: "PUT", + body: JSON.stringify({ + script_name: scriptName, + class_name: className, + ...(workflow.limits && { limits: workflow.limits }), + ...(workflow.schedules && { + schedules: workflow.schedules.map((cron) => ({ cron })), + }), + }), + headers: { "Content-Type": "application/json" }, + }).then( + () => ({ targets: [`workflow: ${workflow.name}`] }), + (error) => { + if ( + error instanceof APIError && + error.code === WORKFLOW_CRON_REQUIRES_PAID_PLAN_CODE && + workflow.schedules + ) { + error.preventReport(); + return { + targets: [], + error: new UserError( + `Workflow "${workflow.name}" has "schedules" configured, but scheduled Workflows require a paid Workers plan.`, + { + cause: error, + telemetryMessage: + "triggers deploy workflow cron requires paid plan", + } + ), + }; + } + return { targets: [], error }; + } + ) + ); + } + const completedDeployments = await Promise.all(deployments); const deployMs = Date.now() - start - uploadMs; diff --git a/packages/vite-plugin-cloudflare/src/__tests__/export-types.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/export-types.spec.ts new file mode 100644 index 0000000000..7aef7bc798 --- /dev/null +++ b/packages/vite-plugin-cloudflare/src/__tests__/export-types.spec.ts @@ -0,0 +1,36 @@ +import { describe, test } from "vitest"; +import { getInitialWorkerNameToExportTypesMap } from "../export-types"; +import type { WorkersResolvedConfig } from "../plugin-config"; + +describe("getInitialWorkerNameToExportTypesMap", () => { + test("discovers declarative Workflow exports", ({ expect }) => { + const resolvedPluginConfig = { + environmentNameToWorkerMap: new Map([ + [ + "worker", + { + config: { + name: "workflow-worker", + exports: { + MyWorkflow: { + type: "workflow", + name: "my-workflow", + }, + }, + migrations: [], + durable_objects: { bindings: [] }, + services: [], + workflows: [], + }, + }, + ], + ]), + } as unknown as WorkersResolvedConfig; + + expect( + getInitialWorkerNameToExportTypesMap(resolvedPluginConfig).get( + "workflow-worker" + ) + ).toEqual({ MyWorkflow: "WorkflowEntrypoint" }); + }); +}); diff --git a/packages/vite-plugin-cloudflare/src/export-types.ts b/packages/vite-plugin-cloudflare/src/export-types.ts index fda84a5b50..a1836b18e2 100644 --- a/packages/vite-plugin-cloudflare/src/export-types.ts +++ b/packages/vite-plugin-cloudflare/src/export-types.ts @@ -75,7 +75,15 @@ function getWorkerNameToWorkflowEntrypointExportsMap( workers: Worker[] ): Map> { const workerNameToWorkflowEntrypointExportsMap = new Map( - workers.map((worker) => [worker.config.name, new Set()]) + workers.map((worker) => [ + worker.config.name, + new Set( + Object.entries(worker.config.exports ?? {}).flatMap( + ([exportName, value]) => + value.type === "workflow" ? [exportName] : [] + ) + ), + ]) ); for (const worker of workers) { diff --git a/packages/workers-utils/src/config/environment.ts b/packages/workers-utils/src/config/environment.ts index a9dbc8da57..f5b5699453 100644 --- a/packages/workers-utils/src/config/environment.ts +++ b/packages/workers-utils/src/config/environment.ts @@ -405,7 +405,23 @@ export interface WorkerEntrypointExport { }; } -export type ConfiguredExport = DurableObjectExport | WorkerEntrypointExport; +export interface WorkflowExport { + type: "workflow"; + /** The account-unique name of the Workflow. */ + name: string; + /** Optional limits for the Workflow. */ + limits?: { + /** Maximum number of steps a Workflow instance can execute. */ + steps?: number; + }; + /** Optional cron schedules for automatically triggering Workflow instances. */ + schedules?: string[]; +} + +export type ConfiguredExport = + | DurableObjectExport + | WorkerEntrypointExport + | WorkflowExport; /** * The declarative `exports` map keyed by export name. Durable Object exports @@ -796,8 +812,8 @@ export type DurableObjectBindings = { export type WorkflowBinding = { /** The name of the binding used to refer to the Workflow */ binding: string; - /** The name of the Workflow */ - name: string; + /** The name of the Workflow. Omitted for an export-based cross-script binding. */ + name?: string; /** The exported class name of the Workflow */ class_name: string; /** The script where the Workflow is defined (if it's external to this Worker) */ diff --git a/packages/workers-utils/src/config/exports.ts b/packages/workers-utils/src/config/exports.ts index 88514db9b4..6413fbcabe 100644 --- a/packages/workers-utils/src/config/exports.ts +++ b/packages/workers-utils/src/config/exports.ts @@ -2,6 +2,7 @@ import type { DurableObjectExport, Exports, WorkerEntrypointExport, + WorkflowExport, } from "./environment"; export type ExportType = Exports[string]["type"]; @@ -9,6 +10,7 @@ export type ExportType = Exports[string]["type"]; export interface PartitionedExports { "durable-object": Record; worker: Record; + workflow: Record; } export function partitionExports( @@ -17,6 +19,7 @@ export function partitionExports( const partitioned: PartitionedExports = { "durable-object": {}, worker: {}, + workflow: {}, }; if (exports === undefined) { diff --git a/packages/workers-utils/src/config/index.ts b/packages/workers-utils/src/config/index.ts index 21f8278d43..576c548068 100644 --- a/packages/workers-utils/src/config/index.ts +++ b/packages/workers-utils/src/config/index.ts @@ -40,6 +40,7 @@ export type { } from "./environment"; export { partitionExports } from "./exports"; export type { ExportType, PartitionedExports } from "./exports"; +export { getWorkflowExports, hasWorkflowExports } from "./workflow-exports"; export function configFormat( configPath: string | undefined diff --git a/packages/workers-utils/src/config/validation.ts b/packages/workers-utils/src/config/validation.ts index 97e3ecc7bf..7a83af2ce3 100644 --- a/packages/workers-utils/src/config/validation.ts +++ b/packages/workers-utils/src/config/validation.ts @@ -2721,6 +2721,7 @@ const validateWorkflowBinding: ValidatorFn = (diagnostics, field, value) => { } let isValid = true; + const workflowName = hasProperty(value, "name") ? value.name : undefined; if (!isRequiredProperty(value, "binding", "string")) { diagnostics.errors.push( @@ -2731,14 +2732,18 @@ const validateWorkflowBinding: ValidatorFn = (diagnostics, field, value) => { isValid = false; } - if (!isRequiredProperty(value, "name", "string")) { + const isExportBasedReference = + workflowName === undefined && + isRequiredProperty(value, "script_name", "string"); + if (!isExportBasedReference && !isRequiredProperty(value, "name", "string")) { diagnostics.errors.push( - `"${field}" bindings should have a string "name" field but got ${JSON.stringify( - value - )}.` + `"${field}" bindings should have a string "name" field, unless they reference a Workflow export in another Worker with "script_name", but got ${JSON.stringify(value)}.` ); isValid = false; - } else if (!isValidWorkflowName(value.name)) { + } else if ( + isRequiredProperty(value, "name", "string") && + !isValidWorkflowName(value.name) + ) { diagnostics.errors.push( `"${field}" binding "name" field is invalid. ${workflowNameFormatMessage}` ); @@ -2773,6 +2778,12 @@ const validateWorkflowBinding: ValidatorFn = (diagnostics, field, value) => { } if (hasProperty(value, "schedules") && value.schedules !== undefined) { + if (isExportBasedReference) { + diagnostics.errors.push( + `"${field}" export-based bindings cannot configure "schedules". Configure schedules on the owning Workflow export.` + ); + isValid = false; + } if (typeof value.schedules === "string") { if (value.schedules.length === 0) { diagnostics.errors.push( @@ -2812,6 +2823,12 @@ const validateWorkflowBinding: ValidatorFn = (diagnostics, field, value) => { } if (hasProperty(value, "limits") && value.limits !== undefined) { + if (isExportBasedReference) { + diagnostics.errors.push( + `"${field}" export-based bindings cannot configure "limits". Configure limits on the owning Workflow export.` + ); + isValid = false; + } if ( typeof value.limits !== "object" || value.limits === null || @@ -6093,6 +6110,92 @@ function validateWorkerExport( return valid; } +function validateWorkflowExport( + diagnostics: Diagnostics, + exportName: string, + workflowExport: Record +): boolean { + let valid = true; + + valid = + validateRequiredProperty( + diagnostics, + `exports.${exportName}`, + "name", + workflowExport.name, + "string" + ) && valid; + if ( + typeof workflowExport.name === "string" && + !isValidWorkflowName(workflowExport.name) + ) { + diagnostics.errors.push( + `"exports.${exportName}.name" is invalid. ${workflowNameFormatMessage}` + ); + valid = false; + } + + if (workflowExport.limits !== undefined) { + if ( + typeof workflowExport.limits !== "object" || + workflowExport.limits === null || + Array.isArray(workflowExport.limits) + ) { + diagnostics.errors.push( + `"exports.${exportName}.limits" should be an object.` + ); + valid = false; + } else { + const limits = workflowExport.limits as Record; + if ( + limits.steps !== undefined && + (typeof limits.steps !== "number" || + !Number.isInteger(limits.steps) || + limits.steps < 1 || + limits.steps > 25_000) + ) { + diagnostics.errors.push( + `"exports.${exportName}.limits.steps" must be an integer between 1 and 25000.` + ); + valid = false; + } + valid = + validateAdditionalProperties( + diagnostics, + `exports.${exportName}.limits`, + Object.keys(limits), + ["steps"] + ) && valid; + } + } + + if (workflowExport.schedules !== undefined) { + if ( + !Array.isArray(workflowExport.schedules) || + workflowExport.schedules.length === 0 || + workflowExport.schedules.length > 100 || + !workflowExport.schedules.every( + (schedule) => typeof schedule === "string" && schedule.length > 0 + ) + ) { + diagnostics.errors.push( + `"exports.${exportName}.schedules" must be an array of 1 to 100 non-empty strings.` + ); + valid = false; + } + } + + valid = + validateAdditionalProperties( + diagnostics, + `exports.${exportName}`, + Object.keys(workflowExport), + ["type", "name", "limits", "schedules"] + ) && valid; + + return valid; +} + function validateWorkerExportCache( diagnostics: Diagnostics, field: string, @@ -6160,10 +6263,13 @@ const validateExports: ValidatorFn = (diagnostics, field, value) => { } else if (exportConfig.type === "worker") { valid = validateWorkerExport(diagnostics, exportName, exportConfig) && valid; + } else if (exportConfig.type === "workflow") { + valid = + validateWorkflowExport(diagnostics, exportName, exportConfig) && valid; } else { valid = false; diagnostics.errors.push( - `"exports.${exportName}.type" must be "durable-object" or "worker", but got ${JSON.stringify(exportConfig.type)}.` + `"exports.${exportName}.type" must be "durable-object", "worker", or "workflow", but got ${JSON.stringify(exportConfig.type)}.` ); } } diff --git a/packages/workers-utils/src/config/workflow-exports.ts b/packages/workers-utils/src/config/workflow-exports.ts new file mode 100644 index 0000000000..79c6db3c52 --- /dev/null +++ b/packages/workers-utils/src/config/workflow-exports.ts @@ -0,0 +1,16 @@ +import { partitionExports } from "./exports"; +import type { Config } from "./config"; +import type { WorkflowExport } from "./environment"; + +/** Returns the Workflow entries from a declarative exports map. */ +export function getWorkflowExports( + exports: Config["exports"] | undefined +): Record { + return partitionExports(exports).workflow; +} + +export function hasWorkflowExports( + exports: Config["exports"] | undefined +): boolean { + return Object.keys(getWorkflowExports(exports)).length > 0; +} diff --git a/packages/workers-utils/src/index.ts b/packages/workers-utils/src/index.ts index d5c0987d54..23e5e43be1 100644 --- a/packages/workers-utils/src/index.ts +++ b/packages/workers-utils/src/index.ts @@ -9,6 +9,10 @@ export type { export * from "./config/environment"; export { partitionExports } from "./config/exports"; export type { ExportType, PartitionedExports } from "./config/exports"; +export { + getWorkflowExports, + hasWorkflowExports, +} from "./config/workflow-exports"; export { getDurableObjectExports, hasDurableObjectExports, diff --git a/packages/workers-utils/src/map-worker-metadata-bindings.ts b/packages/workers-utils/src/map-worker-metadata-bindings.ts index 987f4f4ef2..d72c91194d 100644 --- a/packages/workers-utils/src/map-worker-metadata-bindings.ts +++ b/packages/workers-utils/src/map-worker-metadata-bindings.ts @@ -365,7 +365,9 @@ export function mapWorkerMetadataBindings( ...(configObj.workflows ?? []), { binding: binding.name, - name: binding.workflow_name, + ...(binding.workflow_name !== undefined && { + name: binding.workflow_name, + }), class_name: binding.class_name, script_name: binding.script_name, }, diff --git a/packages/workers-utils/src/types.ts b/packages/workers-utils/src/types.ts index b8ecf7eee6..0d619723f0 100644 --- a/packages/workers-utils/src/types.ts +++ b/packages/workers-utils/src/types.ts @@ -108,7 +108,7 @@ export type WorkerMetadataBinding = | { type: "workflow"; name: string; - workflow_name: string; + workflow_name?: string; class_name: string; script_name?: string; raw?: boolean; diff --git a/packages/workers-utils/src/worker.ts b/packages/workers-utils/src/worker.ts index 57011f7b9f..b75018bf00 100644 --- a/packages/workers-utils/src/worker.ts +++ b/packages/workers-utils/src/worker.ts @@ -188,7 +188,8 @@ export interface CfDurableObject { } export interface CfWorkflow { - name: string; + /** The Workflow resource name. Omitted for an export-based cross-script binding. */ + name?: string; class_name: string; binding: string; script_name?: string; diff --git a/packages/workers-utils/tests/config/exports.test.ts b/packages/workers-utils/tests/config/exports.test.ts index e4ad72844b..c9f9d132e8 100644 --- a/packages/workers-utils/tests/config/exports.test.ts +++ b/packages/workers-utils/tests/config/exports.test.ts @@ -7,6 +7,7 @@ describe("partitionExports", () => { expect(partitionExports(undefined)).toEqual({ "durable-object": {}, worker: {}, + workflow: {}, }); }); @@ -14,13 +15,17 @@ describe("partitionExports", () => { expect(partitionExports({})).toEqual({ "durable-object": {}, worker: {}, + workflow: {}, }); }); - test("partitions Durable Object and Worker exports by type", ({ expect }) => { + test("partitions Durable Object, Worker, and Workflow exports by type", ({ + expect, + }) => { const exports: Exports = { Counter: { type: "durable-object", storage: "sqlite" }, Admin: { type: "worker", cache: { enabled: true } }, + MyWorkflow: { type: "workflow", name: "my-workflow" }, }; expect(partitionExports(exports)).toEqual({ @@ -30,6 +35,9 @@ describe("partitionExports", () => { worker: { Admin: { type: "worker", cache: { enabled: true } }, }, + workflow: { + MyWorkflow: { type: "workflow", name: "my-workflow" }, + }, }); }); }); diff --git a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts index 045dd4a612..2963a54fb5 100644 --- a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts +++ b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts @@ -2219,7 +2219,7 @@ describe("normalizeAndValidateConfig()", () => { expect(diagnostics.hasErrors()).toBe(true); expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` "Processing wrangler configuration: - - "exports.Weird.type" must be "durable-object" or "worker", but got "container"." + - "exports.Weird.type" must be "durable-object", "worker", or "workflow", but got "container"." `); }); @@ -6217,10 +6217,10 @@ describe("normalizeAndValidateConfig()", () => { expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` "Processing wrangler configuration: - "workflows[0]" bindings should have a string "binding" field but got {}. - - "workflows[0]" bindings should have a string "name" field but got {}. + - "workflows[0]" bindings should have a string "name" field, unless they reference a Workflow export in another Worker with "script_name", but got {}. - "workflows[0]" bindings should have a string "class_name" field but got {}. - "workflows[2]" bindings should have a string "binding" field but got {"binding":2000,"name":2111,"class_name":3000}. - - "workflows[2]" bindings should have a string "name" field but got {"binding":2000,"name":2111,"class_name":3000}. + - "workflows[2]" bindings should have a string "name" field, unless they reference a Workflow export in another Worker with "script_name", but got {"binding":2000,"name":2111,"class_name":3000}. - "workflows[2]" bindings should have a string "class_name" field but got {"binding":2000,"name":2111,"class_name":3000}." `); }); diff --git a/packages/workers-utils/tests/map-worker-metadata-bindings.test.ts b/packages/workers-utils/tests/map-worker-metadata-bindings.test.ts index 2899cc8129..8293ac2e89 100644 --- a/packages/workers-utils/tests/map-worker-metadata-bindings.test.ts +++ b/packages/workers-utils/tests/map-worker-metadata-bindings.test.ts @@ -550,6 +550,25 @@ describe("mapWorkerMetadataBindings", () => { }, ]); }); + + it("maps an export-based workflow binding", ({ expect }) => { + const bindings: WorkerMetadataBinding[] = [ + { + type: "workflow", + name: "MY_WORKFLOW", + class_name: "MyWorkflow", + script_name: "wf-worker", + }, + ]; + const result = mapWorkerMetadataBindings(bindings); + expect(result.workflows).toEqual([ + { + binding: "MY_WORKFLOW", + class_name: "MyWorkflow", + script_name: "wf-worker", + }, + ]); + }); }); describe("worker_loaders", () => { diff --git a/packages/wrangler/src/__tests__/create-worker-upload-form/bindings.test.ts b/packages/wrangler/src/__tests__/create-worker-upload-form/bindings.test.ts index 467678df15..c34b9afd3d 100644 --- a/packages/wrangler/src/__tests__/create-worker-upload-form/bindings.test.ts +++ b/packages/wrangler/src/__tests__/create-worker-upload-form/bindings.test.ts @@ -274,6 +274,25 @@ describe("createWorkerUploadForm — bindings", () => { script_name: "workflow-worker", }); }); + + it("includes export-based workflow bindings without a resource name", ({ + expect, + }) => { + const bindings: StartDevWorkerInput["bindings"] = { + MY_WORKFLOW: { + type: "workflow", + class_name: "MyWorkflow", + script_name: "workflow-worker", + }, + }; + const form = createWorkerUploadForm(createEsmWorker(), bindings); + expect(getBindings(form)).toContainEqual({ + name: "MY_WORKFLOW", + type: "workflow", + class_name: "MyWorkflow", + script_name: "workflow-worker", + }); + }); }); describe("pass-through binding types", () => { diff --git a/packages/wrangler/src/__tests__/deploy/check-workflow-conflicts.test.ts b/packages/wrangler/src/__tests__/deploy/check-workflow-conflicts.test.ts index fb1a76f087..cfd21da80c 100644 --- a/packages/wrangler/src/__tests__/deploy/check-workflow-conflicts.test.ts +++ b/packages/wrangler/src/__tests__/deploy/check-workflow-conflicts.test.ts @@ -136,6 +136,36 @@ describe("checkWorkflowConflicts", () => { expect(message).toContain("my-worker"); }); + it("detects conflicts for declarative Workflow exports", async ({ + expect, + }) => { + mockWorkflowGet({ + "my-workflow": { + id: "1", + name: "my-workflow", + script_name: "other-worker", + class_name: "MyWorkflow", + created_on: "", + modified_on: "", + }, + }); + + const result = await checkWorkflowConflicts( + { + exports: { + MyWorkflow: { type: "workflow", name: "my-workflow" }, + }, + } as unknown as Config, + "some-account-id", + "my-worker" + ); + + expect(result).toMatchObject({ + hasConflicts: true, + conflicts: [{ name: "my-workflow", currentOwner: "other-worker" }], + }); + }); + it("should detect multiple conflicts", async ({ expect }) => { mockWorkflowGet({ "workflow-a": { diff --git a/packages/wrangler/src/__tests__/deploy/workflows.test.ts b/packages/wrangler/src/__tests__/deploy/workflows.test.ts index 0443aa21e6..1c232f2acb 100644 --- a/packages/wrangler/src/__tests__/deploy/workflows.test.ts +++ b/packages/wrangler/src/__tests__/deploy/workflows.test.ts @@ -181,6 +181,67 @@ describe("deploy", () => { `); }); + it("deploys a Workflow export from experimental config", async ({ + expect, + }) => { + await fs.promises.writeFile( + "cloudflare.config.ts", + `export default { + name: "test-name", + entrypoint: "./index.js", + compatibilityDate: "2026-07-14", + exports: { + MyWorkflow: { + type: "workflow", + name: "my-workflow", + limits: { steps: 25_000 }, + schedules: ["0 * * * *"], + }, + }, + };` + ); + await fs.promises.writeFile( + "index.js", + `export default {}; + export class MyWorkflow {}` + ); + + msw.use( + http.put( + "*/accounts/:accountId/workflows/:workflowName", + async ({ params, request }) => { + expect(params.workflowName).toBe("my-workflow"); + expect(await request.json()).toEqual({ + script_name: "test-name", + class_name: "MyWorkflow", + limits: { steps: 25_000 }, + schedules: [{ cron: "0 * * * *" }], + }); + return HttpResponse.json( + createFetchResult({ id: "mock-new-workflow-id" }) + ); + } + ) + ); + mockSubDomainRequest(); + mockUploadWorkerRequest({ + expectedBindings: [], + expectedExports: { + MyWorkflow: { + type: "workflow", + name: "my-workflow", + limits: { steps: 25_000 }, + schedules: ["0 * * * *"], + }, + }, + }); + + await runWrangler("deploy --experimental-new-config"); + + expect(std.warn).toBe(""); + expect(std.out).toContain("workflow: my-workflow"); + }); + it("should prompt to create a workers.dev subdomain before deploying owned Workflows", async ({ expect, }) => { diff --git a/packages/wrangler/src/__tests__/unstable-get-miniflare-worker-options.test.ts b/packages/wrangler/src/__tests__/unstable-get-miniflare-worker-options.test.ts index c66fc2f11a..96fd11b6c3 100644 --- a/packages/wrangler/src/__tests__/unstable-get-miniflare-worker-options.test.ts +++ b/packages/wrangler/src/__tests__/unstable-get-miniflare-worker-options.test.ts @@ -8,6 +8,39 @@ import { unstable_getMiniflareWorkerOptions } from "../api"; describe("unstable_getMiniflareWorkerOptions", () => { runInTempDir(); + it("creates local Workflow options from declarative exports", ({ + expect, + }) => { + writeWranglerConfig( + { + name: "workflow-worker", + main: "./index.js", + compatibility_date: "2026-07-14", + exports: { + MyWorkflow: { + type: "workflow", + name: "my-workflow", + limits: { steps: 25_000 }, + }, + }, + }, + "./wrangler.json" + ); + + const { workerOptions } = + unstable_getMiniflareWorkerOptions("./wrangler.json"); + + expect(workerOptions.workflows).toEqual({ + MyWorkflow: { + name: "my-workflow", + className: "MyWorkflow", + scriptName: undefined, + stepLimit: 25_000, + compatibilityFlags: undefined, + }, + }); + }); + describe("zone derivation (used for the outbound CF-Worker header)", () => { it("derives the zone from a single `route` string", ({ expect }) => { writeWranglerConfig( diff --git a/packages/wrangler/src/api/startDevWorker/utils.ts b/packages/wrangler/src/api/startDevWorker/utils.ts index 9be8f5c684..038f297eb5 100644 --- a/packages/wrangler/src/api/startDevWorker/utils.ts +++ b/packages/wrangler/src/api/startDevWorker/utils.ts @@ -215,7 +215,7 @@ export function convertWorkerMetadataBindingsToFlatBindings( >; output[name] = { type: "workflow", - name: b.workflow_name, + ...(b.workflow_name !== undefined && { name: b.workflow_name }), class_name: b.class_name, script_name: b.script_name, raw: b.raw, diff --git a/packages/wrangler/src/dev/miniflare/index.ts b/packages/wrangler/src/dev/miniflare/index.ts index 923a1cb722..3e3adbe4f0 100644 --- a/packages/wrangler/src/dev/miniflare/index.ts +++ b/packages/wrangler/src/dev/miniflare/index.ts @@ -8,6 +8,7 @@ import { import { getBrowserRenderingHeadfulFromEnv, getLocalExplorerEnabledFromEnv, + getWorkflowExports, getWranglerHiddenDirPath, UserError, } from "@cloudflare/workers-utils"; @@ -358,7 +359,7 @@ function workflowEntry( return [ binding, { - name, + name: name ?? className, className, scriptName, ...(stepLimit !== undefined && { stepLimit }), @@ -370,7 +371,7 @@ function workflowEntry( return [ binding, { - name, + name: name ?? className, className, scriptName, remoteProxyConnectionString, @@ -519,7 +520,18 @@ export function buildMiniflareBindingOptions( const queues = extractBindingsOfType("queue", bindings); const pipelines = extractBindingsOfType("pipeline", bindings); const hyperdrives = extractBindingsOfType("hyperdrive", bindings); - const workflows = extractBindingsOfType("workflow", bindings); + const workflowBindings = extractBindingsOfType("workflow", bindings); + const workflows: CfWorkflow[] = [ + ...workflowBindings, + ...Object.entries(getWorkflowExports(config.exports)).map( + ([className, workflow]) => ({ + binding: className, + name: workflow.name, + class_name: className, + limits: workflow.limits, + }) + ), + ]; const durableObjects = extractBindingsOfType( "durable_object_namespace", bindings diff --git a/packages/wrangler/src/type-generation/index.ts b/packages/wrangler/src/type-generation/index.ts index 8f95738cbf..459badbdff 100644 --- a/packages/wrangler/src/type-generation/index.ts +++ b/packages/wrangler/src/type-generation/index.ts @@ -1387,7 +1387,7 @@ async function generatePerEnvironmentTypes( function getWorkflowType(workflow: { binding: string; - name: string; + name?: string; class_name: string; script_name?: string; }): string { @@ -2740,7 +2740,7 @@ function collectAllWorkflows( args: Partial<(typeof typesCommand)["args"]> ): Array<{ binding: string; - name: string; + name?: string; class_name: string; script_name?: string; }> { @@ -2748,7 +2748,7 @@ function collectAllWorkflows( string, { binding: string; - name: string; + name?: string; class_name: string; script_name?: string; } @@ -3892,7 +3892,7 @@ function collectWorkflowsPerEnvironment( Array<{ binding: string; class_name: string; - name: string; + name?: string; script_name?: string; }> > { @@ -3901,7 +3901,7 @@ function collectWorkflowsPerEnvironment( Array<{ binding: string; class_name: string; - name: string; + name?: string; script_name?: string; }> >(); @@ -3912,13 +3912,13 @@ function collectWorkflowsPerEnvironment( ): Array<{ binding: string; class_name: string; - name: string; + name?: string; script_name?: string; }> { const workflows = new Array<{ binding: string; class_name: string; - name: string; + name?: string; script_name?: string; }>(); From 85226c306bb30ea2ead06c96cfc0c9a0f8930311 Mon Sep 17 00:00:00 2001 From: Eric Clemmons Date: Tue, 14 Jul 2026 19:03:24 -0500 Subject: [PATCH 2/5] Validate Workflow export class names --- .../src/__tests__/worker-definition.test.ts | 59 +++++++++++++++++++ packages/config/src/inference.ts | 10 ++++ packages/config/src/worker-definition.ts | 26 +++++++- 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 packages/config/src/__tests__/worker-definition.test.ts diff --git a/packages/config/src/__tests__/worker-definition.test.ts b/packages/config/src/__tests__/worker-definition.test.ts new file mode 100644 index 0000000000..f9222f61d1 --- /dev/null +++ b/packages/config/src/__tests__/worker-definition.test.ts @@ -0,0 +1,59 @@ +import { describe, it } from "vitest"; +import { exports } from "../exports"; +import { defineWorker } from "../worker-definition"; + +describe("defineWorker", () => { + it("returns a Worker definition", ({ expect }) => { + expect( + defineWorker({ + name: "worker", + compatibilityDate: "2026-07-14", + }) + ).toBeDefined(); + }); +}); + +function validateWorkflowExportKeyTypes() { + const MyWorkflow = class {} as new () => Rpc.WorkflowEntrypointBranded; + const entrypoint = { MyWorkflow }; + + defineWorker({ + name: "worker", + compatibilityDate: "2026-07-14", + entrypoint, + exports: { + MyWorkflow: exports.workflow({ name: "my-workflow" }), + }, + }); + + // @ts-expect-error Workflow export keys must match a WorkflowEntrypoint class export + defineWorker({ + name: "worker", + compatibilityDate: "2026-07-14", + entrypoint, + exports: { + MY_WORKFLOW: exports.workflow({ name: "my-workflow" }), + }, + }); + + // @ts-expect-error Workflow export keys must match a WorkflowEntrypoint class export + defineWorker(() => ({ + name: "worker", + compatibilityDate: "2026-07-14", + entrypoint, + exports: { + MY_WORKFLOW: exports.workflow({ name: "my-workflow" }), + }, + })); + + defineWorker({ + name: "worker", + compatibilityDate: "2026-07-14", + entrypoint: "./index.ts", + exports: { + MY_WORKFLOW: exports.workflow({ name: "my-workflow" }), + }, + }); +} + +void validateWorkflowExportKeyTypes; diff --git a/packages/config/src/inference.ts b/packages/config/src/inference.ts index 7f990c3c85..6c91c39dd5 100644 --- a/packages/config/src/inference.ts +++ b/packages/config/src/inference.ts @@ -225,6 +225,16 @@ export type InferExportsByType< }[keyof TExports] : never; +/** Infer named `WorkflowEntrypoint` class exports from a Worker's entry module. */ +export type InferWorkflowEntrypointExports = + InferMainModule extends infer TModule extends WorkerModule + ? { + [K in keyof TModule]: TModule[K] extends Constructor + ? K & string + : never; + }[keyof TModule] + : never; + /** * Infer `WorkerEntrypoint` export names from a config. * Returns named module exports that are not declared as type `"durable-object"` or `"workflow"` in `exports`. diff --git a/packages/config/src/worker-definition.ts b/packages/config/src/worker-definition.ts index 659add97fd..410827da09 100644 --- a/packages/config/src/worker-definition.ts +++ b/packages/config/src/worker-definition.ts @@ -9,6 +9,7 @@ import type { InferExportsByType, InferWorkerName, InferWorkerEntrypointExports, + InferWorkflowEntrypointExports, } from "./inference"; import type { UserConfig } from "./types"; @@ -90,11 +91,32 @@ export type UserConfigExport = | Promise | ((ctx: ConfigContext) => T | Promise); +type ValidateWorkflowExportKeys = T extends { + entrypoint: infer _TEntrypoint extends Record; + exports: infer TExports extends Record; +} + ? { + exports: { + [K in keyof TExports]: TExports[K] extends { type: "workflow" } + ? K extends InferWorkflowEntrypointExports + ? TExports[K] + : never + : TExports[K]; + }; + } + : unknown; + export function defineWorker( - config: (ctx: ConfigContext) => (UserConfig & T) | Promise + config: ( + ctx: ConfigContext + ) => + | (UserConfig & T & ValidateWorkflowExportKeys) + | Promise> ): TypedWorkerDefinition; export function defineWorker( - config: (UserConfig & T) | Promise + config: + | (UserConfig & T & ValidateWorkflowExportKeys) + | Promise> ): TypedWorkerDefinition; export function defineWorker(config: UserConfigExport): WorkerDefinition { return { From 617ee6d202177ce8599c9294adbb81c03d55cdb6 Mon Sep 17 00:00:00 2001 From: Eric Clemmons Date: Tue, 14 Jul 2026 19:05:08 -0500 Subject: [PATCH 3/5] Avoid Workflow export inference cycles --- .../config/src/__tests__/worker-definition.test.ts | 2 +- packages/config/src/inference.ts | 10 ---------- packages/config/src/worker-definition.ts | 5 ++--- 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/packages/config/src/__tests__/worker-definition.test.ts b/packages/config/src/__tests__/worker-definition.test.ts index f9222f61d1..861cd2acdd 100644 --- a/packages/config/src/__tests__/worker-definition.test.ts +++ b/packages/config/src/__tests__/worker-definition.test.ts @@ -14,7 +14,7 @@ describe("defineWorker", () => { }); function validateWorkflowExportKeyTypes() { - const MyWorkflow = class {} as new () => Rpc.WorkflowEntrypointBranded; + const MyWorkflow = class {}; const entrypoint = { MyWorkflow }; defineWorker({ diff --git a/packages/config/src/inference.ts b/packages/config/src/inference.ts index 6c91c39dd5..7f990c3c85 100644 --- a/packages/config/src/inference.ts +++ b/packages/config/src/inference.ts @@ -225,16 +225,6 @@ export type InferExportsByType< }[keyof TExports] : never; -/** Infer named `WorkflowEntrypoint` class exports from a Worker's entry module. */ -export type InferWorkflowEntrypointExports = - InferMainModule extends infer TModule extends WorkerModule - ? { - [K in keyof TModule]: TModule[K] extends Constructor - ? K & string - : never; - }[keyof TModule] - : never; - /** * Infer `WorkerEntrypoint` export names from a config. * Returns named module exports that are not declared as type `"durable-object"` or `"workflow"` in `exports`. diff --git a/packages/config/src/worker-definition.ts b/packages/config/src/worker-definition.ts index 410827da09..42be59a4e7 100644 --- a/packages/config/src/worker-definition.ts +++ b/packages/config/src/worker-definition.ts @@ -9,7 +9,6 @@ import type { InferExportsByType, InferWorkerName, InferWorkerEntrypointExports, - InferWorkflowEntrypointExports, } from "./inference"; import type { UserConfig } from "./types"; @@ -92,13 +91,13 @@ export type UserConfigExport = | ((ctx: ConfigContext) => T | Promise); type ValidateWorkflowExportKeys = T extends { - entrypoint: infer _TEntrypoint extends Record; + entrypoint: infer TEntrypoint extends Record; exports: infer TExports extends Record; } ? { exports: { [K in keyof TExports]: TExports[K] extends { type: "workflow" } - ? K extends InferWorkflowEntrypointExports + ? K extends Exclude ? TExports[K] : never : TExports[K]; From 2f4f76177136a7f51107269ad12d5d9fe2210f47 Mon Sep 17 00:00:00 2001 From: Eric Clemmons Date: Tue, 14 Jul 2026 19:10:46 -0500 Subject: [PATCH 4/5] Revert Workflow export key validation --- .../src/__tests__/worker-definition.test.ts | 59 ------------------- packages/config/src/worker-definition.ts | 25 +------- 2 files changed, 2 insertions(+), 82 deletions(-) delete mode 100644 packages/config/src/__tests__/worker-definition.test.ts diff --git a/packages/config/src/__tests__/worker-definition.test.ts b/packages/config/src/__tests__/worker-definition.test.ts deleted file mode 100644 index 861cd2acdd..0000000000 --- a/packages/config/src/__tests__/worker-definition.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, it } from "vitest"; -import { exports } from "../exports"; -import { defineWorker } from "../worker-definition"; - -describe("defineWorker", () => { - it("returns a Worker definition", ({ expect }) => { - expect( - defineWorker({ - name: "worker", - compatibilityDate: "2026-07-14", - }) - ).toBeDefined(); - }); -}); - -function validateWorkflowExportKeyTypes() { - const MyWorkflow = class {}; - const entrypoint = { MyWorkflow }; - - defineWorker({ - name: "worker", - compatibilityDate: "2026-07-14", - entrypoint, - exports: { - MyWorkflow: exports.workflow({ name: "my-workflow" }), - }, - }); - - // @ts-expect-error Workflow export keys must match a WorkflowEntrypoint class export - defineWorker({ - name: "worker", - compatibilityDate: "2026-07-14", - entrypoint, - exports: { - MY_WORKFLOW: exports.workflow({ name: "my-workflow" }), - }, - }); - - // @ts-expect-error Workflow export keys must match a WorkflowEntrypoint class export - defineWorker(() => ({ - name: "worker", - compatibilityDate: "2026-07-14", - entrypoint, - exports: { - MY_WORKFLOW: exports.workflow({ name: "my-workflow" }), - }, - })); - - defineWorker({ - name: "worker", - compatibilityDate: "2026-07-14", - entrypoint: "./index.ts", - exports: { - MY_WORKFLOW: exports.workflow({ name: "my-workflow" }), - }, - }); -} - -void validateWorkflowExportKeyTypes; diff --git a/packages/config/src/worker-definition.ts b/packages/config/src/worker-definition.ts index 42be59a4e7..659add97fd 100644 --- a/packages/config/src/worker-definition.ts +++ b/packages/config/src/worker-definition.ts @@ -90,32 +90,11 @@ export type UserConfigExport = | Promise | ((ctx: ConfigContext) => T | Promise); -type ValidateWorkflowExportKeys = T extends { - entrypoint: infer TEntrypoint extends Record; - exports: infer TExports extends Record; -} - ? { - exports: { - [K in keyof TExports]: TExports[K] extends { type: "workflow" } - ? K extends Exclude - ? TExports[K] - : never - : TExports[K]; - }; - } - : unknown; - export function defineWorker( - config: ( - ctx: ConfigContext - ) => - | (UserConfig & T & ValidateWorkflowExportKeys) - | Promise> + config: (ctx: ConfigContext) => (UserConfig & T) | Promise ): TypedWorkerDefinition; export function defineWorker( - config: - | (UserConfig & T & ValidateWorkflowExportKeys) - | Promise> + config: (UserConfig & T) | Promise ): TypedWorkerDefinition; export function defineWorker(config: UserConfigExport): WorkerDefinition { return { From 94e52405edd7ede6410588148c57a5425d1d7c51 Mon Sep 17 00:00:00 2001 From: Eric Clemmons Date: Tue, 14 Jul 2026 19:17:01 -0500 Subject: [PATCH 5/5] Provision Workflow exports outside upload metadata --- packages/deploy-helpers/src/deploy/helpers/exports.ts | 5 +++-- packages/wrangler/src/__tests__/deploy/workflows.test.ts | 9 +-------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/deploy-helpers/src/deploy/helpers/exports.ts b/packages/deploy-helpers/src/deploy/helpers/exports.ts index 15b48fa72c..5916e0b17f 100644 --- a/packages/deploy-helpers/src/deploy/helpers/exports.ts +++ b/packages/deploy-helpers/src/deploy/helpers/exports.ts @@ -16,10 +16,11 @@ export async function resolveExportsUploadPayload( const { migrations, exports: durableObjectExports } = await resolveDoLifecyclePayload(props); // Durable Object exports replace migrations. Worker exports can upload with - // either path, but not both. + // either path, but not both. Workflow exports are provisioned through the + // Workflows API after the Worker module is uploaded; the Worker upload API + // does not accept `workflow` entries in its `exports` metadata. const exports = { ...partitionedExports.worker, - ...partitionedExports.workflow, ...(durableObjectExports ?? {}), }; diff --git a/packages/wrangler/src/__tests__/deploy/workflows.test.ts b/packages/wrangler/src/__tests__/deploy/workflows.test.ts index 1c232f2acb..5ff81a1b6e 100644 --- a/packages/wrangler/src/__tests__/deploy/workflows.test.ts +++ b/packages/wrangler/src/__tests__/deploy/workflows.test.ts @@ -226,14 +226,7 @@ describe("deploy", () => { mockSubDomainRequest(); mockUploadWorkerRequest({ expectedBindings: [], - expectedExports: { - MyWorkflow: { - type: "workflow", - name: "my-workflow", - limits: { steps: 25_000 }, - schedules: ["0 * * * *"], - }, - }, + expectedExports: undefined, }); await runWrangler("deploy --experimental-new-config");