workflow 5.0.0 → 5.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.
- package/docs/advanced/dynamic-workflows.mdx +4 -1
- package/docs/advanced/index.mdx +13 -0
- package/docs/advanced/meta.json +5 -0
- package/docs/ai/chat-session-modeling.mdx +8 -8
- package/docs/ai/human-in-the-loop.mdx +7 -3
- package/docs/ai/index.mdx +14 -14
- package/docs/ai/streaming-updates-from-tools.mdx +2 -2
- package/docs/api-reference/vitest/index.mdx +2 -2
- package/docs/api-reference/workflow/create-hook.mdx +4 -0
- package/docs/api-reference/workflow/create-webhook.mdx +1 -1
- package/docs/api-reference/workflow/define-hook.mdx +4 -0
- package/docs/api-reference/workflow-api/get-hook-by-token.mdx +9 -3
- package/docs/api-reference/workflow-api/resume-hook.mdx +4 -0
- package/docs/api-reference/workflow-api/resume-webhook.mdx +4 -0
- package/docs/api-reference/workflow-globals.mdx +4 -0
- package/docs/api-reference/workflow-nest/index.mdx +4 -1
- package/docs/api-reference/workflow-nest/is-workflow-request.mdx +58 -0
- package/docs/api-reference/workflow-nest/meta.json +1 -0
- package/docs/api-reference/workflow-nest/workflow-controller.mdx +6 -2
- package/docs/api-reference/workflow-nest/workflow-module.mdx +9 -1
- package/docs/api-reference/workflow-runtime/world/storage.mdx +29 -0
- package/docs/configuration/build-and-diagnostics.mdx +11 -1
- package/docs/configuration/framework-options.mdx +6 -0
- package/docs/configuration/runtime-tuning.mdx +28 -2
- package/docs/configuration/worlds.mdx +28 -2
- package/docs/cookbook/common-patterns/webhooks.mdx +2 -1
- package/docs/cookbook/integrations/ai-sdk.mdx +8 -8
- package/docs/cookbook/integrations/chat-sdk.mdx +8 -8
- package/docs/cookbook/integrations/sandbox.mdx +8 -8
- package/docs/errors/node-js-module-in-workflow.mdx +36 -0
- package/docs/foundations/errors-and-retries.mdx +3 -3
- package/docs/foundations/hooks.mdx +91 -3
- package/docs/foundations/serialization.mdx +1 -1
- package/docs/foundations/streaming.mdx +1 -1
- package/docs/foundations/workflows-and-steps.mdx +1 -1
- package/docs/getting-started/astro.mdx +7 -9
- package/docs/getting-started/express.mdx +4 -10
- package/docs/getting-started/fastify.mdx +4 -9
- package/docs/getting-started/hono.mdx +4 -10
- package/docs/getting-started/index.mdx +2 -2
- package/docs/getting-started/nestjs.mdx +157 -36
- package/docs/getting-started/next.mdx +15 -18
- package/docs/getting-started/nitro.mdx +4 -10
- package/docs/getting-started/nuxt.mdx +4 -10
- package/docs/getting-started/react-router/v7.mdx +6 -22
- package/docs/getting-started/react-router/v8.mdx +6 -22
- package/docs/getting-started/sveltekit.mdx +7 -9
- package/docs/getting-started/tanstack-start.mdx +7 -9
- package/docs/getting-started/vite.mdx +7 -9
- package/docs/how-it-works/code-transform.mdx +13 -9
- package/docs/how-it-works/encryption.mdx +3 -1
- package/docs/meta.json +1 -1
- package/docs/testing/index.mdx +3 -5
- package/docs/whats-new.mdx +8 -2
- package/docs/worlds/building-a-world.mdx +68 -1
- package/docs/worlds/postgres.mdx +16 -32
- package/docs/worlds/upgrading-to-v5.mdx +29 -8
- package/docs/worlds/vercel.mdx +50 -4
- package/package.json +12 -12
|
@@ -160,6 +160,12 @@ The Postgres World is a self-hosted durable backend for long-running server proc
|
|
|
160
160
|
- Number of concurrent workers polling for jobs.
|
|
161
161
|
- Also bounds concurrent parent-to-child workflow return-value polls.
|
|
162
162
|
|
|
163
|
+
### `pollInterval`
|
|
164
|
+
|
|
165
|
+
- Environment variable: `WORKFLOW_POSTGRES_POLL_INTERVAL_MS`
|
|
166
|
+
- Default: `500`
|
|
167
|
+
- Milliseconds between idle job fetches per worker.
|
|
168
|
+
|
|
163
169
|
### `applicationManagedShutdown`
|
|
164
170
|
|
|
165
171
|
- Environment variable: `WORKFLOW_POSTGRES_APPLICATION_MANAGED_SHUTDOWN` (`1` enables)
|
|
@@ -290,6 +296,7 @@ Platform-provided values such as `VERCEL_DEPLOYMENT_ID`, `VERCEL_PROJECT_ID`, an
|
|
|
290
296
|
- Default: `http`
|
|
291
297
|
- Experimental stream-write transport capability. Set to exactly `ws` to attempt `workflow-stream-ws/v1`. The server authoritatively accepts or declines each upgrade; a decline uses HTTP directly for that writer lifetime. Stream reads remain HTTP and demand-driven.
|
|
292
298
|
- This is not tenant rollout policy or a package-version check. HTTP remains the compatibility path. `/websockets/v1` is independent of REST v2/v4 and persisted workflow `specVersion` values.
|
|
299
|
+
- A throttled (429) socket write or close is retried after the server's `Retry-After`, as over HTTP. It moves to HTTP if the connection ends during the wait or the cumulative wait passes 30 seconds.
|
|
293
300
|
|
|
294
301
|
### `WORKFLOW_DISABLE_ANALYTICS_READS`
|
|
295
302
|
|
|
@@ -310,6 +317,25 @@ When enabled (the default), a suspension's eager `step_created` and `wait_create
|
|
|
310
317
|
|
|
311
318
|
- Factory option: none
|
|
312
319
|
- CLI flag: none
|
|
313
|
-
- Default: `
|
|
314
|
-
-
|
|
320
|
+
- Default: `ws`
|
|
321
|
+
- Ships workflow run events to the Vercel World over a WebSocket instead of one HTTP request each. Set to exactly `http` to opt out; any other value, including unset or empty, uses the WebSocket.
|
|
315
322
|
- Ignored when the World is configured with `projectConfig` and routes through the `api-workflow` proxy: that endpoint is an HTTP-only REST gateway and does not forward a WebSocket upgrade, so events stay on HTTP.
|
|
323
|
+
|
|
324
|
+
### `WORKFLOW_EVENTS_TRANSPORT_WS_OVERRIDE_WORKFLOWS`
|
|
325
|
+
|
|
326
|
+
- Factory option: none
|
|
327
|
+
- CLI flag: none
|
|
328
|
+
- Default: none
|
|
329
|
+
- Comma-separated workflows whose runs use the WebSocket events transport even when `WORKFLOW_EVENTS_TRANSPORT` is `http`. Each entry is a function name (`processOrder`) or a full workflow name (`workflow//./src/workflows/order//processOrder`), matched exactly and case-sensitively.
|
|
330
|
+
- Has no effect unless `WORKFLOW_EVENTS_TRANSPORT=http`, since every workflow already uses the WebSocket by default.
|
|
331
|
+
- See [`WORKFLOW_EVENTS_TRANSPORT_WS_OVERRIDE_WORKFLOWS`](/worlds/vercel#workflow_events_transport_ws_override_workflows) for details.
|
|
332
|
+
|
|
333
|
+
### `WORKFLOW_WS_MAX_MESSAGE_BYTES`
|
|
334
|
+
|
|
335
|
+
- Factory option: none
|
|
336
|
+
- CLI flag: none
|
|
337
|
+
- Default: `12582912` (12 MiB)
|
|
338
|
+
- Clamp: `2097152` to `16777216` (values outside are clamped, with a warning)
|
|
339
|
+
- Largest WebSocket message the Vercel World sends, header included, for both the events and stream-write transports. The ceiling is the 16 MiB WebSocket message limit.
|
|
340
|
+
- Events: a larger frame is sent as several messages and rebuilt on the other side.
|
|
341
|
+
- Stream writes: a larger write group is split into several ordered write requests; a single chunk too large for one message is written over HTTP, as are the rest of that writer's writes.
|
|
@@ -50,7 +50,7 @@ export async function paymentWebhook(orderId: string) {
|
|
|
50
50
|
|
|
51
51
|
### Step function for processing
|
|
52
52
|
|
|
53
|
-
Each webhook request is processed in its own step, giving you full Node.js access for validation, database writes, and responding to the caller
|
|
53
|
+
Each webhook request is processed in its own step, giving you full Node.js access for validation, database writes, and responding to the caller. In production, verify the provider's signature here before trusting the body; the example omits it for brevity.
|
|
54
54
|
|
|
55
55
|
```typescript
|
|
56
56
|
import { type RequestWithResponse } from "workflow";
|
|
@@ -176,6 +176,7 @@ export async function POST(request: Request) {
|
|
|
176
176
|
- **`respondWith: "manual"`** gives you control over the HTTP response from inside a step. Use this when you need to validate the request before responding.
|
|
177
177
|
- **`for await` on a webhook** lets you process multiple events from the same URL. Use `break` to stop listening after a terminal event.
|
|
178
178
|
- **Webhooks auto-generate URLs** at `/.well-known/workflow/v1/webhook/:token`. Pass this URL to external services.
|
|
179
|
+
- **The URL is the only authorization.** Anyone with the webhook URL can send it a request, so verify the provider's signature in the processing step before acting on the payload. See [Hook and webhook security](/docs/foundations/hooks#security).
|
|
179
180
|
- **Race webhooks against `sleep()`** for deadlines. If the callback doesn't arrive in time, the workflow can take a fallback action.
|
|
180
181
|
- **For large payloads**, use a hook and reference token instead of passing the data through the workflow. The event log serializes all step inputs and outputs, so large payloads hurt performance.
|
|
181
182
|
|
|
@@ -41,9 +41,9 @@ One workflow run represents one full conversation. The workflow suspends between
|
|
|
41
41
|
Because the conversation is one workflow run, it stays on the deployment that started it. If each turn should run on the latest deployment while preserving selected state or streams, see [Versioning](/docs/foundations/versioning) for the child-run continuation pattern.
|
|
42
42
|
</Callout>
|
|
43
43
|
|
|
44
|
-
<
|
|
44
|
+
<TabsWithChildren tabs={["Workflow","API Route","Client"]}>
|
|
45
45
|
|
|
46
|
-
<
|
|
46
|
+
<TabContent order={1}>
|
|
47
47
|
|
|
48
48
|
```typescript title="workflows/support.ts" lineNumbers
|
|
49
49
|
import { streamText, stepCountIs } from "ai";
|
|
@@ -129,9 +129,9 @@ export async function supportWorkflow(initialMessages: ModelMessage[]) {
|
|
|
129
129
|
}
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
-
</
|
|
132
|
+
</TabContent>
|
|
133
133
|
|
|
134
|
-
<
|
|
134
|
+
<TabContent order={2}>
|
|
135
135
|
|
|
136
136
|
One endpoint handles first turn, follow-ups, and the `/done` exit. The client sends `runId` in the body to distinguish first vs follow-up.
|
|
137
137
|
|
|
@@ -246,9 +246,9 @@ export async function POST(req: Request) {
|
|
|
246
246
|
}
|
|
247
247
|
```
|
|
248
248
|
|
|
249
|
-
</
|
|
249
|
+
</TabContent>
|
|
250
250
|
|
|
251
|
-
<
|
|
251
|
+
<TabContent order={3}>
|
|
252
252
|
|
|
253
253
|
Store the `runId` in a ref and pass it in the body of every follow-up. `WorkflowChatTransport` forwards it for you.
|
|
254
254
|
|
|
@@ -299,9 +299,9 @@ export function SupportChat() {
|
|
|
299
299
|
}
|
|
300
300
|
```
|
|
301
301
|
|
|
302
|
-
</
|
|
302
|
+
</TabContent>
|
|
303
303
|
|
|
304
|
-
</
|
|
304
|
+
</TabsWithChildren>
|
|
305
305
|
|
|
306
306
|
## How it works
|
|
307
307
|
|
|
@@ -66,9 +66,9 @@ Because the session *is* a workflow run, its history is recoverable from the eve
|
|
|
66
66
|
|
|
67
67
|
This pattern uses three files. The bot definition is separate from the workflow so adapter packages stay out of the workflow sandbox.
|
|
68
68
|
|
|
69
|
-
<
|
|
69
|
+
<TabsWithChildren tabs={["Bot Setup","Workflow","Event Handlers"]}>
|
|
70
70
|
|
|
71
|
-
<
|
|
71
|
+
<TabContent order={1}>
|
|
72
72
|
|
|
73
73
|
Register the `Chat` instance as a singleton so step functions can dynamically import it and resolve adapters + state:
|
|
74
74
|
|
|
@@ -95,9 +95,9 @@ export const bot = new Chat<typeof adapters, ThreadState>({
|
|
|
95
95
|
|
|
96
96
|
`registerSingleton()` is important: Chat SDK re-hydrates `Thread` objects inside step functions, and it needs a registered singleton to resolve adapters and state for those rehydrated instances.
|
|
97
97
|
|
|
98
|
-
</
|
|
98
|
+
</TabContent>
|
|
99
99
|
|
|
100
|
-
<
|
|
100
|
+
<TabContent order={2}>
|
|
101
101
|
|
|
102
102
|
The workflow is a plain loop over a hook. It receives the serialized thread + first message from the handler, revives them via Chat SDK's standalone `reviver`, and every platform-side effect goes inside a `"use step"` helper:
|
|
103
103
|
|
|
@@ -174,9 +174,9 @@ export type ChatTurnPayload = {
|
|
|
174
174
|
};
|
|
175
175
|
```
|
|
176
176
|
|
|
177
|
-
</
|
|
177
|
+
</TabContent>
|
|
178
178
|
|
|
179
|
-
<
|
|
179
|
+
<TabContent order={3}>
|
|
180
180
|
|
|
181
181
|
Handlers live outside the workflow file so adapter dependencies don't leak in. They decide whether to start a new workflow or resume an existing one, then store the `runId` in thread state:
|
|
182
182
|
|
|
@@ -256,9 +256,9 @@ export async function POST(
|
|
|
256
256
|
}
|
|
257
257
|
```
|
|
258
258
|
|
|
259
|
-
</
|
|
259
|
+
</TabContent>
|
|
260
260
|
|
|
261
|
-
</
|
|
261
|
+
</TabsWithChildren>
|
|
262
262
|
|
|
263
263
|
## How it works
|
|
264
264
|
|
|
@@ -88,9 +88,9 @@ When the timer wins:
|
|
|
88
88
|
|
|
89
89
|
The only way out is an explicit `/destroy` command.
|
|
90
90
|
|
|
91
|
-
<
|
|
91
|
+
<TabsWithChildren tabs={["Workflow","API Routes","Client"]}>
|
|
92
92
|
|
|
93
|
-
<
|
|
93
|
+
<TabContent order={1}>
|
|
94
94
|
|
|
95
95
|
```typescript title="workflows/sandbox-session.ts" lineNumbers
|
|
96
96
|
import { defineHook, sleep, getWritable, getWorkflowMetadata } from "workflow";
|
|
@@ -298,9 +298,9 @@ export async function sandboxSessionWorkflow() {
|
|
|
298
298
|
}
|
|
299
299
|
```
|
|
300
300
|
|
|
301
|
-
</
|
|
301
|
+
</TabContent>
|
|
302
302
|
|
|
303
|
-
<
|
|
303
|
+
<TabContent order={2}>
|
|
304
304
|
|
|
305
305
|
Two endpoints manage the session. `/start` accepts an optional `{ runId }`: if the run still exists, it replays the event log from index 0 so a returning client fully rehydrates. `/command` resumes the hook and returns immediately; command output lands on the `/start` stream.
|
|
306
306
|
|
|
@@ -379,9 +379,9 @@ export async function POST(req: Request) {
|
|
|
379
379
|
}
|
|
380
380
|
```
|
|
381
381
|
|
|
382
|
-
</
|
|
382
|
+
</TabContent>
|
|
383
383
|
|
|
384
|
-
<
|
|
384
|
+
<TabContent order={3}>
|
|
385
385
|
|
|
386
386
|
On mount, reconnect to the existing run if `localStorage` contains a `runId`. Otherwise, start a new run. Send commands to `/command` with POST requests. Output arrives on the `/start` stream.
|
|
387
387
|
|
|
@@ -472,9 +472,9 @@ export function SandboxRunner() {
|
|
|
472
472
|
}
|
|
473
473
|
```
|
|
474
474
|
|
|
475
|
-
</
|
|
475
|
+
</TabContent>
|
|
476
476
|
|
|
477
|
-
</
|
|
477
|
+
</TabsWithChildren>
|
|
478
478
|
|
|
479
479
|
## How it works
|
|
480
480
|
|
|
@@ -27,6 +27,42 @@ Workflow functions run in a sandboxed environment without full Node.js runtime a
|
|
|
27
27
|
|
|
28
28
|
Node.js modules have side effects and non-deterministic behavior that could break workflow replay guarantees.
|
|
29
29
|
|
|
30
|
+
## Transitive dependencies and dynamic `require()`
|
|
31
|
+
|
|
32
|
+
The same error is reported for a module the workflow bundle could not inline, even when your own code never imports it directly:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
Workflow bundle cannot run in the workflow sandbox.
|
|
36
|
+
|
|
37
|
+
Imports left external (1):
|
|
38
|
+
• "node:fs"
|
|
39
|
+
imported by node_modules/leaky-pkg/index.js
|
|
40
|
+
via app/workflows/order.ts → node_modules/wrapper-pkg/index.js → node_modules/leaky-pkg/index.js
|
|
41
|
+
|
|
42
|
+
Unresolved require() calls (1):
|
|
43
|
+
• dynamic require() in node_modules/lazy-pkg/index.js (bundle 412:31)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The workflow sandbox has no `require`, so both would throw `ReferenceError: require is not defined` when the bundle loads. Follow the import chain to the first file you own and move that work into a step function, or replace a dynamic `require()` with a static import so the bundler can inline it.
|
|
47
|
+
|
|
48
|
+
A `require()` wrapped in `try`/`catch`, or behind a `typeof require` check, is not reported — that is how packages probe for an optional dependency or for a CommonJS environment, and in the sandbox the error is caught or the call never runs:
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
try {
|
|
52
|
+
loadOptional(require("@emotion/is-prop-valid").default);
|
|
53
|
+
} catch {
|
|
54
|
+
// optional dependency, fall back
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (typeof require !== "undefined") {
|
|
58
|
+
crypto = require("crypto");
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
A `typeof require` check only excuses the branch it skips when `require` is undefined, so `typeof require === "undefined" ? require(x) : y` is still reported. A `try` only counts when it has a `catch`. The checks read the code's structure without running it, so a `catch` that rethrows the error is still treated as a guard, and the bundle fails at runtime if that code is reached.
|
|
63
|
+
|
|
64
|
+
As a last resort — for example a `require()` that can never run — set `WORKFLOW_ALLOW_UNSAFE_FLOW_BUNDLE=1` to downgrade the build failure to a warning. The bundle still fails at runtime if the code is reached.
|
|
65
|
+
|
|
30
66
|
## Quick fix
|
|
31
67
|
|
|
32
68
|
Move any code using Node.js modules to a step function. Step functions have full Node.js runtime access.
|
|
@@ -168,7 +168,7 @@ Uncaught, the run fails immediately with the `USER_ERROR` code, without retrying
|
|
|
168
168
|
|
|
169
169
|
On Vercel, backend connection failures and interrupted event streams use the existing retry policies, even when their error codes are unrecognized. This lets workflows recover from network failures instead of immediately failing with `USER_ERROR`. Persistent failures can still exhaust the retry budget.
|
|
170
170
|
|
|
171
|
-
The SDK also replaces its shared events connection pool after repeated HTTP/2 session failures. Invalid backend URLs, including unsupported protocols and embedded credentials, fail immediately. Fetch requests to blocked ports or with unsupported headers (such as `Expect`) also fail without retrying. Interrupted event writes retain their existing retries; caller cancellations do not trigger another write.
|
|
171
|
+
The SDK also replaces its shared events connection pool after repeated HTTP/2 session failures, and retries event-log reads whose HTTP/2 stream the backend resets. An events request that receives no response data for 60 seconds fails and is retried instead of stalling the invocation. Invalid backend URLs, including unsupported protocols and embedded credentials, fail immediately. Fetch requests to blocked ports or with unsupported headers (such as `Expect`) also fail without retrying. Interrupted event writes retain their existing retries; caller cancellations do not trigger another write.
|
|
172
172
|
|
|
173
173
|
A connection failure does not prove that the backend rejected a write: it may have accepted it before the response was lost. Continue to make step side effects [idempotent](/docs/foundations/idempotency).
|
|
174
174
|
|
|
@@ -206,9 +206,9 @@ try {
|
|
|
206
206
|
| `REPLAY_TIMEOUT` | A workflow replay exceeded the maximum allowed duration |
|
|
207
207
|
| `REPLAY_DIVERGENCE` | A replay could not consume the event log deterministically, usually because of non-deterministic workflow code. |
|
|
208
208
|
| `CORRUPTED_EVENT_LOG` | The event log cannot be replayed: it contains orphaned or mismatched events, or one of its stored payloads is no longer readable from the World's storage. If you see this, please [file an issue](https://github.com/vercel/workflow/issues) |
|
|
209
|
-
| `STREAM_ERROR` | Workflow stream infrastructure failed while reading or writing data. This is an SDK or backend failure rather than an error in workflow code |
|
|
209
|
+
| `STREAM_ERROR` | Workflow stream infrastructure failed while reading or writing data. This is an SDK or backend failure rather than an error in workflow code; retry the run and report persistent failures with the `runId` |
|
|
210
210
|
| `WORLD_CONTRACT_ERROR` | A World response violated the SDK contract; points at a World implementation bug |
|
|
211
|
-
| `DEPLOYMENT_MISMATCH` | The run was delivered to a deployment other than the one it is pinned to, and automatic re-routing did not recover it |
|
|
211
|
+
| `DEPLOYMENT_MISMATCH` | The run was delivered to a deployment other than the one it is pinned to, and automatic re-routing did not recover it. See [deployment-mismatch](/docs/errors/deployment-mismatch) |
|
|
212
212
|
| `RUNTIME_ERROR` | An internal runtime error. If you see this, please [file an issue](https://github.com/vercel/workflow/issues) |
|
|
213
213
|
|
|
214
214
|
<Callout type="info">
|
|
@@ -82,7 +82,7 @@ export async function POST(request: Request) {
|
|
|
82
82
|
|
|
83
83
|
The key points:
|
|
84
84
|
- Hooks allow you to pass **any [serializable data](/docs/foundations/serialization)** as the payload
|
|
85
|
-
- You need the hook's `token` to resume it
|
|
85
|
+
- You need the hook's `token` to resume it, but knowing the token does not authorize the caller. Check who is calling before `resumeHook()`; see [Security](#security)
|
|
86
86
|
- The workflow will resume execution right where it left off
|
|
87
87
|
|
|
88
88
|
### Checking for token conflicts
|
|
@@ -285,7 +285,7 @@ Hooks require you to manually handle HTTP requests and route them to workflows.
|
|
|
285
285
|
When using Workflow SDK, webhooks are automatically wired up at `/.well-known/workflow/v1/webhook/:token` without any additional setup.
|
|
286
286
|
|
|
287
287
|
<Callout type="warn">
|
|
288
|
-
`createWebhook()` exposes a public route at `/.well-known/workflow/v1/webhook/:token`, and the token in that URL is the only authorization performed for incoming requests.
|
|
288
|
+
`createWebhook()` exposes a public route at `/.well-known/workflow/v1/webhook/:token`, and the token in that URL is the only authorization performed for incoming requests. Make sure to read up on [security](#security) before using a webhook for calls that need to be authenticated.
|
|
289
289
|
</Callout>
|
|
290
290
|
|
|
291
291
|
<Callout type="info">
|
|
@@ -459,6 +459,94 @@ export async function eventCollectorWorkflow() {
|
|
|
459
459
|
- You need to send HTTP responses back to the caller
|
|
460
460
|
- You want automatic URL routing without writing API handlers
|
|
461
461
|
|
|
462
|
+
## Security
|
|
463
|
+
|
|
464
|
+
A hook token tells the runtime which hook a payload belongs to. It is not an authentication mechanism, and neither `resumeHook()` nor the webhook endpoint checks who is sending the payload.
|
|
465
|
+
|
|
466
|
+
### Generated tokens are hard to guess, not secret
|
|
467
|
+
|
|
468
|
+
When you don't pass a `token`, the SDK generates one inside the workflow function. Workflow code must produce the same values on every replay, so the generated token comes from the run's deterministic random number generator, the same one that backs [`Math.random()` and `crypto.randomUUID()`](/docs/api-reference/workflow-globals) in workflow functions. That generator is seeded from identifiers of the run, including the run ID, not from a secret key.
|
|
469
|
+
|
|
470
|
+
A generated token is hard to guess without knowing the run, but the values it is derived from are not designed to be kept secret. Treat a generated token like an unlisted link, not like a credential.
|
|
471
|
+
|
|
472
|
+
Custom tokens passed to `createHook({ token })` are usually built from domain data such as an order ID, so they are even easier to reconstruct. That is what makes them useful for routing, and it is why the route that resumes them must do its own authorization. If you can not perform your own authorization on the route that calls `resume` for any reason, and need to generate an unguessable token instead, generate it in a step where `crypto` is not seeded and pass it as `token`:
|
|
473
|
+
|
|
474
|
+
```typescript lineNumbers
|
|
475
|
+
import { createHook } from "workflow";
|
|
476
|
+
|
|
477
|
+
async function generateToken() {
|
|
478
|
+
"use step";
|
|
479
|
+
// Steps run outside the workflow sandbox, so this uses the platform's
|
|
480
|
+
// cryptographic random source instead of the run's seed.
|
|
481
|
+
return crypto.randomUUID();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export async function approvalWorkflow() {
|
|
485
|
+
"use workflow";
|
|
486
|
+
|
|
487
|
+
const token = await generateToken(); // [!code highlight]
|
|
488
|
+
using hook = createHook<{ approved: boolean }>({ token }); // [!code highlight]
|
|
489
|
+
|
|
490
|
+
return (await hook).approved;
|
|
491
|
+
}
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
The step result is recorded in the run's event log like any other step result. See [Encryption](/docs/how-it-works/encryption) to keep it encrypted at rest.
|
|
495
|
+
|
|
496
|
+
### Webhook URLs
|
|
497
|
+
|
|
498
|
+
[`createWebhook()`](/docs/api-reference/workflow/create-webhook) serves a public route at `/.well-known/workflow/v1/webhook/:token`, and matching the token is the only check it performs. Anyone who has the URL, or can compute its token, can resume the workflow with a request of their choosing. That is fine for low-stakes callbacks and prototypes. When a webhook request triggers something consequential, either:
|
|
499
|
+
|
|
500
|
+
- Verify each request before acting on it, for example by checking the provider's HMAC signature in a step with [`respondWith: "manual"`](#dynamic-responses-manual-mode), and keep waiting for the next request when verification fails.
|
|
501
|
+
- Use [`createHook()`](/docs/api-reference/workflow/create-hook) behind your own route and authorize the caller before calling [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook), as shown below.
|
|
502
|
+
|
|
503
|
+
### Authorize before calling `resumeHook()`
|
|
504
|
+
|
|
505
|
+
`resumeHook()` delivers the payload to whichever hook owns the token. The route that calls it has to authenticate the caller and check that they are allowed to resume that specific hook. Knowing the token is not proof of either. One way is to record who may resume the hook in its `metadata`, then compare it to the signed-in user:
|
|
506
|
+
|
|
507
|
+
```typescript lineNumbers
|
|
508
|
+
import { createHook } from "workflow";
|
|
509
|
+
|
|
510
|
+
export async function expenseWorkflow(approverId: string) {
|
|
511
|
+
"use workflow";
|
|
512
|
+
|
|
513
|
+
using hook = createHook<{ approved: boolean }>({
|
|
514
|
+
metadata: { approverId }, // [!code highlight]
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
return (await hook).approved;
|
|
518
|
+
}
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
```typescript lineNumbers
|
|
522
|
+
import { getHookByToken, resumeHook } from "workflow/api";
|
|
523
|
+
|
|
524
|
+
declare function getSession(request: Request): Promise<{ userId: string } | null>; // @setup
|
|
525
|
+
|
|
526
|
+
export async function POST(request: Request) {
|
|
527
|
+
const session = await getSession(request); // [!code highlight]
|
|
528
|
+
if (!session) {
|
|
529
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const { token, approved } = await request.json();
|
|
533
|
+
const hook = await getHookByToken(token);
|
|
534
|
+
const metadata = (await hook.metadata) as { approverId?: string } | undefined;
|
|
535
|
+
if (metadata?.approverId !== session.userId) { // [!code highlight]
|
|
536
|
+
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
await resumeHook(token, { approved });
|
|
540
|
+
return Response.json({ success: true });
|
|
541
|
+
}
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
Take the user identity from your authentication layer, never from the request body. Other examples in these docs omit authorization to stay short.
|
|
545
|
+
|
|
546
|
+
### Randomness in workflow functions
|
|
547
|
+
|
|
548
|
+
The same determinism applies to your own code. `Math.random()`, `crypto.randomUUID()`, and `crypto.getRandomValues()` in a workflow function return values derived from the run's seed, so they are predictable to anyone who knows it. Don't use them for secrets, passwords, one-time codes, or any value that must be unguessable. Generate those in a step, as in the token example above.
|
|
549
|
+
|
|
462
550
|
## Advanced patterns
|
|
463
551
|
|
|
464
552
|
### Type-safe hooks with `defineHook()`
|
|
@@ -514,7 +602,7 @@ This pattern is especially valuable in larger applications where the workflow an
|
|
|
514
602
|
|
|
515
603
|
### Token design
|
|
516
604
|
|
|
517
|
-
Custom tokens are available for `createHook()` with server-side `resumeHook()` only. Webhooks (`createWebhook()`) always generate their own unique tokens.
|
|
605
|
+
Custom tokens are available for `createHook()` with server-side `resumeHook()` only. Webhooks (`createWebhook()`) always generate their own unique tokens. Neither kind of token authorizes the sender; see [Security](#security).
|
|
518
606
|
|
|
519
607
|
When using custom tokens with `createHook()`:
|
|
520
608
|
|
|
@@ -36,7 +36,7 @@ The following types can be serialized and passed through workflow functions:
|
|
|
36
36
|
- `BigInt64Array`, `BigUint64Array`
|
|
37
37
|
- `DataView`
|
|
38
38
|
- `Date`
|
|
39
|
-
- `Float32Array`, `Float64Array`
|
|
39
|
+
- `Float16Array`, `Float32Array`, `Float64Array`
|
|
40
40
|
- `Int8Array`, `Int16Array`, `Int32Array`
|
|
41
41
|
- `Map<Serializable, Serializable>`
|
|
42
42
|
- `RegExp`
|
|
@@ -337,7 +337,7 @@ async function runTurn(holderRunId: string, turn: number) {
|
|
|
337
337
|
Pass `{ namespace: "name" }` to target a [namespaced stream](#namespaced-streams). The writable can also be forwarded through `start()` and into steps.
|
|
338
338
|
|
|
339
339
|
<Callout type="warn">
|
|
340
|
-
Contributors should call `releaseLock()`, which flushes pending writes. Calling `close()` closes the shared stream for every writer.
|
|
340
|
+
Contributors should call `releaseLock()`, which flushes pending writes. Calling `close()` closes the shared stream for every writer. Calling `abort()` keeps the chunks already written and leaves the shared stream open.
|
|
341
341
|
</Callout>
|
|
342
342
|
|
|
343
343
|
The API grants append access, not read access or additional authorization. The owning run controls the stream's lifecycle, and writes to an unknown run fail.
|
|
@@ -49,7 +49,7 @@ export async function processOrderWorkflow(orderId: string) {
|
|
|
49
49
|
|
|
50
50
|
Determinism in the workflow is required to resume the workflow from a suspension. Essentially, the workflow code gets re-run multiple times during its lifecycle, each time using the [event log](/docs/how-it-works/event-sourcing) to resume the workflow to the correct spot.
|
|
51
51
|
|
|
52
|
-
The sandboxed environment that workflows run in already ensures determinism. For instance, `Math.random` and `Date` constructors are fixed in workflow runs, so you are safe to use them, and the framework ensures that the values don't change across replays.
|
|
52
|
+
The sandboxed environment that workflows run in already ensures determinism. For instance, `Math.random` and `Date` constructors are fixed in workflow runs, so you are safe to use them, and the framework ensures that the values don't change across replays. Seeded random values are not secret, so generate secrets and unguessable tokens in a step (see [Hook and webhook security](/docs/foundations/hooks#security)).
|
|
53
53
|
|
|
54
54
|
## Step functions
|
|
55
55
|
|
|
@@ -61,12 +61,12 @@ export default defineConfig({
|
|
|
61
61
|
| --- | --- | --- | --- |
|
|
62
62
|
| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles, which helps stay under the Vercel 250 MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. |
|
|
63
63
|
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
<Details>
|
|
65
|
+
<Summary className="[&_h3]:my-0">
|
|
66
|
+
|
|
67
|
+
### Set up IntelliSense for TypeScript (optional)
|
|
68
|
+
|
|
69
|
+
</Summary>
|
|
70
70
|
|
|
71
71
|
To enable helpful hints in your IDE, set up the workflow plugin in `tsconfig.json`:
|
|
72
72
|
|
|
@@ -83,9 +83,7 @@ To enable helpful hints in your IDE, set up the workflow plugin in `tsconfig.jso
|
|
|
83
83
|
}
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
</AccordionItem>
|
|
88
|
-
</Accordion>
|
|
86
|
+
</Details>
|
|
89
87
|
|
|
90
88
|
</Step>
|
|
91
89
|
|
|
@@ -72,12 +72,9 @@ export default defineNitroConfig({
|
|
|
72
72
|
});
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
Setup IntelliSense for TypeScript (Optional)
|
|
79
|
-
</AccordionTrigger>
|
|
80
|
-
<AccordionContent className="[&_p]:my-2">
|
|
75
|
+
<Details>
|
|
76
|
+
<Summary>Setup IntelliSense for TypeScript (Optional)</Summary>
|
|
77
|
+
|
|
81
78
|
To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`:
|
|
82
79
|
|
|
83
80
|
```json title="tsconfig.json" lineNumbers
|
|
@@ -93,10 +90,7 @@ To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json
|
|
|
93
90
|
}
|
|
94
91
|
```
|
|
95
92
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
</AccordionItem>
|
|
99
|
-
</Accordion>
|
|
93
|
+
</Details>
|
|
100
94
|
|
|
101
95
|
### Update `package.json`
|
|
102
96
|
|
|
@@ -72,12 +72,9 @@ export default defineNitroConfig({
|
|
|
72
72
|
});
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
Setup IntelliSense for TypeScript (Optional)
|
|
79
|
-
</AccordionTrigger>
|
|
80
|
-
<AccordionContent className="[&_p]:my-2">
|
|
75
|
+
<Details>
|
|
76
|
+
<Summary>Setup IntelliSense for TypeScript (Optional)</Summary>
|
|
77
|
+
|
|
81
78
|
To enable helpful hints in your IDE, set up the workflow plugin in `tsconfig.json`:
|
|
82
79
|
|
|
83
80
|
```json title="tsconfig.json" lineNumbers
|
|
@@ -93,9 +90,7 @@ To enable helpful hints in your IDE, set up the workflow plugin in `tsconfig.jso
|
|
|
93
90
|
}
|
|
94
91
|
```
|
|
95
92
|
|
|
96
|
-
|
|
97
|
-
</AccordionItem>
|
|
98
|
-
</Accordion>
|
|
93
|
+
</Details>
|
|
99
94
|
|
|
100
95
|
### Update `package.json`
|
|
101
96
|
|
|
@@ -55,12 +55,9 @@ export default defineConfig({
|
|
|
55
55
|
});
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
Setup IntelliSense for TypeScript (Optional)
|
|
62
|
-
</AccordionTrigger>
|
|
63
|
-
<AccordionContent className="[&_p]:my-2">
|
|
58
|
+
<Details>
|
|
59
|
+
<Summary>Setup IntelliSense for TypeScript (Optional)</Summary>
|
|
60
|
+
|
|
64
61
|
To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`:
|
|
65
62
|
|
|
66
63
|
```json title="tsconfig.json" lineNumbers
|
|
@@ -76,10 +73,7 @@ To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json
|
|
|
76
73
|
}
|
|
77
74
|
```
|
|
78
75
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
</AccordionItem>
|
|
82
|
-
</Accordion>
|
|
76
|
+
</Details>
|
|
83
77
|
|
|
84
78
|
### Update `package.json`
|
|
85
79
|
|
|
@@ -80,14 +80,14 @@ import { SiReactrouter } from "@icons-pack/react-simple-icons";
|
|
|
80
80
|
<div className="flex flex-col items-center justify-center gap-2">
|
|
81
81
|
<Python className="size-16" />
|
|
82
82
|
<span className="font-medium">Python</span>
|
|
83
|
-
<Badge variant="
|
|
83
|
+
<Badge variant="gray" size="sm">Beta</Badge>
|
|
84
84
|
</div>
|
|
85
85
|
</Card>
|
|
86
86
|
<Card href="/docs/getting-started/nestjs">
|
|
87
87
|
<div className="flex flex-col items-center justify-center gap-2">
|
|
88
88
|
<Nest className="size-16 dark:invert" />
|
|
89
89
|
<span className="font-medium">NestJS</span>
|
|
90
|
-
<Badge variant="secondary">
|
|
90
|
+
<Badge variant="secondary">Beta</Badge>
|
|
91
91
|
</div>
|
|
92
92
|
</Card>
|
|
93
93
|
</Cards>
|