workflow 4.8.3 → 4.8.5

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "title": "Changelog",
3
- "pages": ["index", "eager-processing", "resilient-start"],
3
+ "pages": ["index", "resilient-start"],
4
4
  "defaultOpen": false
5
5
  }
@@ -7,321 +7,69 @@ description: Overhaul run start logic to tolerate world storage unavailability,
7
7
 
8
8
  ## Motivation
9
9
 
10
- When `world` storage is unavailable but the queue is up,
11
- `start()` previously failed entirely because `world.events.create(run_created)`
12
- is called before `world.queue()`. This change decouples run creation from queue
13
- dispatch so that runs can still be accepted when storage is degraded.
10
+ When `world` storage is unavailable but the queue is up, `start()` previously failed entirely because `world.events.create(run_created)` is called before `world.queue()`. This change decouples run creation from queue dispatch so that runs can still be accepted when storage is degraded.
14
11
 
15
- Additionally, the runtime previously called `world.runs.get(runId)` before
16
- `run_started`, adding an extra round-trip. By always calling `run_started`
17
- directly, we save that round-trip and can return pre-loaded events in the
18
- response to skip the initial `events.list` call, reducing TTFB.
12
+ Additionally, the runtime previously called `world.runs.get(runId)` before `run_started`, adding an extra round-trip. By always calling `run_started` directly, we save that round-trip and can return pre-loaded events in the response to skip the initial `events.list` call, reducing TTFB.
19
13
 
20
14
  ## Design
21
15
 
22
- ### `start()` changes (packages/core)
16
+ ### `start()` changes
23
17
 
