diff --git a/.changeset/workflow-v2-routes.md b/.changeset/workflow-v2-routes.md
new file mode 100644
index 0000000000..9dd29e264c
--- /dev/null
+++ b/.changeset/workflow-v2-routes.md
@@ -0,0 +1,16 @@
+---
+"@workflow/astro": patch
+"@workflow/builders": patch
+"@workflow/cli": patch
+"@workflow/core": patch
+"@workflow/nest": patch
+"@workflow/next": patch
+"@workflow/nitro": patch
+"@workflow/sveltekit": patch
+"@workflow/utils": patch
+"@workflow/world-local": patch
+"@workflow/world-postgres": patch
+"@workflow/world-testing": patch
+---
+
+Move workflow execution and canonical webhook routes to `/v2`, while retaining the `/v1/webhook` compatibility endpoint and cleaning stale v1 execution artifacts.
diff --git a/.github/workflows/e2e-community-world.yml b/.github/workflows/e2e-community-world.yml
index 330f04d82c..f4bd199a99 100644
--- a/.github/workflows/e2e-community-world.yml
+++ b/.github/workflows/e2e-community-world.yml
@@ -144,7 +144,7 @@ jobs:
WORLD_ID: ${{ inputs.world-id }}
DEPLOYMENT_URL: "http://localhost:3000"
run: |
- DEV_TEST_CONFIG="$(jq -nc --arg name "$APP_NAME" '{name:$name,project:"workbench-\($name)-workflow",generatedStepPath:"app/.well-known/workflow/v1/flow/__step_registrations.js",generatedWorkflowPath:"app/.well-known/workflow/v1/flow/route.js",apiFilePath:"app/api/chat/route.ts",apiFileImportPath:"../../.."}')"
+ DEV_TEST_CONFIG="$(jq -nc --arg name "$APP_NAME" '{name:$name,project:"workbench-\($name)-workflow",generatedStepPath:"app/.well-known/workflow/v2/flow/__step_registrations.js",generatedWorkflowPath:"app/.well-known/workflow/v2/flow/route.js",apiFilePath:"app/api/chat/route.ts",apiFileImportPath:"../../.."}')"
export DEV_TEST_CONFIG
cd "workbench/$APP_NAME" && pnpm dev &
cd "$GITHUB_WORKSPACE"
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 3c9944a03d..4d02fa7f45 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -718,7 +718,7 @@ jobs:
NODE_OPTIONS: "--enable-source-maps"
APP_NAME: "nextjs-turbopack"
DEPLOYMENT_URL: "http://localhost:3000"
- DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/flow/__step_registrations.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000}'
+ DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v2/flow/__step_registrations.js","generatedWorkflowPath":"app/.well-known/workflow/v2/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000}'
- name: Print Next.js server logs
if: always()
diff --git a/AGENTS.md b/AGENTS.md
index c09625990e..61ec13bae7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -166,7 +166,7 @@ cd workbench/nextjs-turbopack && pnpm start
**These are only relevant when writing code using the Workflow SDK**
- Workflow files go in `workflows/` directory (or `src/workflows/` if using src)
-- Generated API routes appear in `app/.well-known/workflow/v1/` (Next.js integration)
+- Generated execution API routes appear in `app/.well-known/workflow/v2/` (Next.js integration); the v1 directory retains manifest and webhook compatibility resources
- Workflow files must contain `"use workflow"` or `"use step"` directives to be processed
- Add `.swc` directory to `.gitignore` for SWC plugin cache artifacts
diff --git a/docs/content/docs/v5/api-reference/workflow-api/resume-webhook.mdx b/docs/content/docs/v5/api-reference/workflow-api/resume-webhook.mdx
index b4ee9f2008..603270d8c9 100644
--- a/docs/content/docs/v5/api-reference/workflow-api/resume-webhook.mdx
+++ b/docs/content/docs/v5/api-reference/workflow-api/resume-webhook.mdx
@@ -59,7 +59,7 @@ Throws an error if the webhook token is not found or invalid.
## Usage Note
-In most cases, you should not need to call `resumeWebhook()` directly. When you use `createWebhook()`, the framework automatically generates a random webhook token and provides a public URL at `/.well-known/workflow/v1/webhook/:token`. External systems can send HTTP requests directly to that URL.
+In most cases, you should not need to call `resumeWebhook()` directly. When you use `createWebhook()`, the framework automatically generates a random webhook token and provides a public URL at `/.well-known/workflow/v2/webhook/:token`. External systems can send HTTP requests directly to that URL. URLs generated by earlier v5 builds under `/.well-known/workflow/v1/webhook/:token` remain supported.
For server-side hook resumption with deterministic tokens, use [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) with [`createHook()`](/docs/api-reference/workflow/create-hook) instead.
diff --git a/docs/content/docs/v5/api-reference/workflow/create-webhook.mdx b/docs/content/docs/v5/api-reference/workflow/create-webhook.mdx
index ac63878d4d..6919c3ce41 100644
--- a/docs/content/docs/v5/api-reference/workflow/create-webhook.mdx
+++ b/docs/content/docs/v5/api-reference/workflow/create-webhook.mdx
@@ -14,7 +14,7 @@ Creates a webhook that can be used to suspend and resume a workflow run upon rec
Webhooks provide a way for external systems to send HTTP requests directly to your workflow. Unlike hooks which accept arbitrary payloads, webhooks work with standard HTTP `Request` objects and can return HTTP `Response` objects.
-`createWebhook()` creates a public endpoint at `/.well-known/workflow/v1/webhook/:token`, and the token in that URL is the only authorization performed for incoming requests resuming that webhook. This is convenient for prototypes and simple resume links because it avoids creating another route, but if you need stronger security, prefer [`createHook()`](/docs/api-reference/workflow/create-hook) behind your own route and authorize the request before calling [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) to avoid unauthenticated workflow resumptions.
+`createWebhook()` creates a public endpoint at `/.well-known/workflow/v2/webhook/:token`, and the token in that URL is the only authorization performed for incoming requests resuming that webhook. Existing URLs under `/.well-known/workflow/v1/webhook/:token` remain accepted for compatibility. This is convenient for prototypes and simple resume links because it avoids creating another route, but if you need stronger security, prefer [`createHook()`](/docs/api-reference/workflow/create-hook) behind your own route and authorize the request before calling [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) to avoid unauthenticated workflow resumptions.
```ts lineNumbers
diff --git a/docs/content/docs/v5/changelog/eager-processing.mdx b/docs/content/docs/v5/changelog/eager-processing.mdx
index 141e656c81..136d3f61f6 100644
--- a/docs/content/docs/v5/changelog/eager-processing.mdx
+++ b/docs/content/docs/v5/changelog/eager-processing.mdx
@@ -16,13 +16,13 @@ The previous architecture used two separate routes,
each backed by its own queue trigger:
```
-Queue: __wkf_workflow_* --> /.well-known/workflow/v1/flow (workflow replay in VM)
+Queue: __wkf_workflow_* --> legacy flow handler (workflow replay in VM)
|
suspension (step needed)
|
queue step to __wkf_step_*
|
-Queue: __wkf_step_* --> /.well-known/workflow/v1/step (step execution in Node.js)
+Queue: __wkf_step_* --> legacy step handler (step execution in Node.js)
|
step completes
|
@@ -35,7 +35,7 @@ Each step required **2 queue messages** (step invoke + workflow continuation) an
## New Architecture
-The two routes are merged into a single handler at `/.well-known/workflow/v1/flow` using `workflowEntrypoint()`. The step route is no longer generated.
+The two routes are merged into a single handler at `/.well-known/workflow/v2/flow` using `workflowEntrypoint()`. A separate step route is no longer generated.
The handler runs an inline execution loop:
@@ -183,7 +183,7 @@ All framework builders were updated to use `createCombinedBundle()`:
### Generated File Layout
```
-.well-known/workflow/v1/
+.well-known/workflow/v2/
flow/
route.js # Handler (workflowEntrypoint)
__step_registrations.js # Step function registrations (side effects)
@@ -412,7 +412,7 @@ The V2 combined bundle was initially emitted as CJS by the standalone CLI and Ve
1. The BOA builder emits `__step_registrations.mjs` and `index.mjs`, writes `"type": "module"` in `package.json`, and sets `handler: "index.mjs"` in `.vc-config.json`.
2. The standalone builder no longer overrides `format`; it inherits the base builder's `'esm'` default.
3. The standalone config outputs `step.mjs` / `flow.mjs` instead of `.js`.
-4. The `world-testing` server uses a native `import { POST } from '../.well-known/workflow/v1/flow.mjs'` instead of `createRequire`.
+4. The `world-testing` server uses a native `import { POST } from '../.well-known/workflow/v2/flow.mjs'` instead of `createRequire`.
5. `createCombinedBundle`'s final esbuild pass (for `bundleFinalOutput: true`) now prepends the same `createRequire(import.meta.url)` banner used by the workflow/webhook bundles so CJS dependencies that call `require()` for Node.js builtins (for example the `events` module referenced by bundled libraries) still resolve at runtime.
6. To avoid a duplicate `__createRequire` declaration, the inner steps bundle that gets inlined by the final pass skips the banner — only the outer bundle emits it. This is threaded through via a new `skipEsmRequireBanner` option on `createStepsBundle`.
@@ -472,7 +472,7 @@ The Redis community-world benchmark still loads an external world package that h
### Build Output API Flow Handler Drift
-The Vercel Build Output API builder still emitted the combined flow function as `index.js`, but the surrounding metadata kept pointing at `index.mjs`. That mismatch meant BOA-based preview deployments published neither `/.well-known/workflow/v1/flow` nor the public manifest, so the Vercel production e2e suite collapsed into manifest `404` errors immediately after deployment.
+The Vercel Build Output API builder still emitted the combined flow function as `index.js`, but the surrounding metadata kept pointing at `index.mjs`. That mismatch meant BOA-based preview deployments published neither `/.well-known/workflow/v2/flow` nor the public manifest, so the Vercel production e2e suite collapsed into manifest `404` errors immediately after deployment.
**Fix**: Updated `packages/builders/src/vercel-build-output-api.ts` to point both `.vc-config.json` and manifest extraction at `flow.func/index.js`, which matches the CommonJS file the builder actually writes.
@@ -484,7 +484,7 @@ The first lazy-world-loading fix switched package resolution over to `createRequ
### Core Logger Still Pulled `debug` Into Webpack Flow Routes
-Even after lazy world loading stopped eagerly importing `@workflow/world-vercel`, the generated Next.js webpack flow route still evaluated `packages/core/src/logger.ts` at module load. That file had a top-level `import debug from 'debug'`, which in turn pulled `debug/src/node` and its `tty` dynamic require into `/.well-known/workflow/v1/flow`. Webpack then failed during page-data collection with `Dynamic require of "tty" is not supported`.
+Even after lazy world loading stopped eagerly importing `@workflow/world-vercel`, the generated Next.js webpack flow route still evaluated `packages/core/src/logger.ts` at module load. That file had a top-level `import debug from 'debug'`, which in turn pulled `debug/src/node` and its `tty` dynamic require into `/.well-known/workflow/v2/flow`. Webpack then failed during page-data collection with `Dynamic require of "tty" is not supported`.
**Fix**: Replace the static `debug` dependency in the core logger with lightweight `process.env.DEBUG` matching plus `console.debug`. That keeps verbose opt-in logging for local debugging without forcing webpack to bundle `debug` and its Node-only terminal helpers into the flow route.
@@ -541,7 +541,7 @@ Both assumptions break for routes that consume `start` (or any other `getWorldLa
1. Webpack and Turbopack tree-shake the named import `{ getWorld } from './runtime/world.js'` out of `runtime.ts` once a consumer only uses `start`. `world.ts` is dropped from the bundle entirely, so its module-load `globalThis[GetWorldFnKey] ??= getWorld` registration never fires.
2. The dynamic-import fallback inside `get-world-lazy.ts` builds the specifier `./world.js` at runtime to evade bundler tracing — but webpack inlines `get-world-lazy.js` into the route bundle, so the relative specifier resolves against `/var/task//.next/server/app//route.js` where no sibling `world.js` exists. Node throws `MODULE_NOT_FOUND`.
-The symptom: the very first request that goes through `start()` on a cold serverless invocation fails. Once any other code path (typically the queue-driven `/.well-known/workflow/v1/flow` route, which uses `getWorld` directly via `workflowEntrypoint`) has loaded `world.ts`, subsequent `start()` calls succeed for the rest of the process lifetime — making the failure flake-shaped: hard to reproduce in dev where everything tends to be warmed, but reliable on first user traffic into a fresh function instance.
+The symptom: the very first request that goes through `start()` on a cold serverless invocation fails. Once any other code path (typically the queue-driven `/.well-known/workflow/v2/flow` route, which uses `getWorld` directly via `workflowEntrypoint`) has loaded `world.ts`, subsequent `start()` calls succeed for the rest of the process lifetime - making the failure flake-shaped: hard to reproduce in dev where everything tends to be warmed, but reliable on first user traffic into a fresh function instance.
**Fix**: Added `@workflow/core/runtime/world-init`, a server-only side-effect module that imports `./world.js` purely for its module-load side effect (the globalThis registration). It's exported via package conditions:
diff --git a/docs/content/docs/v5/cookbook/common-patterns/webhooks.mdx b/docs/content/docs/v5/cookbook/common-patterns/webhooks.mdx
index e327ad7246..b886a45156 100644
--- a/docs/content/docs/v5/cookbook/common-patterns/webhooks.mdx
+++ b/docs/content/docs/v5/cookbook/common-patterns/webhooks.mdx
@@ -171,7 +171,7 @@ export async function POST(request: Request) {
- **`respondWith: "manual"`** gives you control over the HTTP response from inside a step. Use this when you need to validate the request before responding.
- **`for await` on a webhook** lets you process multiple events from the same URL. Use `break` to stop listening after a terminal event.
-- **Webhooks auto-generate URLs** at `/.well-known/workflow/v1/webhook/:token`. Pass this URL to external services.
+- **Webhooks auto-generate URLs** at `/.well-known/workflow/v2/webhook/:token`. Pass this URL to external services. Existing v1 webhook URLs remain supported.
- **Race webhooks against `sleep()`** for deadlines. If the callback doesn't arrive in time, the workflow can take a fallback action.
- **For large payloads**, use a hook + reference token instead of passing the data through the workflow. The event log serializes all step inputs/outputs, so large payloads hurt performance.
diff --git a/docs/content/docs/v5/foundations/hooks.mdx b/docs/content/docs/v5/foundations/hooks.mdx
index 770008026b..9b39d697ee 100644
--- a/docs/content/docs/v5/foundations/hooks.mdx
+++ b/docs/content/docs/v5/foundations/hooks.mdx
@@ -219,10 +219,10 @@ While hooks are powerful, they require you to manually handle HTTP requests and
2. Provides an automatically addressable `url` property pointing to the generated webhook endpoint
3. Handles sending HTTP [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) objects back to the caller
-When using Workflow SDK, webhooks are automatically wired up at `/.well-known/workflow/v1/webhook/:token` without any additional setup.
+When using Workflow SDK, webhooks are automatically wired up at `/.well-known/workflow/v2/webhook/:token` without any additional setup. Existing v1 webhook URLs continue to resolve for compatibility.
-`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. This is convenient for prototypes and a simple developer experience because you can share the webhook URL (endpoint) without creating another route, but if you need stronger security, prefer [`createHook()`](/docs/api-reference/workflow/create-hook) behind your own route and authorize the request before calling [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) to avoid unauthenticated workflow resumptions.
+`createWebhook()` exposes a public route at `/.well-known/workflow/v2/webhook/:token`, and the token in that URL is the only authorization performed for incoming requests. This is convenient for prototypes and a simple developer experience because you can share the webhook URL (endpoint) without creating another route, but if you need stronger security, prefer [`createHook()`](/docs/api-reference/workflow/create-hook) behind your own route and authorize the request before calling [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) to avoid unauthenticated workflow resumptions.
@@ -243,7 +243,7 @@ export async function webhookWorkflow() {
// The webhook is automatically available at this URL
console.log("Send HTTP requests to:", webhook.url);
- // Example: https://your-app.com/.well-known/workflow/v1/webhook/lJHkuMdQ2FxSFTbUMU84k
+ // Example: https://your-app.com/.well-known/workflow/v2/webhook/lJHkuMdQ2FxSFTbUMU84k
// Workflow pauses until an HTTP request is received
const request = await webhook;
diff --git a/docs/content/docs/v5/getting-started/nestjs.mdx b/docs/content/docs/v5/getting-started/nestjs.mdx
index 630ba4c579..c6809c8d34 100644
--- a/docs/content/docs/v5/getting-started/nestjs.mdx
+++ b/docs/content/docs/v5/getting-started/nestjs.mdx
@@ -197,7 +197,7 @@ WorkflowModule.forRoot({
The `.js` local import specifiers in this example are the ESM form.
-The `WorkflowModule` handles workflow bundle building and provides HTTP routing for workflow execution at `.well-known/workflow/v1/`.
+The `WorkflowModule` handles workflow bundle building and provides HTTP routing for workflow execution at `.well-known/workflow/v2/flow`, plus webhook handling at `.well-known/workflow/v2/webhook/:token` with compatibility for existing v1 webhook URLs.
diff --git a/docs/content/docs/v5/getting-started/next.mdx b/docs/content/docs/v5/getting-started/next.mdx
index 0e8ea51391..1eed3b3e9c 100644
--- a/docs/content/docs/v5/getting-started/next.mdx
+++ b/docs/content/docs/v5/getting-started/next.mdx
@@ -85,7 +85,7 @@ If your Next.js app has a [proxy handler](https://nextjs.org/docs/app/api-refere
(formerly known as "middleware"), you'll need to update the matcher pattern to exclude Workflow's
internal paths to prevent the proxy handler from running on them.
-If you see `[local world] Queue operation failed` with `Cannot perform ArrayBuffer.prototype.slice on a detached ArrayBuffer`, your proxy matcher is still intercepting Workflow's internal `POST /.well-known/workflow/v1/flow` request. This is especially easy to miss in Next.js 16, where `proxy.ts` replaced `middleware.ts`.
+If you see `[local world] Queue operation failed` with `Cannot perform ArrayBuffer.prototype.slice on a detached ArrayBuffer`, your proxy matcher is still intercepting Workflow's internal `POST /.well-known/workflow/v2/flow` request. This is especially easy to miss in Next.js 16, where `proxy.ts` replaced `middleware.ts`.
Add `.well-known/workflow/*` to your matcher exclusion list:
diff --git a/docs/content/docs/v5/how-it-works/code-transform.mdx b/docs/content/docs/v5/how-it-works/code-transform.mdx
index 34bbce8e34..9e335a94e6 100644
--- a/docs/content/docs/v5/how-it-works/code-transform.mdx
+++ b/docs/content/docs/v5/how-it-works/code-transform.mdx
@@ -53,8 +53,8 @@ flowchart LR
A["Source Code
with directives"] --> B["Step Mode"]
A --> C["Workflow Mode"]
A --> D["Client Mode"]
- B --> E["step.js
(Step Execution)"]
- C --> F["flow.js
(Workflow Execution)"]
+ B --> E["__step_registrations.js
(Imported by flow)"]
+ C --> F["flow.js
(Combined Execution)"]
D --> G["Your App Code
(Enables `start`)"]
```
@@ -62,8 +62,8 @@ flowchart LR
| Mode | Used In | Purpose | Output API Route | Required? |
|----------|------------|--------------------------------|------------------------------------|-----------|
-| Step | Build time | Bundles step handlers | `.well-known/workflow/v1/step` | Yes |
-| Workflow | Build time | Bundles workflow orchestrators | `.well-known/workflow/v1/flow` | Yes |
+| Step | Build time | Registers step handlers | Included in `.well-known/workflow/v2/flow` | Yes |
+| Workflow | Build time | Bundles workflow orchestrators | `.well-known/workflow/v2/flow` | Yes |
| Client | Build/Runtime | Provides workflow IDs and types to `start` | Your application code | Optional* |
\* Client mode is **recommended** for better developer experience—it provides automatic ID generation and type safety. Without it, you must manually construct workflow IDs or use the build manifest.
@@ -73,7 +73,7 @@ flowchart LR
-**Step Mode** creates the step execution bundle served at `/.well-known/workflow/v1/step`.
+**Step Mode** creates the step registrations imported by the combined handler at `/.well-known/workflow/v2/flow`.
**Input:**
@@ -113,7 +113,7 @@ export async function createUser(email: string) {
-**Workflow Mode** creates the workflow execution bundle served at `/.well-known/workflow/v1/flow`.
+**Workflow Mode** creates the workflow execution bundle served at `/.well-known/workflow/v2/flow`.
**Input:**
@@ -211,11 +211,11 @@ The IDs are generated exactly like in workflow mode to ensure they can be direct
## Generated Files
-When you build your application, the Workflow SDK generates three handler files in `.well-known/workflow/v1/`:
+When you build your application, the Workflow SDK generates a combined flow handler and webhook handler in `.well-known/workflow/v2/`, plus a v1 webhook compatibility handler:
### `flow.js`
-Contains all workflow functions transformed in **workflow mode**. This file is imported by your framework to handle workflow execution requests at `POST /.well-known/workflow/v1/flow`.
+Contains all workflow functions transformed in **workflow mode** and imports the step registrations. This file is imported by your framework to handle workflow and step execution requests at `POST /.well-known/workflow/v2/flow`.
**How it's structured:**
@@ -245,15 +245,13 @@ Most invalid patterns cause **build-time errors**, catching issues before deploy
**Why a VM?** Workflow functions must be deterministic to support replay. The VM sandbox prevents accidental use of non-deterministic APIs or side effects. All side effects should be performed in [step functions](/docs/foundations/workflows-and-steps#step-functions) instead.
-### `step.js`
+### `__step_registrations.js`
-Contains all step functions transformed in **step mode**. This file is imported by your framework to handle step execution requests at `POST /.well-known/workflow/v1/step`.
+Contains all step functions transformed in **step mode**. It is imported by `flow.js`; it is not exposed as a separate HTTP endpoint.
**What it does:**
-- Exports a `POST` handler that accepts Web standard `Request` objects
-- Executes individual steps with full runtime access
-- Returns step results to the orchestration layer
+- Registers individual step functions with full runtime access for the combined flow handler
### `webhook.js`
@@ -336,7 +334,7 @@ These transformations are framework-agnostic—they output standard JavaScript t
If you need to debug transformation issues, you can inspect the generated files:
-1. **Look in `.well-known/workflow/v1/`**: Check the generated `flow.js`, `step.js`,`webhook.js`, and other emitted debug files.
+1. **Look in `.well-known/workflow/v2/`**: Check the generated `flow.js`, `__step_registrations.js`, `webhook.js`, and other emitted debug files.
2. **Check build logs**: Most frameworks log transformation activity during builds
3. **Verify directives**: Ensure `"use workflow"` and `"use step"` are the first statements in functions
4. **Check file locations**: Transformations only apply to files in configured source directories
diff --git a/docs/content/docs/v5/how-it-works/framework-integrations.mdx b/docs/content/docs/v5/how-it-works/framework-integrations.mdx
index 487538670c..835133e120 100644
--- a/docs/content/docs/v5/how-it-works/framework-integrations.mdx
+++ b/docs/content/docs/v5/how-it-works/framework-integrations.mdx
@@ -58,7 +58,7 @@ Let's build a complete integration for Bun. Bun is unique because it serves as b
### Step 1: Generate Handler Files
-Use the `workflow` CLI to generate the handler bundles. The CLI scans your `workflows/` directory and creates `flow.js`, `step.js`, and `webhook.js`.
+Use the `workflow` CLI to generate the handler bundles. The CLI scans your `workflows/` directory and creates a combined `flow.js` handler plus `webhook.js`.
```json title="package.json"
{
@@ -74,9 +74,9 @@ Use the `workflow` CLI to generate the handler bundles. The CLI scans your `work
**What gets generated:**
-- `/.well-known/workflow/v1/flow.js` - Handles workflow execution (workflow mode transform)
-- `/.well-known/workflow/v1/step.js` - Handles step execution (step mode transform)
-- `/.well-known/workflow/v1/webhook.js` - Handles webhook delivery
+- `/.well-known/workflow/v2/flow.js` - Handles workflow and step execution
+- `/.well-known/workflow/v2/webhook.js` - Handles webhook delivery
+- `/.well-known/workflow/v1/webhook.js` - Compatibility alias for existing webhook URLs
Each file exports a `POST` function that accepts Web standard `Request` objects.
@@ -137,9 +137,8 @@ Wire up the generated handlers to HTTP endpoints using `Bun.serve()`:
{/* @skip-typecheck: incomplete code sample */}
```typescript title="server.ts" lineNumbers
-import flow from "./.well-known/workflow/v1/flow.js";
-import step from "./.well-known/workflow/v1/step.js";
-import * as webhook from "./.well-known/workflow/v1/webhook.js";
+import flow from "./.well-known/workflow/v2/flow.js";
+import * as webhook from "./.well-known/workflow/v2/webhook.js";
import { start } from "workflow/api";
import { handleUserSignup } from "./workflows/user-signup.js";
@@ -147,13 +146,12 @@ import { handleUserSignup } from "./workflows/user-signup.js";
const server = Bun.serve({
port: process.env.PORT,
routes: {
- "/.well-known/workflow/v1/flow": {
+ "/.well-known/workflow/v2/flow": {
POST: (req) => flow.POST(req),
},
- "/.well-known/workflow/v1/step": {
- POST: (req) => step.POST(req),
- },
// webhook exports handlers for GET, POST, DELETE, etc.
+ "/.well-known/workflow/v2/webhook/:token": webhook,
+ // Keep accepting webhook URLs created before the v2 endpoint migration.
"/.well-known/workflow/v1/webhook/:token": webhook,
// Example: Start a workflow
@@ -177,11 +175,11 @@ console.log(`Server listening on http://localhost:${server.port}`);
## Understanding the Endpoints
-Your integration must expose three HTTP endpoints. The generated handlers manage all protocol details—you just route requests.
+Your integration must expose two canonical HTTP endpoints and retain the legacy webhook alias. The generated handlers manage all protocol details - you just route requests.
### Workflow Endpoint
-**Route:** `POST /.well-known/workflow/v1/flow`
+**Route:** `POST /.well-known/workflow/v2/flow`
Executes workflow orchestration logic. The workflow function is "rendered" multiple times during execution—each time it progresses until it encounters the next step.
@@ -192,18 +190,16 @@ Executes workflow orchestration logic. The workflow function is "rendered" multi
- Resuming after a webhook or hook triggers
- Recovering from failures
-### Step Endpoint
-
-**Route:** `POST /.well-known/workflow/v1/step`
-
-Executes individual atomic operations within workflows. Each step runs exactly once per execution (unless retried due to failure). Steps have full runtime access (Node.js APIs, file system, databases, etc.).
+This combined endpoint also executes individual step operations. Steps retain full runtime access (Node.js APIs, file system, databases, etc.).
### Webhook Endpoint
-**Route:** `POST /.well-known/workflow/v1/webhook/:token`
+**Route:** `POST /.well-known/workflow/v2/webhook/:token`
Delivers webhook data to running workflows via [`createWebhook()`](/docs/api-reference/workflow/create-webhook). The `:token` parameter identifies which workflow run should receive the data.
+Continue routing `/.well-known/workflow/v1/webhook/:token` to the same handler so previously issued webhook URLs keep working.
+
The webhook file structure varies by framework. Next.js generates `webhook/[token]/route.js` to leverage App Router's dynamic routing, while other frameworks generate a single `webhook.js` handler.
@@ -240,16 +236,15 @@ class MyFrameworkBuilder extends BaseBuilder {
override async build(): Promise {
const inputFiles = await this.getInputFiles();
- await this.createWorkflowsBundle({
- outfile: "/path/to/.well-known/workflow/v1/flow.js",
+ await this.createCombinedBundle({
+ flowOutfile: "/path/to/.well-known/workflow/v2/flow.js",
+ stepsOutfile: "/path/to/.well-known/workflow/v2/__step_registrations.js",
format: "esm",
inputFiles,
});
- await this.createStepsBundle({
- outfile: "/path/to/.well-known/workflow/v1/step.js",
- format: "esm",
- inputFiles,
+ await this.createWebhookBundle({
+ outfile: "/path/to/.well-known/workflow/v2/webhook.js",
});
await this.createWebhookBundle({
@@ -344,26 +339,23 @@ module.exports = {
### HTTP Server
-Route the three endpoints to the generated handlers. The exact implementation depends on your framework's routing API.
+Route the canonical endpoints and legacy webhook alias to the generated handlers. The exact implementation depends on your framework's routing API.
In the bun example above, we left routing to the user. Essentially, the user has to serve routes like this:
{/* @skip-typecheck: incomplete code sample */}
```typescript title="server.ts" lineNumbers
-import flow from "./.well-known/workflow/v1/flow.js";
-import step from "./.well-known/workflow/v1/step.js";
-import * as webhook from "./.well-known/workflow/v1/webhook.js";
+import flow from "./.well-known/workflow/v2/flow.js";
+import * as webhook from "./.well-known/workflow/v2/webhook.js";
-// Expose the 3 generated routes
+// Expose the generated routes and webhook compatibility alias
const server = Bun.serve({
routes: {
- "/.well-known/workflow/v1/flow": {
+ "/.well-known/workflow/v2/flow": {
POST: (req) => flow.POST(req),
},
- "/.well-known/workflow/v1/step": {
- POST: (req) => step.POST(req),
- },
// webhook exports handlers for GET, POST, DELETE, etc.
+ "/.well-known/workflow/v2/webhook/:token": webhook,
"/.well-known/workflow/v1/webhook/:token": webhook,
},
});
@@ -416,7 +408,7 @@ import { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from "@workflow/builders";
For self-hosted or non-Vercel deployments, you are responsible for securing the handler endpoints:
-- **Framework middleware** — Add authentication (API keys, JWT, OIDC) in front of the `/.well-known/workflow/v1/*` routes
+- **Framework middleware** - Add authentication (API keys, JWT, OIDC) in front of the `/.well-known/workflow/v2/*` routes and the v1 webhook compatibility route
- **Network-level security** — Deploy handlers behind a VPC, private network, or firewall rules so only your queue infrastructure can reach them
- **Rate limiting** — Add request validation and rate limiting to prevent abuse
@@ -474,17 +466,17 @@ async function sendOnboardingEmail(user: { id: string; email: string }, callback
Run your build and verify:
-- `.well-known/workflow/v1/flow.js` exists
-- `.well-known/workflow/v1/step.js` exists
-- `.well-known/workflow/v1/webhook.js` exists
+- `.well-known/workflow/v2/flow.js` exists
+- `.well-known/workflow/v2/webhook.js` exists
+- `.well-known/workflow/v1/webhook.js` exists as a compatibility alias
### 2. Test HTTP Endpoints
Start your server and verify routes respond:
```bash
-curl -X POST http://localhost:3000/.well-known/workflow/v1/flow
-curl -X POST http://localhost:3000/.well-known/workflow/v1/step
+curl -X POST http://localhost:3000/.well-known/workflow/v2/flow
+curl -X POST http://localhost:3000/.well-known/workflow/v2/webhook/test
curl -X POST http://localhost:3000/.well-known/workflow/v1/webhook/test
```
diff --git a/packages/astro/src/builder.ts b/packages/astro/src/builder.ts
index 021f7f088c..ad06977b86 100644
--- a/packages/astro/src/builder.ts
+++ b/packages/astro/src/builder.ts
@@ -10,12 +10,16 @@ import {
const WORKFLOW_ROUTES = [
{
- src: '^/\\.well-known/workflow/v1/flow/?$',
- dest: '/.well-known/workflow/v1/flow',
+ src: '^/\\.well-known/workflow/v2/flow/?$',
+ dest: '/.well-known/workflow/v2/flow',
+ },
+ {
+ src: '^/\\.well-known/workflow/v2/webhook/([^/]+?)/?$',
+ dest: '/.well-known/workflow/v2/webhook/[token]',
},
{
src: '^/\\.well-known/workflow/v1/webhook/([^/]+?)/?$',
- dest: '/.well-known/workflow/v1/webhook/[token]',
+ dest: '/.well-known/workflow/v2/webhook/[token]',
},
];
@@ -37,18 +41,24 @@ export class LocalBuilder extends BaseBuilder {
override async build(): Promise {
const pagesDir = resolve(this.config.workingDir, 'src/pages');
- const workflowGeneratedDir = join(pagesDir, '.well-known/workflow/v1');
+ const workflowGeneratedDir = join(pagesDir, '.well-known/workflow/v2');
+ const manifestGeneratedDir = join(pagesDir, '.well-known/workflow/v1');
// Ensure output directories exist
await mkdir(workflowGeneratedDir, { recursive: true });
+ await mkdir(manifestGeneratedDir, { recursive: true });
// Add .gitignore to exclude generated files from version control
if (process.env.VERCEL_DEPLOYMENT_ID === undefined) {
await writeFile(join(workflowGeneratedDir, '.gitignore'), '*');
+ await writeFile(join(manifestGeneratedDir, '.gitignore'), '*');
}
- // Clean up stale V1 step route (may persist via Vercel build cache)
- await rm(join(workflowGeneratedDir, 'step.js'), { force: true });
+ // Remove executable routes that may persist from pre-v2 endpoint builds.
+ await Promise.all([
+ rm(join(manifestGeneratedDir, 'flow.js'), { force: true }),
+ rm(join(manifestGeneratedDir, 'step.js'), { force: true }),
+ ]);
// Get workflow and step files to bundle
const inputFiles = await this.getInputFiles();
@@ -83,12 +93,15 @@ export const prerender = false;`
await writeFile(workflowsRouteFile, workflowsRouteContent);
await this.buildWebhookRoute({ workflowGeneratedDir });
+ await this.buildWebhookRoute({
+ workflowGeneratedDir: manifestGeneratedDir,
+ });
// Generate unified manifest
const workflowBundlePath = join(workflowGeneratedDir, 'flow.js');
const manifestJson = await this.createManifest({
workflowBundlePath,
- manifestDir: workflowGeneratedDir,
+ manifestDir: manifestGeneratedDir,
manifest,
});
@@ -96,7 +109,7 @@ export const prerender = false;`
// Astro maps `foo.json.js` to the URL `/foo.json`
if (this.shouldExposePublicManifest && manifestJson) {
await writeFile(
- join(workflowGeneratedDir, 'manifest.json.js'),
+ join(manifestGeneratedDir, 'manifest.json.js'),
`export function GET() {
return new Response(${JSON.stringify(manifestJson)}, {
headers: { "content-type": "application/json" },
@@ -113,7 +126,7 @@ export const prerender = false;\n`
}: {
workflowGeneratedDir: string;
}) {
- // Create webhook route: .well-known/workflow/v1/webhook/[token].js
+ // Create webhook route: .well-known/workflow/v2/webhook/[token].js
const webhookRouteFile = join(workflowGeneratedDir, 'webhook/[token].js');
await this.createWebhookBundle({
diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts
index f0ff13c2bc..c726abf222 100644
--- a/packages/builders/src/base-builder.ts
+++ b/packages/builders/src/base-builder.ts
@@ -1552,7 +1552,7 @@ export const POST = workflowEntrypoint(workflowCode);`;
async function handler(request) {
const url = new URL(request.url);
- // Extract token from pathname: /.well-known/workflow/v1/webhook/{token}
+ // Extract token from pathname: /.well-known/workflow/v2/webhook/{token}
const pathParts = url.pathname.split('/');
const token = decodeURIComponent(pathParts[pathParts.length - 1]);
diff --git a/packages/builders/src/standalone.ts b/packages/builders/src/standalone.ts
index b9dd9bb741..9196ee445b 100644
--- a/packages/builders/src/standalone.ts
+++ b/packages/builders/src/standalone.ts
@@ -1,3 +1,4 @@
+import { rm } from 'node:fs/promises';
import { BaseBuilder } from './base-builder.js';
import type { WorkflowConfig } from './types.js';
@@ -10,6 +11,15 @@ export class StandaloneBuilder extends BaseBuilder {
}
async build(): Promise {
+ await Promise.all([
+ rm(this.resolvePath('.well-known/workflow/v1/flow.mjs'), {
+ force: true,
+ }),
+ rm(this.resolvePath('.well-known/workflow/v1/step.mjs'), {
+ force: true,
+ }),
+ ]);
+
const inputFiles = await this.getInputFiles();
const tsconfigPath = await this.findTsConfigPath();
@@ -30,7 +40,8 @@ export class StandaloneBuilder extends BaseBuilder {
bundleFinalOutput: true,
});
- await this.buildWebhookFunction();
+ await this.buildWebhookFunction(this.config.webhookBundlePath);
+ await this.buildWebhookFunction('./.well-known/workflow/v1/webhook.mjs');
const manifestDir = this.resolvePath('.well-known/workflow/v1');
await this.createManifest({
@@ -42,10 +53,10 @@ export class StandaloneBuilder extends BaseBuilder {
await this.createClientLibrary();
}
- private async buildWebhookFunction(): Promise {
- console.log('Creating webhook bundle at', this.config.webhookBundlePath);
+ private async buildWebhookFunction(webhookPath: string): Promise {
+ console.log('Creating webhook bundle at', webhookPath);
- const webhookBundlePath = this.resolvePath(this.config.webhookBundlePath);
+ const webhookBundlePath = this.resolvePath(webhookPath);
await this.ensureDirectory(webhookBundlePath);
await this.createWebhookBundle({
diff --git a/packages/builders/src/vercel-build-output-api.ts b/packages/builders/src/vercel-build-output-api.ts
index 03fb55ffdf..57acb7ab53 100644
--- a/packages/builders/src/vercel-build-output-api.ts
+++ b/packages/builders/src/vercel-build-output-api.ts
@@ -1,4 +1,4 @@
-import { copyFile, mkdir, writeFile } from 'node:fs/promises';
+import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { BaseBuilder } from './base-builder.js';
import { WORKFLOW_QUEUE_TRIGGER } from './constants.js';
@@ -7,10 +7,32 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder {
async build(): Promise {
const outputDir = resolve(this.config.workingDir, '.vercel/output');
const functionsDir = join(outputDir, 'functions');
- const workflowGeneratedDir = join(functionsDir, '.well-known/workflow/v1');
+ const workflowGeneratedDir = join(functionsDir, '.well-known/workflow/v2');
+ const manifestGeneratedDir = join(functionsDir, '.well-known/workflow/v1');
+
+ await Promise.all([
+ rm(join(functionsDir, '.well-known/workflow/v1/flow.func'), {
+ recursive: true,
+ force: true,
+ }),
+ rm(join(functionsDir, '.well-known/workflow/v1/step.func'), {
+ recursive: true,
+ force: true,
+ }),
+ rm(join(functionsDir, '.well-known/workflow/v1/webhook'), {
+ recursive: true,
+ force: true,
+ }),
+ rm(join(workflowGeneratedDir, 'manifest.json'), {
+ force: true,
+ }),
+ ]);
// Ensure output directories exist
- await mkdir(workflowGeneratedDir, { recursive: true });
+ await Promise.all([
+ mkdir(workflowGeneratedDir, { recursive: true }),
+ mkdir(manifestGeneratedDir, { recursive: true }),
+ ]);
const inputFiles = await this.getInputFiles();
const tsconfigPath = await this.findTsConfigPath();
@@ -50,7 +72,7 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder {
);
const manifestJson = await this.createManifest({
workflowBundlePath,
- manifestDir: workflowGeneratedDir,
+ manifestDir: manifestGeneratedDir,
manifest,
});
@@ -66,7 +88,7 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder {
await writeFile(join(staticManifestDir, '.gitignore'), '*');
}
await copyFile(
- join(workflowGeneratedDir, 'manifest.json'),
+ join(manifestGeneratedDir, 'manifest.json'),
join(staticManifestDir, 'manifest.json')
);
}
@@ -104,9 +126,13 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder {
const buildOutputConfig = {
version: 3,
routes: [
+ {
+ src: '^\\/\\.well-known\\/workflow\\/v2\\/webhook\\/([^\\/]+)$',
+ dest: '/.well-known/workflow/v2/webhook/[token]',
+ },
{
src: '^\\/\\.well-known\\/workflow\\/v1\\/webhook\\/([^\\/]+)$',
- dest: '/.well-known/workflow/v1/webhook/[token]',
+ dest: '/.well-known/workflow/v2/webhook/[token]',
},
],
};
@@ -117,9 +143,9 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder {
);
console.log(`Build Output API created at ${outputDir}`);
- console.log('Combined function available at /.well-known/workflow/v1/flow');
+ console.log('Combined function available at /.well-known/workflow/v2/flow');
console.log(
- 'Webhook function available at /.well-known/workflow/v1/webhook/[token]'
+ 'Webhook function available at /.well-known/workflow/v2/webhook/[token] (also accepts v1 webhook URLs)'
);
}
}
diff --git a/packages/cli/src/commands/health.ts b/packages/cli/src/commands/health.ts
index c4249dd010..7d2626578e 100644
--- a/packages/cli/src/commands/health.ts
+++ b/packages/cli/src/commands/health.ts
@@ -99,11 +99,11 @@ function resolveLocalBaseUrl(
async function testHttpHealthEndpoint(
baseUrl: string,
- endpoint: 'flow' | 'step',
+ endpoint: 'flow',
verbose: boolean
): Promise<{ ok: boolean; status?: number; error?: string }> {
try {
- const healthUrl = `${baseUrl}/.well-known/workflow/v1/${endpoint}?__health`;
+ const healthUrl = `${baseUrl}/.well-known/workflow/v2/${endpoint}?__health`;
if (verbose) {
logger.debug(`Testing HTTP health at: ${healthUrl}`);
}
diff --git a/packages/cli/src/lib/config/workflow-config.ts b/packages/cli/src/lib/config/workflow-config.ts
index 1ea91cc4cf..09496a76b4 100644
--- a/packages/cli/src/lib/config/workflow-config.ts
+++ b/packages/cli/src/lib/config/workflow-config.ts
@@ -1,5 +1,5 @@
-import type { BuildTarget, WorkflowConfig } from './types.js';
import { resolve } from 'node:path';
+import type { BuildTarget, WorkflowConfig } from './types.js';
function resolveObservabilityCwd(): string {
const raw = process.env.WORKFLOW_OBSERVABILITY_CWD;
@@ -26,9 +26,9 @@ export const getWorkflowConfig = (
dirs: ['./workflows'],
workingDir: resolveObservabilityCwd(),
buildTarget: buildTarget as BuildTarget,
- stepsBundlePath: './.well-known/workflow/v1/step.mjs',
- workflowsBundlePath: './.well-known/workflow/v1/flow.mjs',
- webhookBundlePath: './.well-known/workflow/v1/webhook.mjs',
+ stepsBundlePath: './.well-known/workflow/v2/__step_registrations.mjs',
+ workflowsBundlePath: './.well-known/workflow/v2/flow.mjs',
+ webhookBundlePath: './.well-known/workflow/v2/webhook.mjs',
workflowManifestPath: workflowManifest,
// WIP: generate a client library to easily execute workflows/steps
diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts
index 5f00f28ae1..7064d5d74a 100644
--- a/packages/core/e2e/e2e.test.ts
+++ b/packages/core/e2e/e2e.test.ts
@@ -417,7 +417,7 @@ describe('e2e', () => {
// Attempt to resume via the public webhook endpoint — should get 404
const res = await fetch(
new URL(
- `/.well-known/workflow/v1/webhook/${encodeURIComponent(token)}`,
+ `/.well-known/workflow/v2/webhook/${encodeURIComponent(token)}`,
deploymentUrl
),
{
@@ -472,7 +472,7 @@ describe('e2e', () => {
// Webhook with default response
const res = await fetch(
new URL(
- `/.well-known/workflow/v1/webhook/${encodeURIComponent(token)}`,
+ `/.well-known/workflow/v2/webhook/${encodeURIComponent(token)}`,
deploymentUrl
),
{
@@ -485,7 +485,7 @@ describe('e2e', () => {
const body = await res.text();
expect(body).toBe('');
- // Webhook with static response
+ // The legacy v1 webhook path remains usable for existing external integrations.
const res2 = await fetch(
new URL(
`/.well-known/workflow/v1/webhook/${encodeURIComponent(token2)}`,
@@ -504,7 +504,7 @@ describe('e2e', () => {
// Webhook with manual response
const res3 = await fetch(
new URL(
- `/.well-known/workflow/v1/webhook/${encodeURIComponent(token3)}`,
+ `/.well-known/workflow/v2/webhook/${encodeURIComponent(token3)}`,
deploymentUrl
),
{
@@ -521,7 +521,7 @@ describe('e2e', () => {
expect(returnValue).toHaveLength(3);
expect(returnValue[0].url).toBe(
new URL(
- `/.well-known/workflow/v1/webhook/${encodeURIComponent(token)}`,
+ `/.well-known/workflow/v2/webhook/${encodeURIComponent(token)}`,
deploymentUrl
).href
);
@@ -539,7 +539,7 @@ describe('e2e', () => {
expect(returnValue[2].url).toBe(
new URL(
- `/.well-known/workflow/v1/webhook/${encodeURIComponent(token3)}`,
+ `/.well-known/workflow/v2/webhook/${encodeURIComponent(token3)}`,
deploymentUrl
).href
);
@@ -549,7 +549,7 @@ describe('e2e', () => {
test('webhook route with invalid token', { timeout: 60_000 }, async () => {
const invalidWebhookUrl = new URL(
- `/.well-known/workflow/v1/webhook/${encodeURIComponent('invalid')}`,
+ `/.well-known/workflow/v2/webhook/${encodeURIComponent('invalid')}`,
deploymentUrl
);
const res = await fetch(invalidWebhookUrl, {
@@ -1920,7 +1920,7 @@ describe('e2e', () => {
// Test the flow endpoint health check (V2: combined handler for both workflow + step)
const flowHealthUrl = new URL(
- '/.well-known/workflow/v1/flow?__health',
+ '/.well-known/workflow/v2/flow?__health',
deploymentUrl
);
const flowRes = await fetch(flowHealthUrl, {
@@ -1932,7 +1932,7 @@ describe('e2e', () => {
const flowBody = await flowRes.json();
expect(flowBody).toEqual({
healthy: true,
- endpoint: '/.well-known/workflow/v1/flow',
+ endpoint: '/.well-known/workflow/v2/flow',
// specVersion comes from the World's declared specVersion (e.g. 3
// for world-vercel) or falls back to SPEC_VERSION_CURRENT (2).
specVersion: expect.any(Number),
@@ -3423,7 +3423,7 @@ describe('e2e', () => {
// Workflow should still be running (grace period), so hook should still be findable
await sleep(1_000);
- const hookAfterAbort = await getHookByToken(token).catch(() => null);
+ const _hookAfterAbort = await getHookByToken(token).catch(() => null);
// Hook may or may not be disposed depending on timing, but run should complete
const returnValue = await run1.returnValue;
expect(returnValue.aborted).toBe(true);
diff --git a/packages/core/e2e/local-build.test.ts b/packages/core/e2e/local-build.test.ts
index f734c32fcc..90ab96903b 100644
--- a/packages/core/e2e/local-build.test.ts
+++ b/packages/core/e2e/local-build.test.ts
@@ -83,7 +83,7 @@ async function readFileIfExists(filePath: string): Promise {
*/
const ESM_STEP_BUNDLE_PROJECTS: Record = {
example:
- '.vercel/output/functions/.well-known/workflow/v1/step.func/index.mjs',
+ '.vercel/output/functions/.well-known/workflow/v2/flow.func/__step_registrations.mjs',
};
const DIAGNOSTICS_MANIFEST_PATHS: Record = {
diff --git a/packages/core/src/create-hook.ts b/packages/core/src/create-hook.ts
index 05ea4cc84d..03ee228f1a 100644
--- a/packages/core/src/create-hook.ts
+++ b/packages/core/src/create-hook.ts
@@ -127,7 +127,7 @@ export interface HookOptions {
* Whether this hook can be resumed via the public webhook endpoint.
*
* When `true`, the hook can be triggered by sending an HTTP request to the
- * public `/.well-known/workflow/v1/webhook/{token}` URL. This is automatically
+ * public `/.well-known/workflow/v2/webhook/{token}` URL. This is automatically
* set when using `createWebhook()`.
*
* When `false` (the default), the hook can only be resumed server-side
diff --git a/packages/core/src/runtime/world-init.ts b/packages/core/src/runtime/world-init.ts
index 2b06b84aff..e32a07aac6 100644
--- a/packages/core/src/runtime/world-init.ts
+++ b/packages/core/src/runtime/world-init.ts
@@ -21,7 +21,7 @@
* `world.js` exists — and Node throws `MODULE_NOT_FOUND`. The symptom is a
* cold-start regression: the very first user request that goes through
* `start()` fails until some other code path (typically the queue-driven
- * `/.well-known/workflow/v1/flow` route, which uses `getWorld` directly
+ * `/.well-known/workflow/v2/flow` route, which uses `getWorld` directly
* via `workflowEntrypoint`) has loaded `world.ts` and populated the
* cache.
*
diff --git a/packages/core/src/workflow/create-hook.ts b/packages/core/src/workflow/create-hook.ts
index 1d018f68b7..6ce0b1f2bc 100644
--- a/packages/core/src/workflow/create-hook.ts
+++ b/packages/core/src/workflow/create-hook.ts
@@ -51,7 +51,7 @@ export function createWebhook(
| Webhook;
const { url } = getWorkflowMetadata();
- hook.url = `${url}/.well-known/workflow/v1/webhook/${encodeURIComponent(hook.token)}`;
+ hook.url = `${url}/.well-known/workflow/v2/webhook/${encodeURIComponent(hook.token)}`;
return hook;
}
diff --git a/packages/nest/README.md b/packages/nest/README.md
index 4a16befe50..f01a27b7ea 100644
--- a/packages/nest/README.md
+++ b/packages/nest/README.md
@@ -130,7 +130,7 @@ WorkflowModule.forRoot({
The `@workflow/nest` package provides:
1. **WorkflowModule** - A NestJS module that handles workflow bundle building and HTTP routing
-2. **WorkflowController** - Handles workflow and step execution requests at `.well-known/workflow/v1/`
+2. **WorkflowController** - Handles workflow and step execution requests at `.well-known/workflow/v2/flow` and webhooks at `.well-known/workflow/v2/webhook/:token` (with v1 webhook compatibility)
3. **NestLocalBuilder** - Builds workflow bundles (steps.mjs, workflows.mjs) from your source files
4. **CLI** - Generates `.swcrc` configuration with the SWC plugin properly resolved
diff --git a/packages/nest/src/workflow.controller.ts b/packages/nest/src/workflow.controller.ts
index a040bace25..2700489bb5 100644
--- a/packages/nest/src/workflow.controller.ts
+++ b/packages/nest/src/workflow.controller.ts
@@ -84,9 +84,9 @@ function getOutDir(): string {
* Controller that handles the well-known workflow endpoints.
* Dynamically imports the generated bundles and handles request/response conversion.
*/
-@Controller('.well-known/workflow/v1')
+@Controller('.well-known/workflow')
export class WorkflowController {
- @Post('flow')
+ @Post('v2/flow')
async handleFlow(@Req() req: any, @Res() res: any) {
const outDir = getOutDir();
// Import step registrations (side effects) before the combined handler
@@ -99,7 +99,7 @@ export class WorkflowController {
await sendWebResponse(res, webResponse);
}
- @All('webhook/:token')
+ @All('v2/webhook/:token')
async handleWebhook(@Req() req: any, @Res() res: any) {
const outDir = getOutDir();
const { POST } = await import(
@@ -110,7 +110,12 @@ export class WorkflowController {
await sendWebResponse(res, webResponse);
}
- @Get('manifest.json')
+ @All('v1/webhook/:token')
+ async handleLegacyWebhook(@Req() req: any, @Res() res: any) {
+ return this.handleWebhook(req, res);
+ }
+
+ @Get('v1/manifest.json')
async handleManifest(@Res() res: any) {
if (process.env.WORKFLOW_PUBLIC_MANIFEST !== '1') {
if (typeof res.code === 'function') {
diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts
index f4170ca966..9215c00ced 100644
--- a/packages/next/src/builder-deferred.ts
+++ b/packages/next/src/builder-deferred.ts
@@ -189,7 +189,8 @@ export async function getNextBuilderDeferred() {
private async shouldForceBuildForGeneratedRoutes(): Promise {
const outputDir = await this.findAppDirectory();
const generatedRouteFiles = [
- join(outputDir, '.well-known/workflow/v1/flow/route.js'),
+ join(outputDir, '.well-known/workflow/v2/flow/route.js'),
+ join(outputDir, '.well-known/workflow/v2/webhook/[token]/route.js'),
join(outputDir, '.well-known/workflow/v1/webhook/[token]/route.js'),
];
@@ -206,7 +207,7 @@ export async function getNextBuilderDeferred() {
private async getGeneratedRouteState(
routeFilePath: string
): Promise<'missing' | 'stub' | 'generated'> {
- let routeStats;
+ let routeStats: Awaited>;
try {
routeStats = await stat(routeFilePath);
} catch {
@@ -449,7 +450,8 @@ export async function getNextBuilderDeferred() {
implicitStepFiles: string[]
) {
const outputDir = await this.findAppDirectory();
- const workflowGeneratedDir = join(outputDir, '.well-known/workflow/v1');
+ const workflowGeneratedDir = join(outputDir, '.well-known/workflow/v2');
+ const manifestGeneratedDir = join(outputDir, '.well-known/workflow/v1');
const cacheDir = join(this.config.workingDir, this.getDistDir(), 'cache');
await mkdir(cacheDir, { recursive: true });
const manifestBuildDir = join(cacheDir, 'workflow-generated-manifest');
@@ -509,11 +511,16 @@ export async function getNextBuilderDeferred() {
// Ensure output directories exist
await mkdir(workflowGeneratedDir, { recursive: true });
+ await mkdir(manifestGeneratedDir, { recursive: true });
await this.writeFileIfChanged(
join(workflowGeneratedDir, '.gitignore'),
'*'
);
+ await this.writeFileIfChanged(
+ join(manifestGeneratedDir, '.gitignore'),
+ '*'
+ );
const tsconfigPath = await this.findTsConfigPath();
@@ -539,6 +546,10 @@ export async function getNextBuilderDeferred() {
workflowGeneratedDir,
routeFileName: tempRouteFileName,
});
+ await this.buildWebhookRoute({
+ workflowGeneratedDir: manifestGeneratedDir,
+ routeFileName: tempRouteFileName,
+ });
await this.refreshTrackedDependencyFiles(
workflowGeneratedDir,
tempRouteFileName
@@ -550,7 +561,7 @@ export async function getNextBuilderDeferred() {
classes: { ...combinedResult?.manifest?.classes },
};
- const manifestFilePath = join(workflowGeneratedDir, 'manifest.json');
+ const manifestFilePath = join(manifestGeneratedDir, 'manifest.json');
const manifestBuildPath = join(manifestBuildDir, 'manifest.json');
const workflowBundlePath = join(
workflowGeneratedDir,
@@ -580,6 +591,10 @@ export async function getNextBuilderDeferred() {
join(workflowGeneratedDir, `webhook/[token]/${tempRouteFileName}`),
join(workflowGeneratedDir, 'webhook/[token]/route.js')
);
+ await this.copyFileIfChanged(
+ join(manifestGeneratedDir, `webhook/[token]/${tempRouteFileName}`),
+ join(manifestGeneratedDir, 'webhook/[token]/route.js')
+ );
// Expose manifest as a static file when WORKFLOW_PUBLIC_MANIFEST=1.
// Next.js serves files from public/ at the root URL.
@@ -608,10 +623,14 @@ export async function getNextBuilderDeferred() {
private async cleanupGeneratedArtifactsOnBoot(
outputDir: string
): Promise {
- const workflowGeneratedDir = join(outputDir, '.well-known/workflow/v1');
+ const workflowGeneratedDir = join(outputDir, '.well-known/workflow/v2');
+ const manifestGeneratedDir = join(outputDir, '.well-known/workflow/v1');
const flowRouteDir = join(workflowGeneratedDir, 'flow');
- const stepRouteDir = join(workflowGeneratedDir, 'step');
const webhookRouteDir = join(workflowGeneratedDir, 'webhook/[token]');
+ const legacyWebhookRouteDir = join(
+ manifestGeneratedDir,
+ 'webhook/[token]'
+ );
const staleArtifactPaths = [
join(flowRouteDir, 'route.js.temp'),
@@ -619,10 +638,13 @@ export async function getNextBuilderDeferred() {
join(flowRouteDir, 'route.js.debug.json'),
join(flowRouteDir, '__step_registrations.route.js.temp'),
join(flowRouteDir, '__step_registrations.route.js.temp.debug.json'),
- // V2: clean up stale V1 step route directory
- stepRouteDir,
+ // Clean up executable routes left by earlier v1 builds.
+ join(manifestGeneratedDir, 'flow'),
+ join(manifestGeneratedDir, 'step'),
+ join(manifestGeneratedDir, 'config.json'),
join(webhookRouteDir, 'route.js.temp'),
- join(workflowGeneratedDir, 'manifest.json'),
+ join(legacyWebhookRouteDir, 'route.js.temp'),
+ join(manifestGeneratedDir, 'manifest.json'),
];
await Promise.all([
@@ -634,6 +656,7 @@ export async function getNextBuilderDeferred() {
),
this.removeStaleDeferredTempFiles(flowRouteDir),
this.removeStaleDeferredTempFiles(webhookRouteDir),
+ this.removeStaleDeferredTempFiles(legacyWebhookRouteDir),
]);
}
@@ -935,10 +958,7 @@ export async function getNextBuilderDeferred() {
workflowGeneratedDir: string,
routeFileName: string
): Promise {
- const bundleFiles = [
- join(workflowGeneratedDir, `step/${routeFileName}`),
- join(workflowGeneratedDir, `flow/${routeFileName}`),
- ];
+ const bundleFiles = [join(workflowGeneratedDir, `flow/${routeFileName}`)];
const trackedFiles = new Set();
for (const bundleFile of bundleFiles) {
@@ -1150,17 +1170,25 @@ export async function getNextBuilderDeferred() {
`// ${ROUTE_STUB_FILE_MARKER}`,
'export const __workflowRouteStub = true;',
].join('\n');
- const workflowGeneratedDir = join(outputDir, '.well-known/workflow/v1');
+ const workflowGeneratedDir = join(outputDir, '.well-known/workflow/v2');
+ const manifestGeneratedDir = join(outputDir, '.well-known/workflow/v1');
await mkdir(join(workflowGeneratedDir, 'flow'), { recursive: true });
await mkdir(join(workflowGeneratedDir, 'webhook/[token]'), {
recursive: true,
});
+ await mkdir(join(manifestGeneratedDir, 'webhook/[token]'), {
+ recursive: true,
+ });
await this.writeFileIfChanged(
join(workflowGeneratedDir, '.gitignore'),
'*'
);
+ await this.writeFileIfChanged(
+ join(manifestGeneratedDir, '.gitignore'),
+ '*'
+ );
// V2: Only flow + webhook stubs needed (no separate step route).
// Stubs are replaced by generated output once discovery finishes.
@@ -1172,6 +1200,10 @@ export async function getNextBuilderDeferred() {
join(workflowGeneratedDir, 'webhook/[token]/route.js'),
routeStubContent
);
+ await this.writeFileIfChanged(
+ join(manifestGeneratedDir, 'webhook/[token]/route.js'),
+ routeStubContent
+ );
}
protected async getInputFiles(): Promise {
@@ -1227,7 +1259,7 @@ export async function getNextBuilderDeferred() {
};
await this.writeFileIfChanged(
- join(outputDir, '.well-known/workflow/v1/config.json'),
+ join(outputDir, '.well-known/workflow/v2/config.json'),
JSON.stringify(generatedConfig, null, 2)
);
}
diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts
index 93d587d951..5c94430bb6 100644
--- a/packages/next/src/builder-eager.ts
+++ b/packages/next/src/builder-eager.ts
@@ -1,5 +1,5 @@
import { constants } from 'node:fs';
-import { access, copyFile, mkdir, stat, writeFile } from 'node:fs/promises';
+import { access, copyFile, mkdir, rm, stat, writeFile } from 'node:fs/promises';
import { extname, join, resolve } from 'node:path';
import type { WorkflowManifest } from '@workflow/builders';
import Watchpack from 'watchpack';
@@ -36,11 +36,25 @@ export async function getNextBuilderEager() {
);
const outputDir = await this.findAppDirectory();
- const workflowGeneratedDir = join(outputDir, '.well-known/workflow/v1');
+ const workflowGeneratedDir = join(outputDir, '.well-known/workflow/v2');
+ const manifestGeneratedDir = join(outputDir, '.well-known/workflow/v1');
// Ensure output directories exist
await mkdir(workflowGeneratedDir, { recursive: true });
+ await mkdir(manifestGeneratedDir, { recursive: true });
await writeFile(join(workflowGeneratedDir, '.gitignore'), '*');
+ await writeFile(join(manifestGeneratedDir, '.gitignore'), '*');
+ await Promise.all([
+ rm(join(manifestGeneratedDir, 'flow'), {
+ recursive: true,
+ force: true,
+ }),
+ rm(join(manifestGeneratedDir, 'step'), {
+ recursive: true,
+ force: true,
+ }),
+ rm(join(manifestGeneratedDir, 'config.json'), { force: true }),
+ ]);
const inputFiles = await this.getInputFiles();
const tsconfigPath = await this.findTsConfigPath();
@@ -54,6 +68,9 @@ export async function getNextBuilderEager() {
// V2: Build combined route (replaces separate step + flow routes)
const combinedResult = await this.buildCombinedFunction(options);
await this.buildWebhookRoute({ workflowGeneratedDir });
+ await this.buildWebhookRoute({
+ workflowGeneratedDir: manifestGeneratedDir,
+ });
const writeManifest = async (
sourceManifest: WorkflowManifest | undefined
@@ -68,7 +85,7 @@ export async function getNextBuilderEager() {
const workflowBundlePath = join(workflowGeneratedDir, 'flow/route.js');
const manifestJson = await this.createManifest({
workflowBundlePath,
- manifestDir: workflowGeneratedDir,
+ manifestDir: manifestGeneratedDir,
manifest,
});
@@ -83,7 +100,7 @@ export async function getNextBuilderEager() {
await writeFile(join(publicManifestDir, '.gitignore'), '*');
}
await copyFile(
- join(workflowGeneratedDir, 'manifest.json'),
+ join(manifestGeneratedDir, 'manifest.json'),
join(publicManifestDir, 'manifest.json')
);
}
@@ -102,11 +119,16 @@ export async function getNextBuilderEager() {
'Invariant: expected steps build context in watch mode'
);
}
+ if (!combinedResult?.interimBundleCtx || !combinedResult.bundleFinal) {
+ throw new Error(
+ 'Invariant: expected workflows build context in watch mode'
+ );
+ }
// Use stepsCtx for the watch rebuild (workflow interim ctx from combined)
let workflowsCtx = {
- interimBundleCtx: combinedResult?.interimBundleCtx!,
- bundleFinal: combinedResult?.bundleFinal!,
+ interimBundleCtx: combinedResult.interimBundleCtx,
+ bundleFinal: combinedResult.bundleFinal,
};
const normalizePath = (pathname: string) =>
@@ -429,7 +451,7 @@ export async function getNextBuilderEager() {
};
await writeFile(
- join(outputDir, '.well-known/workflow/v1/config.json'),
+ join(outputDir, '.well-known/workflow/v2/config.json'),
JSON.stringify(generatedConfig, null, 2)
);
}
diff --git a/packages/next/src/builder.ts b/packages/next/src/builder.ts
index e4a4e9ec56..d8644c4732 100644
--- a/packages/next/src/builder.ts
+++ b/packages/next/src/builder.ts
@@ -6,7 +6,8 @@ import { parseEnvironmentFlag } from './environment-flag.js';
export const DEFERRED_BUILDER_MIN_VERSION = '16.2.0-canary.48';
export const WORKFLOW_DEFERRED_ENTRIES = [
- '/.well-known/workflow/v1/flow',
+ '/.well-known/workflow/v2/flow',
+ '/.well-known/workflow/v2/webhook/[token]',
'/.well-known/workflow/v1/webhook/[token]',
] as const;
diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts
index ed68e38a1e..17277807a8 100644
--- a/packages/next/src/index.test.ts
+++ b/packages/next/src/index.test.ts
@@ -41,8 +41,8 @@ vi.mock('./builder.js', () => ({
getNextBuilder: getNextBuilderMock,
shouldUseDeferredBuilder: shouldUseDeferredBuilderMock,
WORKFLOW_DEFERRED_ENTRIES: [
- '/.well-known/workflow/v1/flow',
- '/.well-known/workflow/v1/step',
+ '/.well-known/workflow/v2/flow',
+ '/.well-known/workflow/v2/webhook/[token]',
'/.well-known/workflow/v1/webhook/[token]',
],
}));
diff --git a/packages/nitro/src/index.test.ts b/packages/nitro/src/index.test.ts
index ffb638fbad..08cafde015 100644
--- a/packages/nitro/src/index.test.ts
+++ b/packages/nitro/src/index.test.ts
@@ -66,6 +66,15 @@ describe('@workflow/nitro virtual handlers', () => {
'import "/tmp/.nitro/workflow/webhook.mjs";'
);
expect(webhookSource).toContain('fromWebHandler');
+ expect(
+ nitro.options.handlers.map((h: { route: string }) => h.route)
+ ).toEqual(
+ expect.arrayContaining([
+ '/.well-known/workflow/v2/flow',
+ '/.well-known/workflow/v2/webhook/:token',
+ '/.well-known/workflow/v1/webhook/:token',
+ ])
+ );
});
it('registers the combined flow + webhook virtual handlers for Nitro v3', async () => {
@@ -89,6 +98,15 @@ describe('@workflow/nitro virtual handlers', () => {
'import "/tmp/.nitro/workflow/webhook.mjs";'
);
expect(webhookSource).not.toContain('fromWebHandler');
+ expect(
+ nitro.options.handlers.map((h: { route: string }) => h.route)
+ ).toEqual(
+ expect.arrayContaining([
+ '/.well-known/workflow/v2/flow',
+ '/.well-known/workflow/v2/webhook/:token',
+ '/.well-known/workflow/v1/webhook/:token',
+ ])
+ );
});
it('preserves the side-effect import alongside POST so step registrations are not tree-shaken', async () => {
@@ -140,7 +158,7 @@ describe('@workflow/nitro Vercel functionRules', () => {
await nitroModule.setup(nitro);
const flowRule =
- nitro.options.vercel.functionRules['/.well-known/workflow/v1/flow'];
+ nitro.options.vercel.functionRules['/.well-known/workflow/v2/flow'];
expect(flowRule.maxDuration).toBe('max');
expect(flowRule.experimentalTriggers).toEqual([WORKFLOW_QUEUE_TRIGGER]);
});
@@ -159,8 +177,9 @@ describe('@workflow/nitro Vercel functionRules', () => {
await nitroModule.setup(nitro);
const rules = nitro.options.vercel.functionRules;
+ expect(rules).toHaveProperty('/.well-known/workflow/v2/webhook/:token');
expect(rules).toHaveProperty('/.well-known/workflow/v1/webhook/:token');
- expect(rules).not.toHaveProperty('/.well-known/workflow/v1/webhook/**');
+ expect(rules).not.toHaveProperty('/.well-known/workflow/v2/webhook/**');
const handlerRoutes = nitro.options.handlers.map(
(h: { route: string }) => h.route
@@ -186,7 +205,10 @@ describe('@workflow/nitro Vercel functionRules', () => {
await nitroModule.setup(nitro);
const rules = nitro.options.vercel.functionRules;
- expect(rules['/.well-known/workflow/v1/flow'].runtime).toBe('nodejs22.x');
+ expect(rules['/.well-known/workflow/v2/flow'].runtime).toBe('nodejs22.x');
+ expect(rules['/.well-known/workflow/v2/webhook/:token'].runtime).toBe(
+ 'nodejs22.x'
+ );
expect(rules['/.well-known/workflow/v1/webhook/:token'].runtime).toBe(
'nodejs22.x'
);
@@ -211,6 +233,7 @@ describe('@workflow/nitro Vercel functionRules', () => {
await nitroModule.setup(nitro);
const rules = nitro.options.vercel.functionRules;
+ expect(rules).not.toHaveProperty('/.well-known/workflow/v2/webhook/:token');
expect(rules).not.toHaveProperty('/.well-known/workflow/v1/webhook/:token');
expect(rules).not.toHaveProperty('/.well-known/workflow/v1/manifest.json');
});
@@ -221,7 +244,7 @@ describe('@workflow/nitro Vercel functionRules', () => {
preset: 'vercel',
vercel: {
functionRules: {
- '/.well-known/workflow/v1/flow': {
+ '/.well-known/workflow/v2/flow': {
memory: 3008,
maxDuration: 10,
experimentalTriggers: [],
@@ -233,7 +256,7 @@ describe('@workflow/nitro Vercel functionRules', () => {
await nitroModule.setup(nitro);
const flowRule =
- nitro.options.vercel.functionRules['/.well-known/workflow/v1/flow'];
+ nitro.options.vercel.functionRules['/.well-known/workflow/v2/flow'];
// Untouched user field is preserved
expect(flowRule.memory).toBe(3008);
// Workflow-required fields win
@@ -302,7 +325,7 @@ describe('@workflow/nitro isNitroV2 detection', () => {
// v3 path: functionRules wired up, no `compiled` hook
expect(compiledHooks.length).toBe(0);
expect(
- nitro.options.vercel.functionRules['/.well-known/workflow/v1/flow']
+ nitro.options.vercel.functionRules['/.well-known/workflow/v2/flow']
).toBeDefined();
}
});
diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts
index b010247451..82c75a3b54 100644
--- a/packages/nitro/src/index.ts
+++ b/packages/nitro/src/index.ts
@@ -209,6 +209,11 @@ export default {
});
}
+ addVirtualHandler(
+ nitro,
+ '/.well-known/workflow/v2/webhook/:token',
+ 'workflow/webhook.mjs'
+ );
addVirtualHandler(
nitro,
'/.well-known/workflow/v1/webhook/:token',
@@ -220,7 +225,7 @@ export default {
// handler — no separate step route needed.
addVirtualHandler(
nitro,
- '/.well-known/workflow/v1/flow',
+ '/.well-known/workflow/v2/flow',
'workflow/workflows.mjs'
);
@@ -243,7 +248,7 @@ export default {
const runtime = nitro.options.workflow?.runtime;
const rules = nitro.options.vercel.functionRules;
- const flowPath = '/.well-known/workflow/v1/flow';
+ const flowPath = '/.well-known/workflow/v2/flow';
rules[flowPath] = {
...rules[flowPath],
...(runtime && { runtime }),
@@ -255,8 +260,12 @@ export default {
};
if (runtime) {
- const webhookPath = '/.well-known/workflow/v1/webhook/:token';
- rules[webhookPath] = { ...rules[webhookPath], runtime };
+ for (const webhookPath of [
+ '/.well-known/workflow/v2/webhook/:token',
+ '/.well-known/workflow/v1/webhook/:token',
+ ]) {
+ rules[webhookPath] = { ...rules[webhookPath], runtime };
+ }
if (process.env.WORKFLOW_PUBLIC_MANIFEST === '1') {
const manifestPath = '/.well-known/workflow/v1/manifest.json';
diff --git a/packages/nitro/src/vite.ts b/packages/nitro/src/vite.ts
index d80af7da80..de80cfba78 100644
--- a/packages/nitro/src/vite.ts
+++ b/packages/nitro/src/vite.ts
@@ -54,7 +54,7 @@ export function workflow(options?: ModuleOptions): Plugin[] {
return () => {
server.middlewares.use((req, res, next) => {
// Only handle workflow webhook routes
- if (!req.url?.startsWith('/.well-known/workflow/v1/')) {
+ if (!req.url?.startsWith('/.well-known/workflow/')) {
return next();
}
diff --git a/packages/sveltekit/src/builder.ts b/packages/sveltekit/src/builder.ts
index ffb130c32b..c7a1dd985c 100644
--- a/packages/sveltekit/src/builder.ts
+++ b/packages/sveltekit/src/builder.ts
@@ -42,21 +42,30 @@ export class SvelteKitBuilder extends BaseBuilder {
override async build(): Promise {
// Find SvelteKit routes directory (src/routes or routes)
const routesDir = await this.findRoutesDirectory();
- const workflowGeneratedDir = join(routesDir, '.well-known/workflow/v1');
+ const workflowGeneratedDir = join(routesDir, '.well-known/workflow/v2');
+ const manifestGeneratedDir = join(routesDir, '.well-known/workflow/v1');
// Ensure output directories exist
await mkdir(workflowGeneratedDir, { recursive: true });
+ await mkdir(manifestGeneratedDir, { recursive: true });
// Add .gitignore to exclude generated files from version control
if (process.env.VERCEL_DEPLOYMENT_ID === undefined) {
await writeFile(join(workflowGeneratedDir, '.gitignore'), '*');
+ await writeFile(join(manifestGeneratedDir, '.gitignore'), '*');
}
- // Clean up stale V1 step route directory (may persist via Vercel build cache)
- await rm(join(workflowGeneratedDir, 'step'), {
- recursive: true,
- force: true,
- });
+ // Remove executable routes that may persist from pre-v2 endpoint builds.
+ await Promise.all([
+ rm(join(manifestGeneratedDir, 'flow'), {
+ recursive: true,
+ force: true,
+ }),
+ rm(join(manifestGeneratedDir, 'step'), {
+ recursive: true,
+ force: true,
+ }),
+ ]);
// Get workflow and step files to bundle
const inputFiles = await this.getInputFiles();
@@ -92,12 +101,15 @@ export const POST = async ({request}) => {
await writeFile(workflowsRouteFile, workflowsRouteContent);
await this.buildWebhookRoute({ workflowGeneratedDir });
+ await this.buildWebhookRoute({
+ workflowGeneratedDir: manifestGeneratedDir,
+ });
// Generate unified manifest
const workflowBundlePath = join(workflowGeneratedDir, 'flow/+server.js');
const manifestJson = await this.createManifest({
workflowBundlePath,
- manifestDir: workflowGeneratedDir,
+ manifestDir: manifestGeneratedDir,
manifest,
});
@@ -113,7 +125,7 @@ export const POST = async ({request}) => {
await writeFile(join(staticManifestDir, '.gitignore'), '*');
}
await copyFile(
- join(workflowGeneratedDir, 'manifest.json'),
+ join(manifestGeneratedDir, 'manifest.json'),
join(staticManifestDir, 'manifest.json')
);
}
@@ -124,7 +136,7 @@ export const POST = async ({request}) => {
}: {
workflowGeneratedDir: string;
}) {
- // Create webhook route: .well-known/workflow/v1/webhook/[token]/+server.js
+ // Create webhook route: .well-known/workflow/v2/webhook/[token]/+server.js
const webhookRouteFile = join(
workflowGeneratedDir,
'webhook/[token]/+server.js'
diff --git a/packages/sveltekit/src/index.ts b/packages/sveltekit/src/index.ts
index d3f4a18a64..669eb8e517 100644
--- a/packages/sveltekit/src/index.ts
+++ b/packages/sveltekit/src/index.ts
@@ -21,7 +21,7 @@ process.on('beforeExit', () => {
// The separate step route was removed.
for (const { file, config } of [
{
- file: '.vercel/output/functions/.well-known/workflow/v1/flow.func/.vc-config.json',
+ file: '.vercel/output/functions/.well-known/workflow/v2/flow.func/.vc-config.json',
config: {
maxDuration: 'max',
experimentalTriggers: [WORKFLOW_QUEUE_TRIGGER],
diff --git a/packages/utils/src/get-port.test.ts b/packages/utils/src/get-port.test.ts
index fd44cd1656..c9fa614c6d 100644
--- a/packages/utils/src/get-port.test.ts
+++ b/packages/utils/src/get-port.test.ts
@@ -253,7 +253,7 @@ describe('getWorkflowPort', () => {
if (req.url?.includes('__health')) {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Workflow SDK endpoint is healthy');
- } else if (req.url?.startsWith('/.well-known/workflow/v1/')) {
+ } else if (req.url?.startsWith('/.well-known/workflow/v2/')) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing required headers' }));
} else {
@@ -305,7 +305,7 @@ describe('getWorkflowPort', () => {
if (req.url?.includes('__health')) {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Workflow SDK endpoint is healthy');
- } else if (req.url?.startsWith('/.well-known/workflow/v1/')) {
+ } else if (req.url?.startsWith('/.well-known/workflow/v2/')) {
res.writeHead(400);
res.end();
} else {
@@ -333,7 +333,7 @@ describe('getWorkflowPort', () => {
if (req.url?.includes('__health')) {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Workflow SDK endpoint is healthy');
- } else if (req.url?.startsWith('/.well-known/workflow/v1/')) {
+ } else if (req.url?.startsWith('/.well-known/workflow/v2/')) {
res.writeHead(400);
res.end();
} else {
diff --git a/packages/utils/src/get-port.ts b/packages/utils/src/get-port.ts
index b6806a35a4..693c10e22a 100644
--- a/packages/utils/src/get-port.ts
+++ b/packages/utils/src/get-port.ts
@@ -234,7 +234,7 @@ export async function getPort(): Promise {
// Configuration for HTTP probing
const PROBE_TIMEOUT_MS = 500;
-const PROBE_ENDPOINT = '/.well-known/workflow/v1/flow?__health';
+const PROBE_ENDPOINT = '/.well-known/workflow/v2/flow?__health';
export interface ProbeOptions {
endpoint?: string;
diff --git a/packages/world-local/src/queue.ts b/packages/world-local/src/queue.ts
index 7ac0277dad..bb921fffd0 100644
--- a/packages/world-local/src/queue.ts
+++ b/packages/world-local/src/queue.ts
@@ -91,11 +91,11 @@ function isDetachedArrayBufferQueueError(error: unknown): boolean {
}
function getQueueRoute(queueName: ValidQueueName): {
- pathname: 'flow' | 'step';
+ pathname: 'flow';
prefix: '__wkf_step_' | '__wkf_workflow_';
} {
if (queueName.startsWith('__wkf_step_')) {
- return { pathname: 'step', prefix: '__wkf_step_' };
+ return { pathname: 'flow', prefix: '__wkf_step_' };
}
if (queueName.startsWith('__wkf_workflow_')) {
return { pathname: 'flow', prefix: '__wkf_workflow_' };
@@ -182,7 +182,7 @@ export function createQueue(config: Partial): LocalQueue {
if (directHandler) {
const req = new Request(
- `http://localhost/.well-known/workflow/v1/${pathname}`,
+ `http://localhost/.well-known/workflow/v2/${pathname}`,
{
method: 'POST',
headers,
@@ -194,7 +194,7 @@ export function createQueue(config: Partial): LocalQueue {
const baseUrl = await resolveBaseUrl(config);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- undici v7 dispatcher types don't match @types/node's RequestInit
response = await fetch(
- `${baseUrl}/.well-known/workflow/v1/${pathname}`,
+ `${baseUrl}/.well-known/workflow/v2/${pathname}`,
{
method: 'POST',
duplex: 'half',
diff --git a/packages/world-postgres/HOW_IT_WORKS.md b/packages/world-postgres/HOW_IT_WORKS.md
index 4c13a2a20e..ab6a0960c0 100644
--- a/packages/world-postgres/HOW_IT_WORKS.md
+++ b/packages/world-postgres/HOW_IT_WORKS.md
@@ -37,7 +37,7 @@ Call `world.start()` to initialize graphile-worker workers. When `.start()` is c
When the runtime returns `{ timeoutSeconds }`, the worker schedules a new Graphile job with a future `runAt` time before finishing the current task.
-The worker targets the HTTP-compatible workflow endpoints directly: `.well-known/workflow/v1/flow` for workflows and `.well-known/workflow/v1/step` for steps.
+The worker targets the combined HTTP-compatible workflow endpoint directly: `.well-known/workflow/v2/flow` handles workflow and step queue messages.
In **Next.js**, the `world.start()` call needs to be added to `instrumentation.ts|js` to ensure workers start before request handling. Use `workflow/runtime` for `getWorld` (same as the testing server and other framework plugins):
diff --git a/packages/world-postgres/README.md b/packages/world-postgres/README.md
index d23ceefeae..1d3a8edebf 100644
--- a/packages/world-postgres/README.md
+++ b/packages/world-postgres/README.md
@@ -143,7 +143,7 @@ Make sure your PostgreSQL database is accessible and the user has sufficient per
- Graphile jobs are acknowledged only after the workflow or step execution finishes, or after the worker durably schedules a delayed follow-up job
- Backlog stays in PostgreSQL when all execution slots are busy
- Retry and sleep-style delays use Graphile `runAt` scheduling
-- Workflow and step execution is sent through `/.well-known/workflow/v1/flow` and `/.well-known/workflow/v1/step`
+- Workflow and step execution is sent through the combined `/.well-known/workflow/v2/flow` handler
## Development
diff --git a/packages/world-postgres/src/queue.test.ts b/packages/world-postgres/src/queue.test.ts
index dd2122ecde..17b57a6fe0 100644
--- a/packages/world-postgres/src/queue.test.ts
+++ b/packages/world-postgres/src/queue.test.ts
@@ -2,12 +2,12 @@ import { createServer, type Server } from 'node:http';
import { JsonTransport } from '@vercel/queue';
import { getWorkflowPort } from '@workflow/utils/get-port';
import { MessageId, type QueuePayload } from '@workflow/world';
+import { createLocalWorld } from '@workflow/world-local';
import { makeWorkerUtils, run, type WorkerUtils } from 'graphile-worker';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { createLocalWorld } from '@workflow/world-local';
import { stepEntrypoint } from '../../core/dist/runtime/step-handler.js';
-import { createQueue } from './queue.js';
import { MessageData } from './message.js';
+import { createQueue } from './queue.js';
const transport = new JsonTransport();
const createdQueues: Array> = [];
@@ -77,7 +77,7 @@ describe('postgres queue http execution', () => {
delete process.env.PORT;
});
- it('uses the workflow http step route when the real runtime step handler would fail in-process with Step not found', async () => {
+ it('uses the combined workflow HTTP route when a step cannot execute in-process', async () => {
const requests: Array<{
method: string | undefined;
url: string | undefined;
@@ -121,7 +121,7 @@ describe('postgres queue http execution', () => {
expect(requests).toEqual([
expect.objectContaining({
method: 'POST',
- url: '/.well-known/workflow/v1/step',
+ url: '/.well-known/workflow/v2/flow',
headers: expect.objectContaining({
'x-vqs-queue-name': '__wkf_step_test-step',
'x-vqs-message-attempt': '1',
@@ -164,7 +164,7 @@ describe('postgres queue http execution', () => {
expect(requests).toEqual([
expect.objectContaining({
method: 'POST',
- url: '/.well-known/workflow/v1/step',
+ url: '/.well-known/workflow/v2/flow',
}),
]);
});
@@ -274,7 +274,7 @@ describe('postgres queue http execution', () => {
await expect(task(payload, {} as any)).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith(
- 'http://localhost:3000/.well-known/workflow/v1/flow',
+ 'http://localhost:3000/.well-known/workflow/v2/flow',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
@@ -413,7 +413,7 @@ async function startWorkflowHttpServer(
return;
}
- if (req.method === 'POST' && req.url === '/.well-known/workflow/v1/step') {
+ if (req.method === 'POST' && req.url === '/.well-known/workflow/v2/flow') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
return;
diff --git a/packages/world-postgres/src/queue.ts b/packages/world-postgres/src/queue.ts
index 83624cc6b4..308212e144 100644
--- a/packages/world-postgres/src/queue.ts
+++ b/packages/world-postgres/src/queue.ts
@@ -219,11 +219,11 @@ export function createQueue(
throw new Error('Unable to resolve base URL for workflow queue.');
}
- function getQueueRoute(queueName: ValidQueueName): 'flow' | 'step' {
- if (queueName.startsWith('__wkf_step_')) {
- return 'step';
- }
- if (queueName.startsWith('__wkf_workflow_')) {
+ function getQueueRoute(queueName: ValidQueueName): 'flow' {
+ if (
+ queueName.startsWith('__wkf_step_') ||
+ queueName.startsWith('__wkf_workflow_')
+ ) {
return 'flow';
}
throw new Error('Unknown queue name prefix');
@@ -253,7 +253,7 @@ export function createQueue(
const pathname = getQueueRoute(queueName);
const response = await fetch(
- `${baseUrl}/.well-known/workflow/v1/${pathname}`,
+ `${baseUrl}/.well-known/workflow/v2/${pathname}`,
{
method: 'POST',
duplex: 'half',
diff --git a/packages/world-testing/package.json b/packages/world-testing/package.json
index 5019f8fb0a..34a928b937 100644
--- a/packages/world-testing/package.json
+++ b/packages/world-testing/package.json
@@ -16,7 +16,7 @@
"directory": "packages/world-testing"
},
"scripts": {
- "build": "wf build && node scripts/generate-well-known-dts.mjs && tsc && cp .well-known/workflow/v1/*.mjs dist/.well-known/workflow/v1/",
+ "build": "wf build && node scripts/generate-well-known-dts.mjs && tsc && mkdir -p dist/.well-known/workflow/v1 dist/.well-known/workflow/v2 && cp .well-known/workflow/v1/*.mjs dist/.well-known/workflow/v1/ && cp .well-known/workflow/v2/*.mjs dist/.well-known/workflow/v2/",
"clean": "tsc --build --clean && rm -rf dist .well-known* .workflow-data",
"start": "node --watch src/server.mts",
"test": "vitest"
diff --git a/packages/world-testing/scripts/generate-well-known-dts.mjs b/packages/world-testing/scripts/generate-well-known-dts.mjs
index 4464aadae4..91795865d8 100644
--- a/packages/world-testing/scripts/generate-well-known-dts.mjs
+++ b/packages/world-testing/scripts/generate-well-known-dts.mjs
@@ -8,11 +8,14 @@
*/
import { existsSync, writeFileSync } from 'node:fs';
-const dir = '.well-known/workflow/v1';
const stub =
'export declare const POST: (req: Request) => Response | Promise;\n';
-for (const name of ['flow', 'step', 'webhook']) {
+for (const [dir, name] of [
+ ['.well-known/workflow/v2', 'flow'],
+ ['.well-known/workflow/v2', 'webhook'],
+ ['.well-known/workflow/v1', 'webhook'],
+]) {
const dts = `${dir}/${name}.d.mts`;
if (!existsSync(dts)) {
writeFileSync(dts, stub);
diff --git a/packages/world-testing/src/server.mts b/packages/world-testing/src/server.mts
index 5af3f3a7b1..057327c391 100644
--- a/packages/world-testing/src/server.mts
+++ b/packages/world-testing/src/server.mts
@@ -4,10 +4,10 @@ import { Hono } from 'hono';
import { getHookByToken, getRun, resumeHook, start } from 'workflow/api';
import { getWorld } from 'workflow/runtime';
import * as z from 'zod';
-import { POST as flowPOST } from '../.well-known/workflow/v1/flow.mjs';
import manifest from '../.well-known/workflow/v1/manifest.json' with {
type: 'json',
};
+import { POST as flowPOST } from '../.well-known/workflow/v2/flow.mjs';
if (!process.env.WORKFLOW_TARGET_WORLD) {
console.error(
@@ -45,7 +45,7 @@ const Invoke = z
const flowInvocationCounts = new Map();
const app = new Hono()
- .post('/.well-known/workflow/v1/flow', async (ctx) => {
+ .post('/.well-known/workflow/v2/flow', async (ctx) => {
// Clone the request to read the body for tracking without consuming it.
// We must increment the invocation counter *before* awaiting flowPOST,
// otherwise the workflow may complete (and the test may observe the
@@ -118,7 +118,7 @@ const app = new Hono()
const runId = ctx.req.param('runId');
const world = await getWorld();
const allEvents: { eventType: string; correlationId?: string }[] = [];
- let cursor: string | undefined = undefined;
+ let cursor: string | undefined;
while (true) {
const page = await world.events.list({
runId,
diff --git a/scripts/create-test-matrix.mjs b/scripts/create-test-matrix.mjs
index be2573ea9f..9f429e4ca9 100644
--- a/scripts/create-test-matrix.mjs
+++ b/scripts/create-test-matrix.mjs
@@ -2,15 +2,15 @@
const DEV_TEST_CONFIGS = {
'nextjs-turbopack': {
generatedStepPath:
- 'app/.well-known/workflow/v1/flow/__step_registrations.js',
- generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js',
+ 'app/.well-known/workflow/v2/flow/__step_registrations.js',
+ generatedWorkflowPath: 'app/.well-known/workflow/v2/flow/route.js',
apiFilePath: 'app/api/chat/route.ts',
apiFileImportPath: '../../..',
},
'nextjs-webpack': {
generatedStepPath:
- 'app/.well-known/workflow/v1/flow/__step_registrations.js',
- generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js',
+ 'app/.well-known/workflow/v2/flow/__step_registrations.js',
+ generatedWorkflowPath: 'app/.well-known/workflow/v2/flow/route.js',
apiFilePath: 'app/api/chat/route.ts',
apiFileImportPath: '../../..',
},
@@ -28,8 +28,8 @@ const DEV_TEST_CONFIGS = {
},
sveltekit: {
generatedStepPath:
- 'src/routes/.well-known/workflow/v1/flow/__step_registrations.js',
- generatedWorkflowPath: 'src/routes/.well-known/workflow/v1/flow/+server.js',
+ 'src/routes/.well-known/workflow/v2/flow/__step_registrations.js',
+ generatedWorkflowPath: 'src/routes/.well-known/workflow/v2/flow/+server.js',
apiFilePath: 'src/routes/api/chat/+server.ts',
apiFileImportPath: '../../../..',
workflowsDir: 'src/workflows',
@@ -67,8 +67,8 @@ const DEV_TEST_CONFIGS = {
},
astro: {
generatedStepPath:
- 'src/pages/.well-known/workflow/v1/__step_registrations.js',
- generatedWorkflowPath: 'src/pages/.well-known/workflow/v1/flow.js',
+ 'src/pages/.well-known/workflow/v2/__step_registrations.js',
+ generatedWorkflowPath: 'src/pages/.well-known/workflow/v2/flow.js',
apiFilePath: 'src/pages/api/chat.ts',
apiFileImportPath: '../..',
workflowsDir: 'src/workflows',