workerflow 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * For more details on how to configure Wrangler, refer to:
3
+ * https://developers.cloudflare.com/workers/wrangler/configuration/
4
+ */
5
+ {
6
+ "$schema": "node_modules/wrangler/config-schema.json",
7
+ "name": "demo-workflow",
8
+ "main": "worker/index.ts",
9
+ "compatibility_date": "2026-04-03",
10
+ "assets": {
11
+ "not_found_handling": "single-page-application"
12
+ },
13
+ "observability": {
14
+ "enabled": true
15
+ },
16
+ "upload_source_maps": true,
17
+ "durable_objects": {
18
+ "bindings": [
19
+ {
20
+ "name": "DEMO_WORKFLOW_RUNTIME",
21
+ "class_name": "DemoWorkflowRuntime"
22
+ }
23
+ ]
24
+ },
25
+ "migrations": [
26
+ {
27
+ "tag": "v1",
28
+ "new_sqlite_classes": ["DemoWorkflowRuntime"]
29
+ }
30
+ ],
31
+ "compatibility_flags": ["nodejs_compat"]
32
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "workerflow",
3
+ "description": "Durable execution engine, built on Cloudflare Workers",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js"
10
+ },
11
+ "./definition": {
12
+ "types": "./dist/definition.d.ts",
13
+ "import": "./dist/definition.js"
14
+ }
15
+ },
16
+ "scripts": {
17
+ "build": "tsx ./scripts/build.ts",
18
+ "test": "vitest run",
19
+ "test:watch": "vitest watch",
20
+ "format": "oxfmt --write .",
21
+ "format:check": "oxfmt --check .",
22
+ "lint": "oxlint src/"
23
+ },
24
+ "devDependencies": {
25
+ "@cloudflare/vitest-pool-workers": "^0.14.1",
26
+ "@cloudflare/workers-types": "^4.20260402.1",
27
+ "oxfmt": "^0.43.0",
28
+ "oxlint": "^1.58.0",
29
+ "tsdown": "^0.21.7",
30
+ "tsx": "^4.21.0",
31
+ "turbo": "^2.9.3",
32
+ "typescript": "^6.0.2",
33
+ "vitest": "^4.1.2"
34
+ }
35
+ }
@@ -0,0 +1,29 @@
1
+ import { execSync } from "node:child_process";
2
+ import { build } from "tsdown";
3
+
4
+ async function main() {
5
+ await build({
6
+ clean: true,
7
+ dts: true,
8
+ target: "es2021",
9
+ entry: ["src/index.ts"],
10
+ deps: {
11
+ skipNodeModulesBundle: true,
12
+ neverBundle: ["cloudflare:workers"]
13
+ },
14
+ format: "esm",
15
+ sourcemap: true,
16
+ fixedExtension: false
17
+ });
18
+
19
+ // Format the generated .d.ts files using oxfmt
20
+ execSync("oxfmt --write ./dist/*.d.ts");
21
+
22
+ process.exit(0);
23
+ }
24
+
25
+ main().catch((err) => {
26
+ // Build failures should fail
27
+ console.error(err);
28
+ process.exit(1);
29
+ });
package/src/brand.ts ADDED
@@ -0,0 +1,2 @@
1
+ declare const brand: unique symbol;
2
+ export type Brand<T, TBrand extends string> = T & { [brand]: TBrand };
@@ -0,0 +1,413 @@
1
+ import { WorkerEntrypoint } from "cloudflare:workers";
2
+ import type { Json } from "./json";
3
+ import type { RunStepId, SleepStepId, WaitStepId, WorkflowRuntimeContext } from "./runtime";
4
+ import { AsyncLocalStorage } from "node:async_hooks";
5
+
6
+ declare global {
7
+ interface ErrorConstructor {
8
+ captureStackTrace(targetObject: object, constructorOpt?: Function): void;
9
+ }
10
+ }
11
+
12
+ /**
13
+ * `numOfSuccessfulRunCallbacks` counts successful sibling `run()` completions in this frame during the current `next()`
14
+ * (see `WorkflowDefinition.run`). `parentStepId` is the innermost enclosing run step for nested steps.
15
+ */
16
+ type RunStepFrame = { numOfSuccessfulRunCallbacks: number; parentStepId: RunStepId | null };
17
+
18
+ const STEP_EXECUTION_INTERRUPTED_ERROR_MESSAGE =
19
+ "Step execution was interrupted before its outcome was durably recorded.";
20
+
21
+ export abstract class WorkflowDefinition<TInput extends Json | undefined = Json | undefined> extends WorkerEntrypoint<
22
+ Cloudflare.Env,
23
+ { requestId: string; runtimeInstanceId: string; input: TInput }
24
+ > {
25
+ #context: WorkflowRuntimeContext | undefined;
26
+ #requestId: string;
27
+ #runtimeInstanceId: string;
28
+ #runStepFrameContext: AsyncLocalStorage<RunStepFrame>;
29
+
30
+ /**
31
+ * A set of step ids that have been used in the current workflow execution.
32
+ */
33
+ readonly #seenStepIdsSoFar: Set<RunStepId | SleepStepId | WaitStepId>;
34
+
35
+ constructor(ctx: ExecutionContext, env: Cloudflare.Env) {
36
+ super(ctx, env);
37
+ this.#seenStepIdsSoFar = new Set();
38
+ this.#requestId = this.ctx.props.requestId;
39
+ this.#runtimeInstanceId = this.ctx.props.runtimeInstanceId;
40
+ this.#runStepFrameContext = new AsyncLocalStorage<RunStepFrame>();
41
+ }
42
+
43
+ #getRunStepFrame(): RunStepFrame {
44
+ const frame = this.#runStepFrameContext.getStore();
45
+ if (frame === undefined) {
46
+ throw new Error("Run step frame is unset; run step must go through `WorkflowDefinition.next()`.");
47
+ }
48
+ return frame;
49
+ }
50
+
51
+ /**
52
+ * @param context - The workflow runtime context: this includes methods to create and update steps.
53
+ * @returns A promise that resolves to a hint on how to proceed next. The hint is one of the following:
54
+ *
55
+ * - { done: true; status: "completed" | "failed" }: the workflow has completed or aborted.
56
+ * - { done: false; resume: { type: "immediate" } }: the workflow should resume immediately.
57
+ * - { done: false; resume: { type: "suspended" } }: the workflow should suspend itself and wait for the next alarm or
58
+ * inbound event to resume.
59
+ * @internal
60
+ */
61
+ async next(context: WorkflowRuntimeContext): Promise<
62
+ | { done: true; status: "completed" | "failed" }
63
+ | {
64
+ done: false;
65
+ resume: { type: "immediate" } | { type: "suspended" };
66
+ }
67
+ > {
68
+ this.#context = context;
69
+ try {
70
+ await this.#runStepFrameContext.run({ numOfSuccessfulRunCallbacks: 0, parentStepId: null }, async () => {
71
+ await this.execute();
72
+ });
73
+ return { done: true, status: "completed" };
74
+ } catch (error) {
75
+ if (error instanceof ResumeImmediatelyError) {
76
+ return { done: false, resume: { type: "immediate" } };
77
+ } else if (error instanceof SuspendWorkflowError) {
78
+ return { done: false, resume: { type: "suspended" } };
79
+ } else if (error instanceof AbortWorkflowError) {
80
+ return { done: true, status: "failed" };
81
+ } else if (
82
+ error instanceof NonRetryableStepError ||
83
+ error instanceof MaxAttemptsExceededError ||
84
+ error instanceof WaitStepTimedOutError
85
+ ) {
86
+ console.info(error, { requestId: this.#requestId, runtimeInstanceId: this.#runtimeInstanceId });
87
+ return { done: true, status: "failed" };
88
+ } else {
89
+ // An exception can be thrown when calling a method on the WorkflowContext RPC target.
90
+ // The resulting exception will have a 'remote' property set to 'True' in this case.
91
+ if (error instanceof Error && "remote" in error && error.remote) {
92
+ console.info(error, { requestId: this.#requestId, runtimeInstanceId: this.#runtimeInstanceId });
93
+ /**
94
+ * When calling Durable Objects from a Worker, errors may include .retryable and .overloaded properties
95
+ * indicating whether the operation can be retried. See:
96
+ * https://developers.cloudflare.com/durable-objects/best-practices/rules-of-durable-objects/#handle-errors-and-use-exception-boundaries.
97
+ */
98
+ if ("retryable" in error && error.retryable) {
99
+ return { done: false, resume: { type: "suspended" } };
100
+ }
101
+ // An 'WorkflowInvariantError' indicates that the workflow engine is in an invalid state and the workflow should be aborted.
102
+ else if (error.message.startsWith("WorkflowInvariantError")) {
103
+ return { done: true, status: "failed" };
104
+ }
105
+ // All other remote errors are considered to be transient, so we instruct the workflow to suspend itself and wait for the next alarm to resume.
106
+ else {
107
+ return { done: false, resume: { type: "suspended" } };
108
+ }
109
+ }
110
+ // All other non-remote errors are considered fatal and the workflow should be aborted.
111
+ console.error(error instanceof Error ? error : String(error), {
112
+ requestId: this.#requestId,
113
+ runtimeInstanceId: this.#runtimeInstanceId
114
+ });
115
+ return { done: true, status: "failed" };
116
+ }
117
+ } finally {
118
+ this.#context = undefined;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * @param id - The step id to assert uniqueness of.
124
+ * @throws An error if the step id has already been used in the current
125
+ * workflow execution.
126
+ */
127
+ #assertUniqueStepIdInCurrentExecution(id: RunStepId | SleepStepId | WaitStepId): void {
128
+ if (this.#seenStepIdsSoFar.has(id)) {
129
+ const error = new Error(
130
+ `Step id '${id}' was already used earlier in this workflow execution. Steps must be uniquely identified within a single workflow execution.`
131
+ );
132
+ Error.captureStackTrace(error, WorkflowDefinition.prototype.run);
133
+ throw error;
134
+ }
135
+ this.#seenStepIdsSoFar.add(id);
136
+ }
137
+
138
+ abstract execute(): Promise<void>;
139
+
140
+ protected async run<T extends Json | undefined | void>(
141
+ id: string,
142
+ callback: () => Promise<T>,
143
+ config?: {
144
+ maxAttempts?: number;
145
+ }
146
+ ): Promise<T> {
147
+ const runStepId = id as RunStepId;
148
+ this.#assertUniqueStepIdInCurrentExecution(runStepId);
149
+
150
+ const ctx = this.#context;
151
+ if (ctx === undefined) {
152
+ const error = new Error("Workflow context is unavailable; `run()` must be called from within `execute()`.");
153
+ Error.captureStackTrace(error, WorkflowDefinition.prototype.run);
154
+ throw error;
155
+ }
156
+
157
+ const parentStepId = this.#getRunStepFrame().parentStepId;
158
+
159
+ const step = await ctx.getOrCreateStep(runStepId, {
160
+ type: "run",
161
+ maxAttempts: config?.maxAttempts,
162
+ parentStepId
163
+ });
164
+
165
+ if (this.#getRunStepFrame().numOfSuccessfulRunCallbacks >= 1) {
166
+ throw new ResumeImmediatelyError();
167
+ }
168
+
169
+ if (step.state === "pending") {
170
+ if (step.nextAttemptAt.getTime() > Date.now()) {
171
+ throw new SuspendWorkflowError();
172
+ }
173
+
174
+ const attemptCount = step.attemptCount + 1; // Increment the attempt count by 1 as we're starting a new attempt
175
+ const maxAttempts = step.maxAttempts;
176
+
177
+ await ctx.handleRunAttemptEvent(runStepId, {
178
+ type: "running",
179
+ attemptCount: attemptCount
180
+ });
181
+
182
+ let _result: unknown;
183
+ try {
184
+ _result = await this.#runStepFrameContext.run(
185
+ { numOfSuccessfulRunCallbacks: 0, parentStepId: runStepId },
186
+ async () => await callback()
187
+ );
188
+ } catch (error) {
189
+ // 'ResumeImmediatelyError' and 'SuspendWorkflowError' are rethrown so a nested `run()` does not record a spurious failure on the parent.
190
+ if (error instanceof ResumeImmediatelyError || error instanceof SuspendWorkflowError) {
191
+ throw error;
192
+ }
193
+
194
+ await ctx.handleRunAttemptEvent(runStepId, {
195
+ type: "failed",
196
+ errorMessage: String(error),
197
+ errorName: error instanceof Error ? error.name : undefined,
198
+ attemptCount: attemptCount,
199
+ isNonRetryableStepError: error instanceof NonRetryableStepError
200
+ });
201
+
202
+ if (error instanceof NonRetryableStepError) {
203
+ throw error;
204
+ }
205
+
206
+ if (maxAttempts !== null && attemptCount >= maxAttempts) {
207
+ const error = new MaxAttemptsExceededError();
208
+ Error.captureStackTrace(error, WorkflowDefinition.prototype.run);
209
+ throw error;
210
+ }
211
+
212
+ throw new SuspendWorkflowError();
213
+ }
214
+
215
+ let result: string;
216
+ if (_result === undefined) {
217
+ result = "{}";
218
+ } else {
219
+ result = JSON.stringify({ value: _result });
220
+ }
221
+
222
+ await ctx.handleRunAttemptEvent(runStepId, {
223
+ type: "succeeded",
224
+ attemptCount: attemptCount,
225
+ result: result
226
+ });
227
+
228
+ this.#getRunStepFrame().numOfSuccessfulRunCallbacks += 1;
229
+
230
+ return _result as T;
231
+ } else if (step.state === "running") {
232
+ const maxAttempts = step.maxAttempts;
233
+ const attemptCount = step.attemptCount;
234
+
235
+ // If no direct child row explains the parent still being `running` (see `hasRunningOrWaitingChildSteps`), fail the attempt as interrupted.
236
+ if (!(await ctx.hasRunningOrWaitingChildSteps(runStepId))) {
237
+ await ctx.handleRunAttemptEvent(runStepId, {
238
+ type: "failed",
239
+ errorMessage: STEP_EXECUTION_INTERRUPTED_ERROR_MESSAGE,
240
+ errorName: undefined,
241
+ attemptCount: attemptCount
242
+ });
243
+
244
+ if (maxAttempts !== null && attemptCount >= maxAttempts) {
245
+ const error = new MaxAttemptsExceededError();
246
+ Error.captureStackTrace(error, WorkflowDefinition.prototype.run);
247
+ throw error;
248
+ } else {
249
+ throw new SuspendWorkflowError();
250
+ }
251
+ }
252
+
253
+ // Direct children in non-failure states: continue the same attempt by re-entering the callback.
254
+ let _result: unknown;
255
+ try {
256
+ _result = await this.#runStepFrameContext.run(
257
+ { numOfSuccessfulRunCallbacks: 0, parentStepId: runStepId },
258
+ async () => await callback()
259
+ );
260
+ } catch (error) {
261
+ if (error instanceof ResumeImmediatelyError || error instanceof SuspendWorkflowError) {
262
+ throw error;
263
+ }
264
+
265
+ await ctx.handleRunAttemptEvent(runStepId, {
266
+ type: "failed",
267
+ errorMessage: String(error),
268
+ errorName: error instanceof Error ? error.name : undefined,
269
+ attemptCount: attemptCount,
270
+ isNonRetryableStepError: error instanceof NonRetryableStepError
271
+ });
272
+
273
+ if (error instanceof NonRetryableStepError) {
274
+ throw error;
275
+ }
276
+
277
+ if (maxAttempts !== null && attemptCount >= maxAttempts) {
278
+ const err = new MaxAttemptsExceededError();
279
+ Error.captureStackTrace(err, WorkflowDefinition.prototype.run);
280
+ throw err;
281
+ }
282
+
283
+ throw new SuspendWorkflowError();
284
+ }
285
+
286
+ const result: string = _result === undefined ? "{}" : JSON.stringify({ value: _result });
287
+
288
+ await ctx.handleRunAttemptEvent(runStepId, {
289
+ type: "succeeded",
290
+ attemptCount: attemptCount,
291
+ result: result
292
+ });
293
+
294
+ this.#getRunStepFrame().numOfSuccessfulRunCallbacks += 1;
295
+
296
+ return _result as T;
297
+ } else if (step.state === "failed") {
298
+ throw new AbortWorkflowError();
299
+ } else if (step.state === "succeeded") {
300
+ const parsed: unknown = JSON.parse(step.result);
301
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
302
+ throw new Error(
303
+ "Invalid stored workflow result; expected a non-null object payload; storage may be corrupted or written by an incompatible version."
304
+ );
305
+ }
306
+
307
+ const keys = Object.keys(parsed);
308
+ // "{}" means top-level undefined
309
+ if (keys.length === 0) {
310
+ return undefined as T;
311
+ }
312
+
313
+ if (keys.length === 1 && Object.hasOwn(parsed, "value")) {
314
+ return (parsed as { value: T }).value;
315
+ }
316
+
317
+ throw new Error(
318
+ "Invalid stored workflow result; expected an object payload with a 'value' property or an empty object; storage may be corrupted or written by an incompatible version."
319
+ );
320
+ }
321
+
322
+ throw new Error("Unexpected run step state; expected 'pending', 'running', 'failed', or 'succeeded'.");
323
+ }
324
+
325
+ protected async sleep(id: string, duration: number): Promise<void> {
326
+ const sleepStepId = id as SleepStepId;
327
+ this.#assertUniqueStepIdInCurrentExecution(sleepStepId);
328
+
329
+ const ctx = this.#context;
330
+ if (ctx === undefined) {
331
+ const error = new Error("Workflow context is unavailable; `sleep()` must be called from within `execute()`.");
332
+ Error.captureStackTrace(error, WorkflowDefinition.prototype.sleep);
333
+ throw error;
334
+ }
335
+
336
+ const step = await ctx.getOrCreateStep(sleepStepId, {
337
+ type: "sleep",
338
+ wakeAt: new Date(Date.now() + duration),
339
+ parentStepId: this.#getRunStepFrame().parentStepId
340
+ });
341
+
342
+ // If the sleep step has already elapsed, we return immediately as no further action is needed.
343
+ if (step.state === "elapsed") {
344
+ return;
345
+ } else if (step.state === "waiting") {
346
+ // If the sleep step is not yet due to wake up, we suspend the workflow.
347
+ if (Date.now() < step.wakeAt.getTime()) {
348
+ throw new SuspendWorkflowError();
349
+ }
350
+ // If the sleep step is due to wake up, we mark the step as elapsed and throw a 'ResumeImmediatelyError' to hint the driver to resume the workflow immediately.
351
+ else {
352
+ await ctx.handleSleepStepEvent(sleepStepId, { type: "elapsed" });
353
+ throw new ResumeImmediatelyError();
354
+ }
355
+ }
356
+
357
+ throw new Error("Unexpected sleep step state; expected 'waiting' or 'elapsed'.");
358
+ }
359
+
360
+ protected async wait<T extends Json>(id: string, event: string, config?: { timeoutAt?: number }): Promise<T> {
361
+ const waitStepId = id as WaitStepId;
362
+ this.#assertUniqueStepIdInCurrentExecution(waitStepId);
363
+
364
+ const ctx = this.#context;
365
+ if (ctx === undefined) {
366
+ const error = new Error("Workflow context is unavailable; `wait()` must be called from within `execute()`.");
367
+ Error.captureStackTrace(error, WorkflowDefinition.prototype.wait);
368
+ throw error;
369
+ }
370
+
371
+ const step = await ctx.getOrCreateStep(waitStepId, {
372
+ type: "wait",
373
+ eventName: event,
374
+ timeoutAt: config?.timeoutAt ? new Date(config.timeoutAt) : undefined,
375
+ parentStepId: this.#getRunStepFrame().parentStepId
376
+ });
377
+
378
+ if (step.state === "waiting") {
379
+ // If the wait step has a timeout and the timeout has been reached, we mark the step as timed out and throw an 'AbortWorkflowError' to abort the workflow.
380
+ if (step.timeoutAt !== undefined && Date.now() >= step.timeoutAt.getTime()) {
381
+ await ctx.handleWaitStepEvent(waitStepId, { type: "timed_out" });
382
+ const error = new WaitStepTimedOutError();
383
+ Error.captureStackTrace(error, WorkflowDefinition.prototype.wait);
384
+ throw error;
385
+ }
386
+
387
+ // Otherwise, we hint the driver to suspend the workflow until the next alarm or inbound event to resume.
388
+ throw new SuspendWorkflowError();
389
+ } else if (step.state === "timed_out") {
390
+ // If the wait step has timed out, we throw an 'AbortWorkflowError' to abort the workflow.
391
+ throw new AbortWorkflowError();
392
+ } else if (step.state === "satisfied") {
393
+ // If the wait step has been satisfied, we return the payload of the satisfied step.
394
+ return JSON.parse(step.payload) as T;
395
+ }
396
+
397
+ throw new Error("Unexpected wait step state; expected 'waiting', 'satisfied', or 'timed_out'.");
398
+ }
399
+ }
400
+
401
+ class ResumeImmediatelyError extends Error {}
402
+ class SuspendWorkflowError extends Error {}
403
+ class AbortWorkflowError extends Error {}
404
+
405
+ class MaxAttemptsExceededError extends Error {}
406
+ class WaitStepTimedOutError extends Error {}
407
+
408
+ export class NonRetryableStepError extends Error {
409
+ constructor(message?: string) {
410
+ super(message);
411
+ this.name = "NonRetryableStepError";
412
+ }
413
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { WorkflowDefinition, NonRetryableStepError } from "./definition";
2
+ export { WorkflowRuntime } from "./runtime";
package/src/json.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Represents an indefinitely deep arbitrary JSON data structure. There are
3
+ * four types that make up the Json family:
4
+ *
5
+ * - Json any legal JSON value
6
+ * - JsonScalar any legal JSON leaf value (no lists or objects)
7
+ * - JsonArray a JSON value whose outer type is an array
8
+ * - JsonObject a JSON value whose outer type is an object
9
+ *
10
+ */
11
+ export type Json = JsonScalar | JsonArray | JsonObject;
12
+ export type JsonScalar = string | number | boolean | null;
13
+ export type JsonArray = Json[];
14
+ export type JsonObject = { [key: string]: Json };