24
- - `world.events.create` (run_created) and `world.queue` are now called **in parallel**
25
- via `Promise.allSettled`.
26
- - If `events.create` errors with **429 or 5xx**, we log a warning saying that run
27
- creation failed but the run was accepted — creation will be re-tried async by the
28
- runtime when it processes the queue message. The returned `Run` instance is marked
29
- with `resilientStart = true`.
30
- - If `events.create` errors with **409** (EntityConflictError), the run already exists
31
- (e.g., the queue handler's resilient start path created it first due to a cold-start
32
- race). This is treated as success.
18
+ - `world.events.create` (run_created) and `world.queue` are now called **in parallel** via `Promise.allSettled`.
19
+ - If `events.create` errors with **429 or 5xx**, we log a warning saying that run creation failed but the run was accepted — creation will be re-tried async by the runtime when it processes the queue message. The returned `Run` instance is marked with `resilientStart = true`.
20
+ - If `events.create` errors with **409** (EntityConflictError), the run already exists (e.g., the queue handler's resilient start path created it first due to a cold-start race). This is treated as success.
33
21
  - If `world.queue` fails, we still throw — the run truly failed and was not enqueued.
34
- - The queue invocation now receives all the run inputs (`input`, `deploymentId`,
35
- `workflowName`, `specVersion`, `executionContext`) via `runInput` so the runtime can
36
- create the run later if needed.
37
- - When the runtime re-enqueues itself, it does **not** pass these inputs — only the
38
- first queue cycle carries them.
22
+ - The queue invocation now receives all the run inputs (`input`, `deploymentId`, `workflowName`, `specVersion`, `executionContext`) via `runInput` so the runtime can create the run later if needed.
23
+ - When the runtime re-enqueues itself, it does **not** pass these inputs — only the first queue cycle carries them.
39
24
 
40
- ### `workflowEntrypoint` changes (packages/core)
25
+ ### `workflowEntrypoint` changes
41
26
 
42
- - When calling `world.events.create` with `run_started`, we now also always pass the
43
- run input that was sent through the queue, if available. The response will still be on off:
44
- - **200 with event (now running)**: As usual, but the server could have used the run input to create the run if it didn't exist yet. The response will be opaque to the runtime.
45
- - **200 without event (already running)**: As usual
46
- - **409 or 410 (already finished)**: As usual
27
+ - When calling `world.events.create` with `run_started`, we now also always pass the run input that was sent through the queue, if available. The world is responsible for creating the run if it doesn't already exist.
47
28
 
48
- ### `Run.returnValue` polling (packages/core)
29
+ ### `Run.returnValue` polling
49
30
 
50
- - When `resilientStart` is true on the Run instance (run_created failed), the
51
- `pollReturnValue` loop retries on `WorkflowRunNotFoundError` up to 3 times
52
- (1s + 3s + 6s = 10s total) to give the queue time to deliver and the runtime
53
- to create the run via `run_started`.
54
- - When `resilientStart` is false (normal path), 404 fails immediately — no delay
55
- for the common case of a wrong run ID.
31
+ - When `resilientStart` is true on the Run instance (run_created failed), the `pollReturnValue` loop retries on `WorkflowRunNotFoundError` up to 3 times (1s + 3s + 6s = 10s total) to give the queue time to deliver and the runtime to create the run via `run_started`.
32
+ - When `resilientStart` is false (normal path), 404 fails immediately — no delay for the common case of a wrong run ID.
56
33
 
57
- ### World / workflow-server changes
34
+ ### World contract changes
58
35
 
59
- - Posting `run_started` to a **non-existent** run is now allowed when the run input is
60
- sent along with the payload. The server:
61
- 1. Creates a `run_created` event first (so the event log is consistent).
62
- 2. Strips the input from the `run_started` event data (it lives on `run_created`).
63
- 3. Then creates the `run_started` event normally.
64
- 4. Emits a log and a Datadog metric (`workflow_server.resilient_start.run_created_via_run_started`)
65
- to track when this fallback path is hit.
66
- - When `run_started` encounters an **already-running** run, all worlds return `{ run }`
67
- with `event: undefined` instead of throwing. No duplicate event is created.
36
+ - Posting `run_started` to a **non-existent** run is now allowed when the run input is sent along with the payload. The world creates a `run_created` event first (so the event log is consistent), then creates the `run_started` event normally.
37
+ - When `run_started` encounters an **already-running** run, all worlds return `{ run }` with `event: undefined` instead of throwing. No duplicate event is created.
68
38
 
69
39
  ### Queue transport changes
70
40
 
71
- `Uint8Array` values (the serialized workflow input in `runInput`) don't survive plain
72
- JSON serialization. Each world uses a transport that preserves binary data:
41
+ `Uint8Array` values (the serialized workflow input in `runInput`) don't survive plain JSON serialization. Each world uses a transport that preserves binary data:
73
42
 
74
- - **world-vercel**: CBOR transport — CBOR-encodes the entire queue payload into a
75
- `Buffer` and uses `BufferTransport` from `@vercel/queue`. Uint8Array survives natively.
76
- - **world-local**: `TypedJsonTransport`uses the existing `jsonReplacer`/`jsonReviver`
77
- from `fs.ts` that encode Uint8Array as `{ __type: 'Uint8Array', data: '<base64>' }`.
78
- - **world-postgres**: Inline typed JSON transport — same tagged-envelope approach as
79
- world-local, inlined since world-postgres doesn't import from world-local.
43
+ - **world-vercel**: CBOR transport — CBOR-encodes the entire queue payload into a `Buffer` and uses `BufferTransport` from `@vercel/queue`. Uint8Array survives natively.
44
+ - **world-local**: `TypedJsonTransport` encodes Uint8Array as `{ __type: 'Uint8Array', data: '<base64>' }`.
45
+ - **world-postgres**: Inline typed JSON transport same tagged-envelope approach as world-local.
80
46
 
81
47
  ## Decisions
82
48
 
83
- 1. **Parallel not sequential**: We chose `Promise.allSettled` over sequential calls to
84
- minimize latency in the happy path.
49
+ 1. **Parallel not sequential**: We chose `Promise.allSettled` over sequential calls to minimize latency in the happy path.
85
50
 
86
- 2. **Already-running returns run without event**: When `run_started` encounters an
87
- already-running run, all worlds return `{ run }` with `event: undefined` (no
88
- `events` array) instead of throwing. The runtime detects this by checking for
89
- `result.event === undefined`. This avoids an extra `world.runs.get` round-trip.
51
+ 2. **Already-running returns run without event**: When `run_started` encounters an already-running run, all worlds return `{ run }` with `event: undefined` (no `events` array) instead of throwing. The runtime detects this by checking for `result.event === undefined`. This avoids an extra `world.runs.get` round-trip.
90
52
 
91
- 3. **Events in 200 response**: We only return events on the 200 path (first caller).
92
- On the already-running path, we fall back to the normal `events.list` call. This is
93
- correct because only on 200 can we be certain we know the full event history.
53
+ 3. **Events in 200 response**: We only return events on the 200 path (first caller). On the already-running path, we fall back to the normal `events.list` call. This is correct because only on 200 can we be certain we know the full event history.
94
54
 
95
- 4. **Conditional 404 retry on Run.returnValue**: Only when `resilientStart = true`
96
- (run_created failed). Normal runs fail fast on 404.
55
+ 4. **Conditional 404 retry on Run.returnValue**: Only when `resilientStart = true` (run_created failed). Normal runs fail fast on 404.
97
56
 
98
57
  ## Known concerns
99
58
 
100
- ### Cold-start race on Vercel (observed in CI)
59
+ ### Cold-start race on Vercel
101
60
 
102
- On Vercel, the parallel dispatch can cause the queue message to be processed before
103
- `run_created` completes, if `run_created` hits a cold-start lambda. Confirmed via
104
- Datadog: the `run_started` request hit a warm lambda (23ms) while `run_created` hit
105
- a cold lambda (727ms), even though `run_created` arrived at the edge 116ms earlier.
106
- When this happens:
61
+ On Vercel, the parallel dispatch can cause the queue message to be processed before `run_created` completes, if `run_created` hits a cold-start lambda. When this happens:
107
62
 
108
63
  1. The runtime's resilient start path creates the run from `run_started`.
109
64
  2. The original `run_created` arrives and gets 409 (EntityConflictError).
110
65
  3. `start()` treats the 409 as success (the run exists).
111
66
 
112
- This is handled correctly. The `resilientStart` flag is NOT set on the Run instance
113
- in this case (409 is not a retryable error), so `returnValue` fails fast on 404.
67
+ The `resilientStart` flag is NOT set on the Run instance in this case (409 is not a retryable error), so `returnValue` fails fast on 404.
114
68
 
115
- ### Local Prod test flakiness (resolved)
69
+ ### Atomicity of run entity creation
116
70
 
117
- On world-local, the queue's async IIFE can deliver the message before
118
- `events.create(run_created)` finishes writing to the shared filesystem. The
119
- resilient start path should handle this, but Local Prod tests showed occasional
120
- runs stuck at `pending` (no `run_started` event), and Windows CI showed
121
- "Unconsumed event in event log" errors from duplicate `run_created` events.
71
+ The normal `run_created` path and the resilient start path can race on creating the run entity. In `world-local`, both paths use `writeExclusive` (O_CREAT|O_EXCL) — atomic at the OS level, so exactly one writer wins and the other gets EEXIST. The normal path throws `EntityConflictError` on conflict (handled by `start()` as 409); the resilient start path re-reads the run from disk on conflict.
122
72
 
123
- **Root cause:** A TOCTOU race between the normal `run_created` path and the
124
- resilient start path. Both used `writeJSON` which checks existence with
125
- `fs.access()` (non-atomic), so both could pass the check and write separate
126
- `run_created` events with different event IDs. Fixed by switching both paths to
127
- `writeExclusive` (O_CREAT|O_EXCL) — see retrospective items 12 and 16.
73
+ In `world-postgres`, the resilient start path uses `onConflictDoNothing` plus a re-read on conflict for the same effect, with the same outcome on either side of the race.
128
74
 
129
- ## Follow-up work
130
-
131
- - [x] ~~Investigate Local Prod test flakiness~~ — resolved via `writeExclusive`
132
- for run entity creation (retrospective items 12, 16).
133
- - [ ] Monitor the Datadog metric in production to understand how often the fallback is hit.
134
- - [x] ~~Events optimization for re-enqueue cycles~~ — decided against. The
135
- already-running path returns early without writing an event, so preloading
136
- events there would require an extra filesystem/DB query on every re-enqueue.
137
- More importantly, on Vercel with at-least-once delivery, multiple lambdas can
138
- process the same run concurrently — the event snapshot could be stale or
139
- incomplete. The runtime's fallback to `events.list` is the correct behavior
140
- for re-enqueue cycles.
141
- - [x] ~~CborTransport pass-through~~ — refactored. `encode()`/`decode()` now
142
- live inside `CborTransport.serialize()`/`deserialize()`, matching the pattern
143
- used by TypedJsonTransport (world-local) and the inline transport
144
- (world-postgres). Call sites pass plain objects instead of pre-encoded buffers.
145
-
146
- ## Development retrospective
147
-
148
- Chronological log of mistakes, misunderstandings, and reverted approaches during
149
- development. Included for future reference when working on similar cross-cutting
150
- runtime changes.
151
-
152
- ### 1. Uint8Array corruption through JSON queue transport
153
-
154
- The initial implementation passed `runInput.input` (a `Uint8Array`) directly through
155
- the queue payload. `Uint8Array` doesn't survive `JSON.stringify` — it becomes
156
- `{"0":72,"1":101,...}`. This corrupted the workflow input when the resilient start
157
- path tried to recreate the run from the queue-delivered data.
158
-
159
- Caught by the `spawnWorkflowFromStepWorkflow` e2e test and the `world-testing`
160
- embedded tests, which failed with "Invalid input" from devalue's `unflatten()`.
161
-
162
- Three approaches were tried before landing on the final solution:
163
-
164
- 1. **Base64 encoding** (`btoa`/`atob`) — worked but fragile. The decode side used
165
- `typeof runInput.input === 'string'` as a discriminant, which was flagged as
166
- dangerous since non-binary inputs could also be strings.
167
- 2. **`Array.from()`/`new Uint8Array()`** — replaced base64 with a plain number array.
168
- Two problems: (a) 3x JSON size regression vs base64, and (b) `Array.isArray()`
169
- false-positives on v1Compat runs where `dehydrateWorkflowArguments` returns
170
- devalue's flat Array format.
171
- 3. **CBOR + BufferTransport** (final) — world-vercel CBOR-encodes the queue payload;
172
- world-local and world-postgres use a `TypedJsonTransport` with a tagged envelope.
173
-
174
- ### 2. Forgot to commit world-postgres transport fix (twice)
175
-
176
- After fixing world-local and world-vercel queue transports, the same `JsonTransport`
177
- corruption bug existed in world-postgres. The fix was written during a session but
178
- never committed — lost when the working directory was reset via stash/checkout. This
179
- happened twice. The fix only landed on the third attempt when it was committed and
180
- pushed immediately. All 14 Postgres e2e jobs failed each time.
181
-
182
- ### 3. Incorrect diagnosis of Vercel Prod 409 errors
183
-
184
- Multiple Vercel Prod e2e tests failed with `EntityConflictError: Workflow run with
185
- ID wrun_... already exists` on `run_created`. The initial assumption was that VQS
186
- couldn't deliver the queue message fast enough to beat the `run_created` call.
187
-
188
- Datadog logs showed otherwise: the `run_created` request arrived at Vercel's edge
189
- 116ms before `run_started`, but `run_created` hit a cold-start lambda (727ms) while
190
- `run_started` hit a warm one (23ms). Cold starts can invert expected execution order.
191
-
192
- ### 4. Removed EntityConflictError catch, then had to restore it
193
-
194
- The `workflowEntrypoint` error handler originally caught both `EntityConflictError`
195
- and `RunExpiredError`. When adding the "already-running returns run without event"
196
- behavior, `EntityConflictError` was removed from the catch since the new worlds
197
- wouldn't throw it. Reviewer flagged this: old worlds or world-vercel hitting an
198
- older workflow-server could still throw it. The catch was restored.
199
-
200
- ### 5. Duplicate `startedAt` check
201
-
202
- After refactoring the `run_started` flow, a `workflowRun.startedAt` null check
203
- existed both inside the `try` block and after the `catch` block. The second was
204
- unreachable. Removed after review.
205
-
206
- ### 6. WORKFLOW_SERVER_URL_OVERRIDE left set
207
-
208
- During development, `WORKFLOW_SERVER_URL_OVERRIDE` was set to a test URL pointing
209
- at the workflow-server preview deployment and accidentally committed. The Vercel
210
- bot flagged this. Reset to empty string.
211
-
212
- ### 7. e2e test assertion was wrong
213
-
214
- The resilient start e2e test stubbed `world.events.create` and asserted
215
- `createCallCount >= 2`. But the stub only intercepts calls from the test runner
216
- process — the server uses its own world. `createCallCount` was always 1. Changed
217
- to `expect(createCallCount).toBe(1)`.
218
-
219
- ### 8. Misattributed Local Prod timeouts as "pre-existing"
220
-
221
- Local Prod tests showed 60-second timeouts across various tests. Initially dismissed
222
- as CI flakes. Checking main's CI showed all Local Prod tests pass on main — the
223
- timeouts are caused by our changes. Should have compared against main immediately.
224
-
225
- ### 9. Attempted to revert parallel dispatch
226
-
227
- After identifying Local Prod timeouts, `start()` was partially reverted back to
228
- sequential dispatch. The user pointed out that parallel dispatch is the core value
229
- proposition of the PR. The revert was undone.
230
-
231
- ### 10. WorkflowRunNotFoundError retry was unconditional
232
-
233
- The initial `pollReturnValue` retry on `WorkflowRunNotFoundError` applied to all
234
- `Run` instances. A user calling `getRun()` with a wrong ID would wait 10 seconds
235
- before getting a 404. Fixed by adding a `resilientStart` flag: only retries when
236
- `run_created` actually failed.
237
-
238
- ### 11. Changeset `minor` vs `patch`
239
-
240
- The changeset was created with `"@workflow/core": minor`. Reviewer flagged this as
241
- violating repo rules ("all changes should be patch"). Changed after discussion.
242
-
243
- ### 12. world-local TOCTOU race causing duplicate `run_created` events (Windows CI)
244
-
245
- The resilient start path AND the normal `run_created` path in `world-local/events-storage.ts`
246
- both used `writeJSON` to create the run entity. `writeJSON` checks file existence with
247
- `fs.access()` then writes via temp+rename — a classic TOCTOU race. On the local world,
248
- the queue delivers via an async IIFE in the same event loop, so `events.create(run_created)`
249
- and `events.create(run_started)` (with resilient start) run concurrently:
250
-
251
- 1. Both paths call `fs.access(runPath)` → ENOENT (file doesn't exist yet)
252
- 2. Both proceed to write → the last `fs.rename` wins
253
- 3. Both succeed → both write their own `run_created` event with different event IDs
254
- 4. During replay, the consumer sees two `run_created` events → "Unconsumed event" error
255
-
256
- This caused consistent failures in `world-testing` embedded tests on Windows CI (`hooks`,
257
- `supports null bytes in step results`, `retriable and fatal errors` — all timing out at
258
- 60s with "Unconsumed event in event log" errors). Linux CI was not affected because the
259
- timing was different enough that the race window was rarely hit.
260
-
261
- Fixed by switching BOTH paths to `writeExclusive` (O_CREAT|O_EXCL), which is atomic at
262
- the OS level — exactly one writer wins, the other gets EEXIST. The normal `run_created`
263
- path throws `EntityConflictError` on conflict (handled by `start()` as 409). The resilient
264
- start path re-reads the run from disk on conflict. Either way, only one `run_created`
265
- event is written.
266
-
267
- ### 13. Non-atomic run + run_created event in world-postgres resilient path
268
-
269
- The resilient start path in `world-postgres/storage.ts` did two separate writes (run
270
- insert, then event insert) without a transaction. If the process crashed between them,
271
- the run would exist without a `run_created` event — an inconsistent event log.
272
-
273
- A `drizzle.transaction()` wrapper was attempted but dropped due to TypeScript inference
274
- issues with drizzle's transaction callback and the insert builder's overloads. The current
275
- fix keeps the two writes sequential but adds the same conflict-aware re-read pattern as
276
- world-local: when `onConflictDoNothing` produces no result (run already existed), the run
277
- is re-read so downstream logic sees the real state. The narrow crash window between the
278
- two writes is acceptable — if the run insert succeeds but the event insert crashes, the
279
- run exists and `run_started` will still proceed normally (the event log will be missing a
280
- `run_created` entry, but the run itself is functional).
281
-
282
- ### 14. Missing `WorkflowRunStatus` span attribute after parallel refactor
283
-
284
- The `start()` span previously set `Attribute.WorkflowRunStatus(result.run.status)`, but
285
- this was dropped in the parallel refactor because `result.run` is only available when
286
- `runCreatedResult` fulfilled. The attribute is now conditionally set when the result is
287
- available. In the resilient start case (run_created failed), the attribute is omitted
288
- rather than erroring.
289
-
290
- ### 15. `run_started` eventData leak in world-postgres result
291
-
292
- The `...data` spread in the result construction leaked `eventData` from `run_started`
293
- into the returned event object. Storage was already correct (`storedEventData` is
294
- `undefined` for `run_started`), but the returned result carried the input data. While
295
- harmless (the runtime doesn't use `result.event.eventData`), it was restored to match
296
- the pre-refactor behavior where eventData was explicitly stripped from the result.
297
-
298
- ### 16. Normal `run_created` path also needed `writeExclusive` (Windows CI)
299
-
300
- The initial TOCTOU fix (item 12) only changed the resilient start path to use
301
- `writeExclusive`. The normal `run_created` entity write still used `writeJSON` which
302
- checks existence with `fs.access()` then writes via temp+rename — not atomic. On
303
- Windows CI, the local queue's async IIFE delivered fast enough for both paths to pass
304
- their existence checks simultaneously, producing two `run_created` events with different
305
- event IDs. The events consumer saw the duplicate as "Unconsumed event in event log,"
306
- causing `hooks`, `supports null bytes in step results`, and `retriable and fatal errors`
307
- tests to time out at 60s. Fixed by also switching the normal `run_created` entity write to
308
- `writeExclusive`, making both paths use the same atomic gate.
309
-
310
- ### 17. CborTransport was a pass-through wrapper
311
-
312
- `world-vercel/queue.ts` had `CborTransport` implementing `Transport<Buffer>` with a
313
- no-op `serialize` (identity function) and a `deserialize` that reassembled chunks into
314
- a Buffer without decoding. The actual CBOR `encode()`/`decode()` calls happened at the
315
- call sites — `queue()` pre-encoded before calling `client.send()`, and the handler
316
- post-decoded after receiving from `client.handleCallback()`. This violated the transport
317
- abstraction (every other transport does its encoding inside serialize/deserialize) and
318
- meant the call site had to remember to pre-encode. Refactored to move `encode()`/`decode()`
319
- into the transport methods and changed the type from `Transport<Buffer>` to
320
- `Transport<unknown>`.
321
-
322
- ## Follow-up work (additional)
323
-
324
- - [x] ~~**CborTransport is a pass-through**~~ — Resolved. Moved `encode()`/`decode()`
325
- into `CborTransport.serialize()`/`CborTransport.deserialize()`. The transport is now
326
- self-contained: call sites pass plain objects, and the handler receives decoded objects.
327
- See retrospective item 17.
75
+ The narrow crash window in `world-postgres` between the run insert and the event insert is acceptable — if the run insert succeeds but the event insert crashes, the run exists and `run_started` will still proceed normally (the event log will be missing a `run_created` entry, but the run itself is functional).
@@ -58,6 +58,10 @@ Port resolution priority: `baseUrl` > `port` > `PORT` > auto-detected
58
58
 
59
59
  Maximum number of concurrent queue workers. Default: `100`
60
60
 
61
+ ### `WORKFLOW_LOCAL_RECOVER_ACTIVE_RUNS`
62
+
63
+ Whether pending and running runs found in the data directory are re-enqueued when the world starts. Set to `0` or `false` to skip recovery and leave stale runs untouched. Default: `true`
64
+
61
65
  ### Programmatic configuration
62
66
 
63
67
  {/* @skip-typecheck: incomplete code sample */}
@@ -69,6 +73,7 @@ const world = createLocalWorld({
69
73
  port: 5173,
70
74
  // baseUrl overrides port if set
71
75
  baseUrl: "https://local.example.com:3000",
76
+ recoverActiveRuns: true, // overrides WORKFLOW_LOCAL_RECOVER_ACTIVE_RUNS
72
77
  });
73
78
  ```
74
79
 
@@ -25,6 +25,34 @@ This error can appear when:
25
25
  - Serializing step arguments
26
26
  - Serializing step return values
27
27
 
28
+ ## Where the Error Surfaces
29
+
30
+ Where you observe the failure depends on which boundary it crosses:
31
+
32
+ - **Workflow arguments** — `start()` throws synchronously in your application code.
33
+ - **Step arguments** — the *step* fails, exactly like a step whose body threw a `FatalError`: no retries (the failure is deterministic), and a `try/catch` around the step call in your workflow code observes it. The step's recorded input shows `[input unavailable: step argument serialization failed]`, since the real arguments are precisely what refused to serialize.
34
+ - **Workflow return values** — the workflow body has already returned, so nothing can catch it; the run fails.
35
+
36
+ ```typescript lineNumbers
37
+ async function stepWithBadArguments(value: unknown) {
38
+ "use step";
39
+ return value;
40
+ }
41
+
42
+ export async function processWorkflow(someValue: unknown) {
43
+ "use workflow";
44
+
45
+ try {
46
+ await stepWithBadArguments(someValue);
47
+ } catch (err) {
48
+ // (err as Error).message starts with
49
+ // "Failed to serialize step arguments"
50
+ }
51
+ }
52
+ ```
53
+
54
+ Uncaught, the error propagates out of the workflow body and the run fails immediately with the error code `USER_ERROR` — it does not retry.
55
+
28
56
  ## Why This Happens
29
57
 
30
58
  Workflows persist their state using an event log. Every value that crosses execution boundaries must be:
@@ -139,6 +139,31 @@ callApi.maxRetries = 5; // Retry up to 5 times on failure (6 total attempts)
139
139
  step can run up to 4 times total (1 initial attempt + 3 retries).
140
140
  </Callout>
141
141
 
142
+ ## Serialization Failures
143
+
144
+ A step whose arguments cannot be [serialized](/docs/foundations/serialization) fails like a step whose body threw a `FatalError`: the failure is deterministic, so it skips the retry loop, and a `try/catch` around the step call observes it:
145
+
146
+ ```typescript lineNumbers
147
+ async function someStep(input: unknown) {
148
+ "use step";
149
+ return input;
150
+ }
151
+
152
+ export async function myWorkflow(input: unknown) {
153
+ "use workflow";
154
+
155
+ try {
156
+ await someStep(input);
157
+ } catch (err) {
158
+ if ((err as Error).message.startsWith("Failed to serialize step arguments")) {
159
+ // e.g. `Failed to serialize step arguments at path "..."`
160
+ }
161
+ }
162
+ }
163
+ ```
164
+
165
+ Uncaught, the run fails immediately with the `USER_ERROR` code — without retrying. See [serialization-failed](/docs/errors/serialization-failed) for common causes and fixes.
166
+
142
167
  ## Error Codes
143
168
 
144
169
  When a workflow run fails, the error may include a `code` that classifies the failure. You can access it programmatically via the `Run` class:
@@ -9,6 +9,7 @@ related:
9
9
  ---
10
10
 
11
11
  import { Next, Nitro, SvelteKit, Nuxt, Hono, Bun, AstroDark, AstroLight, TanStack, Vite, Express, Nest, Fastify } from "@/app/[lang]/(home)/components/frameworks";
12
+ import { SiReactrouter } from "@icons-pack/react-simple-icons";
12
13
 
13
14
  <Cards>
14
15
  <Card href="/docs/getting-started/next">
@@ -20,6 +21,12 @@ import { Next, Nitro, SvelteKit, Nuxt, Hono, Bun, AstroDark, AstroLight, TanStac
20
21
  <span className="font-medium">Vite</span>
21
22
  </div>
22
23
  </Card>
24
+ <Card href="/docs/getting-started/react-router">
25
+ <div className="flex flex-col items-center justify-center gap-2">
26
+ <SiReactrouter className="size-16" />
27
+ <span className="font-medium">React Router</span>
28
+ </div>
29
+ </Card>
23
30
  <Card href="/docs/getting-started/astro">
24
31
  <div className="flex flex-col items-center justify-center gap-2">
25
32
  <AstroLight className="size-16 dark:hidden" />
@@ -2,6 +2,7 @@
2
2
  "title": "Getting Started",
3
3
  "pages": [
4
4
  "next",
5
+ "react-router",
5
6
  "astro",
6
7
  "express",
7
8
  "fastify",
@@ -0,0 +1,33 @@
1
+ ---
2
+ title: React Router
3
+ description: Run durable workflows in a React Router framework-mode app using Nitro.
4
+ type: overview
5
+ summary: Choose your React Router version and connect React Router, Nitro, and Workflow SDK.
6
+ related:
7
+ - /docs/getting-started/nitro
8
+ - /docs/getting-started/vite
9
+ - /docs/foundations/workflows-and-steps
10
+ ---
11
+
12
+ React Router framework mode builds the browser application and its server-rendering code, but it still needs a server to receive requests. [Nitro](https://v3.nitro.build) provides that server. Workflow SDK integrates with Nitro to add the durable workflow routes and build artifacts.
13
+
14
+ The three pieces share one Vite build:
15
+
16
+ 1. **React Router** builds your routes, loaders, actions, and browser assets.
17
+ 2. **Nitro** runs the React Router request handler and any routes in `server/routes`.
18
+ 3. **Workflow SDK** finds files with `"use workflow"` and `"use step"`, then adds its runtime routes to Nitro.
19
+
20
+ Choose the guide that matches your React Router major version:
21
+
22
+ <AutoCards />
23
+
24
+ <Callout>
25
+ These guides require **Nitro v3**. Nitro v2 does not provide the Vite
26
+ environment integration used by this setup.
27
+ </Callout>
28
+
29
+ ## What the bridge does
30
+
31
+ The setup adds a small `server/ssr.ts` file. It turns React Router's generated server build into a standard Fetch API handler that Nitro can run. A small Vite plugin keeps React Router's client manifest where React Router expects it, while Nitro produces the output required by the selected deployment preset, such as `.output` for a local Node.js server or `.vercel/output` on Vercel.
32
+
33
+ This is configuration in your application, not a separate React Router adapter. Your React Router routes remain React Router routes, while Nitro owns the HTTP server and Workflow SDK uses Nitro's lifecycle and routing.
@@ -0,0 +1,5 @@
1
+ {
2
+ "title": "React Router",
3
+ "pages": ["v7", "v8"],
4
+ "defaultOpen": true
5
+ }
@@ -0,0 +1,239 @@
1
+ ---
2
+ title: React Router v7
3
+ description: Add durable workflows to a React Router v7 framework-mode app using Nitro v3.
4
+ type: guide
5
+ summary: Enable the Vite Environment API and configure React Router v7, Nitro v3, and Workflow SDK.
6
+ prerequisites:
7
+ - /docs/getting-started/react-router
8
+ related:
9
+ - /docs/getting-started/nitro
10
+ - /docs/foundations/workflows-and-steps
11
+ ---
12
+
13
+ This guide starts with an existing React Router v7 framework-mode app. It is verified with v7.18.1; if your config does not recognize `v8_viteEnvironmentApi`, update to the latest v7 release.
14
+
15
+ <Steps>
16
+
17
+ <Step>
18
+
19
+ ## Install Nitro and Workflow SDK
20
+
21
+ <Tabs items={["npm", "pnpm", "bun", "yarn"]} defaultValue="pnpm">
22
+
23
+ <Tab value="npm">
24
+
25
+ ```bash
26
+ npm install nitro workflow
27
+ ```
28
+
29
+ </Tab>
30
+
31
+ <Tab value="pnpm">
32
+
33
+ ```bash
34
+ pnpm add nitro workflow
35
+ ```
36
+
37
+ </Tab>
38
+
39
+ <Tab value="bun">
40
+
41
+ ```bash
42
+ bun add nitro workflow
43
+ ```
44
+
45
+ </Tab>
46
+
47
+ <Tab value="yarn">
48
+
49
+ ```bash
50
+ yarn add nitro workflow
51
+ ```
52
+
53
+ </Tab>
54
+
55
+ </Tabs>
56
+
57
+ This integration requires Nitro v3.
58
+
59
+ </Step>
60
+
61
+ <Step>
62
+
63
+ ## Enable the Vite Environment API
64
+
65
+ React Router v7 keeps the Vite Environment API behind a future flag. Enable the required flag:
66
+
67
+ ```typescript title="react-router.config.ts" lineNumbers
68
+ import type { Config } from "@react-router/dev/config";
69
+
70
+ export default {
71
+ ssr: true,
72
+ buildDirectory: "build",
73
+ future: {
74
+ v8_viteEnvironmentApi: true, // [!code highlight]
75
+ },
76
+ } satisfies Config;
77
+ ```
78
+
79
+ </Step>
80
+
81
+ <Step>
82
+
83
+ ## Create the React Router server handler
84
+
85
+ Create `server/ssr.ts`:
86
+
87
+ ```typescript title="server/ssr.ts" lineNumbers
88
+ import { createRequestHandler } from "react-router";
89
+
90
+ export default {
91
+ fetch: createRequestHandler(
92
+ () => import("virtual:react-router/server-build"),
93
+ import.meta.env.MODE,
94
+ ),
95
+ };
96
+ ```
97
+
98
+ This adapts React Router's generated server build to the Fetch API handler Nitro expects.
99
+
100
+ </Step>
101
+
102
+ <Step>
103
+
104
+ ## Configure Vite
105
+
106
+ Update `vite.config.ts`:
107
+
108
+ ```typescript title="vite.config.ts" lineNumbers
109
+ import { reactRouter } from "@react-router/dev/vite";
110
+ import { nitro } from "nitro/vite";
111
+ import { cp } from "node:fs/promises";
112
+ import { resolve } from "node:path";
113
+ import { defineConfig } from "vite";
114
+ import { workflow } from "workflow/vite";
115
+ import reactRouterConfig from "./react-router.config";
116
+
117
+ export default defineConfig({
118
+ plugins: [
119
+ reactRouter(),
120
+ nitro({ serverDir: "./server" }),
121
+ {
122
+ name: "react-router-nitro-manifest",
123
+ applyToEnvironment: (environment) => environment.name === "client",
124
+ async writeBundle(options) {
125
+ await cp(
126
+ resolve(options.dir!, ".vite"),
127
+ resolve(reactRouterConfig.buildDirectory, "client/.vite"),
128
+ { recursive: true },
129
+ );
130
+ },
131
+ },
132
+ workflow({ dirs: ["workflows"] }),
133
+ ],
134
+ environments: {
135
+ ssr: {
136
+ build: {
137
+ rollupOptions: {
138
+ input: "./server/ssr.ts",
139
+ },
140
+ },
141
+ },
142
+ },
143
+ });
144
+ ```
145
+
146
+ The small `react-router-nitro-manifest` plugin copies React Router's Vite manifest back to `build/client` after Nitro writes the client build to its deployment output. Keep `dirs: ["workflows"]` so Workflow SDK only scans your source workflow directory. Do not set Nitro's `output.dir`; Nitro uses it to produce the output expected by each deployment preset.
147
+
148
+ </Step>
149
+
150
+ <Step>
151
+
152
+ ## Create a workflow
153
+
154
+ Create `workflows/greeting.ts`:
155
+
156
+ ```typescript title="workflows/greeting.ts" lineNumbers
157
+ export async function greetingWorkflow(name: string) {
158
+ "use workflow";
159
+
160
+ return greet(name);
161
+ }
162
+
163
+ async function greet(name: string) {
164
+ "use step";
165
+
166
+ return `Hello, ${name}!`;
167
+ }
168
+ ```
169
+
170
+ </Step>
171
+
172
+ <Step>
173
+
174
+ ## Start the workflow from a Nitro route
175
+
176
+ Create `server/routes/api/greeting.post.ts`:
177
+
178
+ ```typescript title="server/routes/api/greeting.post.ts" lineNumbers
179
+ import { defineHandler } from "nitro";
180
+ import { start } from "workflow/api";
181
+ import { greetingWorkflow } from "../../../workflows/greeting";
182
+
183
+ export default defineHandler(async (event) => {
184
+ const { name } = (await event.req.json()) as { name: string };
185
+ const run = await start(greetingWorkflow, [name]);
186
+
187
+ return { runId: run.runId };
188
+ });
189
+ ```
190
+
191
+ React Router continues to handle your application routes. Nitro handles this server route at `POST /api/greeting`, as well as Workflow SDK's internal routes.
192
+
193
+ </Step>
194
+
195
+ <Step>
196
+
197
+ ## Run the app
198
+
199
+ Start the development server:
200
+
201
+ ```bash
202
+ pnpm vite dev
203
+ ```
204
+
205
+ Then start a workflow:
206
+
207
+ ```bash
208
+ curl -X POST \
209
+ -H "content-type: application/json" \
210
+ -d '{"name":"Workflow"}' \
211
+ http://localhost:3000/api/greeting
212
+ ```
213
+
214
+ Build and start the production server:
215
+
216
+ ```bash
217
+ pnpm vite build
218
+ node ./.output/server/index.mjs
219
+ ```
220
+
221
+ You can inspect local runs with `pnpm workflow web`.
222
+
223
+ </Step>
224
+
225
+ </Steps>
226
+
227
+ ## Troubleshooting
228
+
229
+ ### Vite reports an invalid SSR input or `path.replace is not a function`
230
+
231
+ Set `future.v8_viteEnvironmentApi` to `true` in `react-router.config.ts`.
232
+
233
+ ### React Router pages return 404
234
+
235
+ Check that the `ssr` environment input points to `./server/ssr.ts`.
236
+
237
+ ### `vite build` finishes output but does not exit
238
+
239
+ Use `workflow@4.6.1` or later with Nitro v3.
@@ -0,0 +1,232 @@
1
+ ---
2
+ title: React Router v8
3
+ description: Add durable workflows to a React Router v8 framework-mode app using Nitro v3.
4
+ type: guide
5
+ summary: Configure React Router v8, Nitro v3, and Workflow SDK in one Vite build.
6
+ prerequisites:
7
+ - /docs/getting-started/react-router
8
+ related:
9
+ - /docs/getting-started/nitro
10
+ - /docs/foundations/workflows-and-steps
11
+ ---
12
+
13
+ This guide starts with an existing React Router v8 framework-mode app.
14
+
15
+ <Steps>
16
+
17
+ <Step>
18
+
19
+ ## Install Nitro and Workflow SDK
20
+
21
+ <Tabs items={["npm", "pnpm", "bun", "yarn"]} defaultValue="pnpm">
22
+
23
+ <Tab value="npm">
24
+
25
+ ```bash
26
+ npm install nitro workflow
27
+ ```
28
+
29
+ </Tab>
30
+
31
+ <Tab value="pnpm">
32
+
33
+ ```bash
34
+ pnpm add nitro workflow
35
+ ```
36
+
37
+ </Tab>
38
+
39
+ <Tab value="bun">
40
+
41
+ ```bash
42
+ bun add nitro workflow
43
+ ```
44
+
45
+ </Tab>
46
+
47
+ <Tab value="yarn">
48
+
49
+ ```bash
50
+ yarn add nitro workflow
51
+ ```
52
+
53
+ </Tab>
54
+
55
+ </Tabs>
56
+
57
+ This integration requires Nitro v3.
58
+
59
+ </Step>
60
+
61
+ <Step>
62
+
63
+ ## Configure React Router
64
+
65
+ Keep server rendering enabled and set an explicit build directory for the manifest bridge:
66
+
67
+ ```typescript title="react-router.config.ts" lineNumbers
68
+ import type { Config } from "@react-router/dev/config";
69
+
70
+ export default {
71
+ ssr: true,
72
+ buildDirectory: "build",
73
+ } satisfies Config;
74
+ ```
75
+
76
+ </Step>
77
+
78
+ <Step>
79
+
80
+ ## Create the React Router server handler
81
+
82
+ Create `server/ssr.ts`:
83
+
84
+ ```typescript title="server/ssr.ts" lineNumbers
85
+ import { createRequestHandler } from "react-router";
86
+
87
+ export default {
88
+ fetch: createRequestHandler(
89
+ () => import("virtual:react-router/server-build"),
90
+ import.meta.env.MODE,
91
+ ),
92
+ };
93
+ ```
94
+
95
+ This adapts React Router's generated server build to the Fetch API handler Nitro expects.
96
+
97
+ </Step>
98
+
99
+ <Step>
100
+
101
+ ## Configure Vite
102
+
103
+ Update `vite.config.ts`:
104
+
105
+ ```typescript title="vite.config.ts" lineNumbers
106
+ import { reactRouter } from "@react-router/dev/vite";
107
+ import { nitro } from "nitro/vite";
108
+ import { cp } from "node:fs/promises";
109
+ import { resolve } from "node:path";
110
+ import { defineConfig } from "vite";
111
+ import { workflow } from "workflow/vite";
112
+ import reactRouterConfig from "./react-router.config";
113
+
114
+ export default defineConfig({
115
+ plugins: [
116
+ reactRouter(),
117
+ nitro({ serverDir: "./server" }),
118
+ {
119
+ name: "react-router-nitro-manifest",
120
+ applyToEnvironment: (environment) => environment.name === "client",
121
+ async writeBundle(options) {
122
+ await cp(
123
+ resolve(options.dir!, ".vite"),
124
+ resolve(reactRouterConfig.buildDirectory, "client/.vite"),
125
+ { recursive: true },
126
+ );
127
+ },
128
+ },
129
+ workflow({ dirs: ["workflows"] }),
130
+ ],
131
+ environments: {
132
+ ssr: {
133
+ build: {
134
+ rollupOptions: {
135
+ input: "./server/ssr.ts",
136
+ },
137
+ },
138
+ },
139
+ },
140
+ });
141
+ ```
142
+
143
+ The small `react-router-nitro-manifest` plugin copies React Router's Vite manifest back to `build/client` after Nitro writes the client build to its deployment output. Keep `dirs: ["workflows"]` so Workflow SDK only scans your source workflow directory. Do not set Nitro's `output.dir`; Nitro uses it to produce the output expected by each deployment preset.
144
+
145
+ </Step>
146
+
147
+ <Step>
148
+
149
+ ## Create a workflow
150
+
151
+ Create `workflows/greeting.ts`:
152
+
153
+ ```typescript title="workflows/greeting.ts" lineNumbers
154
+ export async function greetingWorkflow(name: string) {
155
+ "use workflow";
156
+
157
+ return greet(name);
158
+ }
159
+
160
+ async function greet(name: string) {
161
+ "use step";
162
+
163
+ return `Hello, ${name}!`;
164
+ }
165
+ ```
166
+
167
+ </Step>
168
+
169
+ <Step>
170
+
171
+ ## Start the workflow from a Nitro route
172
+
173
+ Create `server/routes/api/greeting.post.ts`:
174
+
175
+ ```typescript title="server/routes/api/greeting.post.ts" lineNumbers
176
+ import { defineHandler } from "nitro";
177
+ import { start } from "workflow/api";
178
+ import { greetingWorkflow } from "../../../workflows/greeting";
179
+
180
+ export default defineHandler(async (event) => {
181
+ const { name } = (await event.req.json()) as { name: string };
182
+ const run = await start(greetingWorkflow, [name]);
183
+
184
+ return { runId: run.runId };
185
+ });
186
+ ```
187
+
188
+ React Router continues to handle your application routes. Nitro handles this server route at `POST /api/greeting`, as well as Workflow SDK's internal routes.
189
+
190
+ </Step>
191
+
192
+ <Step>
193
+
194
+ ## Run the app
195
+
196
+ Start the development server:
197
+
198
+ ```bash
199
+ pnpm vite dev
200
+ ```
201
+
202
+ Then start a workflow:
203
+
204
+ ```bash
205
+ curl -X POST \
206
+ -H "content-type: application/json" \
207
+ -d '{"name":"Workflow"}' \
208
+ http://localhost:3000/api/greeting
209
+ ```
210
+
211
+ Build and start the production server:
212
+
213
+ ```bash
214
+ pnpm vite build
215
+ node ./.output/server/index.mjs
216
+ ```
217
+
218
+ You can inspect local runs with `pnpm workflow web`.
219
+
220
+ </Step>
221
+
222
+ </Steps>
223
+
224
+ ## Troubleshooting
225
+
226
+ ### React Router pages return 404
227
+
228
+ Check that the `ssr` environment input points to `./server/ssr.ts`.
229
+
230
+ ### `vite build` finishes output but does not exit
231
+
232
+ Use `workflow@4.6.1` or later with Nitro v3.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workflow",
3
- "version": "4.8.3",
3
+ "version": "4.8.5",
4
4
  "description": "Workflow SDK - Build durable, resilient, and observable workflows",
5
5
  "main": "dist/typescript-plugin.cjs",
6
6
  "type": "module",
@@ -57,18 +57,18 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "ms": "2.1.3",
60
- "@workflow/astro": "4.0.18",
61
- "@workflow/cli": "4.3.7",
62
- "@workflow/core": "4.8.3",
60
+ "@workflow/astro": "4.0.20",
61
+ "@workflow/cli": "4.3.9",
62
+ "@workflow/core": "4.8.5",
63
63
  "@workflow/errors": "4.2.1",
64
64
  "@workflow/typescript-plugin": "4.0.3",
65
65
  "@workflow/utils": "4.1.4",
66
- "@workflow/nest": "4.0.19",
67
- "@workflow/nitro": "4.1.9",
68
- "@workflow/nuxt": "4.0.19",
69
- "@workflow/sveltekit": "4.0.18",
70
- "@workflow/next": "4.1.7",
71
- "@workflow/rollup": "4.0.18"
66
+ "@workflow/next": "4.1.9",
67
+ "@workflow/nest": "4.0.21",
68
+ "@workflow/nitro": "4.1.11",
69
+ "@workflow/nuxt": "4.0.21",
70
+ "@workflow/sveltekit": "4.0.20",
71
+ "@workflow/rollup": "4.0.20"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@types/ms": "2.1.0",