diff --git a/.changeset/abort-signal-replay-ordering.md b/.changeset/abort-signal-replay-ordering.md
new file mode 100644
index 0000000000..4b2e971c47
--- /dev/null
+++ b/.changeset/abort-signal-replay-ordering.md
@@ -0,0 +1,5 @@
+---
+'@workflow/core': patch
+---
+
+Fix a race where an `AbortController` aborted from a step was not reflected in a `controller.signal` passed to a subsequent step. The step now commits the abort's durable hook event before completing, and the workflow's suspension waits for the abort to land before serializing downstream step arguments.
diff --git a/.changeset/add-platformatic-world.md b/.changeset/add-platformatic-world.md
new file mode 100644
index 0000000000..bed02b7641
--- /dev/null
+++ b/.changeset/add-platformatic-world.md
@@ -0,0 +1,4 @@
+---
+---
+
+Add Platformatic to `worlds-manifest.json` as a community world, and add a generic `docker` service type to the community-world CI (both the E2E and benchmark reusable workflows) so worlds can declare arbitrary Docker containers in their manifest `services` array. Platformatic's E2E job is gated by the existing `if: false` on `e2e-community` until community worlds ship CBOR queue transport support.
diff --git a/.changeset/cancel-v4-frame-stream.md b/.changeset/cancel-v4-frame-stream.md
deleted file mode 100644
index 9383ae3cc4..0000000000
--- a/.changeset/cancel-v4-frame-stream.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@workflow/world-vercel': patch
----
-
-Cancel the v4 event frame stream when a reader stops early, so the response body's undici connection returns to the pool instead of leaking.
diff --git a/.changeset/fast-workflow-discovery.md b/.changeset/fast-workflow-discovery.md
new file mode 100644
index 0000000000..030302e9c9
--- /dev/null
+++ b/.changeset/fast-workflow-discovery.md
@@ -0,0 +1,6 @@
+---
+'@workflow/builders': patch
+'@workflow/next': patch
+---
+
+Optimize eager workflow discovery and improve default eager build compatibility.
diff --git a/.changeset/perf-memoize-step-hydration.md b/.changeset/perf-memoize-step-hydration.md
new file mode 100644
index 0000000000..deefc926ad
--- /dev/null
+++ b/.changeset/perf-memoize-step-hydration.md
@@ -0,0 +1,6 @@
+---
+'@workflow/core': patch
+'workflow': patch
+---
+
+Memoize hydrated step return values across inline replay iterations, turning the per-invocation step-result decrypt+parse cost from O(N²) to O(N) for sequential workflows. Only primitive results are cached, so deterministic replay is preserved.
diff --git a/.changeset/pre.json b/.changeset/pre.json
index 999f770fbe..3e815d0bf2 100644
--- a/.changeset/pre.json
+++ b/.changeset/pre.json
@@ -52,6 +52,7 @@
},
"changesets": [
"abort-e2e-flakes",
+ "abort-signal-replay-ordering",
"ack-after-step-dispatch",
"afraid-bananas-peel",
"allow-sync-step-functions",
@@ -108,6 +109,7 @@
"event-log-race-repro-infra",
"events-exact-id-search",
"experimental-attributes-docs",
+ "fast-workflow-discovery",
"fatal-retryable-error-serialization",
"features-encryption-metadata",
"few-cups-share",
@@ -201,6 +203,7 @@
"parallel-inline-optimistic-start",
"pending-trace-viewer-gray-indicator",
"perf-cached-workflow-script",
+ "perf-memoize-step-hydration",
"pr-comment-stale-banner-via-path",
"precise-trace-viewer-durations",
"preserve-imports-used-by-hoisted-steps",
@@ -210,6 +213,7 @@
"proud-friends-decide",
"queue-namespace-primitive",
"queued-for-uses-first-step-started",
+ "quiet-lamps-parse",
"quiet-trace-viewer-duration",
"quieter-stream-metadata",
"rare-badgers-judge",
@@ -218,6 +222,7 @@
"reject-empty-hook-token",
"relative-time-card",
"remove-client-mode",
+ "remove-next-lazy-discovery",
"remove-private-subpath",
"remove-sdk-serde-exclusion",
"remove-step-file-copy",
@@ -282,6 +287,7 @@
"trace-viewer-load-more",
"trace-viewer-loading-skeleton",
"trace-viewer-polish",
+ "turbo-mode-first-invocation",
"turbo-next-workbench-outputs",
"turbo-next-workbench-vercel-output",
"update-queue-client-version",
@@ -298,6 +304,7 @@
"vast-oranges-fail",
"vercel-world-custom-dispatcher",
"versioning-docs",
+ "vitest-bundle-local-step-deps",
"warn-external-workflow-packages",
"web-shared-error-family-revivers",
"web-vercel-preset",
diff --git a/.changeset/prewarm-next-swc-cache.md b/.changeset/prewarm-next-swc-cache.md
new file mode 100644
index 0000000000..4dc731d474
--- /dev/null
+++ b/.changeset/prewarm-next-swc-cache.md
@@ -0,0 +1,5 @@
+---
+'@workflow/next': patch
+---
+
+Prewarm the Workflow SWC plugin cache before Next.js starts parallel loader workers.
diff --git a/.changeset/remove-next-lazy-discovery.md b/.changeset/remove-next-lazy-discovery.md
new file mode 100644
index 0000000000..8a600abced
--- /dev/null
+++ b/.changeset/remove-next-lazy-discovery.md
@@ -0,0 +1,8 @@
+---
+'@workflow/next': minor
+'@workflow/builders': patch
+---
+
+Remove the Next.js lazy discovery/deferred builder path and the `workflows.lazyDiscovery` option.
+
+Fall back to direct generated-file overwrites on Windows when atomic rename is blocked by Next.js dev server file handles.
diff --git a/.changeset/stream-read-v3-reconnect.md b/.changeset/stream-read-v3-reconnect.md
new file mode 100644
index 0000000000..71fd0c843c
--- /dev/null
+++ b/.changeset/stream-read-v3-reconnect.md
@@ -0,0 +1,5 @@
+---
+'@workflow/world-vercel': patch
+---
+
+Use v3 endpoint for stream reads, which supports automatic transparent reconnects.
diff --git a/.changeset/turbo-mode-first-invocation.md b/.changeset/turbo-mode-first-invocation.md
new file mode 100644
index 0000000000..da3b777774
--- /dev/null
+++ b/.changeset/turbo-mode-first-invocation.md
@@ -0,0 +1,6 @@
+---
+'workflow': minor
+'@workflow/core': minor
+---
+
+Add turbo mode (on by default, disable with `WORKFLOW_TURBO=0`): on the first delivery of a run's first invocation the runtime backgrounds `run_started`, skips the initial event-log load, and forces optimistic inline start so the run reaches its first steps with no preceding network round-trips. It is safe there because the first delivery has no concurrent handler to race; turbo mode deactivates once a hook or sleep is encountered.
diff --git a/.changeset/turbo-skip-run-started-preload.md b/.changeset/turbo-skip-run-started-preload.md
new file mode 100644
index 0000000000..9fe116882d
--- /dev/null
+++ b/.changeset/turbo-skip-run-started-preload.md
@@ -0,0 +1,7 @@
+---
+'@workflow/core': patch
+'@workflow/world': patch
+'@workflow/world-vercel': patch
+---
+
+Turbo mode now tells world-vercel to skip the run_started event-log preload it never reads, reducing request time.
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 3d0288f96a..65a595db59 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,4 +1,2 @@
# Default owners for the entire repository
-* @vercel/workflow
-
-packages/next/src @ijjk @vercel/workflow
+* @ijjk @vercel/workflow
diff --git a/.github/workflows/benchmark-community-world.yml b/.github/workflows/benchmark-community-world.yml
index a15b29a27e..f19e63c051 100644
--- a/.github/workflows/benchmark-community-world.yml
+++ b/.github/workflows/benchmark-community-world.yml
@@ -19,6 +19,11 @@ on:
description: 'NPM package name for the world'
required: true
type: string
+ package-version:
+ description: 'Optional npm version/dist-tag to pin the world package to (empty = latest)'
+ required: false
+ type: string
+ default: ''
app-name:
description: 'App to test (default: nextjs-turbopack)'
required: false
@@ -30,10 +35,15 @@ on:
type: string
default: '{}'
service-type:
- description: 'Service container to run (none, mongodb, redis)'
+ description: 'Service container to run (none, mongodb, redis, docker)'
required: false
type: string
default: 'none'
+ services:
+ description: 'JSON array of service definitions from the manifest (used when service-type is docker)'
+ required: false
+ type: string
+ default: '[]'
full-suite:
description: 'Run full benchmark suite including long-running tests'
required: false
@@ -81,6 +91,58 @@ jobs:
sleep 2
done
+ - name: Start Docker services
+ if: ${{ inputs.service-type == 'docker' }}
+ # Services start serially in manifest order. Each service's health check
+ # must pass before the next one starts, so any service that depends on
+ # another (e.g. a workflow service that needs postgres) must be listed
+ # AFTER its dependency in the manifest's `services` array.
+ #
+ # `healthCheck.cmd` is executed via `sh -c` below, which means the
+ # manifest is a shell-execution surface. Safe here because
+ # `worlds-manifest.json` is in-repo and PR-reviewed.
+ run: |
+ set -euo pipefail
+ SERVICES='${{ inputs.services }}'
+ count=$(echo "$SERVICES" | jq '. | length')
+ for idx in $(seq 0 $((count - 1))); do
+ svc=$(echo "$SERVICES" | jq -c ".[$idx]")
+ name=$(echo "$svc" | jq -r '.name')
+ image=$(echo "$svc" | jq -r '.image')
+ health_cmd=$(echo "$svc" | jq -r '.healthCheck.cmd // empty')
+ retries=$(echo "$svc" | jq -r '.healthCheck.retries // 10')
+
+ # Build docker run as an array so env values with spaces/quotes
+ # survive intact (no shell re-interpretation via eval).
+ args=(docker run -d --name "$name" --network host)
+ env_lines=$(echo "$svc" | jq -r '.env // {} | to_entries[] | "\(.key)=\(.value)"')
+ if [ -n "$env_lines" ]; then
+ while IFS= read -r line; do
+ args+=(-e "$line")
+ done <<< "$env_lines"
+ fi
+ args+=("$image")
+
+ echo "Starting $name ($image)..."
+ "${args[@]}"
+
+ # Health check runs on the host (--network host shares the network).
+ if [ -n "$health_cmd" ]; then
+ echo "Waiting for $name to be ready..."
+ for i in $(seq 1 "$retries"); do
+ if sh -c "$health_cmd" &>/dev/null; then
+ echo "$name is ready"
+ break
+ fi
+ if [ "$i" -eq "$retries" ]; then
+ echo "ERROR: $name health check did not pass after $retries retries"
+ exit 1
+ fi
+ sleep 2
+ done
+ fi
+ done
+
- name: Setup environment
uses: ./.github/actions/setup-workflow-dev
with:
@@ -109,7 +171,10 @@ jobs:
env:
APP_NAME: ${{ inputs.app-name }}
WORLD_PACKAGE: ${{ inputs.world-package }}
- run: pnpm --filter "$APP_NAME" add "$WORLD_PACKAGE"
+ WORLD_PACKAGE_VERSION: ${{ inputs.package-version }}
+ run: |
+ SPEC="${WORLD_PACKAGE}${WORLD_PACKAGE_VERSION:+@$WORLD_PACKAGE_VERSION}"
+ pnpm --filter "$APP_NAME" add "$SPEC"
# Per-world setup. Hardcoded (not taken from the matrix) so a malicious
# fork PR cannot smuggle arbitrary shell through matrix.world.setup-command.
@@ -175,3 +240,8 @@ jobs:
run: |
docker stop mongodb 2>/dev/null || true
docker stop redis 2>/dev/null || true
+ if [ '${{ inputs.service-type }}' = 'docker' ]; then
+ echo '${{ inputs.services }}' | jq -r '.[].name' 2>/dev/null | while read -r name; do
+ docker stop "$name" 2>/dev/null || true
+ done
+ fi
diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml
index ab618e0502..88edb79feb 100644
--- a/.github/workflows/benchmarks.yml
+++ b/.github/workflows/benchmarks.yml
@@ -513,8 +513,10 @@ jobs:
world-id: ${{ matrix.world.id }}
world-name: ${{ matrix.world.name }}
world-package: ${{ matrix.world.package }}
+ package-version: ${{ matrix.world.version }}
service-type: ${{ matrix.world.service-type }}
env-vars: ${{ matrix.world.env-vars }}
+ services: ${{ matrix.world.services }}
# Run full suite only when manually triggered with full_suite=true
full-suite: ${{ (github.event_name == 'workflow_dispatch' && inputs.full_suite) || contains(github.event.pull_request.labels.*.name, 'stress-test') }}
secrets: inherit
diff --git a/.github/workflows/e2e-community-world.yml b/.github/workflows/e2e-community-world.yml
index 330f04d82c..45c3c54617 100644
--- a/.github/workflows/e2e-community-world.yml
+++ b/.github/workflows/e2e-community-world.yml
@@ -23,6 +23,11 @@ on:
description: 'NPM package name for the world'
required: true
type: string
+ package-version:
+ description: 'Optional npm version/dist-tag to pin the world package to (empty = latest)'
+ required: false
+ type: string
+ default: ''
app-name:
description: 'App to test (default: nextjs-turbopack)'
required: false
@@ -34,10 +39,15 @@ on:
type: string
default: '{}'
service-type:
- description: 'Service container to run (none, mongodb, redis)'
+ description: 'Service container to run (none, mongodb, redis, docker)'
required: false
type: string
default: 'none'
+ services:
+ description: 'JSON array of service definitions from the manifest (used when service-type is docker)'
+ required: false
+ type: string
+ default: '[]'
jobs:
e2e:
@@ -83,6 +93,58 @@ jobs:
sleep 2
done
+ - name: Start Docker services
+ if: ${{ inputs.service-type == 'docker' }}
+ # Services start serially in manifest order. Each service's health check
+ # must pass before the next one starts, so any service that depends on
+ # another (e.g. a workflow service that needs postgres) must be listed
+ # AFTER its dependency in the manifest's `services` array.
+ #
+ # `healthCheck.cmd` is executed via `sh -c` below, which means the
+ # manifest is a shell-execution surface. Safe here because
+ # `worlds-manifest.json` is in-repo and PR-reviewed.
+ run: |
+ set -euo pipefail
+ SERVICES='${{ inputs.services }}'
+ count=$(echo "$SERVICES" | jq '. | length')
+ for idx in $(seq 0 $((count - 1))); do
+ svc=$(echo "$SERVICES" | jq -c ".[$idx]")
+ name=$(echo "$svc" | jq -r '.name')
+ image=$(echo "$svc" | jq -r '.image')
+ health_cmd=$(echo "$svc" | jq -r '.healthCheck.cmd // empty')
+ retries=$(echo "$svc" | jq -r '.healthCheck.retries // 10')
+
+ # Build docker run as an array so env values with spaces/quotes
+ # survive intact (no shell re-interpretation via eval).
+ args=(docker run -d --name "$name" --network host)
+ env_lines=$(echo "$svc" | jq -r '.env // {} | to_entries[] | "\(.key)=\(.value)"')
+ if [ -n "$env_lines" ]; then
+ while IFS= read -r line; do
+ args+=(-e "$line")
+ done <<< "$env_lines"
+ fi
+ args+=("$image")
+
+ echo "Starting $name ($image)..."
+ "${args[@]}"
+
+ # Health check runs on the host (--network host shares the network).
+ if [ -n "$health_cmd" ]; then
+ echo "Waiting for $name to be ready..."
+ for i in $(seq 1 "$retries"); do
+ if sh -c "$health_cmd" &>/dev/null; then
+ echo "$name is ready"
+ break
+ fi
+ if [ "$i" -eq "$retries" ]; then
+ echo "ERROR: $name health check did not pass after $retries retries"
+ exit 1
+ fi
+ sleep 2
+ done
+ fi
+ done
+
- name: Setup environment
uses: ./.github/actions/setup-workflow-dev
with:
@@ -111,7 +173,10 @@ jobs:
env:
APP_NAME: ${{ inputs.app-name }}
WORLD_PACKAGE: ${{ inputs.world-package }}
- run: pnpm --filter "$APP_NAME" add "$WORLD_PACKAGE"
+ WORLD_PACKAGE_VERSION: ${{ inputs.package-version }}
+ run: |
+ SPEC="${WORLD_PACKAGE}${WORLD_PACKAGE_VERSION:+@$WORLD_PACKAGE_VERSION}"
+ pnpm --filter "$APP_NAME" add "$SPEC"
# Per-world setup. Hardcoded (not taken from the matrix) so a malicious
# fork PR cannot smuggle arbitrary shell through matrix.world.setup-command.
@@ -175,3 +240,8 @@ jobs:
run: |
docker stop mongodb 2>/dev/null || true
docker stop redis 2>/dev/null || true
+ if [ '${{ inputs.service-type }}' = 'docker' ]; then
+ echo '${{ inputs.services }}' | jq -r '.[].name' 2>/dev/null | while read -r name; do
+ docker stop "$name" 2>/dev/null || true
+ done
+ fi
diff --git a/.github/workflows/event-log-race-repro.yml b/.github/workflows/event-log-race-repro.yml
index 3e3bec4da3..fc4341cdca 100644
--- a/.github/workflows/event-log-race-repro.yml
+++ b/.github/workflows/event-log-race-repro.yml
@@ -68,7 +68,6 @@ jobs:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
WORKFLOW_PUBLIC_MANIFEST: '1'
- WORKFLOW_NEXT_LAZY_DISCOVERY: '0'
steps:
- name: Checkout Repo
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 1665b31aaa..3740c73bef 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -353,7 +353,6 @@ jobs:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
WORKFLOW_PUBLIC_MANIFEST: '1'
- WORKFLOW_NEXT_LAZY_DISCOVERY: ${{ matrix.app.canary != true && (matrix.app.name == 'nextjs-turbopack' || matrix.app.name == 'nextjs-webpack') && '0' || '' }}
steps:
- name: Checkout Repo
uses: actions/checkout@v4
@@ -477,7 +476,6 @@ jobs:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
WORKFLOW_PUBLIC_MANIFEST: '1'
- WORKFLOW_NEXT_LAZY_DISCOVERY: ${{ matrix.app.lazyDiscovery == false && '0' || matrix.app.lazyDiscovery == true && '1' || '' }}
steps:
- name: Checkout Repo
@@ -561,7 +559,6 @@ jobs:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
WORKFLOW_PUBLIC_MANIFEST: '1'
- WORKFLOW_NEXT_LAZY_DISCOVERY: ${{ matrix.app.lazyDiscovery == false && '0' || matrix.app.lazyDiscovery == true && '1' || '' }}
steps:
- name: Checkout Repo
@@ -666,7 +663,6 @@ jobs:
WORKFLOW_PUBLIC_MANIFEST: '1'
WORKFLOW_TARGET_WORLD: "@workflow/world-postgres"
WORKFLOW_POSTGRES_URL: "postgres://world:world@localhost:5432/world"
- WORKFLOW_NEXT_LAZY_DISCOVERY: ${{ matrix.app.lazyDiscovery == false && '0' || matrix.app.lazyDiscovery == true && '1' || '' }}
steps:
- name: Checkout Repo
@@ -848,7 +844,11 @@ 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}'
+ # Use a real workflow file for the Windows HMR test. Most of this
+ # workbench's workflows are symlinks into workbench/example, and
+ # Windows/Turbopack can rebuild from the symlink path while still
+ # serving the previous module contents from the realpath cache.
+ 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,"testWorkflowFile":"96_many_steps.ts"}'
- name: Print Next.js server logs
if: always()
@@ -927,8 +927,10 @@ jobs:
world-id: ${{ matrix.world.id }}
world-name: ${{ matrix.world.name }}
world-package: ${{ matrix.world.package }}
+ package-version: ${{ matrix.world.version }}
service-type: ${{ matrix.world.service-type }}
env-vars: ${{ matrix.world.env-vars }}
+ services: ${{ matrix.world.services }}
secrets: inherit
# Final job: Aggregate all E2E results and update PR comment
diff --git a/docs/app/[lang]/docs/[[...slug]]/page.tsx b/docs/app/[lang]/docs/[[...slug]]/page.tsx
index 9178fe373f..aa8d2f3375 100644
--- a/docs/app/[lang]/docs/[[...slug]]/page.tsx
+++ b/docs/app/[lang]/docs/[[...slug]]/page.tsx
@@ -3,11 +3,10 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import { createRelativeLink } from 'fumadocs-ui/mdx';
import type { Metadata } from 'next';
import { notFound, permanentRedirect } from 'next/navigation';
-import { rewriteCookbookUrl } from '@/lib/geistdocs/cookbook-source';
import { AgentTraces } from '@/components/custom/agent-traces';
import { FluidComputeCallout } from '@/components/custom/fluid-compute-callout';
-import { PreviewInstallServer } from '@/components/preview-install-server';
import { AskAI } from '@/components/geistdocs/ask-ai';
+import { AutoCards } from '@/components/geistdocs/auto-cards';
import { CopyPage } from '@/components/geistdocs/copy-page';
import {
DocsBody,
@@ -21,10 +20,15 @@ import { getMDXComponents } from '@/components/geistdocs/mdx-components';
import { MobileDocsBar } from '@/components/geistdocs/mobile-docs-bar';
import { OpenInChat } from '@/components/geistdocs/open-in-chat';
import { ScrollTop } from '@/components/geistdocs/scroll-top';
+import { PreviewInstallServer } from '@/components/preview-install-server';
import * as AccordionComponents from '@/components/ui/accordion';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
+import { rewriteCookbookUrl } from '@/lib/geistdocs/cookbook-source';
+import { resolveSectionChildren } from '@/lib/geistdocs/section-children';
import { getLLMText, getPageImage, source } from '@/lib/geistdocs/source';
+import { getDocsTreeForVersion } from '@/lib/geistdocs/version-source';
+import { LATEST_VERSION } from '@/lib/geistdocs/versions';
import { TSDoc } from '@/lib/tsdoc';
// No-op component for world MDX files rendered outside /worlds/ context
@@ -46,7 +50,11 @@ const Page = async ({ params }: PageProps<'/[lang]/docs/[[...slug]]'>) => {
notFound();
}
- const markdown = await getLLMText(page);
+ // v4's sidebar tree is already in the `/docs/...` URL space (no version
+ // rewrite), so the same tree drives both the rendered cards and the markdown
+ // export expansion in getLLMText.
+ const tree = getDocsTreeForVersion(lang, LATEST_VERSION);
+ const markdown = await getLLMText(page, tree);
const MDX = page.data.body;
return (
@@ -78,6 +86,9 @@ const Page = async ({ params }: PageProps<'/[lang]/docs/[[...slug]]'>) => {
a: createRelativeLink(source, page),
// Add your custom components here
+ AutoCards: () => (
+
+ ),
AgentTraces,
FluidComputeCallout,
Badge,
diff --git a/docs/app/[lang]/llms.mdx/[[...slug]]/route.ts b/docs/app/[lang]/llms.mdx/[[...slug]]/route.ts
index dd3abd0c4c..c78142223a 100644
--- a/docs/app/[lang]/llms.mdx/[[...slug]]/route.ts
+++ b/docs/app/[lang]/llms.mdx/[[...slug]]/route.ts
@@ -1,7 +1,10 @@
import { generateNotFoundMarkdown } from '@vercel/agent-readability';
-import { rewriteCookbookUrlsInText } from '@/lib/geistdocs/cookbook-source';
-import { getLLMText, source } from '@/lib/geistdocs/source';
+import {
+ getDocsTreeWithoutCookbook,
+ rewriteCookbookUrlsInText,
+} from '@/lib/geistdocs/cookbook-source';
import { i18n } from '@/lib/geistdocs/i18n';
+import { getLLMText, source } from '@/lib/geistdocs/source';
export const revalidate = false;
@@ -25,7 +28,7 @@ export async function GET(
const sitemapPath =
lang === i18n.defaultLanguage ? '/sitemap.md' : `/${lang}/sitemap.md`;
- const text = await getLLMText(page);
+ const text = await getLLMText(page, getDocsTreeWithoutCookbook(lang, 'v4'));
return new Response(
rewriteCookbookUrlsInText(text) +
diff --git a/docs/app/[lang]/llms.txt/route.ts b/docs/app/[lang]/llms.txt/route.ts
index 343ad45b0c..4be823c8b3 100644
--- a/docs/app/[lang]/llms.txt/route.ts
+++ b/docs/app/[lang]/llms.txt/route.ts
@@ -1,5 +1,8 @@
import type { NextRequest } from 'next/server';
-import { rewriteCookbookUrlsInText } from '@/lib/geistdocs/cookbook-source';
+import {
+ getDocsTreeWithoutCookbook,
+ rewriteCookbookUrlsInText,
+} from '@/lib/geistdocs/cookbook-source';
import { getLLMText, source } from '@/lib/geistdocs/source';
export const revalidate = false;
@@ -9,7 +12,8 @@ export const GET = async (
{ params }: RouteContext<'/[lang]/llms.txt'>
) => {
const { lang } = await params;
- const scan = source.getPages(lang).map(getLLMText);
+ const tree = getDocsTreeWithoutCookbook(lang, 'v4');
+ const scan = source.getPages(lang).map((page) => getLLMText(page, tree));
const scanned = await Promise.all(scan);
return new Response(rewriteCookbookUrlsInText(scanned.join('\n\n')), {
diff --git a/docs/app/[lang]/v5/docs/[[...slug]]/page.tsx b/docs/app/[lang]/v5/docs/[[...slug]]/page.tsx
index eaf088e1ac..812acbdc43 100644
--- a/docs/app/[lang]/v5/docs/[[...slug]]/page.tsx
+++ b/docs/app/[lang]/v5/docs/[[...slug]]/page.tsx
@@ -8,6 +8,7 @@ import type { ComponentProps } from 'react';
import { AgentTraces } from '@/components/custom/agent-traces';
import { FluidComputeCallout } from '@/components/custom/fluid-compute-callout';
import { AskAI } from '@/components/geistdocs/ask-ai';
+import { AutoCards } from '@/components/geistdocs/auto-cards';
import { CopyPage } from '@/components/geistdocs/copy-page';
import {
DocsBody,
@@ -25,13 +26,21 @@ import { PreviewInstallServer } from '@/components/preview-install-server';
import * as AccordionComponents from '@/components/ui/accordion';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
-import { rewriteCookbookUrl } from '@/lib/geistdocs/cookbook-source';
+import {
+ getDocsTreeWithoutCookbook,
+ rewriteCookbookUrl,
+} from '@/lib/geistdocs/cookbook-source';
+import { resolveSectionChildren } from '@/lib/geistdocs/section-children';
import {
getLLMText,
getPageImage,
source,
v5Source,
} from '@/lib/geistdocs/source';
+import {
+ getDocsTreeForVersion,
+ PRE_RELEASE_VERSION,
+} from '@/lib/geistdocs/version-source';
import { TSDoc } from '@/lib/tsdoc';
const WorldTestingPerformanceNoop = () => null;
@@ -50,7 +59,15 @@ const Page = async ({ params }: PageProps<'/[lang]/v5/docs/[[...slug]]'>) => {
notFound();
}
- const markdown = await getLLMText(page);
+ // Cards render in the v5 URL space (`/v5/docs/...`), matching the sidebar tree
+ // so hrefs don't escape to the v4 route. The markdown export, however, mirrors
+ // the page's own `/docs/...` URL space, so it uses the raw (un-rewritten) tree.
+ const cardsTree = getDocsTreeForVersion(lang, PRE_RELEASE_VERSION);
+ const sectionUrl = `${PRE_RELEASE_VERSION.prefix}${page.url}`;
+ const markdown = await getLLMText(
+ page,
+ getDocsTreeWithoutCookbook(lang, 'v5')
+ );
const MDX = page.data.body;
// Inline MDX links use /docs/... paths (matching the source baseUrl). When
@@ -100,6 +117,11 @@ const Page = async ({ params }: PageProps<'/[lang]/v5/docs/[[...slug]]'>) => {
components={getMDXComponents({
a: v5Link,
Card: V5Card,
+ AutoCards: () => (
+
+ ),
AgentTraces,
FluidComputeCallout,
Badge,
diff --git a/docs/components/geistdocs/auto-cards.tsx b/docs/components/geistdocs/auto-cards.tsx
new file mode 100644
index 0000000000..588f58b5da
--- /dev/null
+++ b/docs/components/geistdocs/auto-cards.tsx
@@ -0,0 +1,27 @@
+import { Card, Cards } from 'fumadocs-ui/components/card';
+import type { SectionChild } from '@/lib/geistdocs/section-children';
+
+/**
+ * Renders a card grid for a section landing page from children resolved off the
+ * fumadocs page tree (see `resolveSectionChildren`). Because the list is derived
+ * from `meta.json` + page frontmatter rather than hand-written, the cards can
+ * never drift from the sidebar navigation.
+ *
+ * The `items` are bound per-request in the docs route handlers, where the active
+ * version's page tree and the current page URL are known.
+ */
+export function AutoCards({ items }: { items: SectionChild[] }) {
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ );
+}
diff --git a/docs/content/docs/v4/ai/index.mdx b/docs/content/docs/v4/ai/index.mdx
index 90958b5dac..283dc183a1 100644
--- a/docs/content/docs/v4/ai/index.mdx
+++ b/docs/content/docs/v4/ai/index.mdx
@@ -3,6 +3,7 @@ title: Building Durable AI Agents
description: Build AI agents that survive crashes, scale across requests, and maintain state with durable LLM tool-call loops.
type: overview
summary: Convert a basic AI chat app into a durable, resumable agent using Workflow SDK.
+manualCards: true
related:
- /docs/foundations/workflows-and-steps
- /docs/foundations/streaming
diff --git a/docs/content/docs/v4/api-reference/workflow-next/with-workflow.mdx b/docs/content/docs/v4/api-reference/workflow-next/with-workflow.mdx
index 9f8c0bd4da..c314a01077 100644
--- a/docs/content/docs/v4/api-reference/workflow-next/with-workflow.mdx
+++ b/docs/content/docs/v4/api-reference/workflow-next/with-workflow.mdx
@@ -68,7 +68,6 @@ const nextConfig: NextConfig = {};
export default withWorkflow(nextConfig, {
workflows: {
- lazyDiscovery: true,
local: {
port: 4000,
},
@@ -79,7 +78,6 @@ export default withWorkflow(nextConfig, {
| Option | Type | Default | Description |
| --- | --- | --- | --- |
-| `workflows.lazyDiscovery` | `boolean` | `false` | When `true`, defers workflow discovery until files are requested instead of scanning eagerly at startup. Useful for large projects where startup time matters. |
| `workflows.local.port` | `number` | — | Overrides the `PORT` environment variable for local development. Has no effect when deployed to Vercel. |
| `workflows.sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. See [Source maps](#source-maps) below. |
diff --git a/docs/content/docs/v4/api-reference/workflow/create-hook.mdx b/docs/content/docs/v4/api-reference/workflow/create-hook.mdx
index 67a08f9ca0..c9cb8e3fe6 100644
--- a/docs/content/docs/v4/api-reference/workflow/create-hook.mdx
+++ b/docs/content/docs/v4/api-reference/workflow/create-hook.mdx
@@ -66,7 +66,7 @@ export default Hook;`}
The returned `Hook` object also implements `AsyncIterable`, which allows you to iterate over incoming payloads using `for await...of` syntax.
-Use `hook.getConflict()` to check whether the hook token is already claimed by another active hook, without waiting for hook payload data. Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the registration, then resolves with `null` once `hook_created` is recorded, or with `{ runId }` identifying the conflicting run if another active hook already owns the same token.
+Use `hook.getConflict()` (available starting in `workflow@4.5.0`) to check whether the hook token is already claimed by another active hook, without waiting for hook payload data. Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the registration, then resolves with `null` once `hook_created` is recorded, or with `{ runId }` identifying the conflicting run if another active hook already owns the same token.
## Examples
@@ -117,7 +117,7 @@ export async function slackBotWorkflow(channelId: string) {
### Detecting Token Conflicts
-Use `hook.getConflict()` when the workflow needs to claim a hook token before doing other work, but does not need a payload yet:
+Use `hook.getConflict()` (available starting in `workflow@4.5.0`) when the workflow needs to claim a hook token before doing other work, but does not need a payload yet:
```typescript lineNumbers
import { createHook } from "workflow";
diff --git a/docs/content/docs/v4/deploying/index.mdx b/docs/content/docs/v4/deploying/index.mdx
index 54afe957ad..e30e1a527f 100644
--- a/docs/content/docs/v4/deploying/index.mdx
+++ b/docs/content/docs/v4/deploying/index.mdx
@@ -4,6 +4,7 @@ icon: Rocket
description: Deploy workflows locally, on Vercel, or anywhere using pluggable World adapters.
type: overview
summary: Learn how to deploy workflows to different environments using World adapters.
+manualCards: true
related:
- /docs/deploying/world/local-world
- /docs/deploying/world/postgres-world
diff --git a/docs/content/docs/v4/errors/index.mdx b/docs/content/docs/v4/errors/index.mdx
index 7cd441b0e7..ebc703c681 100644
--- a/docs/content/docs/v4/errors/index.mdx
+++ b/docs/content/docs/v4/errors/index.mdx
@@ -9,50 +9,7 @@ related:
Fix common mistakes when creating and executing workflows in the **Workflow SDK**.
-
-
- Learn how to use fetch in workflow functions.
-
-
- Learn how to handle hook token conflicts between workflows.
-
-
- Learn how to use Node.js modules in workflows.
-
-
- Learn how to handle serialization failures in workflows.
-
-
- Learn how to start an invalid workflow function.
-
-
- Learn how to handle timing delays in workflow functions.
-
-
- Learn how to use the correct `respondWith` values for webhooks.
-
-
- Learn how to send responses when using manual webhook response mode.
-
-
- Learn how to handle corrupted or invalid event logs.
-
-
- Learn how workflow replay divergence is recovered automatically.
-
-
- Resolve step not registered errors caused by deployment mismatches.
-
-
- Diagnose duplicate step_started events from function crashes, timeouts, or OOMs.
-
-
- Resolve workflow not registered errors caused by deployment mismatches.
-
-
- Resolve runtime decryption failures from the SDK's encryption layer.
-
-
+
## Learn More
diff --git a/docs/content/docs/v4/foundations/hooks.mdx b/docs/content/docs/v4/foundations/hooks.mdx
index 1f03ca4f8b..c3e204d4ec 100644
--- a/docs/content/docs/v4/foundations/hooks.mdx
+++ b/docs/content/docs/v4/foundations/hooks.mdx
@@ -87,7 +87,7 @@ The key points:
### Checking for Token Conflicts
-Sometimes you need to know that a hook token has been claimed, but you do not want to wait for external data yet. Await `hook.getConflict()` for that:
+Sometimes you need to know that a hook token has been claimed, but you do not want to wait for external data yet. Await `hook.getConflict()` (available starting in `workflow@4.5.0`) for that:
```typescript lineNumbers
import { createHook } from "workflow";
diff --git a/docs/content/docs/v4/foundations/index.mdx b/docs/content/docs/v4/foundations/index.mdx
index 1eb96ddf69..b79ecb0a44 100644
--- a/docs/content/docs/v4/foundations/index.mdx
+++ b/docs/content/docs/v4/foundations/index.mdx
@@ -10,29 +10,4 @@ related:
Workflow programming can be a slight shift from how you traditionally write real-world applications. Learning the foundations now will go a long way toward helping you use workflows effectively.
-
-
- Learn about the building blocks of durability
-
-
- Trigger workflows and track their execution using the `start()` function.
-
-
- Types of errors and how retrying work in workflows.
-
-
- Respond to external events in your workflow using hooks and webhooks.
-
-
- Stream data in real-time to clients without waiting for the workflow to complete.
-
-
- Understand which types can be passed between workflow and step functions.
-
-
- Prevent duplicate side effects when retrying operations.
-
-
- Understand how runs stay pinned to deployments and when to opt in to newer code.
-
-
+
diff --git a/docs/content/docs/v4/foundations/meta.json b/docs/content/docs/v4/foundations/meta.json
index ce49cfe47b..f8cb62bc10 100644
--- a/docs/content/docs/v4/foundations/meta.json
+++ b/docs/content/docs/v4/foundations/meta.json
@@ -6,7 +6,6 @@
"errors-and-retries",
"hooks",
"streaming",
- "cancellation",
"serialization",
"idempotency",
"versioning"
diff --git a/docs/content/docs/v4/how-it-works/meta.json b/docs/content/docs/v4/how-it-works/meta.json
index 0527f6f404..2261f8acc7 100644
--- a/docs/content/docs/v4/how-it-works/meta.json
+++ b/docs/content/docs/v4/how-it-works/meta.json
@@ -5,8 +5,7 @@
"code-transform",
"framework-integrations",
"event-sourcing",
- "encryption",
- "cancellation"
+ "encryption"
],
"defaultOpen": false
}
diff --git a/docs/content/docs/v4/internal/meta.json b/docs/content/docs/v4/internal/meta.json
index 22171bf486..c9b5d9a652 100644
--- a/docs/content/docs/v4/internal/meta.json
+++ b/docs/content/docs/v4/internal/meta.json
@@ -1,5 +1,5 @@
{
"title": "Internal",
- "pages": ["index", "serializable-abort-controller"],
+ "pages": ["index"],
"defaultOpen": false
}
diff --git a/docs/content/docs/v4/meta.json b/docs/content/docs/v4/meta.json
index 62762d94fa..4e640f0ead 100644
--- a/docs/content/docs/v4/meta.json
+++ b/docs/content/docs/v4/meta.json
@@ -1,6 +1,5 @@
{
"pages": [
- "introduction",
"---",
"getting-started",
"foundations",
diff --git a/docs/content/docs/v5/ai/index.mdx b/docs/content/docs/v5/ai/index.mdx
index ae5a98cac8..739c6cee82 100644
--- a/docs/content/docs/v5/ai/index.mdx
+++ b/docs/content/docs/v5/ai/index.mdx
@@ -3,6 +3,7 @@ title: Building Durable AI Agents
description: Build AI agents that survive crashes, scale across requests, and maintain state with durable LLM tool-call loops.
type: overview
summary: Convert a basic AI chat app into a durable, resumable agent using Workflow SDK.
+manualCards: true
related:
- /docs/foundations/workflows-and-steps
- /docs/foundations/streaming
diff --git a/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx b/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx
index 038c1a4d5e..acfc585691 100644
--- a/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx
+++ b/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx
@@ -92,7 +92,6 @@ const nextConfig: NextConfig = {};
export default withWorkflow(nextConfig, {
workflows: {
- lazyDiscovery: false,
local: {
port: 4000,
},
@@ -103,7 +102,6 @@ export default withWorkflow(nextConfig, {
| Option | Type | Default | Description |
| --- | --- | --- | --- |
-| `workflows.lazyDiscovery` | `boolean` | `true` | Defers workflow discovery until files are requested instead of scanning eagerly at startup. Set to `false` to force eager discovery (scanning the project up front). Requires a Next.js version that supports deferred entries; older versions fall back to eager discovery automatically. |
| `workflows.local.port` | `number` | — | Overrides the `PORT` environment variable for local development. Has no effect when deployed to Vercel. |
| `workflows.sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. See [Source maps](#source-maps) below. |
diff --git a/docs/content/docs/v5/changelog/meta.json b/docs/content/docs/v5/changelog/meta.json
index b65b777d83..10ab651c65 100644
--- a/docs/content/docs/v5/changelog/meta.json
+++ b/docs/content/docs/v5/changelog/meta.json
@@ -4,7 +4,8 @@
"index",
"eager-processing",
"resilient-start",
- "lazy-event-creation"
+ "lazy-event-creation",
+ "turbo-mode"
],
"defaultOpen": false
}
diff --git a/docs/content/docs/v5/changelog/turbo-mode.md b/docs/content/docs/v5/changelog/turbo-mode.md
new file mode 100644
index 0000000000..3d27bcf0aa
--- /dev/null
+++ b/docs/content/docs/v5/changelog/turbo-mode.md
@@ -0,0 +1,87 @@
+---
+title: Turbo mode (fast first invocation)
+description: Fast-path the very first delivery of the first invocation — background run_started, skip the initial event-log load, and force optimistic inline start — so a run blazes through its first steps. A no-op for everything else.
+---
+
+# Turbo mode
+
+## Motivation
+
+The first invocation of a workflow run is where time-to-first-step matters most, yet it pays the most fixed network latency before any user code runs. Three round-trips sit on that critical path today:
+
+1. **`run_started` is awaited.** The handler writes `run_started` and waits for it to return the run entity before doing anything else.
+2. **The event log is loaded.** A full `events.list` runs before the first replay — even though on the very first delivery nothing has written any events yet.
+3. **Optimistic inline start is off by default.** The [optimistic inline start](./lazy-event-creation#optimistic-inline-start-opt-in-off-by-default) optimization (running a step body before its `step_started` is confirmed) is off by default because under contention two handlers can both run a body and corrupt non-idempotent side effects.
+
+Turbo mode removes all three costs **for the first delivery of the first invocation only**, where each is provably safe to remove, then gets out of the way. For every subsequent invocation it is a complete no-op.
+
+## What turbo mode does
+
+When the handler detects the first delivery of the first invocation, it:
+
+1. **Backgrounds `run_started`.** The event is written without awaiting; the run entity is synthesized locally from the queued run input (status `running`, `startedAt` now) so replay can begin immediately. The `run_started` round-trip overlaps replay instead of blocking it. This reuses the [resilient start](./resilient-start) contract — `run_started` carrying the run input creates the run on the fly (synthetic `run_created`) if it doesn't exist yet. Because turbo uses this `run_started` purely as a write barrier and never reads its response, it also asks the World to **skip the `run_started` event-log preload** (the list+resolve the World normally returns so a run can skip its first `events.list`). That preload would be wasted work here — and, since the first `step_started` is chained on the `run_started` barrier, trimming the `run_started` request directly shortens the wait before the first durable `step_started` (and therefore time-to-second-step). A World that ignores the hint stays correct; the runtime simply falls back to `events.list` if it ever needs the log.
+2. **Skips the initial event-log load.** Nothing has been written, so the first replay runs against an empty log. The second loop iteration does a normal incremental load once the first step's events exist.
+3. **Forces optimistic inline start** for that invocation, independent of `WORKFLOW_OPTIMISTIC_INLINE_START`. The step body runs immediately against locally-synthesized state; only the `step_started` network write waits for the backgrounded `run_started`.
+
+The net effect: the first step body starts after just the in-process replay, with `run_started` and `step_started` happening in the background around it, and no `events.list` before it.
+
+## Why this is safe (and where it stops)
+
+### Detection
+
+The first-invocation message is the only one that carries the queued **run input**, and the queue delivery **attempt is 1** (a redelivery is attempt ≥ 2). Together with "not a background-step invocation" and "not a divergence recovery", that uniquely identifies the first delivery of the first invocation — with no new message field and no world/backend change.
+
+### The single-handler guarantee
+
+Forcing optimistic start is unsafe *in general* because two handlers racing the same step's create-claim can both run the body before one wins. On the first delivery of the first invocation there is **no concurrent peer handler** — the run was created moments ago by `start()` and only this one message is in flight. So the body runs exactly once, and forcing optimistic start is safe here even though the global flag is off.
+
+### Turbo exits on the first hook or wait
+
+That single-handler guarantee ends the moment the run creates a **hook** or **wait** (or writes attributes): those introduce later resume/parallel invocations that *can* race. So turbo stops forcing optimistic start as soon as a suspension creates any of them — the inline steps of that suspension fall back to the normal await-then-run path, and the rest of the run behaves exactly as it does today. A pure-step suspension (the common hot path) stays on the fast path.
+
+### Write ordering is preserved
+
+Because `run_started` is backgrounded, every event write is gated on a run-ready barrier so nothing is written before the run exists:
+
+- The optimistic `step_started` is **chained** on the barrier — the body still runs immediately, only the network write waits.
+- The suspension handler **awaits** the barrier before any eager write (`hook_created`, `wait_created`, overflow `step_created`). The pure inline hot path defers all its steps and writes nothing here, so it never blocks on the barrier.
+- Terminal run writes (`run_completed` / `run_failed`) await the barrier too, so a workflow that finishes with no steps still orders its completion after `run_started`.
+
+The event log therefore still reads `run_created → run_started → step_created → step_started → step_completed`. If the backgrounded `run_started` genuinely fails (e.g. the run was cancelled in the meantime), the chained writes surface the real error (`gone` / run-not-found) and the message redelivers as a normal, non-turbo attempt.
+
+The barrier orders **event** writes. The forced-optimistic first step **body** runs immediately, so any side effects it performs *before* the terminal write — stream writes via `getWritable()` and the per-step ops flush — are **not** gated on the barrier and can reach the world before the backgrounded `run_started` lands (and are orphaned if it ultimately fails). This is the same exposure as optimistic inline start and is covered by the stream-safety caveat below; deployments whose first step writes to the workflow stream and require strict `run_created → run_started` ordering of stream data should set `WORKFLOW_TURBO=0`.
+
+### A run cancelled before its first delivery still runs the first step body
+
+The non-turbo path awaits `run_started` up front and, if the run was cancelled or expired between `start()` and this delivery, returns before any workflow/step code runs. Turbo synthesizes `status: 'running'` and runs the first step body optimistically, so such a cancellation is only observed when the backgrounded `run_started` (and the barrier-chained `step_started`) rejects — *after* the body's side effects have executed (they are then discarded via reconciliation). For non-idempotent first steps this is the same "body runs before ownership is confirmed" tradeoff as optimistic inline start; `WORKFLOW_TURBO=0` restores the up-front skip.
+
+### `workflowStartedAt` reflects the first delivery's clock
+
+Replay matching — step/wait/hook correlation IDs, the VM seed, and the in-VM `Date.now()` — is derived from a replay-stable timestamp recovered from the run ID, so it does **not** depend on `startedAt` and is identical on every delivery. The one value that still tracks `startedAt` is the user-facing `getWorkflowMetadata().workflowStartedAt`: under turbo the first delivery synthesizes it from the local clock, while a later (non-turbo) delivery loads the server-canonical `startedAt`, so the two can differ by the start→first-delivery latency. Treat `workflowStartedAt` as an approximate, human-facing timestamp — do **not** branch workflow control flow on it (e.g. `Date.now() - +workflowStartedAt > threshold`), since that can take different paths across deliveries and diverge on replay. For timing logic that must survive replay, use the in-VM `Date.now()` / `new Date()`, which is replay-stable.
+
+### Attributes seeded at `start()` survive the skipped event load
+
+`start({ attributes })` does **not** disable turbo, and it needs no synthetic event in the empty log. Seed attributes are folded into the `run_created` event's data (not separate `attr_set` events) and ride along in the queued run input, so the locally-synthesized run snapshot carries them — turbo skipping the initial `events.list` loses nothing.
+
+This is safe specifically because **attributes are write-only inside a workflow**: there is no in-workflow read API today, and `run_created` is consumed structurally during replay without inspecting its attributes. So an empty initial event log replays identically whether or not the run was seeded with attributes.
+
+That safety is a standing invariant for any future change: if an in-workflow attribute *read* API is ever added, it MUST read from the run snapshot (which turbo populates from the run input) and **not** by replaying `run_created` / `attr_set` events. Reading from the event log would surface seed attributes as empty on the first turbo delivery only — a turbo-exclusive divergence from the non-turbo path. `start()` cannot seed hooks or waits, so there is no start-seeded suspension state for the skipped load to miss.
+
+## Configuration
+
+Turbo mode is **on by default**. Set `WORKFLOW_TURBO=0` (or `false`) to disable it — every invocation then takes the existing awaited path. This is a useful kill-switch for deployments whose first-step bodies are not idempotent and stream-safe (the same caveat as optimistic inline start), or for isolating behavior while debugging.
+
+Turbo forces optimistic inline start on the first invocation regardless of `WORKFLOW_OPTIMISTIC_INLINE_START` (its single-handler guarantee removes the double-execution race that flag guards against). It does, however, **honor an explicit `WORKFLOW_OPTIMISTIC_INLINE_START=0`**: because forced optimistic start still runs the body before `step_started`/`run_started` is confirmed, an operator who has explicitly disabled optimistic start keeps the await-then-run path even under turbo (the rest of turbo — backgrounded `run_started`, skipped initial load — still applies). With the flag unset (the default), turbo forces it on.
+
+Turbo mode is purely client-side and builds on the lazy/optimistic inline start support already shipped — it requires no world or backend changes.
+
+## Considered: running ahead of durable writes (not implemented)
+
+Turbo overlaps the *start* round-trips with a step's body, but it still **awaits each `step_completed` before advancing** to the next step. We explored going further — "run-ahead": within a single invocation, execute the workflow forward across a sequential chain *without* awaiting each step's event writes, draining `step_started`/`step_completed` through a background FIFO queue and only blocking on a full drain before acking. A run of three sub-millisecond steps would then fire all the bodies back-to-back while the six event posts caught up in the background, turning per-step latency into `max(Σ body, Σ post)` instead of `Σ(body + post)`.
+
+We decided **not** to ship it, for two reasons:
+
+1. **Re-execution blast radius on failure.** Awaiting each completion means a crash re-runs essentially one in-flight step. Running ahead leaves many completions undrained at once, so a crash or `maxDuration` SIGTERM re-runs *all* of them on redelivery — a much larger at-least-once blast radius, precisely on the latency-sensitive runs most likely to pack many steps into one invocation.
+2. **Divergent branches from non-durable results.** Advancing past a step before its result is durable lets the workflow commit to a forward path that a crash-and-redeliver can re-decide differently. A `Promise.race([B, C])` resolved by local timing can pick `B`, run `D(B)`, then crash before `step_completed_B` is durable — and the redelivery may re-resolve to `C`, so `D` executed against a winner the durable history never records. The same shape appears for a branch on a non-deterministic step output (`B(v1)` runs, crash, redelivery commits `B(v2)`). Idempotency doesn't cover these — `D(B)`/`D(C)` and `B(v1)`/`B(v2)` are *different* operations, not retries of one. A "run ahead only while at most one result is undurable" gate would contain the race case (a race needs ≥2 concurrent undurable steps) but not the non-deterministic-output case, and that residual hazard plus the re-execution blast radius outweighed the gain.
+
+So turbo deliberately stops at forced-optimistic *start* and awaits each `step_completed` before moving on: re-execution after a crash stays deterministic (each step re-runs against the same durable inputs) and bounded (roughly one step, not the whole chain). The idea is recorded here in case a future change (e.g. a determinism signal on steps, or deterministic race resolution) makes run-ahead safe enough to revisit.
diff --git a/docs/content/docs/v5/deploying/index.mdx b/docs/content/docs/v5/deploying/index.mdx
index 54afe957ad..e30e1a527f 100644
--- a/docs/content/docs/v5/deploying/index.mdx
+++ b/docs/content/docs/v5/deploying/index.mdx
@@ -4,6 +4,7 @@ icon: Rocket
description: Deploy workflows locally, on Vercel, or anywhere using pluggable World adapters.
type: overview
summary: Learn how to deploy workflows to different environments using World adapters.
+manualCards: true
related:
- /docs/deploying/world/local-world
- /docs/deploying/world/postgres-world
diff --git a/docs/content/docs/v5/errors/index.mdx b/docs/content/docs/v5/errors/index.mdx
index 7cd441b0e7..ebc703c681 100644
--- a/docs/content/docs/v5/errors/index.mdx
+++ b/docs/content/docs/v5/errors/index.mdx
@@ -9,50 +9,7 @@ related:
Fix common mistakes when creating and executing workflows in the **Workflow SDK**.
-
-
- Learn how to use fetch in workflow functions.
-
-
- Learn how to handle hook token conflicts between workflows.
-
-
- Learn how to use Node.js modules in workflows.
-
-
- Learn how to handle serialization failures in workflows.
-
-
- Learn how to start an invalid workflow function.
-
-
- Learn how to handle timing delays in workflow functions.
-
-
- Learn how to use the correct `respondWith` values for webhooks.
-
-
- Learn how to send responses when using manual webhook response mode.
-
-
- Learn how to handle corrupted or invalid event logs.
-
-
- Learn how workflow replay divergence is recovered automatically.
-
-
- Resolve step not registered errors caused by deployment mismatches.
-
-
- Diagnose duplicate step_started events from function crashes, timeouts, or OOMs.
-
-
- Resolve workflow not registered errors caused by deployment mismatches.
-
-
- Resolve runtime decryption failures from the SDK's encryption layer.
-
-
+
## Learn More
diff --git a/docs/content/docs/v5/foundations/index.mdx b/docs/content/docs/v5/foundations/index.mdx
index 1eb96ddf69..b79ecb0a44 100644
--- a/docs/content/docs/v5/foundations/index.mdx
+++ b/docs/content/docs/v5/foundations/index.mdx
@@ -10,29 +10,4 @@ related:
Workflow programming can be a slight shift from how you traditionally write real-world applications. Learning the foundations now will go a long way toward helping you use workflows effectively.
-
-
- Learn about the building blocks of durability
-
-
- Trigger workflows and track their execution using the `start()` function.
-
-
- Types of errors and how retrying work in workflows.
-
-
- Respond to external events in your workflow using hooks and webhooks.
-
-
- Stream data in real-time to clients without waiting for the workflow to complete.
-
-
- Understand which types can be passed between workflow and step functions.
-
-
- Prevent duplicate side effects when retrying operations.
-
-
- Understand how runs stay pinned to deployments and when to opt in to newer code.
-
-
+
diff --git a/docs/content/docs/v5/meta.json b/docs/content/docs/v5/meta.json
index 62762d94fa..4e640f0ead 100644
--- a/docs/content/docs/v5/meta.json
+++ b/docs/content/docs/v5/meta.json
@@ -1,6 +1,5 @@
{
"pages": [
- "introduction",
"---",
"getting-started",
"foundations",
diff --git a/docs/content/docs/v5/observability/index.mdx b/docs/content/docs/v5/observability/index.mdx
index 92e7d067e8..66e49c32e7 100644
--- a/docs/content/docs/v5/observability/index.mdx
+++ b/docs/content/docs/v5/observability/index.mdx
@@ -79,11 +79,4 @@ When deployed to Vercel, workflow data is [encrypted end-to-end](/docs/how-it-wo
## More Observability Features
-
-
- Distributed tracing with OpenTelemetry for workflow runs, steps, and queue deliveries.
-
-
- Attach experimental metadata to workflow runs for observability.
-
-
+
diff --git a/docs/lib/geistdocs/section-children.ts b/docs/lib/geistdocs/section-children.ts
new file mode 100644
index 0000000000..44f7a91efd
--- /dev/null
+++ b/docs/lib/geistdocs/section-children.ts
@@ -0,0 +1,66 @@
+import type { Folder, Node, Root } from 'fumadocs-core/page-tree';
+import type { ReactNode } from 'react';
+
+export interface SectionChild {
+ title: ReactNode;
+ url: string;
+ description?: ReactNode;
+ icon?: ReactNode;
+}
+
+/**
+ * Find the folder whose index page is served at `sectionUrl` (e.g. the
+ * `foundations` folder for `/docs/foundations`). Searches the tree recursively
+ * so it works regardless of nesting depth.
+ */
+function findSectionFolder(
+ nodes: Node[],
+ sectionUrl: string
+): Folder | undefined {
+ for (const node of nodes) {
+ if (node.type !== 'folder') continue;
+ if (node.index?.url === sectionUrl) return node;
+ const nested = findSectionFolder(node.children, sectionUrl);
+ if (nested) return nested;
+ }
+ return undefined;
+}
+
+/**
+ * Resolve the child pages of a section's landing page directly from the
+ * fumadocs page tree — the same tree that builds the sidebar (driven by
+ * `meta.json` + page frontmatter). This is the single source of truth shared by
+ * the `` component, the markdown export in `getLLMText`, and the
+ * docs lint, so the card grid can never drift from the navigation.
+ *
+ * Children are returned in navigation order. Both leaf pages and sub-folders
+ * (which surface via their own index page) become cards; separators and
+ * index-less folders are skipped.
+ */
+export function resolveSectionChildren(
+ tree: Root,
+ sectionUrl: string
+): SectionChild[] {
+ const folder = findSectionFolder(tree.children, sectionUrl);
+ if (!folder) return [];
+
+ const children: SectionChild[] = [];
+ for (const child of folder.children) {
+ if (child.type === 'page') {
+ children.push({
+ title: child.name,
+ url: child.url,
+ description: child.description,
+ icon: child.icon,
+ });
+ } else if (child.type === 'folder' && child.index) {
+ children.push({
+ title: child.index.name ?? child.name,
+ url: child.index.url,
+ description: child.index.description ?? child.description,
+ icon: child.index.icon ?? child.icon,
+ });
+ }
+ }
+ return children;
+}
diff --git a/docs/lib/geistdocs/source.ts b/docs/lib/geistdocs/source.ts
index 74e015e42a..03a66cacb9 100644
--- a/docs/lib/geistdocs/source.ts
+++ b/docs/lib/geistdocs/source.ts
@@ -1,8 +1,10 @@
+import type { Root } from 'fumadocs-core/page-tree';
import { type InferPageType, loader } from 'fumadocs-core/source';
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
import { v4docs, v5docs } from '@/.source/server';
import { basePath } from '@/geistdocs';
import { i18n } from './i18n';
+import { resolveSectionChildren } from './section-children';
// See https://fumadocs.dev/docs/headless/source-api for more info
export const source = loader({
@@ -30,8 +32,44 @@ export const getPageImage = (page: InferPageType) => {
};
};
-export const getLLMText = async (page: InferPageType) => {
- const processed = await page.data.getText('processed');
+// Matches the `` placeholder (self-closing or paired) so the
+// markdown export can substitute the rendered card list. The component itself
+// only renders in the React tree, so without this the agent-facing markdown
+// (llms.txt, .md routes, copy-page) would lose every child link.
+const AUTO_CARDS_RE = /]*?(?:\/>|>[\s\S]*?<\/AutoCards>)/g;
+
+const asText = (value: unknown): string =>
+ typeof value === 'string' ? value : '';
+
+/**
+ * Render the ``/`` JSX a section landing page would have contained
+ * by hand, derived from the page tree. Matching the existing serialized format
+ * keeps the markdown export consistent across converted and unconverted pages.
+ */
+function renderAutoCardsMarkdown(tree: Root, sectionUrl: string): string {
+ const cards = resolveSectionChildren(tree, sectionUrl)
+ .map((child) => {
+ const title = asText(child.title);
+ const description = asText(child.description);
+ const open = ``;
+ return description ? `${open}${description}` : `${open}`;
+ })
+ .join('\n');
+ return `\n${cards}\n`;
+}
+
+export const getLLMText = async (
+ page: InferPageType,
+ tree?: Root
+) => {
+ let processed = await page.data.getText('processed');
+ if (tree) {
+ // `.replace` is a no-op when there's no placeholder, and the replacer only
+ // walks the tree on an actual match.
+ processed = processed.replace(AUTO_CARDS_RE, () =>
+ renderAutoCardsMarkdown(tree, page.url)
+ );
+ }
const { title, description, product, type, summary, prerequisites, related } =
page.data;
diff --git a/docs/scripts/lint.ts b/docs/scripts/lint.ts
index 6090340ff6..13fa808e74 100755
--- a/docs/scripts/lint.ts
+++ b/docs/scripts/lint.ts
@@ -1,13 +1,18 @@
-import { readdir, readFile } from 'node:fs/promises';
+import { readdir, readFile, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
+import type { Folder, Node, Root } from 'fumadocs-core/page-tree';
import GithubSlugger from 'github-slugger';
import {
type FileObject,
printErrors,
validateFiles,
} from 'next-validate-link';
-import { rewriteCookbookUrl } from '../lib/geistdocs/cookbook-source';
+import {
+ getDocsTreeWithoutCookbook,
+ rewriteCookbookUrl,
+} from '../lib/geistdocs/cookbook-source';
+import { resolveSectionChildren } from '../lib/geistdocs/section-children';
import { source, v5Source } from '../lib/geistdocs/source';
import { getWorldIds } from '../lib/worlds-data';
import nextConfig from '../next.config';
@@ -311,6 +316,188 @@ async function checkLinks() {
}
await checkStaticAppLinks();
+ checkSectionCards(v4Pages, v5Pages);
+ await checkMetaEntriesResolve();
+}
+
+/**
+ * Enforce that every section landing page accounts for all of its navigation
+ * children. A page passes if it either:
+ * - renders `` (cards are derived from the tree — drift is
+ * structurally impossible), or
+ * - declares `manualCards: true` in frontmatter (intentionally curated), or
+ * - has a `` for every child page in the section.
+ *
+ * The existing link validation already covers the reverse direction (cards that
+ * point at pages which don't exist), so together the two checks keep the card
+ * grid and the sidebar in lockstep.
+ */
+function sectionMissingCards(
+ folder: Folder,
+ tree: Root,
+ rawByUrl: Map
+): { sourcePath: string; missing: string[] } | null {
+ const sectionUrl = folder.index?.url;
+ if (!sectionUrl) return null;
+
+ const expected = resolveSectionChildren(tree, sectionUrl).map((c) =>
+ normalizePathname(c.url)
+ );
+ if (expected.length === 0) return null;
+
+ const loaded = rawByUrl.get(sectionUrl);
+ if (!loaded) return null;
+ const { raw, page } = loaded;
+
+ // Drift is impossible (AutoCards) or intentionally owned by the author.
+ if (/ normalizePathname(href))
+ );
+ const missing = expected.filter((url) => !carded.has(url));
+ return missing.length > 0 ? { sourcePath: page.absolutePath, missing } : null;
+}
+
+function checkSectionCards(v4Pages: LoadedPage[], v5Pages: LoadedPage[]) {
+ const errors: { sourcePath: string; missing: string[] }[] = [];
+ for (const [pages, version] of [
+ [v4Pages, 'v4'],
+ [v5Pages, 'v5'],
+ ] as const) {
+ const tree = getDocsTreeWithoutCookbook('en', version);
+ const rawByUrl = new Map(pages.map((p) => [p.page.url, p]));
+ for (const folder of collectSectionFolders(tree.children)) {
+ const result = sectionMissingCards(folder, tree, rawByUrl);
+ if (result) errors.push(result);
+ }
+ }
+
+ if (errors.length > 0) {
+ console.error(
+ '\nSection landing pages missing cards for navigation children:'
+ );
+ for (const error of errors) {
+ console.error(`- ${error.sourcePath}: ${error.missing.join(', ')}`);
+ }
+ console.error(
+ ' Fix by using (recommended), adding the missing ' +
+ 'entries, or setting `manualCards: true` if the page is intentionally curated.'
+ );
+ process.exitCode = 1;
+ }
+}
+
+/** Recursively collect folders that have an index page (section landing pages). */
+function collectSectionFolders(nodes: Node[]): Folder[] {
+ const folders: Folder[] = [];
+ for (const node of nodes) {
+ if (node.type !== 'folder') continue;
+ if (node.index) folders.push(node);
+ folders.push(...collectSectionFolders(node.children));
+ }
+ return folders;
+}
+
+function getCardHrefs(raw: string): string[] {
+ const hrefs: string[] = [];
+ const pattern = /]*?\bhref="([^"]+)"/g;
+ let match = pattern.exec(raw);
+ while (match !== null) {
+ hrefs.push(match[1].split('#')[0]);
+ match = pattern.exec(raw);
+ }
+ return hrefs;
+}
+
+function hasManualCardsFlag(raw: string): boolean {
+ const frontmatter = raw.match(/^---\n([\s\S]*?)\n---/)?.[1];
+ return frontmatter ? /^manualCards:\s*true\s*$/m.test(frontmatter) : false;
+}
+
+/**
+ * Validate that every plain-slug `pages` entry in a `meta.json` resolves to a
+ * real page file or sub-folder. Catches dangling navigation entries (e.g. a
+ * `cancellation` entry with no `cancellation.mdx`). Fumadocs control tokens
+ * (`...rest`, `---separators---`, `[label](url)` links, `!exclude`) are skipped.
+ */
+// Fumadocs control tokens that don't reference a page: rest globs, separators,
+// external links, and exclusions.
+function isMetaControlToken(entry: string): boolean {
+ return (
+ entry === 'index' ||
+ entry.startsWith('...') ||
+ entry.startsWith('---') ||
+ entry.startsWith('[') ||
+ entry.startsWith('!')
+ );
+}
+
+async function metaEntryResolves(dir: string, entry: string): Promise {
+ return (
+ (await pathExists(join(dir, `${entry}.mdx`))) ||
+ (await pathExists(join(dir, `${entry}.md`))) ||
+ (await pathExists(join(dir, entry)))
+ );
+}
+
+async function unresolvedMetaEntries(metaPath: string): Promise {
+ let pages: unknown;
+ try {
+ pages = JSON.parse(await readFile(metaPath, 'utf8')).pages;
+ } catch {
+ return [];
+ }
+ if (!Array.isArray(pages)) return [];
+
+ const dir = metaPath.slice(0, metaPath.lastIndexOf('/'));
+ const unresolved: string[] = [];
+ for (const entry of pages) {
+ if (typeof entry !== 'string' || isMetaControlToken(entry)) continue;
+ if (!(await metaEntryResolves(dir, entry))) unresolved.push(entry);
+ }
+ return unresolved;
+}
+
+async function checkMetaEntriesResolve() {
+ const errors: { sourcePath: string; entry: string }[] = [];
+ const contentRoot = join(DOCS_DIR, 'content/docs');
+
+ for (const metaPath of await findFiles(contentRoot, 'meta.json')) {
+ for (const entry of await unresolvedMetaEntries(metaPath)) {
+ errors.push({ sourcePath: metaPath, entry });
+ }
+ }
+
+ if (errors.length > 0) {
+ console.error('\nmeta.json entries with no matching page or folder:');
+ for (const error of errors) {
+ console.error(`- ${error.sourcePath} -> "${error.entry}"`);
+ }
+ process.exitCode = 1;
+ }
+}
+
+async function findFiles(dir: string, name: string): Promise {
+ const out: string[] = [];
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ out.push(...(await findFiles(full, name)));
+ } else if (entry.name === name) {
+ out.push(full);
+ }
+ }
+ return out;
+}
+
+async function pathExists(p: string): Promise {
+ try {
+ await stat(p);
+ return true;
+ } catch {
+ return false;
+ }
}
async function checkStaticAppLinks() {
diff --git a/docs/source.config.ts b/docs/source.config.ts
index 60962e8d65..31928bf68c 100644
--- a/docs/source.config.ts
+++ b/docs/source.config.ts
@@ -38,6 +38,11 @@ const docsSchema = frontmatterSchema.extend({
.optional(),
summary: z.string().optional(),
keywords: z.array(z.string()).optional(),
+ // Opt a section landing page out of the card↔nav completeness lint when its
+ // `` grid is intentionally curated (e.g. links outside the section or
+ // deliberately omits children). Exhaustive list pages should use ``
+ // instead, which derives cards from the page tree and can never drift.
+ manualCards: z.boolean().optional(),
});
// You can customise Zod schemas for frontmatter and `meta.json` here
diff --git a/packages/astro/CHANGELOG.md b/packages/astro/CHANGELOG.md
index ac93871426..1da99e86b5 100644
--- a/packages/astro/CHANGELOG.md
+++ b/packages/astro/CHANGELOG.md
@@ -1,5 +1,14 @@
# @workflow/astro
+## 5.0.0-beta.21
+
+### Patch Changes
+
+- Updated dependencies [[`5291f15`](https://github.com/vercel/workflow/commit/5291f1549fee4d8b042cc03b6696fd8b6cb798fc), [`b713d84`](https://github.com/vercel/workflow/commit/b713d8417b1334abd1e30bdc50701d0d96dee39d), [`57cccaf`](https://github.com/vercel/workflow/commit/57cccaf3734f4afa8218e1ea729a9bb886c691f3)]:
+ - @workflow/builders@5.0.0-beta.21
+ - @workflow/rollup@5.0.0-beta.21
+ - @workflow/vite@5.0.0-beta.21
+
## 5.0.0-beta.20
### Patch Changes
diff --git a/packages/astro/package.json b/packages/astro/package.json
index da04e035c2..af07f1931a 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -1,6 +1,6 @@
{
"name": "@workflow/astro",
- "version": "5.0.0-beta.20",
+ "version": "5.0.0-beta.21",
"description": "Astro integration for Workflow SDK",
"type": "module",
"main": "dist/index.js",
diff --git a/packages/builders/CHANGELOG.md b/packages/builders/CHANGELOG.md
index bee305a5b6..0f66693515 100644
--- a/packages/builders/CHANGELOG.md
+++ b/packages/builders/CHANGELOG.md
@@ -1,5 +1,20 @@
# @workflow/builders
+## 5.0.0-beta.21
+
+### Patch Changes
+
+- [#2546](https://github.com/vercel/workflow/pull/2546) [`5291f15`](https://github.com/vercel/workflow/commit/5291f1549fee4d8b042cc03b6696fd8b6cb798fc) Thanks [@ijjk](https://github.com/ijjk)! - Optimize eager workflow discovery and improve default eager build compatibility.
+
+- [#2324](https://github.com/vercel/workflow/pull/2324) [`b713d84`](https://github.com/vercel/workflow/commit/b713d8417b1334abd1e30bdc50701d0d96dee39d) Thanks [@VaguelySerious](https://github.com/VaguelySerious)! - Decode escaped workflowCode template literals before graph extraction so unicode-escape identifiers parse correctly.
+
+- [#2545](https://github.com/vercel/workflow/pull/2545) [`57cccaf`](https://github.com/vercel/workflow/commit/57cccaf3734f4afa8218e1ea729a9bb886c691f3) Thanks [@ijjk](https://github.com/ijjk)! - Remove the Next.js lazy discovery/deferred builder path and the `workflows.lazyDiscovery` option.
+
+ Fall back to direct generated-file overwrites on Windows when atomic rename is blocked by Next.js dev server file handles.
+
+- Updated dependencies [[`6de5ea5`](https://github.com/vercel/workflow/commit/6de5ea5c2f32b474274f5dabe5f3663e03622ac5), [`66ca0dc`](https://github.com/vercel/workflow/commit/66ca0dcc096440f39dd234e04669e1fc7bf2d615), [`3e82a12`](https://github.com/vercel/workflow/commit/3e82a12712b1efe229ac2b1623dc6c8fc7be7055)]:
+ - @workflow/core@5.0.0-beta.21
+
## 5.0.0-beta.20
### Minor Changes
diff --git a/packages/builders/package.json b/packages/builders/package.json
index 4090fed570..91a5eb78b7 100644
--- a/packages/builders/package.json
+++ b/packages/builders/package.json
@@ -1,6 +1,6 @@
{
"name": "@workflow/builders",
- "version": "5.0.0-beta.20",
+ "version": "5.0.0-beta.21",
"description": "Shared builder infrastructure for Workflow SDK",
"type": "module",
"main": "./dist/index.js",
diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts
index 044142a23f..062d61cc46 100644
--- a/packages/builders/src/base-builder.ts
+++ b/packages/builders/src/base-builder.ts
@@ -1,5 +1,12 @@
import { randomUUID } from 'node:crypto';
-import { mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises';
+import {
+ mkdir,
+ readFile,
+ realpath,
+ rename,
+ rm,
+ writeFile,
+} from 'node:fs/promises';
import { createRequire } from 'node:module';
import { basename, dirname, join, relative, resolve } from 'node:path';
import { promisify } from 'node:util';
@@ -15,8 +22,11 @@ import {
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createWorkflowEntrypointOptionsCode } from './constants.js';
-import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js';
+import {
+ fastDiscoverEntries,
+ type DiscoveredEntries,
+} from './fast-discovery.js';
import {
getImportPath,
resolveModuleSpecifier,
@@ -32,6 +42,8 @@ import { extractWorkflowGraphs } from './workflows-extractor.js';
const enhancedResolve = promisify(enhancedResolveOriginal);
const require = createRequire(import.meta.url);
+export type { DiscoveredEntries } from './fast-discovery.js';
+
/**
* Legacy opt-in for source maps on the final workflow wrapper + webhook
* bundles (which default to off, unlike the step/interim workflow bundles
@@ -120,10 +132,73 @@ function moduleIdentityKey(file: string, moduleSpecifierRoot: string): string {
return file.replace(/\\/g, '/');
}
-export interface DiscoveredEntries {
- discoveredSteps: Set;
- discoveredWorkflows: Set;
- discoveredSerdeFiles: Set;
+type ManifestEntryLocation = {
+ filePath: string;
+ name: string;
+};
+
+function formatIdLocation(location: ManifestEntryLocation): string {
+ return `${location.filePath}#${location.name}`;
+}
+
+function assertUniqueManifestIds(
+ entriesByFile: Record> | undefined,
+ ids: Map,
+ getId: (entry: TEntry) => string,
+ label: 'step' | 'workflow'
+): void {
+ for (const [filePath, entries] of Object.entries(entriesByFile || {})) {
+ for (const [name, data] of Object.entries(entries)) {
+ const id = getId(data);
+ const existing = ids.get(id);
+ const current = { filePath, name };
+ if (
+ existing &&
+ (existing.filePath !== current.filePath ||
+ existing.name !== current.name)
+ ) {
+ const idName = label === 'step' ? 'workflow step ID' : 'workflow ID';
+ const functionName = `${label} function`;
+ const capitalizedLabel = label === 'step' ? 'Step' : 'Workflow';
+ throw new WorkflowBuildError(
+ `Duplicate ${idName} "${id}" generated for ${formatIdLocation(existing)} and ${formatIdLocation(current)}.`,
+ {
+ hint:
+ `${capitalizedLabel} IDs must be unique across a build. ` +
+ `If you own one of the colliding files, rename the ${functionName} or export ` +
+ `the package file through a unique package subpath. If the collision is in a ` +
+ `transitive dependency you don't control, file an issue with the upstream ` +
+ `package or pin to a non-colliding version.`,
+ }
+ );
+ }
+ ids.set(id, current);
+ }
+ }
+}
+
+function mergeWorkflowManifest(
+ target: WorkflowManifest,
+ incoming: WorkflowManifest,
+ stepIds: Map,
+ workflowIds: Map
+): void {
+ assertUniqueManifestIds(
+ incoming.steps,
+ stepIds,
+ (data) => data.stepId,
+ 'step'
+ );
+ assertUniqueManifestIds(
+ incoming.workflows,
+ workflowIds,
+ (data) => data.workflowId,
+ 'workflow'
+ );
+
+ target.workflows = Object.assign(target.workflows || {}, incoming.workflows);
+ target.steps = Object.assign(target.steps || {}, incoming.steps);
+ target.classes = Object.assign(target.classes || {}, incoming.classes);
}
/**
@@ -288,6 +363,10 @@ export abstract class BaseBuilder {
private discoveredEntries: WeakMap =
new WeakMap();
+ public clearDiscoveredEntriesCache(): void {
+ this.discoveredEntries = new WeakMap();
+ }
+
/**
* Pseudo-packages that should not be checked for workflow patterns.
*/
@@ -414,17 +493,16 @@ export abstract class BaseBuilder {
const discoverStart = Date.now();
- // Resolve the SDK runtime entry point so that the discovery pass
- // traces through it and discovers serde classes (like `Run`) that
- // live inside SDK packages. Without this, files like `run.js` are
- // only discovered when user code happens to import them.
+ // Resolve the SDK runtime serde entry point so that the discovery pass
+ // discovers classes like `Run` that live inside SDK packages. Without this,
+ // files like `run.js` are only discovered when user code imports them.
// This is resolved here (rather than in callers) so that the original
// `inputs` array reference is preserved for WeakMap caching — callers
// like createWorkflowsBundle and createStepsBundle can share the same
// cache entry when they pass the same inputFiles array.
const resolvedWorkflowRuntime = await enhancedResolve(
outdir,
- 'workflow/runtime'
+ '@workflow/core/runtime/run'
).catch(() => undefined);
const entryPoints = resolvedWorkflowRuntime
? [...inputs, resolvedWorkflowRuntime]
@@ -432,28 +510,13 @@ export abstract class BaseBuilder {
const effectiveTsconfigPath =
tsconfigPath ?? (await this.findTsConfigPath());
- const esbuildTsconfigOptions = await getEsbuildTsconfigOptions(
- effectiveTsconfigPath
- );
- try {
- await esbuild.build({
- treeShaking: true,
- entryPoints,
- plugins: [
- createDiscoverEntriesPlugin(state, this.transformProjectRoot),
- ],
- platform: 'node',
- write: false,
- outdir,
- bundle: true,
- sourcemap: false,
- absWorkingDir: this.config.workingDir,
- logLevel: 'silent',
- ...esbuildTsconfigOptions,
- // External packages that should not be bundled during discovery
- external: this.config.externalPackages || [],
- });
- } catch (_) {}
+
+ await fastDiscoverEntries({
+ entryPoints,
+ state,
+ defaultTsconfigPath: effectiveTsconfigPath,
+ workingDir: this.config.workingDir,
+ });
this.logBaseBuilderInfo(
`Discovering workflow directives`,
@@ -467,6 +530,37 @@ export abstract class BaseBuilder {
return state;
}
+ /**
+ * Writes generated files atomically where possible. On Windows, Next.js can
+ * briefly hold generated route files open while compiling them, which makes
+ * rename-over-existing fail with EPERM/EACCES. In that case, fall back to a
+ * direct overwrite so watch rebuilds can still make progress.
+ */
+ private async writeGeneratedFile(
+ targetPath: string,
+ content: string
+ ): Promise {
+ const tempPath = `${targetPath}.${randomUUID()}.tmp`;
+ await writeFile(tempPath, content);
+ try {
+ await rename(tempPath, targetPath);
+ } catch (error) {
+ const errorCode =
+ error && typeof error === 'object' && 'code' in error
+ ? (error as NodeJS.ErrnoException).code
+ : undefined;
+ if (
+ process.platform === 'win32' &&
+ (errorCode === 'EPERM' || errorCode === 'EACCES')
+ ) {
+ await writeFile(targetPath, content);
+ await rm(tempPath, { force: true });
+ return;
+ }
+ throw error;
+ }
+ }
+
/**
* Writes debug information to a JSON file for troubleshooting build issues.
* Uses atomic write (temp file + rename) to prevent race conditions when
@@ -504,12 +598,7 @@ export abstract class BaseBuilder {
2
);
- // Write atomically: write to temp file, then rename.
- // rename() is atomic on POSIX systems and provides best-effort atomicity on Windows.
- // Prevents race conditions where concurrent builds read partially-written files.
- const tempPath = `${targetPath}.${randomUUID()}.tmp`;
- await writeFile(tempPath, mergedData);
- await rename(tempPath, targetPath);
+ await this.writeGeneratedFile(targetPath, mergedData);
} catch (error: unknown) {
console.warn('Failed to write debug file:', error);
}
@@ -587,11 +676,139 @@ export abstract class BaseBuilder {
return relativePath;
}
+ protected createRouteImportSpecifier(file: string, routeDir: string): string {
+ const { importPath, isPackage } = getImportPath(
+ file,
+ this.config.workingDir
+ );
+ if (isPackage) {
+ return importPath;
+ }
+
+ let relativePath = relative(routeDir, file).replace(/\\/g, '/');
+ if (!relativePath.startsWith('./') && !relativePath.startsWith('../')) {
+ relativePath = `./${relativePath}`;
+ }
+ return relativePath;
+ }
+
+ private async createStepSourceRegistrationFile({
+ inputFiles,
+ outfile,
+ tsconfigPath,
+ discoveredEntries,
+ }: {
+ inputFiles: string[];
+ outfile: string;
+ tsconfigPath?: string;
+ discoveredEntries?: DiscoveredEntries;
+ }): Promise {
+ const stepsBundleStart = Date.now();
+ const workflowManifest: WorkflowManifest = {};
+ const builtInSteps = 'workflow/internal/builtins';
+ const resolvedBuiltInSteps = (await enhancedResolve(
+ dirname(outfile),
+ builtInSteps
+ ).catch((err) => {
+ throw new WorkflowBuildError(
+ `Failed to resolve built-in steps sources.\n\nCaused by: ${String(err)}`,
+ {
+ hint: 'run `pnpm install workflow` to resolve this issue.',
+ cause: err,
+ }
+ );
+ })) as string;
+
+ const discovered =
+ discoveredEntries ??
+ (await this.discoverEntries(inputFiles, dirname(outfile), tsconfigPath));
+ const stepFiles = [...discovered.discoveredSteps].sort();
+ const workflowFiles = [...discovered.discoveredWorkflows].sort();
+ const serdeFiles = [...discovered.discoveredSerdeFiles].sort();
+ const stepFilesSet = new Set(stepFiles);
+ const serdeOnlyFiles = serdeFiles.filter((f) => !stepFilesSet.has(f));
+
+ await this.writeDebugFile(outfile, {
+ stepFiles,
+ workflowFiles,
+ serdeOnlyFiles,
+ sourceImports: true,
+ });
+
+ const emittedImportIdentities = new Set([builtInSteps]);
+ const importStatements: string[] = [];
+ const routeDir = dirname(outfile);
+ const addRegistrationImport = (specifier: string): void => {
+ importStatements.push(`import ${JSON.stringify(specifier)};`);
+ };
+ const addRegistrationFileImport = (file: string): void => {
+ const identity = moduleIdentityKey(file, this.moduleSpecifierRoot);
+ if (emittedImportIdentities.has(identity)) {
+ return;
+ }
+ emittedImportIdentities.add(identity);
+ addRegistrationImport(this.createRouteImportSpecifier(file, routeDir));
+ };
+
+ addRegistrationImport(builtInSteps);
+ for (const file of stepFiles) {
+ addRegistrationFileImport(file);
+ }
+ for (const file of serdeOnlyFiles) {
+ addRegistrationFileImport(file);
+ }
+
+ const output = `// biome-ignore-all lint: generated file
+/* eslint-disable */
+${importStatements.join('\n')}
+
+export const __steps_registered = true;
+`;
+ await mkdir(dirname(outfile), { recursive: true });
+ const tempPath = `${outfile}.${randomUUID()}.tmp`;
+ await writeFile(tempPath, output);
+ await rename(tempPath, outfile);
+
+ const manifestFiles = Array.from(
+ new Set([...stepFiles, ...serdeOnlyFiles, resolvedBuiltInSteps])
+ ).sort();
+ const stepIds = new Map();
+ const workflowIds = new Map();
+ await Promise.all(
+ manifestFiles.map(async (file) => {
+ const source = await readFile(file, 'utf8');
+ const relativeFilepath = this.getRelativeFilepath(file);
+ const { workflowManifest: fileManifest } = await applySwcTransform(
+ relativeFilepath,
+ source,
+ 'step',
+ file,
+ this.transformProjectRoot,
+ this.moduleSpecifierRoot
+ );
+ mergeWorkflowManifest(
+ workflowManifest,
+ fileManifest,
+ stepIds,
+ workflowIds
+ );
+ })
+ );
+
+ await this.ensureSwcIgnored();
+ this.logBaseBuilderInfo(
+ 'Created step registrations',
+ `${Date.now() - stepsBundleStart}ms`
+ );
+ return workflowManifest;
+ }
+
/**
* Creates a bundle for workflow step functions.
* Steps have full Node.js runtime access and handle side effects, API calls, etc.
*
* @param externalizeNonSteps - If true, only bundles step entry points and externalizes other code
+ * @param sourceStepRegistrationImports - If true, emits a source import registration file instead of bundling step registrations
* @param bundleTransitiveLocalStepDependencies - If true, also bundles project-local files imported by step entries for direct runtime loading
* @returns Build context (for watch mode) and the collected workflow manifest
*/
@@ -601,6 +818,7 @@ export abstract class BaseBuilder {
outfile,
externalizeNonSteps,
bundleTransitiveLocalStepDependencies,
+ sourceStepRegistrationImports,
rewriteTsExtensions,
tsconfigPath,
discoveredEntries,
@@ -612,6 +830,7 @@ export abstract class BaseBuilder {
format?: 'cjs' | 'esm';
externalizeNonSteps?: boolean;
bundleTransitiveLocalStepDependencies?: boolean;
+ sourceStepRegistrationImports?: boolean;
rewriteTsExtensions?: boolean;
discoveredEntries?: DiscoveredEntries;
/**
@@ -659,6 +878,22 @@ export abstract class BaseBuilder {
const stepFilesSet = new Set(stepFiles);
const serdeOnlyFiles = serdeFiles.filter((f) => !stepFilesSet.has(f));
+ if (
+ sourceStepRegistrationImports &&
+ externalizeNonSteps &&
+ !bundleTransitiveLocalStepDependencies
+ ) {
+ return {
+ context: undefined,
+ manifest: await this.createStepSourceRegistrationFile({
+ inputFiles,
+ outfile,
+ tsconfigPath,
+ discoveredEntries: discovered,
+ }),
+ };
+ }
+
// log the step files for debugging
await this.writeDebugFile(outfile, {
stepFiles,
@@ -1213,11 +1448,7 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
const outputDir = dirname(outfile);
await mkdir(outputDir, { recursive: true });
- // Atomic write: write to temp file then rename to prevent
- // file watchers from reading partial file during write
- const tempPath = `${outfile}.${randomUUID()}.tmp`;
- await writeFile(tempPath, workflowFunctionCode);
- await rename(tempPath, outfile);
+ await this.writeGeneratedFile(outfile, workflowFunctionCode);
return;
}
@@ -1312,6 +1543,7 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
tsconfigPath,
externalizeNonSteps,
bundleTransitiveLocalStepDependencies,
+ sourceStepRegistrationImports,
discoveredEntries,
}: {
inputFiles: string[];
@@ -1324,6 +1556,7 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
tsconfigPath?: string;
externalizeNonSteps?: boolean;
bundleTransitiveLocalStepDependencies?: boolean;
+ sourceStepRegistrationImports?: boolean;
discoveredEntries?: DiscoveredEntries;
}): Promise<{
manifest: WorkflowManifest;
@@ -1346,6 +1579,7 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
format: bundleFinalOutput ? 'esm' : format,
externalizeNonSteps,
bundleTransitiveLocalStepDependencies,
+ sourceStepRegistrationImports,
tsconfigPath,
discoveredEntries,
// Skip the createRequire banner here — when bundleFinalOutput is true
@@ -1396,10 +1630,7 @@ const workflowCode = \`${escapedVMCode}\`;
export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCode});`;
if (!bundleFinalOutput) {
- // Write directly (Next.js will bundle)
- const tempPath = `${flowOutfile}.${randomUUID()}.tmp`;
- await writeFile(tempPath, combinedFunctionCode);
- await rename(tempPath, flowOutfile);
+ await this.writeGeneratedFile(flowOutfile, combinedFunctionCode);
} else {
// Bundle the combined code for standalone use
const bundleStartTime = Date.now();
@@ -1469,9 +1700,7 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
const outputDir = dirname(flowOutfile);
await mkdir(outputDir, { recursive: true });
- const tempPath = `${flowOutfile}.${randomUUID()}.tmp`;
- await writeFile(tempPath, code);
- await rename(tempPath, flowOutfile);
+ await this.writeGeneratedFile(flowOutfile, code);
};
if (this.config.watch) {
diff --git a/packages/builders/src/fast-discovery.test.ts b/packages/builders/src/fast-discovery.test.ts
new file mode 100644
index 0000000000..c7966d895c
--- /dev/null
+++ b/packages/builders/src/fast-discovery.test.ts
@@ -0,0 +1,515 @@
+import {
+ mkdirSync,
+ mkdtempSync,
+ realpathSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { BaseBuilder, type DiscoveredEntries } from './base-builder.js';
+import {
+ importParents,
+ parentHasChild,
+} from './discover-entries-esbuild-plugin.js';
+import type { StandaloneConfig } from './types.js';
+
+class TestBuilder extends BaseBuilder {
+ async build(): Promise {
+ // no-op
+ }
+
+ public discoverEntriesPublic(
+ inputs: string[],
+ outdir: string,
+ tsconfigPath?: string
+ ): Promise {
+ return this.discoverEntries(inputs, outdir, tsconfigPath);
+ }
+
+ public createRouteImportSpecifierPublic(
+ file: string,
+ routeDir: string
+ ): string {
+ return this.createRouteImportSpecifier(file, routeDir);
+ }
+}
+
+const realTmpdir = realpathSync(tmpdir());
+
+function normalize(path: string): string {
+ return path.replace(/\\/g, '/');
+}
+
+function writeFile(path: string, contents: string): void {
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(path, contents, 'utf-8');
+}
+
+function createBuilder(workingDir: string): TestBuilder {
+ const config: StandaloneConfig = {
+ buildTarget: 'standalone',
+ workingDir,
+ dirs: ['.'],
+ stepsBundlePath: join(workingDir, 'steps.js'),
+ workflowsBundlePath: join(workingDir, 'workflows.js'),
+ webhookBundlePath: join(workingDir, 'webhook.js'),
+ };
+ return new TestBuilder(config);
+}
+
+describe('fast workflow discovery', () => {
+ let testRoot: string;
+
+ beforeEach(() => {
+ testRoot = mkdtempSync(join(realTmpdir, 'workflow-fast-discovery-'));
+ importParents.clear();
+ });
+
+ afterEach(() => {
+ importParents.clear();
+ rmSync(testRoot, { recursive: true, force: true });
+ });
+
+ it('discovers transitive relative step imports and tracks the parent chain', async () => {
+ const entryFile = join(testRoot, 'src', 'entry.ts');
+ const workflowFile = join(testRoot, 'src', 'workflow.ts');
+ const stepFile = join(testRoot, 'src', 'step.ts');
+
+ writeFile(entryFile, `import './workflow';\n`);
+ writeFile(workflowFile, `import { doStep } from './step';\nvoid doStep;\n`);
+ writeFile(
+ stepFile,
+ `export async function doStep() {
+ 'use step';
+ return 1;
+}
+`
+ );
+
+ const discovered = await createBuilder(testRoot).discoverEntriesPublic(
+ [entryFile],
+ join(testRoot, 'out')
+ );
+
+ expect(discovered.discoveredSteps).toEqual(new Set([normalize(stepFile)]));
+ expect(parentHasChild(normalize(entryFile), normalize(stepFile))).toBe(
+ true
+ );
+ });
+
+ it('discovers workflow files reached through an imported package re-export', async () => {
+ const entryFile = join(testRoot, 'src', 'entry.ts');
+ const packageRoot = join(testRoot, 'node_modules', 'workflow-pkg');
+ const packageIndex = join(packageRoot, 'index.js');
+ const packageWorkflow = join(packageRoot, 'workflow.js');
+
+ writeFile(entryFile, `import { run } from 'workflow-pkg';\nvoid run;\n`);
+ writeFile(
+ join(packageRoot, 'package.json'),
+ JSON.stringify({
+ name: 'workflow-pkg',
+ version: '1.0.0',
+ main: 'index.js',
+ dependencies: {
+ workflow: '^1.0.0',
+ },
+ })
+ );
+ writeFile(packageIndex, `export { run } from './workflow.js';\n`);
+ writeFile(
+ packageWorkflow,
+ `export async function run() {
+ "use workflow";
+ return "ok";
+}
+`
+ );
+
+ const discovered = await createBuilder(testRoot).discoverEntriesPublic(
+ [entryFile],
+ join(testRoot, 'out')
+ );
+
+ expect(discovered.discoveredWorkflows).toEqual(
+ new Set([normalize(packageWorkflow)])
+ );
+ expect(
+ parentHasChild(normalize(packageIndex), normalize(packageWorkflow))
+ ).toBe(true);
+ });
+
+ it('discovers files reached through tsconfig path aliases', async () => {
+ const entryFile = join(testRoot, 'src', 'entry.ts');
+ const registryFile = join(testRoot, 'src', '_workflows.ts');
+ const workflowFile = join(testRoot, 'src', 'workflows', 'workflow.ts');
+ const tsconfigFile = join(testRoot, 'tsconfig.json');
+
+ writeFile(
+ tsconfigFile,
+ JSON.stringify({
+ compilerOptions: {
+ paths: {
+ '@/*': ['./src/*'],
+ },
+ },
+ })
+ );
+ writeFile(entryFile, `import { allWorkflows } from '@/_workflows';\n`);
+ writeFile(
+ registryFile,
+ `import * as workflow from './workflows/workflow';
+export const allWorkflows = { workflow };
+`
+ );
+ writeFile(
+ workflowFile,
+ `export async function run() {
+ 'use workflow';
+ return 'ok';
+}
+`
+ );
+
+ const discovered = await createBuilder(testRoot).discoverEntriesPublic(
+ [entryFile],
+ join(testRoot, 'out'),
+ tsconfigFile
+ );
+
+ expect(discovered.discoveredWorkflows).toEqual(
+ new Set([normalize(workflowFile)])
+ );
+ expect(parentHasChild(normalize(entryFile), normalize(workflowFile))).toBe(
+ true
+ );
+ });
+
+ it('discovers path aliases inherited through tsconfig extends', async () => {
+ const entryFile = join(testRoot, 'src', 'entry.ts');
+ const workflowFile = join(testRoot, 'src', 'workflows', 'workflow.ts');
+ const baseTsconfigFile = join(testRoot, 'tsconfig.base.json');
+ const tsconfigFile = join(testRoot, 'tsconfig.json');
+
+ writeFile(
+ baseTsconfigFile,
+ JSON.stringify({
+ compilerOptions: {
+ baseUrl: '.',
+ paths: {
+ '@base/*': ['./src/*'],
+ },
+ },
+ })
+ );
+ writeFile(
+ tsconfigFile,
+ JSON.stringify({
+ extends: './tsconfig.base.json',
+ })
+ );
+ writeFile(entryFile, `import { run } from '@base/workflows/workflow';\n`);
+ writeFile(
+ workflowFile,
+ `export async function run() {
+ 'use workflow';
+ return 'ok';
+}
+`
+ );
+
+ const discovered = await createBuilder(testRoot).discoverEntriesPublic(
+ [entryFile],
+ join(testRoot, 'out'),
+ tsconfigFile
+ );
+
+ expect(discovered.discoveredWorkflows).toEqual(
+ new Set([normalize(workflowFile)])
+ );
+ });
+
+ it('discovers path aliases with multiple wildcards', async () => {
+ const entryFile = join(testRoot, 'src', 'entry.ts');
+ const workflowFile = join(
+ testRoot,
+ 'src',
+ 'features',
+ 'billing',
+ 'flows',
+ 'charge.ts'
+ );
+ const tsconfigFile = join(testRoot, 'tsconfig.json');
+
+ writeFile(
+ tsconfigFile,
+ JSON.stringify({
+ compilerOptions: {
+ paths: {
+ '@feature/*/workflow/*': ['./src/features/*/flows/*'],
+ },
+ },
+ })
+ );
+ writeFile(
+ entryFile,
+ `import { charge } from '@feature/billing/workflow/charge';\n`
+ );
+ writeFile(
+ workflowFile,
+ `export async function charge() {
+ 'use workflow';
+ return 'ok';
+}
+`
+ );
+
+ const discovered = await createBuilder(testRoot).discoverEntriesPublic(
+ [entryFile],
+ join(testRoot, 'out'),
+ tsconfigFile
+ );
+
+ expect(discovered.discoveredWorkflows).toEqual(
+ new Set([normalize(workflowFile)])
+ );
+ });
+
+ it('uses nearest nested jsconfig aliases in monorepo packages', async () => {
+ const rootTsconfigFile = join(testRoot, 'tsconfig.json');
+ const packageRoot = join(testRoot, 'packages', 'app');
+ const entryFile = join(packageRoot, 'src', 'entry.js');
+ const workflowFile = join(packageRoot, 'src', 'workflow.js');
+
+ writeFile(
+ rootTsconfigFile,
+ JSON.stringify({
+ compilerOptions: {
+ paths: {
+ '@root/*': ['./root/*'],
+ },
+ },
+ })
+ );
+ writeFile(
+ join(packageRoot, 'jsconfig.json'),
+ JSON.stringify({
+ compilerOptions: {
+ paths: {
+ '#/*': ['./src/*'],
+ },
+ },
+ })
+ );
+ writeFile(entryFile, `import { run } from '#/workflow';\n`);
+ writeFile(
+ workflowFile,
+ `export async function run() {
+ "use workflow";
+ return "ok";
+}
+`
+ );
+
+ const discovered = await createBuilder(testRoot).discoverEntriesPublic(
+ [entryFile],
+ join(testRoot, 'out')
+ );
+
+ expect(discovered.discoveredWorkflows).toEqual(
+ new Set([normalize(workflowFile)])
+ );
+ });
+
+ it('only treats serde files as registration candidates when they define static serde methods', async () => {
+ const entryFile = join(testRoot, 'src', 'entry.ts');
+ const reducerFile = join(testRoot, 'src', 'reducer.ts');
+ const serdeFile = join(testRoot, 'src', 'serde.ts');
+
+ writeFile(
+ entryFile,
+ `import './reducer';
+import './serde';
+`
+ );
+ writeFile(
+ reducerFile,
+ `import { WORKFLOW_SERIALIZE } from '@workflow/serde';
+
+export function reducer(value: unknown) {
+ return value?.constructor?.[WORKFLOW_SERIALIZE];
+}
+`
+ );
+ writeFile(
+ serdeFile,
+ `import { WORKFLOW_SERIALIZE as WS } from '@workflow/serde';
+
+export class Value {
+ static classId = 'Value';
+ static [WS](value: Value) {
+ return value;
+ }
+}
+`
+ );
+
+ const discovered = await createBuilder(testRoot).discoverEntriesPublic(
+ [entryFile],
+ join(testRoot, 'out')
+ );
+
+ expect(discovered.discoveredSerdeFiles).toEqual(
+ new Set([normalize(serdeFile)])
+ );
+ });
+
+ it('categorizes step, workflow, and serde usage independently', async () => {
+ const entryFile = join(testRoot, 'src', 'entry.ts');
+ const stepFile = join(testRoot, 'src', 'step.ts');
+ const workflowFile = join(testRoot, 'src', 'workflow.ts');
+ const serdeFile = join(testRoot, 'src', 'serde.ts');
+
+ writeFile(
+ entryFile,
+ `import './step';
+import './workflow';
+import './serde';
+`
+ );
+ writeFile(
+ stepFile,
+ `export async function runStep() {
+ 'use step';
+ return 'ok';
+}
+`
+ );
+ writeFile(
+ workflowFile,
+ `export async function runWorkflow() {
+ 'use workflow';
+ return 'ok';
+}
+`
+ );
+ writeFile(
+ serdeFile,
+ `export class Value {
+ static classId = 'Value';
+ static [Symbol.for('workflow-serialize')](value: Value) {
+ return value;
+ }
+}
+`
+ );
+
+ const discovered = await createBuilder(testRoot).discoverEntriesPublic(
+ [entryFile],
+ join(testRoot, 'out')
+ );
+
+ expect(discovered.discoveredSteps).toEqual(new Set([normalize(stepFile)]));
+ expect(discovered.discoveredWorkflows).toEqual(
+ new Set([normalize(workflowFile)])
+ );
+ expect(discovered.discoveredSerdeFiles).toEqual(
+ new Set([normalize(serdeFile)])
+ );
+ });
+
+ it('ignores serde examples that only appear inside comments', async () => {
+ const entryFile = join(testRoot, 'src', 'entry.ts');
+ const docsFile = join(testRoot, 'src', 'docs.ts');
+
+ writeFile(entryFile, `import './docs';\n`);
+ writeFile(
+ docsFile,
+ `/**
+ * import { WORKFLOW_SERIALIZE } from '@workflow/serde';
+ *
+ * class Example {
+ * static [WORKFLOW_SERIALIZE](value) {
+ * return value;
+ * }
+ * }
+ */
+export const WORKFLOW_SERIALIZE = Symbol.for('workflow-serialize');
+`
+ );
+
+ const discovered = await createBuilder(testRoot).discoverEntriesPublic(
+ [entryFile],
+ join(testRoot, 'out')
+ );
+
+ expect(discovered.discoveredSerdeFiles).toEqual(new Set());
+ });
+
+ it('relativizes nested package step registration imports', () => {
+ const routeDir = join(testRoot, 'app', '.well-known', 'workflow', 'v1');
+ const directPackageFile = join(
+ testRoot,
+ 'node_modules',
+ 'direct-pkg',
+ 'step.js'
+ );
+ const nestedPackageFile = join(
+ testRoot,
+ 'node_modules',
+ 'parent-pkg',
+ 'node_modules',
+ 'nested-pkg',
+ 'step.js'
+ );
+
+ writeFile(
+ join(testRoot, 'package.json'),
+ JSON.stringify({
+ dependencies: {
+ 'direct-pkg': '1.0.0',
+ },
+ })
+ );
+ writeFile(
+ join(testRoot, 'node_modules', 'direct-pkg', 'package.json'),
+ JSON.stringify({
+ name: 'direct-pkg',
+ version: '1.0.0',
+ exports: {
+ './step': './step.js',
+ },
+ })
+ );
+ writeFile(directPackageFile, `export const step = true;\n`);
+ writeFile(
+ join(
+ testRoot,
+ 'node_modules',
+ 'parent-pkg',
+ 'node_modules',
+ 'nested-pkg',
+ 'package.json'
+ ),
+ JSON.stringify({
+ name: 'nested-pkg',
+ version: '1.0.0',
+ exports: {
+ './step': './step.js',
+ },
+ })
+ );
+ writeFile(nestedPackageFile, `export const step = true;\n`);
+
+ const builder = createBuilder(testRoot);
+ expect(
+ builder.createRouteImportSpecifierPublic(directPackageFile, routeDir)
+ ).toBe('direct-pkg/step');
+ expect(
+ builder.createRouteImportSpecifierPublic(nestedPackageFile, routeDir)
+ ).toBe(
+ '../../../../node_modules/parent-pkg/node_modules/nested-pkg/step.js'
+ );
+ });
+});
diff --git a/packages/builders/src/fast-discovery.ts b/packages/builders/src/fast-discovery.ts
new file mode 100644
index 0000000000..e57f0864dc
--- /dev/null
+++ b/packages/builders/src/fast-discovery.ts
@@ -0,0 +1,915 @@
+import { access, readFile } from 'node:fs/promises';
+import { builtinModules, createRequire } from 'node:module';
+import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
+import { promisify } from 'node:util';
+import enhancedResolveOriginal from 'enhanced-resolve';
+import { findUp } from 'find-up';
+import JSON5 from 'json5';
+import { importParents } from './discover-entries-esbuild-plugin.js';
+import { detectWorkflowPatterns } from './transform-utils.js';
+
+const FAST_DISCOVERY_SOURCE_EXTENSIONS = [
+ '.ts',
+ '.tsx',
+ '.mts',
+ '.cts',
+ '.js',
+ '.jsx',
+ '.mjs',
+ '.cjs',
+];
+const FAST_DISCOVERY_SOURCE_EXTENSION_SET = new Set(
+ FAST_DISCOVERY_SOURCE_EXTENSIONS
+);
+const fastDiscoveryResolve = promisify(
+ enhancedResolveOriginal.create({
+ extensions: [...FAST_DISCOVERY_SOURCE_EXTENSIONS, '.json', '.node'],
+ fullySpecified: false,
+ conditionNames: ['node', 'import', 'require'],
+ })
+);
+const require = createRequire(import.meta.url);
+
+const FAST_DISCOVERY_READ_CONCURRENCY = 32;
+const FAST_DISCOVERY_RESOLVE_CONCURRENCY = 32;
+const FAST_DISCOVERY_FILE_CONCURRENCY = 128;
+const PACKAGE_JSON = 'package.json';
+const NODE_BUILTIN_SPECIFIERS = new Set([
+ ...builtinModules,
+ ...builtinModules.map((moduleName) => `node:${moduleName}`),
+]);
+const IMPORT_SPECIFIER_PATTERNS = [
+ /\bfrom\s+['"]([^'"]+)['"]/g,
+ /(?:^|[;\n])\s*import\s+['"]([^'"]+)['"]/g,
+ /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
+ /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
+];
+
+export interface DiscoveredEntries {
+ discoveredSteps: Set;
+ discoveredWorkflows: Set;
+ discoveredSerdeFiles: Set;
+}
+
+interface FastDiscoverEntriesOptions {
+ entryPoints: string[];
+ state: DiscoveredEntries;
+ defaultTsconfigPath: string | undefined;
+ workingDir: string;
+}
+
+interface PackageInfo {
+ root: string;
+ hasWorkflowDependency: boolean;
+}
+
+interface TsconfigPathAlias {
+ pattern: string;
+ patternParts: string[];
+ targets: Array<{
+ template: string;
+ parts: string[];
+ }>;
+}
+
+interface TsconfigPathAliasLoadResult {
+ aliases: TsconfigPathAlias[];
+ baseUrl: string | undefined;
+}
+
+function createLimiter(concurrency: number) {
+ let activeCount = 0;
+ const queue: Array<() => void> = [];
+
+ const acquire = async () => {
+ await new Promise((resolve) => {
+ const run = () => {
+ activeCount++;
+ resolve();
+ };
+
+ if (activeCount < concurrency) {
+ run();
+ } else {
+ queue.push(run);
+ }
+ });
+ };
+
+ const release = () => {
+ activeCount--;
+ const next = queue.shift();
+ if (next) {
+ next();
+ }
+ };
+
+ return async function limit(fn: () => Promise): Promise {
+ await acquire();
+ try {
+ return await fn();
+ } finally {
+ release();
+ }
+ };
+}
+
+function normalizePath(filePath: string): string {
+ return filePath.replace(/\\/g, '/');
+}
+
+function isJsTsFile(filePath: string): boolean {
+ return FAST_DISCOVERY_SOURCE_EXTENSION_SET.has(extname(filePath));
+}
+
+function isRelativeOrAbsoluteSpecifier(specifier: string): boolean {
+ return specifier.startsWith('.') || isAbsolute(specifier);
+}
+
+function getPackageNameFromSpecifier(specifier: string): string | null {
+ const strippedSpecifier = stripImportSpecifierQuery(specifier);
+ if (isRelativeOrAbsoluteSpecifier(strippedSpecifier)) {
+ return null;
+ }
+
+ if (strippedSpecifier.startsWith('@')) {
+ const [scope, name] = strippedSpecifier.split('/');
+ return scope && name ? `${scope}/${name}` : null;
+ }
+
+ return strippedSpecifier.split('/')[0] || null;
+}
+
+function stripImportSpecifierQuery(specifier: string): string {
+ const queryIndex = specifier.indexOf('?');
+ const hashIndex = specifier.indexOf('#');
+ const endIndex =
+ queryIndex === -1
+ ? hashIndex
+ : hashIndex === -1
+ ? queryIndex
+ : Math.min(queryIndex, hashIndex);
+ return endIndex === -1 ? specifier : specifier.slice(0, endIndex);
+}
+
+function shouldSkipFastDiscoveryImport(specifier: string): boolean {
+ if (NODE_BUILTIN_SPECIFIERS.has(specifier)) {
+ return true;
+ }
+
+ const pathLikeSpecifier = stripImportSpecifierQuery(specifier);
+ if (
+ !pathLikeSpecifier.startsWith('.') &&
+ !isAbsolute(pathLikeSpecifier) &&
+ !pathLikeSpecifier.includes('/')
+ ) {
+ return false;
+ }
+
+ const extension = extname(pathLikeSpecifier);
+ return (
+ extension !== '' && !FAST_DISCOVERY_SOURCE_EXTENSION_SET.has(extension)
+ );
+}
+
+function matchTsconfigPathAlias(
+ specifier: string,
+ alias: TsconfigPathAlias
+): string[] | null {
+ if (alias.patternParts.length === 1) {
+ return specifier === alias.pattern ? [] : null;
+ }
+
+ const captures: string[] = [];
+ let position = 0;
+ const firstPart = alias.patternParts[0];
+ if (!specifier.startsWith(firstPart)) {
+ return null;
+ }
+ position = firstPart.length;
+
+ for (let i = 1; i < alias.patternParts.length; i++) {
+ const part = alias.patternParts[i];
+ if (i === alias.patternParts.length - 1) {
+ if (!specifier.endsWith(part)) {
+ return null;
+ }
+ captures.push(specifier.slice(position, specifier.length - part.length));
+ return captures;
+ }
+
+ const nextIndex = specifier.indexOf(part, position);
+ if (nextIndex === -1) {
+ return null;
+ }
+ captures.push(specifier.slice(position, nextIndex));
+ position = nextIndex + part.length;
+ }
+
+ return captures;
+}
+
+function applyTsconfigPathTarget(
+ target: TsconfigPathAlias['targets'][number],
+ captures: string[]
+): string {
+ if (target.parts.length === 1) {
+ return target.template;
+ }
+
+ let resolved = target.parts[0];
+ for (let i = 1; i < target.parts.length; i++) {
+ resolved += (captures[i - 1] ?? '') + target.parts[i];
+ }
+ return resolved;
+}
+
+function isGeneratedBuildArtifactPath(filePath: string): boolean {
+ const normalizedPath = normalizePath(filePath);
+ return (
+ normalizedPath.includes('/.nitro/') ||
+ normalizedPath.includes('/.output/') ||
+ normalizedPath.includes('/.next/') ||
+ normalizedPath.includes('/.nuxt/') ||
+ normalizedPath.includes('/.svelte-kit/') ||
+ normalizedPath.includes('/.vercel/') ||
+ normalizedPath.includes('/.well-known/workflow/')
+ );
+}
+
+function isNodeModulesPath(filePath: string): boolean {
+ const normalizedPath = normalizePath(filePath);
+ return (
+ normalizedPath.includes('/node_modules/') ||
+ normalizedPath.includes('/.pnpm/')
+ );
+}
+
+function addImportParent(parent: string, child: string): void {
+ const normalizedParent = normalizePath(parent);
+ const normalizedChild = normalizePath(child);
+ let children = importParents.get(normalizedParent);
+ if (!children) {
+ children = new Set();
+ importParents.set(normalizedParent, children);
+ }
+ children.add(normalizedChild);
+}
+
+function extractImportSpecifiers(source: string): string[] {
+ if (
+ !source.includes('import') &&
+ !source.includes('require') &&
+ !source.includes('from')
+ ) {
+ return [];
+ }
+
+ const specifiers = new Set();
+
+ for (const importPattern of IMPORT_SPECIFIER_PATTERNS) {
+ for (const match of source.matchAll(importPattern)) {
+ const specifier = match[1];
+ if (specifier) {
+ specifiers.add(specifier);
+ }
+ }
+ }
+
+ return Array.from(specifiers);
+}
+
+function hasWorkflowDependency(dependencies: unknown): boolean {
+ if (
+ typeof dependencies !== 'object' ||
+ dependencies === null ||
+ Array.isArray(dependencies)
+ ) {
+ return false;
+ }
+
+ return Object.keys(dependencies).some(
+ (dependency) =>
+ dependency === 'workflow' || dependency.startsWith('@workflow/')
+ );
+}
+
+function stripComments(source: string): string {
+ return source
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/(^|[^:])\/\/.*$/gm, '$1');
+}
+
+function hasLikelySerdeClass(source: string): boolean {
+ if (!source.includes('static') || !source.includes('[')) {
+ return false;
+ }
+
+ const uncommentedSource = stripComments(source);
+ if (
+ /static\s+\[\s*(?:WORKFLOW_(?:SERIALIZE|DESERIALIZE)|Symbol\.for\s*\(\s*['"]workflow-(?:serialize|deserialize)['"]\s*\))\s*\]\s*\(/.test(
+ uncommentedSource
+ )
+ ) {
+ return true;
+ }
+
+ if (
+ !/from\s+['"]@workflow\/serde['"]|require\s*\(\s*['"]@workflow\/serde['"]\s*\)/.test(
+ uncommentedSource
+ )
+ ) {
+ return false;
+ }
+
+ return /static\s+\[\s*[$A-Z_a-z][$\w]*\s*\]\s*\(/.test(uncommentedSource);
+}
+
+async function loadTsconfigPathAliases(
+ tsconfigPath: string | undefined,
+ seen = new Set()
+): Promise {
+ if (!tsconfigPath) {
+ return [];
+ }
+
+ return (await loadTsconfigPathAliasConfig(tsconfigPath, seen)).aliases;
+}
+
+async function loadTsconfigPathAliasConfig(
+ tsconfigPath: string,
+ seen: Set
+): Promise {
+ const normalizedTsconfigPath = resolve(tsconfigPath);
+ if (seen.has(normalizedTsconfigPath)) {
+ return { aliases: [], baseUrl: undefined };
+ }
+
+ seen.add(normalizedTsconfigPath);
+
+ try {
+ const source = await readFile(normalizedTsconfigPath, 'utf8');
+ const parsed = JSON5.parse(source) as {
+ extends?: unknown;
+ compilerOptions?: {
+ baseUrl?: unknown;
+ paths?: unknown;
+ };
+ };
+ const compilerOptions = parsed.compilerOptions;
+
+ let baseConfig: TsconfigPathAliasLoadResult = {
+ aliases: [],
+ baseUrl: undefined,
+ };
+ if (typeof parsed.extends === 'string') {
+ const baseTsconfigPath = await resolveTsconfigExtendsPath(
+ parsed.extends,
+ normalizedTsconfigPath
+ );
+ if (baseTsconfigPath) {
+ baseConfig = await loadTsconfigPathAliasConfig(baseTsconfigPath, seen);
+ }
+ }
+
+ const baseUrl =
+ typeof compilerOptions?.baseUrl === 'string'
+ ? resolve(dirname(normalizedTsconfigPath), compilerOptions.baseUrl)
+ : baseConfig.baseUrl;
+
+ if (
+ !compilerOptions ||
+ typeof compilerOptions.paths !== 'object' ||
+ compilerOptions.paths === null ||
+ Array.isArray(compilerOptions.paths)
+ ) {
+ return {
+ aliases: baseConfig.aliases,
+ baseUrl,
+ };
+ }
+
+ const baseDir = baseUrl ?? dirname(normalizedTsconfigPath);
+ const aliases: TsconfigPathAlias[] = [];
+
+ for (const [pattern, rawTargets] of Object.entries(compilerOptions.paths)) {
+ if (!Array.isArray(rawTargets)) {
+ continue;
+ }
+
+ const targets = rawTargets
+ .filter((target): target is string => typeof target === 'string')
+ .map((target) => {
+ const template = resolve(baseDir, target);
+ return {
+ template,
+ parts: template.split('*'),
+ };
+ });
+ if (targets.length === 0) {
+ continue;
+ }
+
+ aliases.push({
+ pattern,
+ patternParts: pattern.split('*'),
+ targets,
+ });
+ }
+
+ return { aliases, baseUrl };
+ } catch {
+ return { aliases: [], baseUrl: undefined };
+ } finally {
+ seen.delete(normalizedTsconfigPath);
+ }
+}
+
+async function resolveTsconfigExtendsPath(
+ extendsValue: string,
+ tsconfigPath: string
+): Promise {
+ const configDir = dirname(tsconfigPath);
+ if (extendsValue.startsWith('.') || isAbsolute(extendsValue)) {
+ const resolved = isAbsolute(extendsValue)
+ ? extendsValue
+ : resolve(configDir, extendsValue);
+ return findExistingTsconfigPath(resolved);
+ }
+
+ try {
+ return require.resolve(extendsValue, { paths: [configDir] });
+ } catch {}
+
+ try {
+ return require.resolve(`${extendsValue}/tsconfig.json`, {
+ paths: [configDir],
+ });
+ } catch {
+ return undefined;
+ }
+}
+
+async function findExistingTsconfigPath(
+ candidatePath: string
+): Promise {
+ const candidates =
+ extname(candidatePath) === ''
+ ? [
+ `${candidatePath}.json`,
+ join(candidatePath, 'tsconfig.json'),
+ candidatePath,
+ ]
+ : [candidatePath];
+
+ for (const candidate of candidates) {
+ try {
+ await access(candidate);
+ return candidate;
+ } catch {}
+ }
+
+ return undefined;
+}
+
+async function findPackageInfo(
+ filePath: string,
+ packageInfoCache: Map>
+): Promise {
+ let currentDir = dirname(filePath);
+
+ while (currentDir && currentDir !== dirname(currentDir)) {
+ const cached = packageInfoCache.get(currentDir);
+ if (cached) {
+ const cachedInfo = await cached;
+ if (cachedInfo) {
+ return cachedInfo;
+ }
+ currentDir = dirname(currentDir);
+ continue;
+ }
+
+ const packageJsonPath = join(currentDir, PACKAGE_JSON);
+ const packageInfoPromise = readFile(packageJsonPath, 'utf8')
+ .then((source): PackageInfo => {
+ const parsed = JSON.parse(source) as {
+ name?: unknown;
+ dependencies?: unknown;
+ peerDependencies?: unknown;
+ optionalDependencies?: unknown;
+ devDependencies?: unknown;
+ };
+ const packageName = typeof parsed.name === 'string' ? parsed.name : '';
+ return {
+ root: normalizePath(currentDir),
+ hasWorkflowDependency:
+ packageName === 'workflow' ||
+ packageName.startsWith('@workflow/') ||
+ hasWorkflowDependency(parsed.dependencies) ||
+ hasWorkflowDependency(parsed.peerDependencies) ||
+ hasWorkflowDependency(parsed.optionalDependencies) ||
+ hasWorkflowDependency(parsed.devDependencies),
+ };
+ })
+ .catch(() => null);
+
+ packageInfoCache.set(currentDir, packageInfoPromise);
+ const packageInfo = await packageInfoPromise;
+ if (packageInfo) {
+ return packageInfo;
+ }
+
+ currentDir = dirname(currentDir);
+ }
+
+ return null;
+}
+
+export async function fastDiscoverEntries({
+ entryPoints,
+ state,
+ defaultTsconfigPath,
+ workingDir,
+}: FastDiscoverEntriesOptions): Promise {
+ const readLimit = createLimiter(FAST_DISCOVERY_READ_CONCURRENCY);
+ const resolveLimit = createLimiter(FAST_DISCOVERY_RESOLVE_CONCURRENCY);
+ const resolveCache = new Map>();
+ const fileExistsCache = new Map>();
+ const tsconfigPathByDirCache = new Map>();
+ const tsconfigAliasesCache = new Map>();
+ const packageInfoCache = new Map>();
+ const packageSpecifierInfoCache = new Map<
+ string,
+ Promise
+ >();
+ const queuedFiles = new Set();
+ const processedFiles = new Set();
+ const queue: string[] = [];
+ const enqueueFile = (filePath: string | undefined | null): void => {
+ if (!filePath) return;
+ const normalizedPath = normalizePath(filePath);
+ if (
+ queuedFiles.has(normalizedPath) ||
+ processedFiles.has(normalizedPath) ||
+ !isJsTsFile(normalizedPath) ||
+ isGeneratedBuildArtifactPath(normalizedPath)
+ ) {
+ return;
+ }
+ queuedFiles.add(normalizedPath);
+ queue.push(normalizedPath);
+ };
+
+ const readSource = async (filePath: string): Promise => {
+ return await readLimit(async () => {
+ try {
+ return await readFile(filePath, 'utf8');
+ } catch {
+ return null;
+ }
+ });
+ };
+
+ const fileExists = (filePath: string): Promise => {
+ const cached = fileExistsCache.get(filePath);
+ if (cached) {
+ return cached;
+ }
+
+ const promise = readLimit(async () => {
+ try {
+ await access(filePath);
+ return true;
+ } catch {
+ return false;
+ }
+ });
+ fileExistsCache.set(filePath, promise);
+ return promise;
+ };
+
+ const findTsconfigPathForImporter = (
+ importer: string
+ ): Promise => {
+ if (isNodeModulesPath(importer)) {
+ return Promise.resolve(defaultTsconfigPath);
+ }
+
+ const importerDir = dirname(importer);
+ const cached = tsconfigPathByDirCache.get(importerDir);
+ if (cached) {
+ return cached;
+ }
+
+ const promise = findUp(['tsconfig.json', 'jsconfig.json'], {
+ cwd: importerDir,
+ }).then((found) => found ?? defaultTsconfigPath);
+ tsconfigPathByDirCache.set(importerDir, promise);
+ return promise;
+ };
+
+ const loadAliasesForTsconfig = (
+ configPath: string | undefined
+ ): Promise => {
+ if (!configPath) {
+ return Promise.resolve([]);
+ }
+
+ const cached = tsconfigAliasesCache.get(configPath);
+ if (cached) {
+ return cached;
+ }
+
+ const promise = loadTsconfigPathAliases(configPath);
+ tsconfigAliasesCache.set(configPath, promise);
+ return promise;
+ };
+
+ const resolvePathLikeSpecifier = async (
+ importer: string,
+ specifier: string
+ ): Promise => {
+ const strippedSpecifier = stripImportSpecifierQuery(specifier);
+ const basePath = isAbsolute(strippedSpecifier)
+ ? strippedSpecifier
+ : resolve(dirname(importer), strippedSpecifier);
+ const extension = extname(basePath);
+ if (extension !== '') {
+ return FAST_DISCOVERY_SOURCE_EXTENSION_SET.has(extension) &&
+ (await fileExists(basePath))
+ ? normalizePath(basePath)
+ : null;
+ }
+
+ for (const candidate of [
+ ...FAST_DISCOVERY_SOURCE_EXTENSIONS.map(
+ (candidateExtension) => `${basePath}${candidateExtension}`
+ ),
+ ...FAST_DISCOVERY_SOURCE_EXTENSIONS.map((candidateExtension) =>
+ join(basePath, `index${candidateExtension}`)
+ ),
+ ]) {
+ if (await fileExists(candidate)) {
+ return normalizePath(candidate);
+ }
+ }
+
+ return null;
+ };
+
+ const resolveWithTsconfigPaths = async (
+ importer: string,
+ specifier: string
+ ): Promise => {
+ if (specifier.startsWith('.') || isAbsolute(specifier)) {
+ return null;
+ }
+
+ const tsconfigPath = await findTsconfigPathForImporter(importer);
+ const tsconfigPathAliases = await loadAliasesForTsconfig(tsconfigPath);
+ if (tsconfigPathAliases.length === 0) {
+ return null;
+ }
+
+ for (const alias of tsconfigPathAliases) {
+ const captures = matchTsconfigPathAlias(specifier, alias);
+ if (!captures) {
+ continue;
+ }
+
+ for (const target of alias.targets) {
+ const targetPath = applyTsconfigPathTarget(target, captures);
+ try {
+ const resolved = await resolvePathLikeSpecifier(importer, targetPath);
+ if (resolved) {
+ return resolved;
+ }
+ } catch {}
+ }
+ }
+
+ return null;
+ };
+
+ const findPackageInfoBySpecifier = (
+ packageName: string
+ ): Promise => {
+ const cached = packageSpecifierInfoCache.get(packageName);
+ if (cached) {
+ return cached;
+ }
+
+ const packageInfoPromise = (async () => {
+ let packageJsonPath: string;
+ try {
+ packageJsonPath = require.resolve(`${packageName}/package.json`, {
+ paths: [workingDir],
+ });
+ } catch {
+ return null;
+ }
+
+ try {
+ const source = await readLimit(() => readFile(packageJsonPath, 'utf8'));
+ const parsed = JSON.parse(source) as {
+ name?: unknown;
+ dependencies?: unknown;
+ peerDependencies?: unknown;
+ optionalDependencies?: unknown;
+ devDependencies?: unknown;
+ };
+ const parsedPackageName =
+ typeof parsed.name === 'string' ? parsed.name : packageName;
+ return {
+ root: normalizePath(dirname(packageJsonPath)),
+ hasWorkflowDependency:
+ parsedPackageName === 'workflow' ||
+ parsedPackageName.startsWith('@workflow/') ||
+ hasWorkflowDependency(parsed.dependencies) ||
+ hasWorkflowDependency(parsed.peerDependencies) ||
+ hasWorkflowDependency(parsed.optionalDependencies) ||
+ hasWorkflowDependency(parsed.devDependencies),
+ };
+ } catch {
+ return null;
+ }
+ })();
+ packageSpecifierInfoCache.set(packageName, packageInfoPromise);
+ return packageInfoPromise;
+ };
+
+ const shouldResolveBareSpecifier = async (
+ specifier: string
+ ): Promise => {
+ const packageName = getPackageNameFromSpecifier(specifier);
+ if (!packageName) {
+ return true;
+ }
+ if (packageName === 'workflow' || packageName.startsWith('@workflow/')) {
+ return true;
+ }
+
+ const packageInfo = await findPackageInfoBySpecifier(packageName);
+ if (!packageInfo) {
+ return true;
+ }
+ if (packageInfo.hasWorkflowDependency) {
+ return true;
+ }
+
+ return false;
+ };
+
+ const resolveImport = (
+ importer: string,
+ specifier: string
+ ): Promise => {
+ const cacheKey = `${dirname(importer)}\0${specifier}`;
+ const cached = resolveCache.get(cacheKey);
+ if (cached) {
+ return cached;
+ }
+
+ const resolvedPromise = resolveLimit(async () => {
+ if (isRelativeOrAbsoluteSpecifier(specifier)) {
+ return resolvePathLikeSpecifier(importer, specifier);
+ }
+
+ const resolvedAlias = await resolveWithTsconfigPaths(importer, specifier);
+ if (resolvedAlias) {
+ return resolvedAlias;
+ }
+
+ if (!(await shouldResolveBareSpecifier(specifier))) {
+ return null;
+ }
+
+ try {
+ const resolved = await fastDiscoveryResolve(
+ dirname(importer),
+ specifier
+ );
+ return typeof resolved === 'string' ? normalizePath(resolved) : null;
+ } catch {
+ return null;
+ }
+ });
+ resolveCache.set(cacheKey, resolvedPromise);
+ return resolvedPromise;
+ };
+
+ const shouldFollowImportsFromFile = async (
+ importer: string,
+ forceFollow: boolean
+ ): Promise => {
+ if (forceFollow) {
+ return true;
+ }
+ if (!isNodeModulesPath(importer)) {
+ return true;
+ }
+
+ const packageInfo = await findPackageInfo(importer, packageInfoCache);
+ return packageInfo?.hasWorkflowDependency === true;
+ };
+
+ const processImportSpecifier = async (
+ filePath: string,
+ specifier: string,
+ forceFollowImports: boolean
+ ): Promise => {
+ if (shouldSkipFastDiscoveryImport(specifier)) {
+ return;
+ }
+ if (!(await shouldFollowImportsFromFile(filePath, forceFollowImports))) {
+ return;
+ }
+
+ const resolved = await resolveImport(filePath, specifier);
+ if (!resolved) {
+ return;
+ }
+
+ addImportParent(filePath, resolved);
+ if (!isJsTsFile(resolved) || isGeneratedBuildArtifactPath(resolved)) {
+ return;
+ }
+
+ if (specifier.startsWith('.')) {
+ enqueueFile(resolved);
+ return;
+ }
+
+ enqueueFile(resolved);
+ };
+
+ const processFile = async (filePath: string): Promise => {
+ queuedFiles.delete(filePath);
+ if (processedFiles.has(filePath)) {
+ return;
+ }
+ processedFiles.add(filePath);
+ const source = await readSource(filePath);
+ if (source === null) {
+ return;
+ }
+
+ const patterns = detectWorkflowPatterns(source);
+ if (patterns.hasUseWorkflow) {
+ state.discoveredWorkflows.add(filePath);
+ }
+ if (patterns.hasUseStep) {
+ state.discoveredSteps.add(filePath);
+ }
+ if (patterns.hasSerde && hasLikelySerdeClass(source)) {
+ state.discoveredSerdeFiles.add(filePath);
+ }
+
+ const forceFollowImports = patterns.hasDirective || patterns.hasSerde;
+ if (
+ !forceFollowImports &&
+ !(await shouldFollowImportsFromFile(filePath, false))
+ ) {
+ return;
+ }
+
+ const specifiers = extractImportSpecifiers(source);
+ if (specifiers.length === 0) {
+ return;
+ }
+
+ await Promise.all(
+ specifiers.map((specifier) =>
+ processImportSpecifier(filePath, specifier, forceFollowImports)
+ )
+ );
+ };
+
+ for (const entryPoint of entryPoints) {
+ enqueueFile(entryPoint);
+ }
+
+ const inFlight = new Set>();
+ const scheduleFiles = () => {
+ while (
+ queue.length > 0 &&
+ inFlight.size < FAST_DISCOVERY_FILE_CONCURRENCY
+ ) {
+ const filePath = queue.shift();
+ if (!filePath) {
+ continue;
+ }
+
+ const promise = processFile(filePath).finally(() => {
+ inFlight.delete(promise);
+ });
+ inFlight.add(promise);
+ }
+ };
+
+ scheduleFiles();
+ while (inFlight.size > 0) {
+ await Promise.race(inFlight);
+ scheduleFiles();
+ }
+}
diff --git a/packages/builders/src/step-source-registration.test.ts b/packages/builders/src/step-source-registration.test.ts
new file mode 100644
index 0000000000..91c3062621
--- /dev/null
+++ b/packages/builders/src/step-source-registration.test.ts
@@ -0,0 +1,124 @@
+import {
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ realpathSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { BaseBuilder, type DiscoveredEntries } from './base-builder.js';
+import type { StandaloneConfig } from './types.js';
+
+class TestBuilder extends BaseBuilder {
+ async build(): Promise {
+ // no-op
+ }
+
+ protected get shouldLogBaseBuilderInfo(): boolean {
+ return false;
+ }
+
+ public createSourceStepRegistrations(
+ inputFiles: string[],
+ outfile: string,
+ discoveredEntries: DiscoveredEntries
+ ) {
+ return this.createStepsBundle({
+ inputFiles,
+ outfile,
+ externalizeNonSteps: true,
+ bundleTransitiveLocalStepDependencies: false,
+ sourceStepRegistrationImports: true,
+ discoveredEntries,
+ });
+ }
+}
+
+const realTmpdir = realpathSync(tmpdir());
+
+function writeFile(path: string, contents: string): void {
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(path, contents, 'utf-8');
+}
+
+function createBuilder(workingDir: string): TestBuilder {
+ const config: StandaloneConfig = {
+ buildTarget: 'standalone',
+ workingDir,
+ dirs: ['.'],
+ stepsBundlePath: join(workingDir, '.workflow', 'steps.js'),
+ workflowsBundlePath: join(workingDir, '.workflow', 'workflows.js'),
+ webhookBundlePath: join(workingDir, '.workflow', 'webhook.js'),
+ };
+ return new TestBuilder(config);
+}
+
+describe('step source registration', () => {
+ let testRoot: string;
+
+ beforeEach(() => {
+ testRoot = mkdtempSync(join(realTmpdir, 'workflow-step-registration-'));
+ writeFile(
+ join(testRoot, 'node_modules', 'workflow', 'package.json'),
+ JSON.stringify({ name: 'workflow', version: '1.0.0' })
+ );
+ writeFile(
+ join(testRoot, 'node_modules', 'workflow', 'internal', 'builtins.js'),
+ 'export const __builtins = true;\n'
+ );
+ });
+
+ afterEach(() => {
+ rmSync(testRoot, { recursive: true, force: true });
+ });
+
+ it('imports serde-only files for step context class registration', async () => {
+ const entryFile = join(testRoot, 'src', 'entry.ts');
+ const stepFile = join(testRoot, 'src', 'step.ts');
+ const serdeFile = join(testRoot, 'src', 'serde.ts');
+ const outfile = join(testRoot, '.workflow', 'steps.js');
+
+ mkdirSync(dirname(outfile), { recursive: true });
+ writeFile(entryFile, `export { runStep } from './step';\n`);
+ writeFile(
+ stepFile,
+ `export async function runStep() {
+ 'use step';
+ return 1;
+}
+`
+ );
+ writeFile(
+ serdeFile,
+ `export class Value {
+ static classId = 'Value';
+ static [Symbol.for('workflow-serialize')](value: Value) {
+ return value;
+ }
+ static [Symbol.for('workflow-deserialize')](value: Value) {
+ return value;
+ }
+}
+`
+ );
+
+ const discoveredEntries: DiscoveredEntries = {
+ discoveredSteps: new Set([stepFile]),
+ discoveredWorkflows: new Set(),
+ discoveredSerdeFiles: new Set([serdeFile]),
+ };
+
+ const { manifest } = await createBuilder(
+ testRoot
+ ).createSourceStepRegistrations([entryFile], outfile, discoveredEntries);
+ const generated = readFileSync(outfile, 'utf-8');
+
+ expect(generated).toContain('import "workflow/internal/builtins";');
+ expect(generated).toContain('import "../src/step.ts";');
+ expect(generated).toContain('import "../src/serde.ts";');
+ expect(Object.keys(manifest.classes ?? {})).toContain('src/serde.ts');
+ });
+});
diff --git a/packages/builders/src/transform-utils.test.ts b/packages/builders/src/transform-utils.test.ts
index 5ce399e4e2..44d1462ddd 100644
--- a/packages/builders/src/transform-utils.test.ts
+++ b/packages/builders/src/transform-utils.test.ts
@@ -333,11 +333,7 @@ describe('transform-utils patterns', () => {
expect(result.hasSerde).toBe(true);
});
- it('regexp detection matches directives inside template literals (false positive)', () => {
- // detectWorkflowPatterns uses regexp and will match directive-like
- // strings inside template literals. The discover-entries plugin
- // handles this by using the SWC plugin manifest (AST-level) for
- // directive discovery instead of relying on regexp alone.
+ it('does not detect directives inside template literals', () => {
const source = `'use client';
const CODE_SNIPPET = \`import { sleep } from "workflow";
@@ -347,6 +343,88 @@ export async function handleUserSignup(email: string) {
}
\`;
export default function Page() { return null; }
+`;
+ const result = detectWorkflowPatterns(source);
+ expect(result.hasUseWorkflow).toBe(false);
+ expect(result.hasDirective).toBe(false);
+ });
+
+ it('does not detect single-quoted step directives inside template literals', () => {
+ const source = `const CODE_SNIPPET = \`
+export async function doThing() {
+ 'use step';
+}
+\`;
+`;
+ const result = detectWorkflowPatterns(source);
+ expect(result.hasUseStep).toBe(false);
+ expect(result.hasDirective).toBe(false);
+ });
+
+ it('does not detect directives inside comments', () => {
+ const source = `/*
+export async function run() {
+ "use workflow";
+}
+*/
+
+// 'use step';
+export const value = 1;
+`;
+ const result = detectWorkflowPatterns(source);
+ expect(result.hasUseWorkflow).toBe(false);
+ expect(result.hasUseStep).toBe(false);
+ expect(result.hasDirective).toBe(false);
+ });
+
+ it('does not detect directive-looking strings inside multiline calls', () => {
+ const source = `console.log(
+ "use step"
+);
+
+console.log(
+ 'use workflow'
+);
+`;
+ const result = detectWorkflowPatterns(source);
+ expect(result.hasUseWorkflow).toBe(false);
+ expect(result.hasUseStep).toBe(false);
+ expect(result.hasDirective).toBe(false);
+ });
+
+ it('does not detect quoted directive-looking strings inside multiline calls', () => {
+ const source = `console.log(
+ '"use step"'
+);
+
+console.log(
+ "'use step'"
+);
+`;
+ const result = detectWorkflowPatterns(source);
+ expect(result.hasUseStep).toBe(false);
+ expect(result.hasDirective).toBe(false);
+ });
+
+ it('still detects real directives after template literals', () => {
+ const source = `const CODE_SNIPPET = \`
+ "use workflow";
+\`;
+
+export async function run() {
+ "use workflow";
+}
+`;
+ const result = detectWorkflowPatterns(source);
+ expect(result.hasUseWorkflow).toBe(true);
+ expect(result.hasDirective).toBe(true);
+ });
+
+ it('still detects directives after other directive prologue entries', () => {
+ const source = `export async function run() {
+ "use strict";
+ "use workflow";
+}
`;
const result = detectWorkflowPatterns(source);
expect(result.hasUseWorkflow).toBe(true);
diff --git a/packages/builders/src/transform-utils.ts b/packages/builders/src/transform-utils.ts
index eedb372efc..7ac1733bdb 100644
--- a/packages/builders/src/transform-utils.ts
+++ b/packages/builders/src/transform-utils.ts
@@ -22,11 +22,45 @@ export const workflowSerdeSymbolPattern =
export const workflowSerdeComputedPropertyPattern =
/\[\s*WORKFLOW_(?:SERIALIZE|DESERIALIZE)\s*\]/;
+const templateLiteralPattern = /`(?:\\[\s\S]|[^`\\])*`/g;
+const commentPattern = /\/\*[\s\S]*?\*\/|\/\/[^\r\n]*/g;
+const directiveLinePattern = /^\s*(['"])(use workflow|use step)\1;?\s*$/;
+const stringDirectiveLinePattern = /^\s*(['"])[^'"]+\1;?\s*$/;
+
// Pattern to detect generated workflow route files that should be excluded
// These files are generated by the build process and should not be re-processed
export const generatedWorkflowPathPattern =
/[/\\]\.well-known[/\\]workflow[/\\]/;
+function hasDirective(source: string, directive: 'use workflow' | 'use step') {
+ let previousMeaningfulLine: string | undefined;
+
+ for (const line of source.split(/\r?\n/)) {
+ const trimmedLine = line.trim();
+ if (trimmedLine === '') {
+ continue;
+ }
+
+ const directiveMatch = directiveLinePattern.exec(trimmedLine);
+ if (directiveMatch) {
+ if (
+ directiveMatch[2] === directive &&
+ (previousMeaningfulLine === undefined ||
+ previousMeaningfulLine.endsWith('{') ||
+ stringDirectiveLinePattern.test(previousMeaningfulLine))
+ ) {
+ return true;
+ }
+ previousMeaningfulLine = trimmedLine;
+ continue;
+ }
+
+ previousMeaningfulLine = trimmedLine;
+ }
+
+ return false;
+}
+
/**
* Detects workflow-related patterns in source code.
*/
@@ -51,11 +85,32 @@ export interface WorkflowPatternMatch {
* @returns Object with flags for each detected pattern
*/
export function detectWorkflowPatterns(source: string): WorkflowPatternMatch {
- const hasUseWorkflow = useWorkflowPattern.test(source);
- const hasUseStep = useStepPattern.test(source);
- const hasSerdeImport = workflowSerdeImportPattern.test(source);
- const hasSerdeSymbol = workflowSerdeSymbolPattern.test(source);
+ const hasDirectiveSubstring =
+ source.includes('use workflow') || source.includes('use step');
+ const sourceForDirectives =
+ hasDirectiveSubstring && (source.includes('`') || source.includes('/'))
+ ? source
+ .replace(templateLiteralPattern, (match) =>
+ match.replace(/[^\r\n]/g, ' ')
+ )
+ .replace(commentPattern, (match) => match.replace(/[^\r\n]/g, ' '))
+ : source;
+ const hasUseWorkflow =
+ source.includes('use workflow') &&
+ hasDirective(sourceForDirectives, 'use workflow');
+ const hasUseStep =
+ source.includes('use step') &&
+ hasDirective(sourceForDirectives, 'use step');
+ const hasSerdeImport =
+ source.includes('@workflow/serde') &&
+ workflowSerdeImportPattern.test(source);
+ const hasSerdeSymbol =
+ (source.includes('workflow-serialize') ||
+ source.includes('workflow-deserialize')) &&
+ workflowSerdeSymbolPattern.test(source);
const hasSerdeComputedProperty =
+ (source.includes('WORKFLOW_SERIALIZE') ||
+ source.includes('WORKFLOW_DESERIALIZE')) &&
workflowSerdeComputedPropertyPattern.test(source);
return {
diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md
index 337365e7e2..505b67a97b 100644
--- a/packages/cli/CHANGELOG.md
+++ b/packages/cli/CHANGELOG.md
@@ -1,5 +1,14 @@
# @workflow/cli
+## 5.0.0-beta.21
+
+### Patch Changes
+
+- Updated dependencies [[`6de5ea5`](https://github.com/vercel/workflow/commit/6de5ea5c2f32b474274f5dabe5f3663e03622ac5), [`5291f15`](https://github.com/vercel/workflow/commit/5291f1549fee4d8b042cc03b6696fd8b6cb798fc), [`66ca0dc`](https://github.com/vercel/workflow/commit/66ca0dcc096440f39dd234e04669e1fc7bf2d615), [`b713d84`](https://github.com/vercel/workflow/commit/b713d8417b1334abd1e30bdc50701d0d96dee39d), [`57cccaf`](https://github.com/vercel/workflow/commit/57cccaf3734f4afa8218e1ea729a9bb886c691f3), [`3e82a12`](https://github.com/vercel/workflow/commit/3e82a12712b1efe229ac2b1623dc6c8fc7be7055)]:
+ - @workflow/core@5.0.0-beta.21
+ - @workflow/builders@5.0.0-beta.21
+ - @workflow/web@5.0.0-beta.21
+
## 5.0.0-beta.20
### Patch Changes
diff --git a/packages/cli/package.json b/packages/cli/package.json
index d883cff597..3ddf8df1be 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@workflow/cli",
- "version": "5.0.0-beta.20",
+ "version": "5.0.0-beta.21",
"description": "Command-line interface for Workflow SDK",
"type": "module",
"bin": {
diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md
index 5ca571ee0a..514165af41 100644
--- a/packages/core/CHANGELOG.md
+++ b/packages/core/CHANGELOG.md
@@ -1,5 +1,17 @@
# @workflow/core
+## 5.0.0-beta.21
+
+### Minor Changes
+
+- [#2526](https://github.com/vercel/workflow/pull/2526) [`3e82a12`](https://github.com/vercel/workflow/commit/3e82a12712b1efe229ac2b1623dc6c8fc7be7055) Thanks [@VaguelySerious](https://github.com/VaguelySerious)! - Add turbo mode (on by default, disable with `WORKFLOW_TURBO=0`): on the first delivery of a run's first invocation the runtime backgrounds `run_started`, skips the initial event-log load, and forces optimistic inline start so the run reaches its first steps with no preceding network round-trips. It is safe there because the first delivery has no concurrent handler to race; turbo mode deactivates once a hook or sleep is encountered.
+
+### Patch Changes
+
+- [#2412](https://github.com/vercel/workflow/pull/2412) [`6de5ea5`](https://github.com/vercel/workflow/commit/6de5ea5c2f32b474274f5dabe5f3663e03622ac5) Thanks [@VaguelySerious](https://github.com/VaguelySerious)! - Fix a race where an `AbortController` aborted from a step was not reflected in a `controller.signal` passed to a subsequent step. The step now commits the abort's durable hook event before completing, and the workflow's suspension waits for the abort to land before serializing downstream step arguments.
+
+- [#2472](https://github.com/vercel/workflow/pull/2472) [`66ca0dc`](https://github.com/vercel/workflow/commit/66ca0dcc096440f39dd234e04669e1fc7bf2d615) Thanks [@pranaygp](https://github.com/pranaygp)! - Memoize hydrated step return values across inline replay iterations, turning the per-invocation step-result decrypt+parse cost from O(N²) to O(N) for sequential workflows. Only primitive results are cached, so deterministic replay is preserved.
+
## 5.0.0-beta.20
### Minor Changes
diff --git a/packages/core/e2e/dev.test.ts b/packages/core/e2e/dev.test.ts
index b283a08196..f4750d67cf 100644
--- a/packages/core/e2e/dev.test.ts
+++ b/packages/core/e2e/dev.test.ts
@@ -1,10 +1,7 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { afterEach, beforeAll, describe, expect, test } from 'vitest';
-import {
- getWorkbenchAppPath,
- isNextLazyDiscoveryEnabledForTest,
-} from './utils';
+import { getWorkbenchAppPath } from './utils';
export interface DevTestConfig {
generatedStepPath: string;
@@ -41,10 +38,6 @@ export function createDevTests(config?: DevTestConfig) {
// Each prewarm/trigger fetch is hard-bounded by this so cleanup never hangs
// on a wedged dev server.
const PREWARM_FETCH_TIMEOUT_MS = 5_000;
- // Workflow requests can include Next's first compile of the API route and
- // the deferred flow route. CI routinely exceeds 5s there even when the
- // workflow itself is healthy, so keep those requests bounded separately.
- const WORKFLOW_FETCH_TIMEOUT_MS = 30_000;
// The afterEach cleanup can issue two *sequential* prewarms (before and
// after deleting an added file) while the dev server is mid-rebuild — the
// teardown of a test that added a workflow file and edited an import is
@@ -65,14 +58,6 @@ export function createDevTests(config?: DevTestConfig) {
const usesNextFlowRoute = generatedWorkflow.includes(
path.join('app', '.well-known', 'workflow', 'v1', 'flow', 'route.js')
);
- const usesDeferredBuilder =
- isNextLazyDiscoveryEnabledForTest() && usesNextFlowRoute;
- const usesNextEagerBuilder =
- !isNextLazyDiscoveryEnabledForTest() && usesNextFlowRoute;
- const deferredWorkflowCodePath = path.join(
- path.dirname(generatedWorkflow),
- '__workflow_code.txt'
- );
const workflowManifestPath = path.join(
appPath,
'app/.well-known/workflow/v1/manifest.json'
@@ -86,6 +71,15 @@ export function createDevTests(config?: DevTestConfig) {
Object.keys(entry)
);
};
+ const readManifestWorkflowFunctionNames = async (): Promise => {
+ const manifestJson = await fs.readFile(workflowManifestPath, 'utf8');
+ const manifest = JSON.parse(manifestJson) as {
+ workflows?: Record>;
+ };
+ return Object.values(manifest.workflows || {}).flatMap((entry) =>
+ Object.keys(entry)
+ );
+ };
const readFileIfExists = async (
filePath: string
): Promise => {
@@ -104,12 +98,9 @@ export function createDevTests(config?: DevTestConfig) {
}
};
const readGeneratedWorkflowOutput = async (): Promise => {
- const outputs = [
- await readFileIfExists(generatedWorkflow),
- usesDeferredBuilder
- ? await readFileIfExists(deferredWorkflowCodePath)
- : null,
- ].filter((output): output is string => output !== null);
+ const outputs = [await readFileIfExists(generatedWorkflow)].filter(
+ (output): output is string => output !== null
+ );
if (outputs.length === 0) {
throw new Error('Generated workflow outputs were not found');
@@ -129,101 +120,6 @@ export function createDevTests(config?: DevTestConfig) {
});
};
- const triggerWorkflowRun = async (
- workflowName: string,
- args: unknown[] = []
- ) => {
- if (!deploymentUrl) {
- return;
- }
-
- const response = await fetch(
- new URL('/api/workflows/start', deploymentUrl),
- {
- method: 'POST',
- headers: {
- 'content-type': 'application/json',
- },
- body: JSON.stringify({
- workflowName,
- args,
- }),
- signal: AbortSignal.timeout(WORKFLOW_FETCH_TIMEOUT_MS),
- }
- );
-
- if (!response.ok) {
- throw new Error(
- `Failed to trigger workflow "${workflowName}": ${response.status}`
- );
- }
- };
-
- const triggerPagesWorkflowRun = async ({
- workflowFile,
- workflowFn,
- args = [],
- }: {
- workflowFile: string;
- workflowFn: string;
- args?: unknown[];
- }): Promise => {
- if (!deploymentUrl) {
- throw new Error('DEPLOYMENT_URL is required to start a workflow');
- }
-
- const url = new URL('/api/trigger-pages', deploymentUrl);
- url.searchParams.set('workflowFile', workflowFile);
- url.searchParams.set('workflowFn', workflowFn);
- if (args.length > 0) {
- url.searchParams.set('args', args.map(String).join(','));
- }
-
- const response = await fetch(url, {
- method: 'POST',
- signal: AbortSignal.timeout(WORKFLOW_FETCH_TIMEOUT_MS),
- });
- if (!response.ok) {
- throw new Error(
- `Failed to trigger workflow "${workflowFn}" from "${workflowFile}": ${
- response.status
- } ${await response.text()}`
- );
- }
-
- const result = (await response.json()) as { runId?: unknown };
- if (typeof result.runId !== 'string') {
- throw new Error(
- `Workflow trigger response did not include a runId: ${JSON.stringify(
- result
- )}`
- );
- }
- return result.runId;
- };
-
- const awaitPagesWorkflowRun = async (runId: string): Promise => {
- if (!deploymentUrl) {
- throw new Error('DEPLOYMENT_URL is required to await a workflow');
- }
-
- const url = new URL('/api/trigger-pages', deploymentUrl);
- url.searchParams.set('runId', runId);
-
- const response = await fetch(url, {
- signal: AbortSignal.timeout(WORKFLOW_FETCH_TIMEOUT_MS),
- });
- if (!response.ok) {
- throw new Error(
- `Failed to await workflow run "${runId}": ${
- response.status
- } ${await response.text()}`
- );
- }
-
- return await response.json();
- };
-
const prewarm = async () => {
// Pre-warm the app with bounded requests so cleanup hooks cannot hang.
await Promise.all([
@@ -290,7 +186,7 @@ export function createDevTests(config?: DevTestConfig) {
restoreFiles.length = 0;
}, CLEANUP_HOOK_TIMEOUT_MS);
- test('should rebuild on workflow change', { timeout: 30_000 }, async () => {
+ test('should rebuild on workflow change', { timeout: 70_000 }, async () => {
const workflowFile = path.join(appPath, workflowsDir, testWorkflowFile);
const content = await fs.readFile(workflowFile, 'utf8');
@@ -309,14 +205,22 @@ export async function myNewWorkflow() {
await pollUntil({
description: 'generated workflow to include myNewWorkflow',
+ timeoutMs: usesNextFlowRoute ? 50_000 : 25_000,
check: async () => {
+ if (usesNextFlowRoute) {
+ const manifestFunctionNames =
+ await readManifestWorkflowFunctionNames();
+ expect(manifestFunctionNames).toContain('myNewWorkflow');
+ return;
+ }
+
const workflowContent = await readGeneratedWorkflowOutput();
expect(workflowContent).toContain('myNewWorkflow');
},
});
});
- test('should rebuild on step change', { timeout: 30_000 }, async () => {
+ test('should rebuild on step change', { timeout: 70_000 }, async () => {
const stepFile = path.join(appPath, workflowsDir, testWorkflowFile);
const content = await fs.readFile(stepFile, 'utf8');
@@ -334,6 +238,7 @@ export async function myNewStep() {
restoreFiles.push({ path: stepFile, content });
await pollUntil({
description: 'generated step outputs to include myNewStep',
+ timeoutMs: usesNextFlowRoute ? 50_000 : 25_000,
check: async () => {
const stepRouteContent = await readFileIfExists(generatedStep);
if (stepRouteContent?.includes('myNewStep')) {
@@ -341,9 +246,8 @@ export async function myNewStep() {
}
// Next flow-route builders regenerate manifest.json on every
- // rebuild. In lazy mode there is no standalone step registration
- // file; in eager mode the bundled file may not preserve function
- // names as plain text.
+ // rebuild. The bundled file may not preserve function names as
+ // plain text.
if (usesNextFlowRoute) {
const manifestFunctionNames = await readManifestStepFunctionNames();
expect(manifestFunctionNames).toContain('myNewStep');
@@ -355,107 +259,6 @@ export async function myNewStep() {
});
});
- test.skipIf(!usesDeferredBuilder)(
- 'should execute updated workflow logic after HMR without restart',
- { timeout: 120_000 },
- async () => {
- const workflowFileName = '98_duplicate_case.ts';
- const workflowFile = path.join(appPath, workflowsDir, workflowFileName);
- const workflowFileKey = `workflows/${workflowFileName}`;
- const workflowFn = 'addTenWorkflow';
- const content = await fs.readFile(workflowFile, 'utf8');
- const originalLine = ' const b = await add(a, 3);';
- const updatedLine = ' const b = await add(a, 30);';
-
- if (!content.includes(originalLine)) {
- throw new Error(
- `Expected ${workflowFile} to contain ${JSON.stringify(
- originalLine
- )}`
- );
- }
-
- const baselineRunId = await triggerPagesWorkflowRun({
- workflowFile: workflowFileKey,
- workflowFn,
- args: [100],
- });
- await expect(awaitPagesWorkflowRun(baselineRunId)).resolves.toBe(110);
-
- await fs.writeFile(
- workflowFile,
- content.replace(originalLine, updatedLine)
- );
- restoreFiles.push({ path: workflowFile, content });
-
- await pollUntil({
- description:
- 'updated workflow logic to be used by the deferred flow route',
- timeoutMs: 75_000,
- check: async () => {
- const runId = await triggerPagesWorkflowRun({
- workflowFile: workflowFileKey,
- workflowFn,
- args: [100],
- });
- await expect(awaitPagesWorkflowRun(runId)).resolves.toBe(137);
- },
- });
- }
- );
-
- test.skipIf(!usesDeferredBuilder)(
- 'should rebuild on imported step dependency change',
- { timeout: 60_000 },
- async () => {
- const importedStepFile = path.join(
- appPath,
- workflowsDir,
- '_imported_step_only.ts'
- );
- const content = await fs.readFile(importedStepFile, 'utf8');
- const marker = 'importedStepOnlyHotReloadMarker';
-
- await fs.writeFile(
- importedStepFile,
- `${content}
-
-export async function ${marker}() {
- 'use step'
- return 'updated'
-}
-`
- );
- restoreFiles.push({ path: importedStepFile, content });
-
- const apiFile = path.join(appPath, finalConfig.apiFilePath);
- const apiFileContent = await fs.readFile(apiFile, 'utf8');
-
- await pollUntil({
- description:
- 'manifest.json to include imported step hot-reload marker',
- timeoutMs: 50_000,
- check: async () => {
- try {
- await triggerWorkflowRun('importedStepOnlyWorkflow');
- } catch (error) {
- // Turbopack on Windows occasionally caches a stale resolver
- // failure (e.g. `Could not parse module
- // '@workflow/core/dist/runtime/start.js'`) after an HMR
- // cascade and returns 500 to every request until something
- // invalidates its cache. Rewriting the api file is enough to
- // force a fresh resolve on the next request, so we treat the
- // 500 as transient and keep polling instead of bailing out.
- await fs.writeFile(apiFile, apiFileContent);
- throw error;
- }
- const manifestFunctionNames = await readManifestStepFunctionNames();
- expect(manifestFunctionNames).toContain(marker);
- },
- });
- }
- );
-
test(
'should rebuild on adding workflow file',
{ timeout: 60_000 },
@@ -490,7 +293,7 @@ ${apiFileContent}`
description: 'generated workflow to include newWorkflowFile',
timeoutMs: 50_000,
check: async () => {
- if (usesNextEagerBuilder) {
+ if (usesNextFlowRoute) {
const manifestJson = await fs.readFile(
workflowManifestPath,
'utf8'
@@ -513,143 +316,6 @@ ${apiFileContent}`
});
}
);
-
- test.skipIf(!usesDeferredBuilder)(
- 'should include steps discovered from workflow imports',
- { timeout: 60_000 },
- async () => {
- const workflowFile = path.join(
- appPath,
- workflowsDir,
- 'discovered-via-workflow.ts'
- );
- const stepFile = path.join(
- appPath,
- workflowsDir,
- 'discovered-via-workflow-step.ts'
- );
-
- await fs.writeFile(
- workflowFile,
- `'use workflow';
-import { discoveredViaWorkflowStep } from './discovered-via-workflow-step';
-
-export async function discoveredViaWorkflow() {
- await discoveredViaWorkflowStep();
- return 'ok';
-}
-`
- );
- await fs.writeFile(
- stepFile,
- `'use step';
-
-export async function discoveredViaWorkflowStep() {
- return 'ok';
-}
-`
- );
- restoreFiles.push({ path: workflowFile, content: '' });
- restoreFiles.push({ path: stepFile, content: '' });
-
- const apiFile = path.join(appPath, finalConfig.apiFilePath);
- const apiFileContent = await fs.readFile(apiFile, 'utf8');
- restoreFiles.push({ path: apiFile, content: apiFileContent });
-
- await fs.writeFile(
- apiFile,
- `import '${finalConfig.apiFileImportPath}/${workflowsDir}/discovered-via-workflow';
-${apiFileContent}`
- );
-
- await pollUntil({
- description:
- 'manifest.json to include discoveredViaWorkflowStep after discovery',
- timeoutMs: 25_000,
- check: async () => {
- await fetchWithTimeout('/api/chat');
- const manifestFunctionNames = await readManifestStepFunctionNames();
- expect(manifestFunctionNames).toContain(
- 'discoveredViaWorkflowStep'
- );
- },
- });
-
- // Tear down in-test (rather than relying on afterEach) so we can wait
- // for the deferred builder to drop the discovered step from the
- // manifest before the next test file runs. Rewrite the temporary
- // sources to inert modules before deleting them; otherwise the rebuild
- // can race with deletion and get stuck on an ENOENT from the SWC
- // transform while the generated route still imports the old files.
- await fs.writeFile(apiFile, apiFileContent);
- await fs.writeFile(workflowFile, 'export const removed = true;\n');
- await fs.writeFile(stepFile, 'export const removed = true;\n');
- await pollUntil({
- description:
- 'manifest.json to drop discoveredViaWorkflowStep after cleanup',
- timeoutMs: 25_000,
- check: async () => {
- await fetchWithTimeout('/api/chat');
- const manifestFunctionNames = await readManifestStepFunctionNames();
- expect(manifestFunctionNames).not.toContain(
- 'discoveredViaWorkflowStep'
- );
- },
- });
- await fs.unlink(workflowFile);
- await fs.unlink(stepFile);
- for (const trackedPath of [apiFile, workflowFile, stepFile]) {
- const idx = restoreFiles.findIndex(
- (item) => item.path === trackedPath
- );
- if (idx !== -1) {
- restoreFiles.splice(idx, 1);
- }
- }
- }
- );
-
- test.skipIf(!usesDeferredBuilder)(
- 'should reference package step sources discovered via manifest entries',
- { timeout: 30_000 },
- async () => {
- await pollUntil({
- description:
- 'generated workflow outputs to reference @workflow/ai package steps',
- timeoutMs: 25_000,
- check: async () => {
- await fetchWithTimeout('/api/chat');
- const manifestJson = await fs.readFile(
- workflowManifestPath,
- 'utf8'
- );
- const manifest = JSON.parse(manifestJson) as {
- steps?: Record;
- };
- const manifestStepFiles = Object.keys(manifest.steps || {});
- expect(
- manifestStepFiles.some((filePath) =>
- /ai\/(src|dist)\/agent\/durable-agent\.(ts|js)$/.test(filePath)
- )
- ).toBe(true);
-
- // Package step sources are imported directly (not copied). Verify
- // the generated route imports the @workflow/ai package or
- // otherwise references `durable-agent` via its resolved path.
- const generatedRouteContent =
- (await readFileIfExists(generatedStep)) ??
- (await readFileIfExists(generatedWorkflow));
- if (!generatedRouteContent) {
- throw new Error('generated workflow outputs were not found');
- }
- expect(
- generatedRouteContent.includes('@workflow/ai') ||
- generatedRouteContent.includes('durable-agent')
- ).toBe(true);
- },
- });
- }
- );
});
}
diff --git a/packages/core/e2e/local-build.test.ts b/packages/core/e2e/local-build.test.ts
index a5198c1f8a..9cdbc0e526 100644
--- a/packages/core/e2e/local-build.test.ts
+++ b/packages/core/e2e/local-build.test.ts
@@ -3,10 +3,7 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import { describe, expect, test } from 'vitest';
import { usesVercelWorld } from '../../utils/src/world-target';
-import {
- getWorkbenchAppPath,
- isNextLazyDiscoveryEnabledForTest,
-} from './utils';
+import { getWorkbenchAppPath } from './utils';
interface CommandResult {
stdout: string;
@@ -92,18 +89,6 @@ const DIAGNOSTICS_MANIFEST_PATHS: Record = {
'nextjs-turbopack': '.next/diagnostics/workflows-manifest.json',
};
-const DEFERRED_BUILD_MODE_PROJECTS = new Set([
- 'nextjs-webpack',
- 'nextjs-turbopack',
-]);
-const DEFERRED_BUILD_UNSUPPORTED_WARNING = 'lazyDiscovery requires Next.js >=';
-const EAGER_DISCOVERY_LOG = 'Discovering workflow directives';
-const WORKFLOW_BUNDLE_LOGS = [
- 'Created intermediate workflow bundle',
- 'Created final workflow bundle',
-];
-const STEP_BUNDLE_LOG = 'Created steps bundle';
-
describe.each([
'example',
'nextjs-webpack',
@@ -133,22 +118,6 @@ describe.each([
expect(result.output).not.toContain('Error:');
- if (
- DEFERRED_BUILD_MODE_PROJECTS.has(project) &&
- isNextLazyDiscoveryEnabledForTest()
- ) {
- const deferredBuildSupported = !result.output.includes(
- DEFERRED_BUILD_UNSUPPORTED_WARNING
- );
- if (deferredBuildSupported) {
- expect(result.output).not.toContain(EAGER_DISCOVERY_LOG);
- for (const workflowBundleLog of WORKFLOW_BUNDLE_LOGS) {
- expect(result.output).not.toContain(workflowBundleLog);
- }
- expect(result.output).not.toContain(STEP_BUNDLE_LOG);
- }
- }
-
const diagnosticsManifestPath = usesVercelWorld()
? '.vercel/output/diagnostics/workflows-manifest.json'
: DIAGNOSTICS_MANIFEST_PATHS[project];
diff --git a/packages/core/e2e/utils.test.ts b/packages/core/e2e/utils.test.ts
index 4952ceabfc..6615893d1f 100644
--- a/packages/core/e2e/utils.test.ts
+++ b/packages/core/e2e/utils.test.ts
@@ -6,11 +6,9 @@ const ORIGINAL_ENV = { ...process.env };
function setStepSourceMapEnv({
appName,
dev,
- lazyDiscovery,
}: {
appName: string;
dev: boolean;
- lazyDiscovery?: boolean;
}) {
process.env.APP_NAME = appName;
process.env.DEPLOYMENT_URL = 'http://localhost:3000';
@@ -20,12 +18,6 @@ function setStepSourceMapEnv({
} else {
delete process.env.DEV_TEST_CONFIG;
}
-
- if (lazyDiscovery === undefined) {
- delete process.env.WORKFLOW_NEXT_LAZY_DISCOVERY;
- } else {
- process.env.WORKFLOW_NEXT_LAZY_DISCOVERY = lazyDiscovery ? '1' : '0';
- }
}
afterEach(() => {
@@ -33,41 +25,19 @@ afterEach(() => {
});
describe('hasStepSourceMaps', () => {
- test('expects source filenames for webpack local dev with lazy discovery enabled', () => {
+ test('expects source filenames for webpack local dev', () => {
setStepSourceMapEnv({
appName: 'nextjs-webpack',
dev: true,
- lazyDiscovery: true,
});
expect(hasStepSourceMaps()).toBe(true);
});
- test('does not expect source filenames for webpack local dev with lazy discovery disabled', () => {
- setStepSourceMapEnv({
- appName: 'nextjs-webpack',
- dev: true,
- lazyDiscovery: false,
- });
-
- expect(hasStepSourceMaps()).toBe(false);
- });
-
- test('does not expect source filenames for turbopack local dev with lazy discovery disabled', () => {
- setStepSourceMapEnv({
- appName: 'nextjs-turbopack',
- dev: true,
- lazyDiscovery: false,
- });
-
- expect(hasStepSourceMaps()).toBe(false);
- });
-
- test('does not expect source filenames for turbopack local dev with lazy discovery enabled', () => {
+ test('does not expect source filenames for turbopack local dev', () => {
setStepSourceMapEnv({
appName: 'nextjs-turbopack',
dev: true,
- lazyDiscovery: true,
});
expect(hasStepSourceMaps()).toBe(false);
@@ -77,7 +47,6 @@ describe('hasStepSourceMaps', () => {
setStepSourceMapEnv({
appName: 'nextjs-webpack',
dev: false,
- lazyDiscovery: false,
});
expect(hasStepSourceMaps()).toBe(false);
diff --git a/packages/core/e2e/utils.ts b/packages/core/e2e/utils.ts
index b669393089..47cf3711d1 100644
--- a/packages/core/e2e/utils.ts
+++ b/packages/core/e2e/utils.ts
@@ -6,7 +6,6 @@ import { fileURLToPath } from 'node:url';
import { createVercelWorld } from '@workflow/world-vercel';
import { onTestFailed } from 'vitest';
import { getTrustedSourcesHeaders } from '../../../scripts/trusted-sources-headers.mjs';
-import { parseEnvironmentFlag } from '../../next/src/environment-flag.js';
import type { Run } from '../src/runtime';
import { getWorld, setWorld } from '../src/runtime';
@@ -95,10 +94,6 @@ function splitArgs(raw: string): string[] {
return value.split(/\s+/);
}
-export function isNextLazyDiscoveryEnabledForTest(): boolean {
- return parseEnvironmentFlag(process.env.WORKFLOW_NEXT_LAZY_DISCOVERY) ?? true;
-}
-
export function getWorkbenchAppPath(overrideAppName?: string): string {
const explicitWorkbenchPath = process.env.WORKBENCH_APP_PATH;
const appName = process.env.APP_NAME ?? overrideAppName;
@@ -135,12 +130,6 @@ export function hasStepSourceMaps(): boolean {
if (appName === 'nextjs-turbopack') {
return false;
}
- // Webpack's eager Next flow route executes steps from the generated
- // __step_registrations.js bundle. Lazy discovery imports step sources through
- // the flow route and preserves source filenames in local dev stacks.
- if (appName === 'nextjs-webpack' && !isNextLazyDiscoveryEnabledForTest()) {
- return false;
- }
// V2 carve-out: the V2 combined flow handler does not yet wire up inline
// source maps for step bundles across the framework integrations on Vercel.
// To unblock CI while V2 source-map coverage catches up, treat every
@@ -436,8 +425,8 @@ export function getFallbackWorkflowId(
): string {
const fileWithoutExt = workflowFile.replace(/\.tsx?$/, '');
// Keep this in sync with the SWC transform ID format. This fallback is
- // intentionally coupled so tests can continue running when deferred manifest
- // publication lags behind discovery in staged/out-of-monorepo scenarios.
+ // intentionally coupled so tests can continue running when manifest
+ // publication lags in staged/out-of-monorepo scenarios.
return `workflow//./${fileWithoutExt}//${workflowFn}`;
}
@@ -463,8 +452,8 @@ export async function getWorkflowMetadata(
return metadata;
}
- // Deferred discovery can grow the manifest during test execution, so poll
- // briefly before failing to avoid races in staged/out-of-monorepo mode.
+ // Manifest publication can lag in staged/out-of-monorepo tests, so poll
+ // briefly before failing to avoid races.
const deadline = Date.now() + manifestRetryTimeoutMs;
while (Date.now() < deadline) {
manifest = await fetchManifest(deploymentUrl, { forceRefresh: true });
@@ -479,9 +468,9 @@ export async function getWorkflowMetadata(
await sleep(manifestRetryIntervalMs);
}
- // Deferred discovery can lag behind manifest publication in staged/out-of-
- // monorepo tests. Fall back to the deterministic workflow ID format used by
- // the transform so tests can continue exercising runtime behavior.
+ // Manifest publication can lag in staged/out-of-monorepo tests. Fall back to
+ // the deterministic workflow ID format used by the transform so tests can
+ // continue exercising runtime behavior.
const fallbackWorkflowId = getFallbackWorkflowId(workflowFile, workflowFn);
console.warn(
`Workflow "${workflowFn}" not found in manifest for "${workflowFile}" after ${manifestRetryTimeoutMs}ms; ` +
diff --git a/packages/core/package.json b/packages/core/package.json
index ecd5596c28..0c230f26ea 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@workflow/core",
- "version": "5.0.0-beta.20",
+ "version": "5.0.0-beta.21",
"description": "Core runtime and engine for Workflow SDK",
"type": "module",
"main": "dist/index.js",
diff --git a/packages/core/src/abort-controller-step.test.ts b/packages/core/src/abort-controller-step.test.ts
index 49ccd23761..4a68c5e647 100644
--- a/packages/core/src/abort-controller-step.test.ts
+++ b/packages/core/src/abort-controller-step.test.ts
@@ -8,9 +8,13 @@
*/
import { FatalError } from '@workflow/errors';
-import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ dehydrateStepArguments,
+ hydrateStepArguments,
+} from './serialization.js';
+import { contextStorage, type StepContext } from './step/context-storage.js';
import { ABORT_HOOK_TOKEN, ABORT_STREAM_NAME } from './symbols.js';
-import { contextStorage } from './step/context-storage.js';
// ============================================================================
// Mocks
@@ -173,6 +177,10 @@ function createStepContext(ops: Promise[]) {
workflowId: 'wf_test',
},
ops,
+ // The mock reviveAbortController above routes everything (stream write +
+ // hook resume) into `ops`, so this stays empty; the real routing into
+ // preCompletionOps is covered by the dedicated suite at the end of the file.
+ preCompletionOps: [],
};
}
@@ -580,3 +588,117 @@ describe('AbortSignal deserialized in step context', () => {
});
});
});
+
+/**
+ * Exercises the REAL `reviveAbortController` (via `hydrateStepArguments`) rather
+ * than the mock copy above, to lock in where a step-initiated abort routes its
+ * two async operations.
+ *
+ * Regression: a step that aborts a controller must record the durable
+ * `hook_received` event BEFORE it completes. If that hook resume is flushed in
+ * the background (`ctx.ops`), the workflow continuation enqueued by
+ * `step_completed` can advance past the abort — dispatching a later step with a
+ * stale, non-aborted `signal` — before the event exists (the abortFromStep E2E
+ * flake). The hook resume must land in `ctx.preCompletionOps`, which the step
+ * handler awaits before writing `step_completed`. The real-time stream write
+ * (which reaches an in-flight sibling) stays in the background `ctx.ops`.
+ */
+describe('step-initiated abort: durable hook resume is committed before completion', () => {
+ beforeEach(() => {
+ mockResumeHook.mockClear();
+ mockStreamReads.readResults.clear();
+ mockStreamReads.writeLog = [];
+ mockStreamReads.closeLog = [];
+ });
+
+ async function reviveControllerInStep(): Promise<{
+ controller: AbortController;
+ ops: Promise[];
+ preCompletionOps: Promise[];
+ }> {
+ // A controller carrying the abort symbols serializes through the workflow
+ // reducer with a streamName + hookToken — the shape a step receives.
+ const source = new AbortController();
+ (source as any)[ABORT_STREAM_NAME] = 'strm_pre_completion_abort';
+ (source as any)[ABORT_HOOK_TOKEN] = 'abrt_pre_completion';
+ (source.signal as any)[ABORT_STREAM_NAME] = 'strm_pre_completion_abort';
+ (source.signal as any)[ABORT_HOOK_TOKEN] = 'abrt_pre_completion';
+
+ const dehydrated = await dehydrateStepArguments(
+ [source],
+ 'wrun_test',
+ undefined
+ );
+
+ const ops: Promise[] = [];
+ const preCompletionOps: Promise[] = [];
+ const store: StepContext = {
+ stepMetadata: {
+ stepName: 'aborter',
+ stepId: 'step_test',
+ stepStartedAt: new Date(),
+ attempt: 1,
+ },
+ workflowMetadata: {
+ workflowName: 'wf',
+ workflowRunId: 'wrun_test',
+ workflowStartedAt: new Date(),
+ features: { encryption: false },
+ },
+ ops,
+ preCompletionOps,
+ encryptionKey: undefined,
+ };
+
+ const controller = await contextStorage.run(store, async () => {
+ const [revived] = (await hydrateStepArguments(
+ dehydrated,
+ 'wrun_test',
+ undefined,
+ ops
+ )) as [AbortController];
+ return revived;
+ });
+
+ return { controller, ops, preCompletionOps };
+ }
+
+ it('routes the hook resume to preCompletionOps, not the background ops', async () => {
+ const { controller, preCompletionOps } = await reviveControllerInStep();
+ expect(controller.signal.aborted).toBe(false);
+
+ const store: StepContext = {
+ stepMetadata: {
+ stepName: 'aborter',
+ stepId: 'step_test',
+ stepStartedAt: new Date(),
+ attempt: 1,
+ },
+ workflowMetadata: {
+ workflowName: 'wf',
+ workflowRunId: 'wrun_test',
+ workflowStartedAt: new Date(),
+ features: { encryption: false },
+ },
+ ops: [],
+ preCompletionOps,
+ encryptionKey: undefined,
+ };
+
+ // abort() reads the step context at call time.
+ contextStorage.run(store, () => controller.abort('aborted from step'));
+
+ expect(controller.signal.aborted).toBe(true);
+ // The durable hook resume is queued for pre-completion draining.
+ expect(preCompletionOps.length).toBe(1);
+
+ // Draining preCompletionOps (what the step handler awaits before
+ // step_completed) actually fires the resume with the correct payload.
+ await Promise.all(preCompletionOps);
+ expect(mockResumeHook).toHaveBeenCalledTimes(1);
+ expect(mockResumeHook).toHaveBeenCalledWith('abrt_pre_completion', {
+ aborted: true,
+ reason: 'aborted from step',
+ });
+ });
+});
diff --git a/packages/core/src/abort-replay-ordering.test.ts b/packages/core/src/abort-replay-ordering.test.ts
new file mode 100644
index 0000000000..aaca28d32f
--- /dev/null
+++ b/packages/core/src/abort-replay-ordering.test.ts
@@ -0,0 +1,167 @@
+/**
+ * Regression test for the abort-signal replay-ordering flake.
+ *
+ * E2E `abortFromStepWorkflow: step abort cancels an in-flight sibling step`
+ * intermittently observed `stepSawAborted === false`: a workflow aborts a
+ * controller from one step, then — after the parallel work settles — passes
+ * `controller.signal` to a *subsequent* step (`checkSignalState`), which read
+ * `aborted: false`.
+ *
+ * Root cause: the workflow VM's controller is aborted when the events consumer
+ * processes the `hook_received` event, but `_setAborted` is deferred behind
+ * `await hydrateStepReturnValue(...)` (async reason decrypt/deserialize) on the
+ * promiseQueue. Unlike step-result and hook-payload deliveries, the abort
+ * delivery did NOT participate in `ctx.pendingDeliveries`, so `scheduleWhenIdle`
+ * — which the suspension handler uses to decide when to dehydrate queued step
+ * arguments — could fire while the abort was still in flight. The downstream
+ * step's `controller.signal` argument was then serialized with `aborted: false`.
+ * Because the hydration latency (decryption) varies run-to-run, the test flaked.
+ *
+ * The fix bumps `pendingDeliveries` around the abort delivery, holding the
+ * idle/suspension gate until `_setAborted` lands. These tests inject hydration
+ * latency that outlasts a macrotask, so a regression (no counter) is caught
+ * deterministically rather than depending on real decryption timing.
+ */
+
+import type { Event } from '@workflow/world';
+import * as nanoid from 'nanoid';
+import { monotonicFactory } from 'ulid';
+import { describe, expect, it, vi } from 'vitest';
+import { EventsConsumer } from './events-consumer.js';
+import {
+ scheduleWhenIdle,
+ type WorkflowOrchestratorContext,
+} from './private.js';
+import { createContext } from './vm/index.js';
+import { createCreateAbortController } from './workflow/abort-controller.js';
+
+// Simulate the production reason-payload decryption gap: every abort reason
+// hydration is delayed past a macrotask boundary. Only the read side is
+// slowed — `dehydrateStepReturnValue` (used to build the test payload) keeps
+// its real implementation via the spread of `actual`.
+const HYDRATE_DELAY_MS = 50;
+vi.mock('./serialization.js', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ hydrateStepReturnValue: async (
+ ...args: Parameters
+ ) => {
+ await new Promise((resolve) => setTimeout(resolve, HYDRATE_DELAY_MS));
+ return actual.hydrateStepReturnValue(...args);
+ },
+ };
+});
+
+// Imported after the mock declaration; vi.mock is hoisted so this still
+// resolves to the mocked module.
+const { dehydrateStepReturnValue } = await import('./serialization.js');
+
+let ctx: WorkflowOrchestratorContext;
+
+function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext {
+ const context = createContext({
+ seed: 'test-abort-replay-ordering',
+ fixedTimestamp: 1714857600000,
+ });
+ const ulid = monotonicFactory(() => context.globalThis.Math.random());
+ const workflowStartedAt = context.globalThis.Date.now();
+ return {
+ runId: 'wrun_test',
+ encryptionKey: undefined,
+ globalThis: context.globalThis,
+ eventsConsumer: new EventsConsumer(events, {
+ onUnconsumedEvent: () => {},
+ getPromiseQueue: () => ctx.promiseQueue,
+ }),
+ invocationsQueue: new Map(),
+ generateUlid: () => ulid(workflowStartedAt),
+ generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
+ new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
+ ),
+ onWorkflowError: vi.fn(),
+ promiseQueue: Promise.resolve(),
+ pendingDeliveries: 0,
+ };
+}
+
+/**
+ * Probe a same-seeded context to discover the deterministic correlationId and
+ * token the abort hook will use, so we can author the matching hook_received
+ * event before the controller subscribes.
+ */
+function probeAbortHook(): { correlationId: string; token: string } {
+ const probeCtx = setupWorkflowContext([]);
+ const ProbeAbortController = createCreateAbortController(probeCtx);
+ new ProbeAbortController();
+ const item = [...probeCtx.invocationsQueue.values()].find(
+ (i) => i.type === 'hook'
+ );
+ if (!item || item.type !== 'hook') {
+ throw new Error('expected probe abort hook item');
+ }
+ return { correlationId: item.correlationId, token: item.token };
+}
+
+async function makeAbortReceipt(): Promise {
+ const { correlationId, token } = probeAbortHook();
+ const ops: Promise[] = [];
+ const payload = await dehydrateStepReturnValue(
+ { reason: 'aborted from step' },
+ 'wrun_test',
+ undefined,
+ ops
+ );
+ return {
+ eventId: 'evnt_abort',
+ runId: 'wrun_test',
+ eventType: 'hook_received',
+ correlationId,
+ eventData: { token, payload },
+ createdAt: new Date(),
+ };
+}
+
+describe('abort signal replay ordering', () => {
+ it('holds scheduleWhenIdle until the abort reason hydration lands', async () => {
+ const receipt = await makeAbortReceipt();
+ ctx = setupWorkflowContext([receipt]);
+
+ const AbortController = createCreateAbortController(ctx);
+ const controller = new AbortController();
+
+ // scheduleWhenIdle is exactly what the suspension handler uses to gate
+ // dehydration of queued step arguments. Whatever `aborted` reads here is
+ // what a step dispatched right after the abort would serialize.
+ const captured = new Promise((resolve) => {
+ scheduleWhenIdle(ctx, () => resolve(controller.signal.aborted));
+ });
+
+ // Pre-fix: the abort delivery was invisible to pendingDeliveries, so the
+ // idle gate fired before the ~50ms hydration completed and captured false.
+ await expect(captured).resolves.toBe(true);
+ expect(controller.signal.aborted).toBe(true);
+ expect(controller.signal.reason).toBe('aborted from step');
+ expect(ctx.pendingDeliveries).toBe(0);
+ });
+
+ it('counts the in-flight abort as a pending delivery while it hydrates', async () => {
+ const receipt = await makeAbortReceipt();
+ ctx = setupWorkflowContext([receipt]);
+
+ const AbortController = createCreateAbortController(ctx);
+ const controller = new AbortController();
+
+ // Let the events consumer's process.nextTick run so hook_received is
+ // consumed, but not long enough for the injected hydration delay to elapse.
+ await new Promise((resolve) => process.nextTick(resolve));
+
+ expect(ctx.pendingDeliveries).toBe(1);
+ expect(controller.signal.aborted).toBe(false);
+
+ // After the queue drains, the abort has landed and the counter is released.
+ await ctx.promiseQueue;
+ expect(ctx.pendingDeliveries).toBe(0);
+ expect(controller.signal.aborted).toBe(true);
+ });
+});
diff --git a/packages/core/src/private.ts b/packages/core/src/private.ts
index 2b9c9a055a..2379016d33 100644
--- a/packages/core/src/private.ts
+++ b/packages/core/src/private.ts
@@ -7,6 +7,7 @@ import type { CryptoKey } from './encryption.js';
import type { EventsConsumer } from './events-consumer.js';
import type { QueueItem } from './global.js';
import type { Serializable } from './schemas.js';
+import type { StepHydrationCache } from './step-hydration-cache.js';
export type StepFunction<
Args extends Serializable[] = any[],
@@ -151,8 +152,10 @@ export interface WorkflowOrchestratorContext {
promiseQueue: Promise;
/**
* Counter of in-flight async data delivery operations (step result
- * hydration, hook payload hydration). Suspensions must wait for this
- * to reach 0 before firing, to avoid preempting data delivery.
+ * hydration, hook payload hydration, abort signal hydration). Suspensions
+ * must wait for this to reach 0 before firing, to avoid preempting data
+ * delivery — e.g. dehydrating a step's arguments while an abort that should
+ * be reflected in those arguments is still hydrating its reason.
*/
pendingDeliveries: number;
/**
@@ -186,6 +189,24 @@ export interface WorkflowOrchestratorContext {
* that do not initialize it degrade gracefully to the previous behavior.
*/
pendingDeliveryBarriers?: Map;
+ /**
+ * Per-run memoization cache for hydrated step return values, keyed by the
+ * `step_completed` event id. Owned by the inline replay loop in `runtime.ts`
+ * and threaded through each `runWorkflow` call so it survives across replay
+ * iterations of the SAME run (a fresh context is created each iteration) but
+ * never leaks across unrelated runs.
+ *
+ * On replay K of a sequential N-step workflow, the step consumer would
+ * otherwise re-decrypt and re-parse the results of all K already-completed
+ * steps — O(N²) across an invocation. This cache makes a completed step's
+ * result available in O(1) on subsequent replays. Only primitive results are
+ * memoized, so a shared reference can never let one replay's mutation leak
+ * into the next; see `step-hydration-cache.ts` for the full rationale.
+ *
+ * Optional so contexts that do not initialize it (test harnesses) degrade
+ * gracefully to re-hydrating every replay — identical to previous behavior.
+ */
+ stepHydrationCache?: StepHydrationCache;
}
/** The kind of branch-deciding delivery a barrier represents. */
diff --git a/packages/core/src/reconnecting-framed-stream.test.ts b/packages/core/src/reconnecting-framed-stream.test.ts
index b399f10799..3b2793ed6f 100644
--- a/packages/core/src/reconnecting-framed-stream.test.ts
+++ b/packages/core/src/reconnecting-framed-stream.test.ts
@@ -175,6 +175,53 @@ describe('createReconnectingFramedStream', () => {
expect(calls).toEqual([0, 2]);
});
+ it('retries the reopen itself against the budget instead of failing fatally', async () => {
+ // After 2 frames the connection drops (read error → reconnect at index 2).
+ // The first *reopen* attempt also fails — the server is briefly
+ // unavailable during the reconnect window. That transient failure of the
+ // reopen is the exact blip this wrapper exists to survive, so it must be
+ // counted against the budget and retried, not treated as fatal. The
+ // second reopen succeeds and the stream completes.
+ const calls: number[] = [];
+ let reopenAttempts = 0;
+ const world = {
+ streams: {
+ get: vi.fn(
+ async (_runId: string, _name: string, startIndex?: number) => {
+ const idx = startIndex ?? 0;
+ calls.push(idx);
+ if (idx === 0) {
+ return scriptedStream([
+ { kind: 'value', value: payloadFrame(1) },
+ { kind: 'value', value: payloadFrame(2) },
+ { kind: 'error', err: new Error('max-duration abort') },
+ ]);
+ }
+ // Reopen at index 2: throw on the first attempt, succeed on the next.
+ reopenAttempts++;
+ if (reopenAttempts === 1) {
+ throw new Error('reopen failed: server briefly unavailable');
+ }
+ return scriptedStream([
+ { kind: 'value', value: payloadFrame(3) },
+ { kind: 'close' },
+ ]);
+ }
+ ),
+ },
+ } as unknown as World;
+ setWorld(world);
+
+ const stream = createReconnectingFramedStream(RUN_ID, 's', 0);
+ const chunks = await readAll(stream);
+
+ // The failed reopen did not surface to the consumer; the stream recovered.
+ expect(chunks).toEqual([payloadFrame(1), payloadFrame(2), payloadFrame(3)]);
+ // index 0 once, then index 2 twice — the failed reopen and the retry both
+ // resume from the same position.
+ expect(calls).toEqual([0, 2, 2]);
+ });
+
it('respects an initial non-zero startIndex on reconnect', async () => {
const { world, calls } = makeWorldWithScriptedStreams({
10: () =>
diff --git a/packages/core/src/runtime.test.ts b/packages/core/src/runtime.test.ts
index f2738f471f..ea4dcdbd2d 100644
--- a/packages/core/src/runtime.test.ts
+++ b/packages/core/src/runtime.test.ts
@@ -1330,3 +1330,298 @@ describe('workflowEntrypoint step-dispatch ack ordering', () => {
expect(stepIdMessages).toHaveLength(0);
});
});
+
+describe('workflowEntrypoint turbo mode', () => {
+ const ORIG_TURBO = process.env.WORKFLOW_TURBO;
+ const ORIG_OPT = process.env.WORKFLOW_OPTIMISTIC_INLINE_START;
+
+ // Default: turbo ON (unset) and the global optimistic flag OFF (unset). Any
+ // optimistic behavior observed in these tests therefore comes from turbo
+ // forcing it — never from WORKFLOW_OPTIMISTIC_INLINE_START.
+ beforeEach(() => {
+ delete process.env.WORKFLOW_TURBO;
+ delete process.env.WORKFLOW_OPTIMISTIC_INLINE_START;
+ turboOrder = [];
+ });
+ afterEach(() => {
+ if (ORIG_TURBO === undefined) delete process.env.WORKFLOW_TURBO;
+ else process.env.WORKFLOW_TURBO = ORIG_TURBO;
+ if (ORIG_OPT === undefined) {
+ delete process.env.WORKFLOW_OPTIMISTIC_INLINE_START;
+ } else {
+ process.env.WORKFLOW_OPTIMISTIC_INLINE_START = ORIG_OPT;
+ }
+ setWorld(undefined);
+ vi.clearAllMocks();
+ waitUntilPromises.length = 0;
+ });
+
+ const xform = (name: string) =>
+ `;globalThis.__private_workflows = new Map();
+ globalThis.__private_workflows.set(${JSON.stringify(name)}, ${name});`;
+
+ // The step body records 'body' the moment it runs — its position relative to
+ // 'run_started_resolved' / 'step_started_called' is what proves (or disproves)
+ // optimistic start. Registered once; reads the current `turboOrder` binding.
+ let turboOrder: string[] = [];
+ registerStepFunction('turboStep', async () => {
+ turboOrder.push('body');
+ return undefined;
+ });
+
+ const oneStepWorkflow = `const s = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("turboStep");
+ async function workflow() { return await s(); }${xform('workflow')}`;
+
+ // A step raced against a sleep: the suspension creates a wait, which makes
+ // turbo exit (no forced optimistic start) for the inline step.
+ const stepAndSleepWorkflow = `const s = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("turboStep");
+ const sleep = globalThis[Symbol.for("WORKFLOW_SLEEP")];
+ async function workflow() {
+ const [r] = await Promise.all([s(), sleep('1h')]);
+ return r;
+ }${xform('workflow')}`;
+
+ async function makeRunInput(runId: string) {
+ return {
+ input: await dehydrateWorkflowArguments([], runId, undefined, []),
+ deploymentId: 'test-deployment',
+ workflowName: 'workflow',
+ specVersion: SPEC_VERSION_CURRENT,
+ executionContext: {},
+ };
+ }
+
+ /**
+ * Drives the handler with a first-invocation message (runInput present) at the
+ * given delivery `attempt`. `runStartedGate`, when provided, holds the
+ * `run_started` create until released — its resolution pushes
+ * 'run_started_resolved' so tests can assert the body ran before or after it.
+ */
+ async function driveTurbo(opts: {
+ runId: string;
+ attempt: number;
+ source: string;
+ runStartedGate?: Promise;
+ }) {
+ const { runId, attempt, source } = opts;
+ const order = turboOrder;
+ const durable: Event[] = [];
+ let seq = 0;
+ const rec = (data: any): Event => {
+ seq += 1;
+ const e = {
+ eventId: `e-${seq}`,
+ runId,
+ createdAt: new Date(),
+ ...data,
+ } as Event;
+ durable.push(e);
+ return e;
+ };
+ const runEntity: WorkflowRun = {
+ runId,
+ workflowName: 'workflow',
+ status: 'running',
+ input: await dehydrateWorkflowArguments([], runId, undefined, []),
+ createdAt: new Date('2024-01-01T00:00:00.000Z'),
+ updatedAt: new Date('2024-01-01T00:00:00.000Z'),
+ startedAt: new Date('2024-01-01T00:00:00.000Z'),
+ deploymentId: 'test-deployment',
+ };
+
+ const eventsCreate = vi.fn(async (_runId: string, data: any) => {
+ if (data.eventType === 'run_started') {
+ if (opts.runStartedGate) await opts.runStartedGate;
+ order.push('run_started_resolved');
+ return { run: runEntity, events: [] as Event[] };
+ }
+ if (data.eventType === 'step_started') {
+ order.push('step_started_called');
+ const d = data.eventData as { stepName?: string; input?: unknown };
+ if (d?.input !== undefined) {
+ rec({
+ eventType: 'step_created',
+ specVersion: SPEC_VERSION_CURRENT,
+ correlationId: data.correlationId,
+ eventData: { stepName: d.stepName, input: d.input },
+ });
+ }
+ return {
+ event: rec(data),
+ step: {
+ runId,
+ stepId: data.correlationId,
+ stepName: d?.stepName,
+ status: 'running' as const,
+ attempt: 1,
+ input: d?.input,
+ startedAt: new Date(),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ...(d?.input !== undefined ? { stepCreated: true } : {}),
+ };
+ }
+ if (data.eventType === 'wait_created') order.push('wait_created');
+ return { event: rec(data) };
+ });
+
+ setWorld({
+ specVersion: SPEC_VERSION_CURRENT,
+ createQueueHandler: vi.fn(
+ (_p: string, handler: (m: unknown, md: unknown) => Promise) =>
+ async () => {
+ await handler(
+ {
+ runId,
+ requestedAt: new Date('2024-01-01T00:00:00.000Z'),
+ runInput: await makeRunInput(runId),
+ },
+ {
+ requestId: 'req_turbo',
+ attempt,
+ queueName: '__wkf_workflow_workflow',
+ messageId: 'msg_turbo',
+ }
+ );
+ return new Response(null, { status: 204 });
+ }
+ ),
+ events: {
+ create: eventsCreate,
+ list: vi.fn(async () => ({
+ data: [...durable],
+ hasMore: false,
+ cursor: 'cursor_turbo',
+ })),
+ },
+ runs: { get: vi.fn(async () => runEntity) },
+ queue: vi.fn(async () => ({ messageId: null })),
+ getEncryptionKeyForRun: vi.fn(async () => undefined),
+ } as any);
+
+ const handlerPromise = workflowEntrypoint(source)(
+ new Request('https://example.test')
+ ) as Promise;
+ return { handlerPromise, order, eventsCreate };
+ }
+
+ it('backgrounds run_started and forces optimistic start on the first delivery', async () => {
+ let release!: () => void;
+ const gate = new Promise((r) => {
+ release = r;
+ });
+
+ const { handlerPromise, order, eventsCreate } = await driveTurbo({
+ runId: 'wrun_turbo_first',
+ attempt: 1,
+ source: oneStepWorkflow,
+ runStartedGate: gate,
+ });
+
+ // The body runs while run_started is still in flight — proving run_started
+ // was backgrounded AND optimistic start was forced (the env flag is off).
+ // The full VM replay leading up to the body can exceed vi.waitFor's default
+ // 1s timeout on slow CI runners (notably Windows), so widen it.
+ await vi.waitFor(() => expect(order).toContain('body'), {
+ timeout: 15_000,
+ });
+ expect(order).not.toContain('run_started_resolved');
+ // The lazy step_started is chained on the run-ready barrier, so it is not
+ // even issued until run_started lands.
+ expect(order).not.toContain('step_started_called');
+
+ release();
+ const res = await handlerPromise;
+ expect(res.status).toBe(204);
+ // After release: step_started fires, ordered strictly after run_started.
+ expect(order).toContain('step_started_called');
+ expect(order.indexOf('run_started_resolved')).toBeLessThan(
+ order.indexOf('step_started_called')
+ );
+ // run_started was created exactly once (idempotent first write).
+ const runStartedCreates = eventsCreate.mock.calls.filter(
+ (c) => (c[1] as any).eventType === 'run_started'
+ );
+ expect(runStartedCreates).toHaveLength(1);
+ });
+
+ it('does not turbo on a redelivery (attempt > 1): run_started is awaited first', async () => {
+ const { handlerPromise, order } = await driveTurbo({
+ runId: 'wrun_turbo_redeliver',
+ attempt: 2,
+ source: oneStepWorkflow,
+ });
+
+ const res = await handlerPromise;
+ expect(res.status).toBe(204);
+ // Non-turbo awaits run_started up front, so the body runs strictly after it.
+ expect(order.indexOf('run_started_resolved')).toBeLessThan(
+ order.indexOf('body')
+ );
+ });
+
+ it('does not turbo when WORKFLOW_TURBO=0 (parity with the awaited path)', async () => {
+ process.env.WORKFLOW_TURBO = '0';
+ const { handlerPromise, order } = await driveTurbo({
+ runId: 'wrun_turbo_off',
+ attempt: 1,
+ source: oneStepWorkflow,
+ });
+
+ const res = await handlerPromise;
+ expect(res.status).toBe(204);
+ expect(order.indexOf('run_started_resolved')).toBeLessThan(
+ order.indexOf('body')
+ );
+ });
+
+ it('asks the World to skip the run_started preload only under turbo', async () => {
+ // The backgrounded run_started is used purely as a write barrier and its
+ // preloaded events are never read (preloadedEvents is forced to []), so
+ // turbo passes skipPreload to drop the wasted server-side
+ // list+resolve that the chained first step_started waits behind.
+ const turbo = await driveTurbo({
+ runId: 'wrun_turbo_skip_preload',
+ attempt: 1,
+ source: oneStepWorkflow,
+ });
+ expect((await turbo.handlerPromise).status).toBe(204);
+ const turboRunStarted = turbo.eventsCreate.mock.calls.find(
+ (c) => (c[1] as any).eventType === 'run_started'
+ );
+ expect((turboRunStarted?.[2] as any)?.skipPreload).toBe(true);
+
+ // A redelivery (attempt > 1) is not turbo: it awaits run_started and
+ // consumes the preload to skip its initial events.list, so it must NOT ask
+ // the server to skip it.
+ const redeliver = await driveTurbo({
+ runId: 'wrun_turbo_skip_preload_redeliver',
+ attempt: 2,
+ source: oneStepWorkflow,
+ });
+ expect((await redeliver.handlerPromise).status).toBe(204);
+ const redeliverRunStarted = redeliver.eventsCreate.mock.calls.find(
+ (c) => (c[1] as any).eventType === 'run_started'
+ );
+ expect((redeliverRunStarted?.[2] as any)?.skipPreload).toBeUndefined();
+ });
+
+ it('exits turbo (no forced optimistic) when the suspension creates a wait', async () => {
+ const { handlerPromise, order } = await driveTurbo({
+ runId: 'wrun_turbo_wait',
+ attempt: 1,
+ source: stepAndSleepWorkflow,
+ });
+
+ const res = await handlerPromise;
+ expect(res.status).toBe(204);
+ // A wait was created this suspension, so turbo exited: the inline step took
+ // the normal await-then-run path, i.e. step_started was awaited BEFORE the
+ // body ran (the opposite ordering from the forced-optimistic case above).
+ expect(order).toContain('wait_created');
+ expect(order.indexOf('step_started_called')).toBeLessThan(
+ order.indexOf('body')
+ );
+ });
+});
diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts
index da1f7ca615..61fe73af8a 100644
--- a/packages/core/src/runtime.ts
+++ b/packages/core/src/runtime.ts
@@ -28,6 +28,7 @@ import { describeError } from './describe-error.js';
import { WorkflowSuspension } from './global.js';
import { runtimeLogger } from './logger.js';
import {
+ isTurboEnabled,
MAX_QUEUE_DELIVERIES,
REPLAY_DIVERGENCE_MAX_RETRIES,
} from './runtime/constants.js';
@@ -55,6 +56,10 @@ import {
} from './runtime/world.js';
import { dehydrateRunError } from './serialization.js';
import { remapErrorStack } from './source-map.js';
+import {
+ createStepHydrationCache,
+ type StepHydrationCache,
+} from './step-hydration-cache.js';
import * as Attribute from './telemetry/semantic-conventions.js';
import {
buildInvocationSpanLinks,
@@ -483,6 +488,16 @@ export function workflowEntrypoint(
let cachedEvents: Event[] | null = null;
let eventsCursor: string | null = null;
+ // Per-run cache of hydrated step return values, shared across
+ // every replay iteration of THIS invocation. Each iteration
+ // builds a fresh workflow context, so the cache is owned here
+ // (outside that context) and threaded into runWorkflow. It
+ // turns the otherwise O(N²) re-decrypt + re-parse of completed
+ // step results across N replays into O(N). Scoped to this run
+ // only — never reused across runs. See step-hydration-cache.ts.
+ const stepHydrationCache: StepHydrationCache =
+ createStepHydrationCache();
+
// Inline-delta optimization: when an inline step's terminal
// write returns the event-log delta since the pre-write
// cursor (a supporting World only), we stash it here so the
@@ -503,6 +518,80 @@ export function workflowEntrypoint(
let preloadedEvents: Event[] | undefined;
let preloadedEventsCursor: string | null | undefined;
+ // Turbo mode fast-paths the very first delivery of the very
+ // first invocation, where it is provably safe to: background
+ // `run_started`, skip the initial event-log load (nothing has
+ // been written yet), and force optimistic inline start (no
+ // concurrent peer handler exists to race the create-claim).
+ // `runInput` is only present on the start()-enqueued message,
+ // and `attempt === 1` (1-based) means this is the first
+ // delivery; `incomingStepId` would mark a background-step
+ // invocation and `replayDivergence` a recovery replay — both
+ // ineligible. The single-handler guarantee that makes forced
+ // optimistic start safe ends once a hook/wait/attr is created,
+ // so turbo exits at that point (see `forceOptimisticStart`).
+ const turbo =
+ isTurboEnabled() &&
+ runInput !== undefined &&
+ metadata.attempt === 1 &&
+ incomingStepId === undefined &&
+ !replayDivergence;
+
+ // Turbo mode only: resolves once the backgrounded
+ // `run_started` has landed (or rejects if it failed). Threaded
+ // into handleSuspension and executeStep so no step/hook/wait
+ // write races ahead of the run's creation. Undefined outside
+ // turbo, where `run_started` is awaited up front.
+ let runReadyBarrier: Promise | undefined;
+
+ // Order a terminal run write (run_completed / run_failed) after
+ // the backgrounded run_started in turbo mode — a no-step
+ // workflow can otherwise reach run_completed before the run
+ // exists. Best-effort: a barrier rejection is swallowed for
+ // ordering only; if run_started truly failed the terminal write
+ // surfaces the real error (run not found / gone) and the message
+ // redelivers. No-op outside turbo.
+ const awaitRunReady = async (): Promise => {
+ if (runReadyBarrier) {
+ try {
+ await runReadyBarrier;
+ } catch {
+ // intentional: ordering barrier only — see above.
+ }
+ }
+ };
+
+ // Re-invoke the orchestrator. Outside turbo this returns
+ // `{ timeoutSeconds }`, which makes the queue reschedule the
+ // CURRENT delivery's message. In turbo that is a trap: the
+ // current message carries `runInput`, and on async queues
+ // (e.g. graphile-worker) a reschedule comes back as delivery
+ // attempt 1 — so turbo re-engages, skips the event-log load
+ // again, replays against an empty log, never observes the
+ // hook/attr event this invocation just wrote, and re-suspends
+ // forever (the run wedges). Under turbo we instead enqueue an
+ // explicit continuation that carries NO `runInput`, so the
+ // next delivery is a normal (non-turbo) load-and-replay that
+ // observes the committed events and makes progress; we then
+ // return `undefined` so the queue treats this delivery as done
+ // rather than also rescheduling it.
+ const reinvoke = async (
+ delaySeconds: number
+ ): Promise<{ timeoutSeconds: number } | undefined> => {
+ if (!turbo) return { timeoutSeconds: delaySeconds };
+ await queueMessage(
+ world,
+ getWorkflowQueueName(workflowName, namespace),
+ {
+ runId,
+ traceCarrier: await nextTraceCarrier(),
+ requestedAt: new Date(),
+ },
+ delaySeconds > 0 ? { delaySeconds } : undefined
+ );
+ return undefined;
+ };
+
// If incoming message has a stepId, this is a background step
// execution. Execute the step, then check if all parallel steps
// from the batch are done. If so, replay inline (saving a queue
@@ -652,118 +741,188 @@ export function workflowEntrypoint(
// Contract: events.create('run_started') must be idempotent
// for runs already in 'running' status (return the run
// without error), not just for pending → running transitions.
- try {
- const result = await world.events.create(
+ const runStartedEvent = {
+ eventType: 'run_started' as const,
+ // Use the spec version from the original start() call
+ // when available, so the resilient start path creates
+ // the run with the correct version (not always current).
+ specVersion:
+ runInput?.specVersion ?? SPEC_VERSION_CURRENT,
+ // Pass run input from queue so the server can
+ // create the run if run_created was missed.
+ // Uint8Array values survive the queue natively
+ // (CBOR on world-vercel, JSON reviver on world-local).
+ ...(runInput
+ ? {
+ eventData: {
+ input: runInput.input,
+ deploymentId: runInput.deploymentId,
+ workflowName: runInput.workflowName,
+ executionContext: runInput.executionContext,
+ attributes: runInput.attributes,
+ allowReservedAttributes:
+ runInput.allowReservedAttributes,
+ },
+ }
+ : {}),
+ };
+
+ if (turbo && runInput) {
+ // Turbo: background `run_started` and synthesize the run
+ // entity locally so replay can begin without waiting for
+ // the round-trip. Safe here because this is the first
+ // delivery of the first invocation — start() created the
+ // run moments ago and no events have been written yet. The
+ // barrier is consumed by every downstream write (suspension
+ // handler, optimistic step_started, terminal run writes) so
+ // nothing is written before the run exists.
+ const startedPromise = world.events.create(
runId,
- {
- eventType: 'run_started',
- // Use the spec version from the original start() call
- // when available, so the resilient start path creates
- // the run with the correct version (not always current).
- specVersion:
- runInput?.specVersion ?? SPEC_VERSION_CURRENT,
- // Pass run input from queue so the server can
- // create the run if run_created was missed.
- // Uint8Array values survive the queue natively
- // (CBOR on world-vercel, JSON reviver on world-local).
- ...(runInput
- ? {
- eventData: {
- input: runInput.input,
- deploymentId: runInput.deploymentId,
- workflowName: runInput.workflowName,
- executionContext: runInput.executionContext,
- attributes: runInput.attributes,
- allowReservedAttributes:
- runInput.allowReservedAttributes,
- },
- }
- : {}),
- },
- { requestId }
+ runStartedEvent,
+ // We background this purely as a write barrier and
+ // never read its preloaded events (preloadedEvents is
+ // forced to [] below), so tell the World to skip the
+ // run_started event-log preload. That trims the
+ // run_started request the chained first step_started
+ // waits on — shortening time-to-second-step — and the
+ // wasted list+resolve it would otherwise compute.
+ { requestId, skipPreload: true }
);
- if (!result.run) {
- throw new WorkflowRuntimeError(
- `Event creation for 'run_started' did not return the run entity for run "${runId}"`
+ runReadyBarrier = startedPromise;
+ // Attach a no-op rejection handler so an early failure
+ // never surfaces as an unhandledRejection before a consumer
+ // (await/then) is attached; consumers still observe it.
+ startedPromise.catch(() => {});
+ // Skip the initial events.list: nothing has been written to
+ // the log yet on a first delivery (run_started is still in
+ // flight). An empty preloaded set routes iteration 1 through
+ // the no-load preloaded branch; iteration 2 then takes the
+ // existing post-preloaded full reload to pick up a cursor
+ // (no spurious "cursor missing" warning). `[]` is
+ // intentionally truthy here — do not change the load
+ // branches' `if (preloadedEvents)` checks to test length.
+ preloadedEvents = [];
+ const now = new Date();
+ workflowRun = {
+ runId,
+ status: 'running',
+ deploymentId: runInput.deploymentId,
+ workflowName: runInput.workflowName,
+ specVersion: runInput.specVersion,
+ executionContext: runInput.executionContext,
+ input: runInput.input,
+ // Seed attributes from start() ride along in `runInput`
+ // (they live in `run_created`'s eventData, not separate
+ // `attr_set` events), so the synthesized snapshot carries
+ // them even though we skip the initial events.list. This
+ // is correct ONLY while attributes are write-only:
+ // there is no in-workflow read API today (see workflow.ts
+ // "structural until a read API is introduced"), so the
+ // empty preloaded log can't diverge on a read. If a read
+ // API is ever added it MUST read from this snapshot, not
+ // by replaying run_created/attr_set events — otherwise
+ // turbo's empty initial log would surface seed attributes
+ // as `{}` on the first delivery only.
+ attributes: runInput.attributes ?? {},
+ startedAt: now,
+ createdAt: now,
+ updatedAt: now,
+ };
+ workflowStartedAt = +now;
+ span?.setAttributes({
+ ...Attribute.WorkflowRunStatus('running'),
+ ...Attribute.WorkflowStartedAt(workflowStartedAt),
+ });
+ } else {
+ try {
+ const result = await world.events.create(
+ runId,
+ runStartedEvent,
+ { requestId }
);
- }
- workflowRun = result.run;
+ if (!result.run) {
+ throw new WorkflowRuntimeError(
+ `Event creation for 'run_started' did not return the run entity for run "${runId}"`
+ );
+ }
+ workflowRun = result.run;
- // If the response includes events, use them to skip
- // the initial events.list call and reduce TTFB.
- if (
- result.events &&
- result.events.length > 0 &&
- result.hasMore !== true
- ) {
- preloadedEvents = result.events;
- preloadedEventsCursor = result.cursor;
- }
+ // If the response includes events, use them to skip
+ // the initial events.list call and reduce TTFB.
+ if (
+ result.events &&
+ result.events.length > 0 &&
+ result.hasMore !== true
+ ) {
+ preloadedEvents = result.events;
+ preloadedEventsCursor = result.cursor;
+ }
- if (!workflowRun.startedAt) {
- throw new WorkflowRuntimeError(
- `Workflow run "${runId}" has no "startedAt" timestamp`
- );
- }
- } catch (err) {
- // Run was concurrently completed/failed/cancelled
- if (
- EntityConflictError.is(err) ||
- RunExpiredError.is(err)
- ) {
- // EntityConflictError: run was concurrently
- // completed/failed/cancelled during setup.
- // RunExpiredError: run already in terminal state.
- // In both cases, skip processing this message.
- runtimeLogger.info(
- 'Run already finished during setup, skipping',
- { workflowRunId: runId, message: err.message }
- );
- return;
- } else {
- const errorCode = getWorkflowSetupErrorCode(err);
- if (!errorCode) {
- throw err;
+ if (!workflowRun.startedAt) {
+ throw new WorkflowRuntimeError(
+ `Workflow run "${runId}" has no "startedAt" timestamp`
+ );
+ }
+ } catch (err) {
+ // Run was concurrently completed/failed/cancelled
+ if (
+ EntityConflictError.is(err) ||
+ RunExpiredError.is(err)
+ ) {
+ // EntityConflictError: run was concurrently
+ // completed/failed/cancelled during setup.
+ // RunExpiredError: run already in terminal state.
+ // In both cases, skip processing this message.
+ runtimeLogger.info(
+ 'Run already finished during setup, skipping',
+ { workflowRunId: runId, message: err.message }
+ );
+ return;
+ } else {
+ const errorCode = getWorkflowSetupErrorCode(err);
+ if (!errorCode) {
+ throw err;
+ }
+ await recordFatalRunError({
+ world,
+ workflowRun,
+ runId,
+ requestId,
+ err,
+ errorCode,
+ logMessage:
+ 'Fatal runtime error during workflow setup',
+ });
+ return;
}
- await recordFatalRunError({
- world,
- workflowRun,
- runId,
- requestId,
- err,
- errorCode,
- logMessage:
- 'Fatal runtime error during workflow setup',
- });
- return;
}
- }
- workflowStartedAt = +workflowRun.startedAt;
+ workflowStartedAt = +workflowRun.startedAt;
- span?.setAttributes({
- ...Attribute.WorkflowRunStatus(workflowRun.status),
- ...Attribute.WorkflowStartedAt(workflowStartedAt),
- });
+ span?.setAttributes({
+ ...Attribute.WorkflowRunStatus(workflowRun.status),
+ ...Attribute.WorkflowStartedAt(workflowStartedAt),
+ });
- if (workflowRun.status !== 'running') {
- // Workflow has already completed or failed, so we can skip it
- runtimeLogger.info(
- 'Workflow already completed or failed, skipping',
- {
- workflowRunId: runId,
- status: workflowRun.status,
- }
- );
+ if (workflowRun.status !== 'running') {
+ // Workflow has already completed or failed, so we can skip it
+ runtimeLogger.info(
+ 'Workflow already completed or failed, skipping',
+ {
+ workflowRunId: runId,
+ status: workflowRun.status,
+ }
+ );
- // TODO: for `cancel`, we actually want to propagate a WorkflowCancelled event
- // inside the workflow context so the user can gracefully exit. this is SIGTERM
- // TODO: furthermore, there should be a timeout or a way to force cancel SIGKILL
- // so that we actually exit here without replaying the workflow at all, in the case
- // the replaying the workflow is itself failing.
+ // TODO: for `cancel`, we actually want to propagate a WorkflowCancelled event
+ // inside the workflow context so the user can gracefully exit. this is SIGTERM
+ // TODO: furthermore, there should be a timeout or a way to force cancel SIGKILL
+ // so that we actually exit here without replaying the workflow at all, in the case
+ // the replaying the workflow is itself failing.
- return;
- }
+ return;
+ }
+ } // end else (non-turbo run_started)
} // end if (!workflowRun)
// Resolve the encryption key for this run's deployment.
@@ -1075,7 +1234,8 @@ export function workflowEntrypoint(
workflowCode,
workflowRun,
events,
- encryptionKey
+ encryptionKey,
+ stepHydrationCache
);
runtimeLogger.debug('Workflow replay completed', {
workflowRunId: runId,
@@ -1085,6 +1245,10 @@ export function workflowEntrypoint(
// Workflow completed
try {
+ // Turbo: a workflow that finishes with no steps reaches
+ // here before the backgrounded run_started; order the
+ // terminal write after it so the run exists.
+ await awaitRunReady();
await world.events.create(
runId,
{
@@ -1144,6 +1308,7 @@ export function workflowEntrypoint(
run: workflowRun,
span,
requestId,
+ runReadyBarrier,
});
} catch (suspensionError) {
if (!FatalError.is(suspensionError)) {
@@ -1170,6 +1335,9 @@ export function workflowEntrypoint(
}
);
try {
+ // Turbo: order the terminal write after the
+ // backgrounded run_started so the run exists.
+ await awaitRunReady();
await world.events.create(
runId,
{
@@ -1232,7 +1400,7 @@ export function workflowEntrypoint(
// Hook conflict: break loop, re-invoke via queue
if (suspensionResult.hasHookConflict) {
- return { timeoutSeconds: 0 };
+ return await reinvoke(0);
}
// Native workflow attribute events are resolved through
@@ -1241,7 +1409,7 @@ export function workflowEntrypoint(
// durable attribute event can win without executing
// the losing step.
if (suspensionResult.hasAttributeEvents) {
- return { timeoutSeconds: 0 };
+ return await reinvoke(0);
}
const pendingSteps = suspensionResult.pendingSteps;
@@ -1364,7 +1532,7 @@ export function workflowEntrypoint(
// queued or none pending) the run would sit idle
// until some unrelated message woke it.
if (suspensionResult.hasAwaitedHookCreation) {
- return { timeoutSeconds: 0 };
+ return await reinvoke(0);
}
return;
}
@@ -1419,6 +1587,35 @@ export function workflowEntrypoint(
!suspensionResult.waitTimeout &&
!hasOpenHookOrWait(cachedEvents ?? []);
+ // Turbo mode forces optimistic inline start for this
+ // batch — but only while the run is still "clean" (a pure
+ // step suspension). The moment a hook or wait (or attr) is
+ // created, later resume/parallel invocations become
+ // possible, so the single-handler guarantee that makes
+ // forced optimistic start safe no longer holds — turbo
+ // exits and the steps take the normal (env-gated)
+ // await-then-run path. The hook-conflict / attr cases
+ // already returned early above and the awaited-hook case
+ // emptied lazyInlineSteps; the checks below are defensive.
+ //
+ // The `suspensionResult.*` flags only reflect what THIS
+ // batch created, so they do not catch a hook/wait opened
+ // in an earlier iteration of the same delivery (e.g. a
+ // fire-and-forget `createHook(...)` that doesn't block the
+ // workflow, letting the replay loop continue to later pure
+ // step suspensions). Once any hook or wait is open in the
+ // cumulative log, resume/parallel invocations are possible
+ // for the rest of the run, so turbo must latch off
+ // permanently — checked here via `hasOpenHookOrWait` over
+ // the cumulative `cachedEvents`.
+ const forceOptimisticStart =
+ turbo &&
+ !suspensionResult.waitTimeout &&
+ !suspensionResult.hasHookEvents &&
+ !suspensionResult.hasAttributeEvents &&
+ !suspensionResult.hasAwaitedHookCreation &&
+ !hasOpenHookOrWait(cachedEvents ?? []);
+
// Execute the inline steps in parallel. The replay
// budget is paused for the whole batch — step duration is
// bounded by the platform's function maxDuration, not the
@@ -1444,6 +1641,12 @@ export function workflowEntrypoint(
// input on step_started so the world creates the
// step on the fly.
lazyStepInput: s.dehydratedInput,
+ // Turbo: force optimistic start and hold the lazy
+ // step_started until the backgrounded run_started
+ // lands (the body still runs immediately). Both
+ // are undefined/false outside turbo.
+ forceOptimisticStart,
+ runReadyBarrier,
...(requestInlineDelta && preInlineWriteCursor
? {
inlineDeltaSinceCursor:
@@ -1536,7 +1739,7 @@ export function workflowEntrypoint(
// the in-invocation flush window (<= 500ms + waitUntil),
// so ops settle before the post-backoff redelivery
// replays and reads them.
- return { timeoutSeconds: throttleTimeout };
+ return await reinvoke(throttleTimeout);
}
if (toRetry.length > 0) {
@@ -1736,6 +1939,9 @@ export function workflowEntrypoint(
// type identity and custom properties round-trip
// through the event log.
try {
+ // Turbo: order the terminal write after the
+ // backgrounded run_started so the run exists.
+ await awaitRunReady();
await world.events.create(
runId,
{
diff --git a/packages/core/src/runtime/constants.test.ts b/packages/core/src/runtime/constants.test.ts
index 441e19d547..d7ffc7fde9 100644
--- a/packages/core/src/runtime/constants.test.ts
+++ b/packages/core/src/runtime/constants.test.ts
@@ -5,6 +5,8 @@ import {
getMaxInlineSteps,
getReplayTimeoutMs,
isOptimisticInlineStartEnabled,
+ isOptimisticInlineStartExplicitlyDisabled,
+ isTurboEnabled,
MAX_INLINE_STEPS,
MAX_MAX_INLINE_STEPS,
MAX_REPLAY_TIMEOUT_MS,
@@ -204,3 +206,79 @@ describe('isOptimisticInlineStartEnabled', () => {
expect(isOptimisticInlineStartEnabled()).toBe(false);
});
});
+
+describe('isOptimisticInlineStartExplicitlyDisabled', () => {
+ const originalEnv = process.env.WORKFLOW_OPTIMISTIC_INLINE_START;
+
+ afterEach(() => {
+ if (originalEnv === undefined) {
+ delete process.env.WORKFLOW_OPTIMISTIC_INLINE_START;
+ } else {
+ process.env.WORKFLOW_OPTIMISTIC_INLINE_START = originalEnv;
+ }
+ });
+
+ it('is false when unset (off-by-default, but not an explicit opt-out)', () => {
+ delete process.env.WORKFLOW_OPTIMISTIC_INLINE_START;
+ expect(isOptimisticInlineStartExplicitlyDisabled()).toBe(false);
+ });
+
+ it('is false when empty', () => {
+ process.env.WORKFLOW_OPTIMISTIC_INLINE_START = '';
+ expect(isOptimisticInlineStartExplicitlyDisabled()).toBe(false);
+ });
+
+ it('is true for an explicit "0"', () => {
+ process.env.WORKFLOW_OPTIMISTIC_INLINE_START = '0';
+ expect(isOptimisticInlineStartExplicitlyDisabled()).toBe(true);
+ });
+
+ it('is true for "false" (case-insensitive)', () => {
+ process.env.WORKFLOW_OPTIMISTIC_INLINE_START = 'False';
+ expect(isOptimisticInlineStartExplicitlyDisabled()).toBe(true);
+ });
+
+ it('is false when enabled', () => {
+ process.env.WORKFLOW_OPTIMISTIC_INLINE_START = '1';
+ expect(isOptimisticInlineStartExplicitlyDisabled()).toBe(false);
+ });
+});
+
+describe('isTurboEnabled', () => {
+ const originalEnv = process.env.WORKFLOW_TURBO;
+
+ afterEach(() => {
+ if (originalEnv === undefined) {
+ delete process.env.WORKFLOW_TURBO;
+ } else {
+ process.env.WORKFLOW_TURBO = originalEnv;
+ }
+ });
+
+ it('defaults to enabled when unset', () => {
+ delete process.env.WORKFLOW_TURBO;
+ expect(isTurboEnabled()).toBe(true);
+ });
+
+ it('defaults to enabled when empty', () => {
+ process.env.WORKFLOW_TURBO = '';
+ expect(isTurboEnabled()).toBe(true);
+ });
+
+ it('is disabled by an explicit "0"', () => {
+ process.env.WORKFLOW_TURBO = '0';
+ expect(isTurboEnabled()).toBe(false);
+ });
+
+ it('is disabled by "false" (case-insensitive)', () => {
+ process.env.WORKFLOW_TURBO = 'FALSE';
+ expect(isTurboEnabled()).toBe(false);
+ });
+
+ it('stays enabled for "1" and other truthy values', () => {
+ process.env.WORKFLOW_TURBO = '1';
+ expect(isTurboEnabled()).toBe(true);
+ process.env.WORKFLOW_TURBO = 'yes';
+ expect(isTurboEnabled()).toBe(true);
+ });
+});
diff --git a/packages/core/src/runtime/constants.ts b/packages/core/src/runtime/constants.ts
index 92bdf96a7e..c8fddcf69d 100644
--- a/packages/core/src/runtime/constants.ts
+++ b/packages/core/src/runtime/constants.ts
@@ -202,6 +202,44 @@ export function isOptimisticInlineStartEnabled(): boolean {
return raw === '1' || raw.toLowerCase() === 'true';
}
+/**
+ * Whether an operator has **explicitly disabled** optimistic inline start via
+ * `WORKFLOW_OPTIMISTIC_INLINE_START=0` / `=false`. Distinct from "unset": unset
+ * leaves the optimization off by default but lets turbo force it on; an explicit
+ * `0`/`false` is an operator opt-out that turbo must honor (turbo's forced
+ * optimistic start still runs a step body before `step_started`/`run_started` is
+ * confirmed, the property such an operator is opting out of), so
+ * `forceOptimisticStart` defers to this. Reads the env var lazily.
+ */
+export function isOptimisticInlineStartExplicitlyDisabled(): boolean {
+ const raw = process.env.WORKFLOW_OPTIMISTIC_INLINE_START;
+ if (raw === undefined || raw === '') return false;
+ return raw === '0' || raw.toLowerCase() === 'false';
+}
+
+/**
+ * Whether "turbo mode" is enabled. Turbo mode fast-paths the *first delivery of
+ * the first invocation* of a run (detected by the entrypoint via `runInput`
+ * presence + `metadata.attempt === 1`): it backgrounds the `run_started` event
+ * creation, skips the initial event-log load (nothing has been written yet),
+ * and forces optimistic inline step start for that invocation — independent of
+ * `WORKFLOW_OPTIMISTIC_INLINE_START`.
+ *
+ * Forcing optimistic start is safe here because the first delivery has no
+ * concurrent peer handler to race the step create-claim, so a step body runs
+ * exactly once. That single-handler guarantee ends as soon as the run creates a
+ * hook or wait (which introduce resume/parallel invocations), so the runtime
+ * exits turbo at that point.
+ *
+ * Reads `process.env.WORKFLOW_TURBO` lazily. Default **ON**; disabled only by an
+ * explicit `'0'` / `'false'` (case-insensitive).
+ */
+export function isTurboEnabled(): boolean {
+ const raw = process.env.WORKFLOW_TURBO;
+ if (raw === undefined || raw === '') return true;
+ return !(raw === '0' || raw.toLowerCase() === 'false');
+}
+
// A replay-consumer mismatch can be caused by a transient divergent replay
// rather than an invalid persisted history. Queue bounded recovery replays
// before recording terminal corruption for a run that cannot replay.
diff --git a/packages/core/src/runtime/step-executor.ts b/packages/core/src/runtime/step-executor.ts
index 96386c3032..352c0cc0d5 100644
--- a/packages/core/src/runtime/step-executor.ts
+++ b/packages/core/src/runtime/step-executor.ts
@@ -33,7 +33,10 @@ import {
promoteAbortErrorToFatal,
} from '../types.js';
-import { isOptimisticInlineStartEnabled } from './constants.js';
+import {
+ isOptimisticInlineStartEnabled,
+ isOptimisticInlineStartExplicitlyDisabled,
+} from './constants.js';
import { getPortLazy } from './get-port-lazy.js';
import { memoizeEncryptionKey } from './helpers.js';
import { safeWaitUntil } from './wait-until.js';
@@ -99,6 +102,23 @@ export interface StepExecutorParams {
* handler is the sole inline writer for the run on this iteration.
*/
inlineDeltaSinceCursor?: string;
+ /**
+ * Force optimistic inline start regardless of
+ * `WORKFLOW_OPTIMISTIC_INLINE_START`. Set by turbo mode on the first delivery
+ * of the first invocation, where forcing it is safe: there is no concurrent
+ * peer handler to race the create-claim, so the body runs exactly once. Only
+ * meaningful together with `lazyStepInput` (a brand-new lazy step).
+ */
+ forceOptimisticStart?: boolean;
+ /**
+ * Turbo mode only: a promise that resolves once the backgrounded
+ * `run_started` has landed. When set, the lazy/optimistic `step_started` is
+ * chained on it so the step is never created before its run exists. The body
+ * still runs immediately against locally-synthesized state — only the network
+ * write waits — so the `run_started` round-trip overlaps the body. `undefined`
+ * outside turbo, where `run_started` was already awaited up front.
+ */
+ runReadyBarrier?: Promise;
}
/**
@@ -201,6 +221,13 @@ export async function executeStep(
// return `skipped` and never write the failure twice.
if (params.lazyStepInput !== undefined) {
try {
+ // Turbo: this lazy `step_started` must not precede the backgrounded
+ // `run_started`. Order it after the run-ready barrier (best-effort —
+ // a barrier rejection means the run doesn't exist, and the create
+ // below surfaces the real error). No-op outside turbo.
+ if (params.runReadyBarrier) {
+ await params.runReadyBarrier.catch(() => {});
+ }
await world.events.create(workflowRunId, {
eventType: 'step_started',
specVersion: SPEC_VERSION_CURRENT,
@@ -310,8 +337,18 @@ export async function executeStep(
// discard the body result. Running the body before confirming ownership can
// execute a step more than once when handlers race — inline step bodies
// must be idempotent; disable via WORKFLOW_OPTIMISTIC_INLINE_START=0.
+ //
+ // Turbo mode passes `forceOptimisticStart` to enable this regardless of the
+ // env flag (its single-handler guarantee removes the race). But it still
+ // defers to an *explicit* `WORKFLOW_OPTIMISTIC_INLINE_START=0`: forced
+ // optimistic start runs the body before `step_started`/`run_started` is
+ // confirmed, which is exactly the property an operator opts out of with that
+ // flag, so an explicit opt-out wins over turbo's force.
const optimisticStart =
- params.lazyStepInput !== undefined && isOptimisticInlineStartEnabled();
+ params.lazyStepInput !== undefined &&
+ (isOptimisticInlineStartEnabled() ||
+ (params.forceOptimisticStart === true &&
+ !isOptimisticInlineStartExplicitlyDisabled()));
let step: Step;
// Settled outcome of the in-flight optimistic `step_started`. Handlers are
@@ -338,12 +375,20 @@ export async function executeStep(
};
if (optimisticStart) {
- const startedPromise = world.events.create(workflowRunId, {
- eventType: 'step_started',
- specVersion: SPEC_VERSION_CURRENT,
- correlationId: stepId,
- eventData: { stepName, workflowName, input: params.lazyStepInput },
- });
+ // Chain the lazy `step_started` on the run-ready barrier (turbo mode):
+ // the step can't be created before its run exists, but the body below
+ // runs immediately against synthesized state, so the `run_started`
+ // round-trip overlaps the body rather than blocking it. Outside turbo the
+ // barrier is undefined and this is a plain create.
+ const startedPromise = (params.runReadyBarrier ?? Promise.resolve()).then(
+ () =>
+ world.events.create(workflowRunId, {
+ eventType: 'step_started',
+ specVersion: SPEC_VERSION_CURRENT,
+ correlationId: stepId,
+ eventData: { stepName, workflowName, input: params.lazyStepInput },
+ })
+ );
optimisticStartSettled = startedPromise.then(
() => ({ ok: true as const }),
(err) => ({ ok: false as const, err })
@@ -474,6 +519,11 @@ export async function executeStep(
return { type: 'failed' };
}
+ // Ops that must be durably committed before step completion (e.g. a
+ // step-initiated abort's hook_received event). See StepContext. Declared
+ // outside the try so the failure path below can also drain them.
+ const preCompletionOps: Promise[] = [];
+
try {
const attempt = step.attempt;
@@ -535,6 +585,7 @@ export async function executeStep(
},
workflowDeploymentId: params.workflowDeploymentId,
ops,
+ preCompletionOps,
closureVars: hydratedInput.closureVars,
encryptionKey,
},
@@ -608,11 +659,31 @@ export async function executeStep(
// Optimistic start: the body ran before `step_started` was confirmed.
// Reconcile it now — if we lost the create-claim (or the run is
// gone/throttled) discard this result and don't write step_completed.
+ // Reconcile before draining preCompletionOps: a discarded result means
+ // the winning handler owns the outcome (and re-fires any abort
+ // idempotently), so there's no point paying the abort-commit latency.
if (optimisticStart) {
const reconcile = await reconcileOptimisticStart();
if (reconcile) return reconcile;
}
+ // Commit must-be-durable ops (e.g. a step-initiated abort's
+ // hook_received event) before writing step_completed, so any workflow
+ // continuation triggered by that event observes the abort rather than
+ // racing it. These ops swallow their own errors, so awaiting only
+ // enforces ordering (the `.catch()` defends the no-reject contract on
+ // StepContext.preCompletionOps).
+ //
+ // Tradeoff: correctness requires the hook be durable before completion,
+ // so — unlike the background `ops` flush above — this cannot be capped
+ // with a resolve-on-timeout race. A slow resume therefore adds its
+ // latency to a step that aborts a controller, and a true hang holds
+ // completion until the platform/queue execution timeout fires; the queue
+ // then redelivers and the step retries, re-firing the abort idempotently.
+ if (preCompletionOps.length > 0) {
+ await Promise.all(preCompletionOps).catch(() => {});
+ }
+
// Create step_completed event. When the caller supplied a
// sinceCursor (inline sequential execution), thread it through so a
// supporting World returns the event-log delta on the result,
@@ -682,12 +753,23 @@ export async function executeStep(
// Optimistic start: the body threw before `step_started` was confirmed.
// Reconcile first — if we lost the create-claim (or the run is
// gone/throttled) the body error is moot; discard it and don't write a
- // terminal event (the winning handler owns the outcome).
+ // terminal event (the winning handler owns the outcome). Reconcile
+ // before draining preCompletionOps for the same reason as the success
+ // path: a discarded outcome doesn't need the abort committed here.
if (optimisticStart) {
const reconcile = await reconcileOptimisticStart();
if (reconcile) return reconcile;
}
+ // Order any must-be-durable ops (e.g. a step-initiated abort's
+ // hook_received event) ahead of step_failed too — a step that aborts and
+ // then throws must still have the abort recorded before the failure
+ // continuation observes it. Same latency tradeoff and no-reject contract
+ // as the success path above. See StepContext.preCompletionOps.
+ if (preCompletionOps.length > 0) {
+ await Promise.all(preCompletionOps).catch(() => {});
+ }
+
const effectiveErr = promoteAbortErrorToFatal(err);
const normalizedError = await normalizeUnknownError(effectiveErr);
diff --git a/packages/core/src/runtime/step-handler.test.ts b/packages/core/src/runtime/step-handler.test.ts
index b4cb64df43..dc5a4bd670 100644
--- a/packages/core/src/runtime/step-handler.test.ts
+++ b/packages/core/src/runtime/step-handler.test.ts
@@ -1294,4 +1294,96 @@ describe('executeStep optimistic inline start', () => {
expect(result.type).toBe('skipped');
expect(mockStepFn).not.toHaveBeenCalled();
});
+
+ it('forces optimistic start via forceOptimisticStart when the flag is unset', async () => {
+ // Turbo passes forceOptimisticStart; with the env var UNSET (off by default
+ // but not an explicit opt-out), turbo forces optimistic start on.
+ delete process.env.WORKFLOW_OPTIMISTIC_INLINE_START;
+ mockEventsCreate
+ .mockReset()
+ .mockImplementation((_runId: string, event: { eventType: string }) => {
+ if (event.eventType === 'step_started') {
+ return Promise.reject(new EntityConflictError('lost create race'));
+ }
+ return Promise.resolve({ event: {} });
+ });
+
+ const world = await getWorld();
+ const result = await executeStep({
+ world: world as never,
+ ...baseParams,
+ forceOptimisticStart: true,
+ });
+
+ // Forced optimistic: the body ran before the (lost) create-claim resolved —
+ // unlike the env-disabled case above, where the body never runs.
+ expect(mockStepFn).toHaveBeenCalledTimes(1);
+ expect(result.type).toBe('skipped');
+ });
+
+ it('forceOptimisticStart defers to an EXPLICIT WORKFLOW_OPTIMISTIC_INLINE_START=0', async () => {
+ // An operator who explicitly set the flag to 0 has opted out of "body runs
+ // before start is confirmed"; that opt-out wins over turbo's force, so the
+ // step takes the await-then-run path and the body never runs on a 409.
+ process.env.WORKFLOW_OPTIMISTIC_INLINE_START = '0';
+ mockEventsCreate
+ .mockReset()
+ .mockImplementation((_runId: string, event: { eventType: string }) => {
+ if (event.eventType === 'step_started') {
+ return Promise.reject(new EntityConflictError('already running'));
+ }
+ return Promise.resolve({ event: {} });
+ });
+
+ const world = await getWorld();
+ const result = await executeStep({
+ world: world as never,
+ ...baseParams,
+ forceOptimisticStart: true,
+ });
+
+ expect(result.type).toBe('skipped');
+ expect(mockStepFn).not.toHaveBeenCalled();
+ });
+
+ it('holds the optimistic step_started until runReadyBarrier resolves, but runs the body immediately', async () => {
+ delete process.env.WORKFLOW_OPTIMISTIC_INLINE_START;
+ let release!: () => void;
+ const barrier = new Promise((r) => {
+ release = r;
+ });
+ const calls: string[] = [];
+ mockEventsCreate
+ .mockReset()
+ .mockImplementation((_runId: string, event: { eventType: string }) => {
+ calls.push(event.eventType);
+ return Promise.resolve({ event: {} });
+ });
+ mockStepFn.mockReset().mockImplementation(async () => {
+ calls.push('body');
+ return 'step-result';
+ });
+
+ const world = await getWorld();
+ const resultPromise = executeStep({
+ world: world as never,
+ ...baseParams,
+ forceOptimisticStart: true,
+ runReadyBarrier: barrier,
+ });
+
+ // The body runs immediately against synthesized state; step_started is NOT
+ // issued until the run-ready barrier resolves. Widen the default 1s
+ // vi.waitFor timeout — reaching the body can be slow on cold CI (Windows).
+ await vi.waitFor(() => expect(calls).toContain('body'), {
+ timeout: 15_000,
+ });
+ expect(calls).not.toContain('step_started');
+
+ release();
+ const result = await resultPromise;
+ expect(result.type).toBe('completed');
+ expect(calls).toContain('step_started');
+ expect(calls.indexOf('body')).toBeLessThan(calls.indexOf('step_started'));
+ });
});
diff --git a/packages/core/src/runtime/step-handler.ts b/packages/core/src/runtime/step-handler.ts
index 041eecfc68..2a8ba2631f 100644
--- a/packages/core/src/runtime/step-handler.ts
+++ b/packages/core/src/runtime/step-handler.ts
@@ -615,6 +615,9 @@ function createStepHandler(namespace?: string) {
// operations (e.g., stream loading) are added to `ops` and executed later
// via Promise.all(ops) - their timing is not included in this measurement.
const ops: Promise[] = [];
+ // Ops that must be durably committed before step completion (e.g. a
+ // step-initiated abort's hook_received event). See StepContext.
+ const preCompletionOps: Promise[] = [];
const hydratedInput = await trace(
'step.hydrate',
{},
@@ -672,6 +675,7 @@ function createStepHandler(namespace?: string) {
},
workflowDeploymentId: process.env.VERCEL_DEPLOYMENT_ID,
ops,
+ preCompletionOps,
closureVars: hydratedInput.closureVars,
encryptionKey,
},
@@ -686,6 +690,19 @@ function createStepHandler(namespace?: string) {
cancelAbortReaders(...args, thisVal, hydratedInput.closureVars);
+ // Commit must-be-durable ops (e.g. a step-initiated abort's
+ // hook_received event) before writing step_completed/step_failed,
+ // so any workflow continuation triggered by that event observes the
+ // abort rather than racing it. Producers swallow their own errors
+ // per the no-reject contract on StepContext.preCompletionOps; the
+ // `.catch()` defends it, since this await sits outside the
+ // user-code try/catch (a stray rejection here would otherwise
+ // surface as an infra error → queue re-delivery, not a step
+ // failure). The await only enforces ordering.
+ if (preCompletionOps.length > 0) {
+ await Promise.all(preCompletionOps).catch(() => {});
+ }
+
span?.setAttributes({
...Attribute.QueueExecutionTimeMs(executionTimeMs),
});
diff --git a/packages/core/src/runtime/suspension-handler.ts b/packages/core/src/runtime/suspension-handler.ts
index e31b8ba42a..bca1453693 100644
--- a/packages/core/src/runtime/suspension-handler.ts
+++ b/packages/core/src/runtime/suspension-handler.ts
@@ -35,6 +35,16 @@ export interface SuspensionHandlerParams {
run: WorkflowRun;
span?: Span;
requestId?: string;
+ /**
+ * Turbo mode only: a promise that resolves once the backgrounded
+ * `run_started` has landed (the run exists). When present, every world write
+ * this suspension performs (`hook_created`, `wait_created`, eager overflow
+ * `step_created`, …) is gated on it so the write never races ahead of the
+ * run's creation. The pure inline hot path defers all of its steps and writes
+ * nothing here, so it never awaits this barrier. `undefined` outside turbo,
+ * where `run_started` was already awaited up front.
+ */
+ runReadyBarrier?: Promise;
}
/**
@@ -85,6 +95,14 @@ export interface SuspensionHandlerResult {
hasAwaitedHookCreation: boolean;
/** Whether native workflow attribute events were written for replay. */
hasAttributeEvents: boolean;
+ /**
+ * Whether this suspension created any hook (`hook_created`) events. Unlike
+ * `hasHookConflict` / `hasAwaitedHookCreation`, this is true even for a plain
+ * fire-and-forget hook with no conflict and no awaiter. Turbo mode uses it to
+ * detect "a hook was created this suspension" and stop forcing optimistic
+ * inline start (a hook introduces later resume invocations that could race).
+ */
+ hasHookEvents: boolean;
}
async function createHookEvent({
@@ -164,8 +182,26 @@ export async function handleSuspension({
run,
span,
requestId,
+ runReadyBarrier,
}: SuspensionHandlerParams): Promise {
const runId = run.runId;
+ // Turbo mode: hold every world write below until the backgrounded
+ // `run_started` has *settled*, so we never write a step/hook/wait event for a
+ // run that does not exist yet. A no-op outside turbo (barrier undefined) and
+ // on the pure inline hot path, which defers all steps and writes nothing.
+ // Awaiting the same (usually already-settled) promise more than once is cheap.
+ // A barrier rejection is swallowed for ordering only: if `run_started` truly
+ // failed the run does not exist, so the subsequent write surfaces the real
+ // error (run not found / gone) and the message redelivers.
+ const ensureRunReady = async (): Promise => {
+ if (runReadyBarrier) {
+ try {
+ await runReadyBarrier;
+ } catch {
+ // intentional: ordering barrier only — see above.
+ }
+ }
+ };
// Separate queue items by type
const stepItems = suspension.steps.filter(
(item): item is StepInvocationQueueItem => item.type === 'step'
@@ -234,6 +270,7 @@ export async function handleSuspension({
let hasAwaitedHookCreation = false;
if (hookEvents.length > 0) {
+ await ensureRunReady();
const results = await Promise.all(
hookEvents.map(({ hookEvent, queueItem }) =>
createHookEvent({
@@ -253,6 +290,7 @@ export async function handleSuspension({
// Process hook disposals — these release hook tokens for reuse by other workflows.
if (hooksNeedingDisposal.length > 0) {
+ await ensureRunReady();
await Promise.all(
hooksNeedingDisposal.map(async (queueItem) => {
const hookDisposedEvent: CreateEventRequest = {
@@ -306,6 +344,7 @@ export async function handleSuspension({
);
if (hooksNeedingAbort.length > 0) {
+ await ensureRunReady();
await Promise.all(
hooksNeedingAbort.map(async (queueItem) => {
try {
@@ -459,6 +498,7 @@ export async function handleSuspension({
},
};
try {
+ await ensureRunReady();
await world.events.create(runId, stepEvent, { requestId });
createdStepCorrelationIds.add(queueItem.correlationId);
} catch (err) {
@@ -491,6 +531,7 @@ export async function handleSuspension({
},
};
try {
+ await ensureRunReady();
await world.events.create(runId, waitEvent, { requestId });
} catch (err) {
if (EntityConflictError.is(err)) {
@@ -512,6 +553,7 @@ export async function handleSuspension({
ops.push(
(async () => {
try {
+ await ensureRunReady();
await world.events.create(
runId,
{
@@ -612,6 +654,7 @@ export async function handleSuspension({
hasHookConflict,
hasAwaitedHookCreation,
hasAttributeEvents: attributeItems.length > 0,
+ hasHookEvents: hookEvents.length > 0,
};
}
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index 399a2a2ba9..603bfa7120 100644
--- a/packages/core/src/serialization.ts
+++ b/packages/core/src/serialization.ts
@@ -659,26 +659,44 @@ export function createReconnectingFramedStream(
}
async function reconnect(): Promise {
- reconnectCount++;
- totalReconnectCount++;
- if (reconnectCount > FRAMED_STREAM_MAX_RECONNECTS) {
- throw new Error(
- `Stream "${name}" exceeded maximum reconnection attempts (${FRAMED_STREAM_MAX_RECONNECTS})`
- );
- }
- if (totalReconnectCount > FRAMED_STREAM_MAX_TOTAL_RECONNECTS) {
- throw new Error(
- `Stream "${name}" exceeded maximum total reconnection attempts (${FRAMED_STREAM_MAX_TOTAL_RECONNECTS})`
- );
- }
if (reader) {
await reader.cancel().catch(() => {});
reader = undefined;
}
+ // Advance the resume position past the frames already delivered, then
+ // drop any partial-frame bytes — the reopened connection re-sends from a
+ // frame boundary at the new index.
currentStartIndex += consumedFrames;
consumedFrames = 0;
buffer = new Uint8Array(0);
- await connect();
+
+ // Retry the reopen itself against the reconnect budget. A transient
+ // failure of connect() — the server briefly unavailable during the
+ // reconnect window — is the exact blip this wrapper exists to survive, so
+ // count it against the budget and try again rather than treating it as
+ // fatal. Only budget exhaustion (a server that stays down) terminates the
+ // stream.
+ for (;;) {
+ reconnectCount++;
+ totalReconnectCount++;
+ if (reconnectCount > FRAMED_STREAM_MAX_RECONNECTS) {
+ throw new Error(
+ `Stream "${name}" exceeded maximum reconnection attempts (${FRAMED_STREAM_MAX_RECONNECTS})`
+ );
+ }
+ if (totalReconnectCount > FRAMED_STREAM_MAX_TOTAL_RECONNECTS) {
+ throw new Error(
+ `Stream "${name}" exceeded maximum total reconnection attempts (${FRAMED_STREAM_MAX_TOTAL_RECONNECTS})`
+ );
+ }
+ try {
+ await connect();
+ return;
+ } catch {
+ // Reopen failed transiently; loop to retry, counting against the
+ // budget so a server that never recovers still terminates the stream.
+ }
+ }
}
return new ReadableStream({
@@ -1745,21 +1763,32 @@ function reviveAbortController(
);
if (value.hookToken) {
- ctx.ops.push(
- (async () => {
- try {
- const { resumeHook: resumeHookFn } = await import(
- './runtime/resume-hook.js'
- );
- await resumeHookFn(value.hookToken, {
- aborted: true,
- reason,
- });
- } catch {
- // Best-effort hook resume — retry on next replay
- }
- })()
- );
+ // The durable hook resume (which writes the `hook_received` event that
+ // records this abort in the workflow's event log) must be committed
+ // before the step completes. Otherwise the workflow continuation
+ // enqueued by `step_completed` can advance past the abort — dispatching
+ // a later step with a stale, non-aborted `signal` — before the event
+ // exists. Route it to `preCompletionOps` (awaited inline before
+ // completion) rather than `ops` (best-effort, background). The stream
+ // write above stays in `ops`: it must fire ASAP to reach an in-flight
+ // sibling step and is not the durable record.
+ // Swallow errors here so the promise can only ever enforce ordering
+ // when awaited (see the no-reject contract on
+ // StepContext.preCompletionOps); a failed resume retries on next replay.
+ const hookResume = (async () => {
+ try {
+ const { resumeHook: resumeHookFn } = await import(
+ './runtime/resume-hook.js'
+ );
+ await resumeHookFn(value.hookToken, {
+ aborted: true,
+ reason,
+ });
+ } catch {
+ // Best-effort hook resume — retry on next replay
+ }
+ })();
+ ctx.preCompletionOps.push(hookResume);
}
}
};
diff --git a/packages/core/src/step-hydration-cache.test.ts b/packages/core/src/step-hydration-cache.test.ts
new file mode 100644
index 0000000000..2010719813
--- /dev/null
+++ b/packages/core/src/step-hydration-cache.test.ts
@@ -0,0 +1,184 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ createStepHydrationCache,
+ getOrHydrateStepReturnValue,
+ isMemoizablePrimitive,
+ MAX_MEMOIZED_PRIMITIVE_LENGTH,
+} from './step-hydration-cache.js';
+
+describe('isMemoizablePrimitive', () => {
+ it('returns true for primitives', () => {
+ expect(isMemoizablePrimitive('hello')).toBe(true);
+ expect(isMemoizablePrimitive(42)).toBe(true);
+ expect(isMemoizablePrimitive(0)).toBe(true);
+ expect(isMemoizablePrimitive(true)).toBe(true);
+ expect(isMemoizablePrimitive(false)).toBe(true);
+ expect(isMemoizablePrimitive(null)).toBe(true);
+ expect(isMemoizablePrimitive(undefined)).toBe(true);
+ expect(isMemoizablePrimitive(10n)).toBe(true);
+ expect(isMemoizablePrimitive(Symbol('x'))).toBe(true);
+ });
+
+ it('returns false for objects, arrays, and functions', () => {
+ expect(isMemoizablePrimitive({})).toBe(false);
+ expect(isMemoizablePrimitive({ a: 1 })).toBe(false);
+ expect(isMemoizablePrimitive([])).toBe(false);
+ expect(isMemoizablePrimitive([1, 2, 3])).toBe(false);
+ expect(isMemoizablePrimitive(() => {})).toBe(false);
+ expect(isMemoizablePrimitive(new Date())).toBe(false);
+ expect(isMemoizablePrimitive(new Map())).toBe(false);
+ });
+
+ it('memoizes a string at the length bound but not beyond it', () => {
+ const atBound = 'x'.repeat(MAX_MEMOIZED_PRIMITIVE_LENGTH);
+ const overBound = 'x'.repeat(MAX_MEMOIZED_PRIMITIVE_LENGTH + 1);
+ expect(isMemoizablePrimitive(atBound)).toBe(true);
+ expect(isMemoizablePrimitive(overBound)).toBe(false);
+ });
+
+ it('bounds oversized bigints by their decimal length', () => {
+ const overBound = BigInt('9'.repeat(MAX_MEMOIZED_PRIMITIVE_LENGTH + 1));
+ expect(isMemoizablePrimitive(overBound)).toBe(false);
+ // A small bigint is always memoizable.
+ expect(isMemoizablePrimitive(123n)).toBe(true);
+ });
+});
+
+describe('getOrHydrateStepReturnValue', () => {
+ it('hydrates on a miss and returns the value', async () => {
+ const cache = createStepHydrationCache();
+ const hydrate = vi.fn().mockResolvedValue('result');
+ const value = await getOrHydrateStepReturnValue(cache, 'evnt_0', hydrate);
+ expect(value).toBe('result');
+ expect(hydrate).toHaveBeenCalledTimes(1);
+ });
+
+ it('memoizes a primitive: second call with same eventId does not re-hydrate', async () => {
+ const cache = createStepHydrationCache();
+ const hydrate = vi.fn().mockResolvedValue('result');
+
+ const first = await getOrHydrateStepReturnValue(cache, 'evnt_0', hydrate);
+ const second = await getOrHydrateStepReturnValue(cache, 'evnt_0', hydrate);
+
+ expect(first).toBe('result');
+ expect(second).toBe('result');
+ // The expensive hydrate must only run once across replays.
+ expect(hydrate).toHaveBeenCalledTimes(1);
+ });
+
+ it('memoizes falsy primitives (0, false, "", null, undefined) as hits', async () => {
+ for (const sample of [0, false, '', null, undefined]) {
+ const cache = createStepHydrationCache();
+ const hydrate = vi.fn().mockResolvedValue(sample);
+ const first = await getOrHydrateStepReturnValue(cache, 'evnt', hydrate);
+ const second = await getOrHydrateStepReturnValue(cache, 'evnt', hydrate);
+ expect(first).toBe(sample);
+ expect(second).toBe(sample);
+ expect(hydrate).toHaveBeenCalledTimes(1);
+ }
+ });
+
+ it('does NOT memoize non-primitives: re-hydrates a fresh object each replay', async () => {
+ const cache = createStepHydrationCache();
+ // Return a fresh object every call so we can assert distinct references.
+ const hydrate = vi.fn().mockImplementation(async () => ({ count: 0 }));
+
+ const first = (await getOrHydrateStepReturnValue(
+ cache,
+ 'evnt_0',
+ hydrate
+ )) as { count: number };
+ // Simulate workflow code mutating the result on this replay.
+ first.count++;
+
+ const second = (await getOrHydrateStepReturnValue(
+ cache,
+ 'evnt_0',
+ hydrate
+ )) as { count: number };
+
+ // A fresh object must be produced — the mutation from the first replay
+ // must NOT leak into the second.
+ expect(hydrate).toHaveBeenCalledTimes(2);
+ expect(second).not.toBe(first);
+ expect(second.count).toBe(0);
+ });
+
+ it('memoizes a string at the length bound (cache hit on replay)', async () => {
+ const cache = createStepHydrationCache();
+ const atBound = 'x'.repeat(MAX_MEMOIZED_PRIMITIVE_LENGTH);
+ const hydrate = vi.fn().mockResolvedValue(atBound);
+
+ const first = await getOrHydrateStepReturnValue(cache, 'evnt_0', hydrate);
+ const second = await getOrHydrateStepReturnValue(cache, 'evnt_0', hydrate);
+
+ expect(first).toBe(atBound);
+ expect(second).toBe(atBound);
+ expect(hydrate).toHaveBeenCalledTimes(1);
+ expect(cache.size).toBe(1);
+ });
+
+ it('does NOT memoize an oversized string: re-hydrates every replay and stays unbounded-free', async () => {
+ const cache = createStepHydrationCache();
+ const big = 'x'.repeat(MAX_MEMOIZED_PRIMITIVE_LENGTH + 1);
+ const hydrate = vi.fn().mockResolvedValue(big);
+
+ const first = await getOrHydrateStepReturnValue(cache, 'evnt_0', hydrate);
+ const second = await getOrHydrateStepReturnValue(cache, 'evnt_0', hydrate);
+
+ // Correct value still returned, but the large payload is never retained:
+ // it falls through to a fresh re-hydrate on every replay.
+ expect(first).toBe(big);
+ expect(second).toBe(big);
+ expect(hydrate).toHaveBeenCalledTimes(2);
+ expect(cache.size).toBe(0);
+ });
+
+ it('keys by eventId: different events hydrate independently', async () => {
+ const cache = createStepHydrationCache();
+ const hydrate = vi
+ .fn()
+ .mockResolvedValueOnce('a')
+ .mockResolvedValueOnce('b');
+
+ const a = await getOrHydrateStepReturnValue(cache, 'evnt_0', hydrate);
+ const b = await getOrHydrateStepReturnValue(cache, 'evnt_1', hydrate);
+
+ expect(a).toBe('a');
+ expect(b).toBe('b');
+ expect(hydrate).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not cache when no cache is provided', async () => {
+ const hydrate = vi.fn().mockResolvedValue('result');
+ await getOrHydrateStepReturnValue(undefined, 'evnt_0', hydrate);
+ await getOrHydrateStepReturnValue(undefined, 'evnt_0', hydrate);
+ expect(hydrate).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not cache when eventId is undefined', async () => {
+ const cache = createStepHydrationCache();
+ const hydrate = vi.fn().mockResolvedValue('result');
+ await getOrHydrateStepReturnValue(cache, undefined, hydrate);
+ await getOrHydrateStepReturnValue(cache, undefined, hydrate);
+ expect(hydrate).toHaveBeenCalledTimes(2);
+ expect(cache.size).toBe(0);
+ });
+
+ it('does not cache rejected hydrations: re-attempts on the next call', async () => {
+ const cache = createStepHydrationCache();
+ const hydrate = vi
+ .fn()
+ .mockRejectedValueOnce(new Error('boom'))
+ .mockResolvedValueOnce('recovered');
+
+ await expect(
+ getOrHydrateStepReturnValue(cache, 'evnt_0', hydrate)
+ ).rejects.toThrow('boom');
+
+ // A subsequent replay re-attempts (no parked rejected promise).
+ const value = await getOrHydrateStepReturnValue(cache, 'evnt_0', hydrate);
+ expect(value).toBe('recovered');
+ expect(hydrate).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/core/src/step-hydration-cache.ts b/packages/core/src/step-hydration-cache.ts
new file mode 100644
index 0000000000..b2a93144e2
--- /dev/null
+++ b/packages/core/src/step-hydration-cache.ts
@@ -0,0 +1,188 @@
+/**
+ * Per-run memoization cache for hydrated step return values.
+ *
+ * ## Why
+ *
+ * The inline replay loop (`runtime.ts`) re-runs the workflow body from the top
+ * on every iteration, re-consuming the full event log each time. For every
+ * already-completed step, the step consumer (`step.ts`) re-runs
+ * `hydrateStepReturnValue` — which AES-GCM-decrypts and devalue-parses the
+ * serialized result — even though that exact result was already hydrated on
+ * every prior replay. For a sequential workflow of N steps, replay K hydrates
+ * K results, so the total work across a single invocation is O(N²)
+ * decrypt+parse operations.
+ *
+ * This cache makes a completed step's hydrated result available in O(1) on
+ * subsequent replays within the SAME invocation, turning the aggregate cost
+ * into O(N).
+ *
+ * ## Scope / lifetime
+ *
+ * The cache is owned by the inline loop in `runtime.ts` (one per workflow run
+ * invocation) and passed into `runWorkflow` so it survives across the loop's
+ * iterations but never leaks across unrelated runs or process-level
+ * invocations. A fresh `runWorkflow` / `WorkflowOrchestratorContext` is created
+ * each iteration, so the cache must live OUTSIDE the per-iteration context.
+ *
+ * ## Keying
+ *
+ * Entries are keyed by the persisted event's `eventId` — a stable,
+ * world-assigned identifier for the `step_completed` event whose serialized
+ * `result` is being hydrated. The same event (same `eventId`) carries the same
+ * immutable serialized bytes across every replay, so a hit is guaranteed to
+ * correspond to the identical input.
+ *
+ * ## Identity safety (why primitives only)
+ *
+ * `hydrateStepReturnValue` (devalue.parse) produces a FRESH object graph on
+ * every call, and each replay iteration runs in a FRESH workflow VM. The
+ * current (uncached) behavior therefore hands the workflow a brand-new value
+ * on every replay. If we cached and returned the SAME object reference across
+ * replays, workflow code that mutates a step result (`const r = await step();
+ * r.count++`) would observe the mutation from a previous replay on the next
+ * replay — a non-deterministic divergence. Structured-cloning on each hit is
+ * both lossy (revivers reconstruct stream handles, step-function proxies,
+ * Request/Response, and AbortController/AbortSignal class instances that don't
+ * survive a structured clone) and still O(size).
+ *
+ * So we only cache values for which returning the same reference on every
+ * replay is provably indistinguishable from re-hydrating: JavaScript
+ * primitives (string, number, boolean, bigint, symbol, null, undefined).
+ * Primitives are immutable and compared by value, so sharing the reference is
+ * byte-for-byte equivalent to re-parsing. Any non-primitive result falls
+ * through to a full re-hydrate every replay, preserving current behavior
+ * exactly. This trades some of the optimization away in the object-returning
+ * case in exchange for keeping deterministic replay airtight.
+ *
+ * ## Memory characteristic
+ *
+ * Cached entries hold the decrypted/devalue-parsed *plaintext* of a step
+ * result, which is retained for the rest of the invocation on top of the
+ * serialized bytes already held in `cachedEvents`. So the residual cost is:
+ *
+ * - **Scoped to one workflow-run invocation.** A fresh `Map` is created per
+ * invocation (in `runtime.ts`) and is unreachable / GC'd when the invocation
+ * returns. Nothing accumulates across runs or across process-level
+ * invocations.
+ * - **Bounded by the number of primitive-returning completed steps in that
+ * run** — at most one small entry per such step.
+ * - **Primitives only, and additionally byte-bounded.** Most primitives
+ * (numbers, booleans, null/undefined, symbols, short ids/strings) are tiny
+ * and fixed-size. The only primitive that can be large is a string (or a
+ * pathologically long bigint), so to keep the doubled-residency worst case
+ * bounded we *do not* memoize string/bigint results whose character length
+ * exceeds {@link MAX_MEMOIZED_PRIMITIVE_LENGTH}. A large string is cheap to
+ * re-hydrate relative to its footprint, so letting it fall through to the
+ * existing per-replay re-hydrate path costs little and caps peak retained
+ * memory.
+ *
+ * (This is a much weaker concern than a *process-wide* cache: the dominant
+ * residency — the full event log in `cachedEvents` — already exists for the
+ * same lifetime, and everything here is freed together with it when the
+ * invocation ends.)
+ */
+
+/**
+ * Upper bound, in characters, on a string/bigint primitive that may be
+ * memoized. Beyond this, the value falls through to a fresh re-hydrate on every
+ * replay so the cache never holds a large plaintext payload for the lifetime of
+ * the invocation. 4 KiB comfortably covers ids, counts, flags, and typical
+ * short string results while excluding the large-payload case the bound exists
+ * to guard. Other primitive types (number, boolean, symbol, null, undefined)
+ * are inherently small and are never length-checked.
+ */
+export const MAX_MEMOIZED_PRIMITIVE_LENGTH = 4096;
+
+/**
+ * Returns true for values that are safe to memoize and return by reference
+ * across replays: JS primitives. Objects and functions are excluded because
+ * sharing a mutable reference across replays could change observable behavior.
+ *
+ * Strings and bigints are additionally bounded by length: a value longer than
+ * {@link MAX_MEMOIZED_PRIMITIVE_LENGTH} characters is treated as non-memoizable
+ * so the cache never retains a large plaintext payload for the whole invocation
+ * (see the module-level "Memory characteristic" docs). It re-hydrates fresh on
+ * every replay instead — cheap relative to its footprint.
+ *
+ * Note: `typeof null === 'object'`, so it is handled explicitly. `undefined`,
+ * `string`, `number`, `boolean`, `bigint`, and `symbol` are all primitives.
+ */
+export function isMemoizablePrimitive(value: unknown): boolean {
+ if (value === null) return true;
+ const t = typeof value;
+ if (t === 'object' || t === 'function') return false;
+ // Bound the only primitive types that can carry a large payload.
+ if (t === 'string') {
+ return (value as string).length <= MAX_MEMOIZED_PRIMITIVE_LENGTH;
+ }
+ if (t === 'bigint') {
+ return (value as bigint).toString().length <= MAX_MEMOIZED_PRIMITIVE_LENGTH;
+ }
+ return true;
+}
+
+/**
+ * Cache of hydrated step return values for a single workflow run invocation.
+ *
+ * Keyed by `step_completed` event id; the value is the already-hydrated
+ * primitive result. Only successful, primitive hydrations are stored (see
+ * {@link getOrHydrateStepReturnValue}), so a non-`undefined` `has(eventId)`
+ * always means "this step completed with a memoizable primitive value".
+ */
+export type StepHydrationCache = Map;
+
+/**
+ * Create an empty per-invocation step hydration cache.
+ */
+export function createStepHydrationCache(): StepHydrationCache {
+ return new Map();
+}
+
+/**
+ * Return the hydrated step result for `eventId`, using `cache` as a per-run
+ * memo. On a hit, the cached primitive is returned without re-running the
+ * expensive decrypt + devalue-parse. On a miss, `hydrate()` runs and its
+ * result is memoized only when it is a small primitive (see the module docs for
+ * the identity-safety rationale and the length bound on string/bigint results).
+ *
+ * This always returns a `Promise` and `await`s `hydrate()` even on the miss
+ * path, so the caller's `await` inside its serial `promiseQueue` slot keeps the
+ * same scheduling on both hit and miss — a cache hit resolves through the exact
+ * same promise-chain position a re-hydrate would have, preserving the
+ * deterministic delivery order that `pendingDeliveries`, the delivery barriers,
+ * and `Promise.race`/`Promise.all` replay all depend on.
+ *
+ * `has(eventId)` is used rather than `get(eventId) !== undefined` so that a
+ * legitimately memoized `undefined` step result still registers as a hit.
+ *
+ * When `cache` or `eventId` is absent (lightweight test harnesses, or a context
+ * that predates this plumbing), this degrades to calling `hydrate()` directly
+ * with no memoization — identical to the previous behavior.
+ *
+ * Errors are intentionally never cached: a rejected hydrate propagates to the
+ * caller (which rejects the step promise) and the next replay re-attempts it,
+ * matching the uncached behavior and avoiding a parked rejected promise.
+ */
+export async function getOrHydrateStepReturnValue(
+ cache: StepHydrationCache | undefined,
+ eventId: string | undefined,
+ hydrate: () => Promise
+): Promise {
+ if (!cache || eventId === undefined) {
+ return hydrate();
+ }
+
+ if (cache.has(eventId)) {
+ return cache.get(eventId);
+ }
+
+ const value = await hydrate();
+ // Only memoize values that are safe to return by reference across replays
+ // AND small enough to retain for the invocation. Non-primitives and
+ // oversized string/bigint values fall through and are re-hydrated fresh on
+ // every replay (see isMemoizablePrimitive / MAX_MEMOIZED_PRIMITIVE_LENGTH).
+ if (isMemoizablePrimitive(value)) {
+ cache.set(eventId, value);
+ }
+ return value;
+}
diff --git a/packages/core/src/step-hydration-memoization.test.ts b/packages/core/src/step-hydration-memoization.test.ts
new file mode 100644
index 0000000000..1db0221244
--- /dev/null
+++ b/packages/core/src/step-hydration-memoization.test.ts
@@ -0,0 +1,189 @@
+import type { Event } from '@workflow/world';
+import * as nanoid from 'nanoid';
+import { monotonicFactory } from 'ulid';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { EventsConsumer } from './events-consumer.js';
+import type { WorkflowOrchestratorContext } from './private.js';
+import { dehydrateStepReturnValue } from './serialization.js';
+import { createUseStep } from './step.js';
+import {
+ createStepHydrationCache,
+ type StepHydrationCache,
+} from './step-hydration-cache.js';
+import { createContext } from './vm/index.js';
+
+/**
+ * These tests verify the end-to-end behavior of the per-run step hydration
+ * cache as exercised through the real `createUseStep` consumer — proving both
+ * that the expensive hydrate is skipped on subsequent replays AND that the
+ * deterministic, event-log-ordered delivery through `promiseQueue` is
+ * preserved on cache hits.
+ *
+ * Each replay iteration of a run builds a fresh `WorkflowOrchestratorContext`
+ * but shares a single `stepHydrationCache` (owned by the inline loop). We
+ * simulate that here by constructing two contexts that share one cache.
+ */
+
+// Build a context that shares a caller-provided hydration cache, mirroring how
+// the inline loop threads one cache across replay iterations.
+function setupWorkflowContext(
+ events: Event[],
+ stepHydrationCache?: StepHydrationCache
+): WorkflowOrchestratorContext {
+ const context = createContext({
+ seed: 'test',
+ fixedTimestamp: 1753481739458,
+ });
+ const ulid = monotonicFactory(() => context.globalThis.Math.random());
+ const workflowStartedAt = context.globalThis.Date.now();
+ return {
+ runId: 'wrun_test',
+ encryptionKey: undefined,
+ globalThis: context.globalThis,
+ eventsConsumer: new EventsConsumer(events, {
+ onUnconsumedEvent: () => {},
+ getPromiseQueue: () => Promise.resolve(),
+ }),
+ invocationsQueue: new Map(),
+ generateUlid: () => ulid(workflowStartedAt),
+ generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
+ new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
+ ),
+ onWorkflowError: vi.fn(),
+ promiseQueue: Promise.resolve(),
+ pendingDeliveries: 0,
+ pendingDeliveryBarriers: new Map(),
+ stepHydrationCache,
+ };
+}
+
+async function makeStepEvents(): Promise {
+ return [
+ {
+ eventId: 'evnt_0',
+ runId: 'wrun_test',
+ eventType: 'step_completed',
+ correlationId: 'step_01K11TFZ62YS0YYFDQ3E8B9YCV',
+ eventData: {
+ stepName: 'step1',
+ result: await dehydrateStepReturnValue('one', 'wrun_test', undefined),
+ },
+ createdAt: new Date(),
+ },
+ {
+ eventId: 'evnt_1',
+ runId: 'wrun_test',
+ eventType: 'step_completed',
+ correlationId: 'step_01K11TFZ62YS0YYFDQ3E8B9YCW',
+ eventData: {
+ stepName: 'step2',
+ result: await dehydrateStepReturnValue('two', 'wrun_test', undefined),
+ },
+ createdAt: new Date(),
+ },
+ ];
+}
+
+describe('step hydration memoization through the step consumer', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('skips re-hydration of primitive step results on a second replay sharing the cache', async () => {
+ const events = await makeStepEvents();
+ const cache = createStepHydrationCache();
+
+ const serialization = await import('./serialization.js');
+ const hydrateSpy = vi.spyOn(serialization, 'hydrateStepReturnValue');
+
+ // --- Replay 1: fresh context, shared cache. Both steps hydrate. ---
+ const ctx1 = setupWorkflowContext(events, cache);
+ const useStep1 = createUseStep(ctx1);
+ const [r1a, r1b] = await Promise.all([
+ useStep1('step1')(),
+ useStep1('step2')(),
+ ]);
+ expect(r1a).toBe('one');
+ expect(r1b).toBe('two');
+ expect(hydrateSpy).toHaveBeenCalledTimes(2);
+
+ // --- Replay 2: brand-new context, SAME cache. No re-hydration. ---
+ hydrateSpy.mockClear();
+ const ctx2 = setupWorkflowContext(events, cache);
+ const useStep2 = createUseStep(ctx2);
+ const [r2a, r2b] = await Promise.all([
+ useStep2('step1')(),
+ useStep2('step2')(),
+ ]);
+ expect(r2a).toBe('one');
+ expect(r2b).toBe('two');
+ // The expensive decrypt+parse must NOT run again on the second replay.
+ expect(hydrateSpy).toHaveBeenCalledTimes(0);
+ });
+
+ it('preserves event-log resolution order on cache hits even with variable timing', async () => {
+ const events = await makeStepEvents();
+ const cache = createStepHydrationCache();
+
+ // Replay 1: populate the cache (no timing games needed).
+ const ctx1 = setupWorkflowContext(events, cache);
+ const useStep1 = createUseStep(ctx1);
+ await Promise.all([useStep1('step1')(), useStep1('step2')()]);
+
+ // Replay 2: all results are cache hits, but force the second event's
+ // delivery slot to be observed quickly while the first is artificially
+ // slowed — proving ordering is enforced by promiseQueue, not by hydrate
+ // timing (which is now a no-op on hits).
+ const ctx2 = setupWorkflowContext(events, cache);
+ const useStep2 = createUseStep(ctx2);
+
+ const promiseA = useStep2('step1')();
+ const promiseB = useStep2('step2')();
+
+ const resolveOrder: string[] = [];
+ promiseA.then((v) => resolveOrder.push(`A:${v}`));
+ promiseB.then((v) => resolveOrder.push(`B:${v}`));
+
+ const [a, b] = await Promise.all([promiseA, promiseB]);
+ expect(a).toBe('one');
+ expect(b).toBe('two');
+ // Must resolve in event-log order regardless of caching.
+ expect(resolveOrder).toEqual(['A:one', 'B:two']);
+ });
+
+ it('re-hydrates object results on every replay (no shared mutable reference)', async () => {
+ const events: Event[] = [
+ {
+ eventId: 'evnt_0',
+ runId: 'wrun_test',
+ eventType: 'step_completed',
+ correlationId: 'step_01K11TFZ62YS0YYFDQ3E8B9YCV',
+ eventData: {
+ stepName: 'obj',
+ result: await dehydrateStepReturnValue(
+ { count: 0 },
+ 'wrun_test',
+ undefined
+ ),
+ },
+ createdAt: new Date(),
+ },
+ ];
+ const cache = createStepHydrationCache();
+
+ // Replay 1: hydrate the object, then mutate it (as workflow code might).
+ const ctx1 = setupWorkflowContext(events, cache);
+ const useStep1 = createUseStep(ctx1);
+ const first = (await useStep1('obj')()) as { count: number };
+ first.count = 99;
+
+ // Replay 2: must produce a FRESH object, not the mutated one.
+ const ctx2 = setupWorkflowContext(events, cache);
+ const useStep2 = createUseStep(ctx2);
+ const second = (await useStep2('obj')()) as { count: number };
+
+ expect(second).not.toBe(first);
+ expect(second.count).toBe(0);
+ expect(cache.size).toBe(0); // object results are never memoized
+ });
+});
diff --git a/packages/core/src/step.ts b/packages/core/src/step.ts
index a00b88db0a..d3c02c0942 100644
--- a/packages/core/src/step.ts
+++ b/packages/core/src/step.ts
@@ -9,6 +9,7 @@ import {
} from './private.js';
import type { Serializable } from './schemas.js';
import { hydrateStepError, hydrateStepReturnValue } from './serialization.js';
+import { getOrHydrateStepReturnValue } from './step-hydration-cache.js';
export function createUseStep(ctx: WorkflowOrchestratorContext) {
return function useStep(
@@ -183,14 +184,34 @@ export function createUseStep(ctx: WorkflowOrchestratorContext) {
// takes variable time, promises resolve in event log order.
// Each step's hydration + resolve waits for all prior hydrations
// to complete before executing, preserving deterministic ordering.
+ //
+ // Memoization: on every replay this consumer re-runs and would
+ // otherwise re-decrypt + re-parse the same serialized result — O(N²)
+ // across an invocation for a sequential N-step workflow. The
+ // per-run `stepHydrationCache` short-circuits that work on
+ // subsequent replays. Crucially, the cache lookup happens INSIDE
+ // this same promiseQueue slot (and still resolves via `resolve`),
+ // so a cache hit occupies the exact same position in the ordered
+ // delivery chain a re-hydrate would have — preserving the
+ // determinism that pendingDeliveries, the delivery barriers, and
+ // Promise.race/all replay depend on. Only primitive results are
+ // memoized; non-primitives re-hydrate fresh each replay so a shared
+ // reference can never carry a mutation between replays.
+ const completedEventId = event.eventId;
+ const serializedResult = event.eventData.result;
ctx.pendingDeliveries++;
ctx.promiseQueue = ctx.promiseQueue.then(async () => {
try {
- const hydratedResult = await hydrateStepReturnValue(
- event.eventData.result,
- ctx.runId,
- ctx.encryptionKey,
- ctx.globalThis
+ const hydratedResult = await getOrHydrateStepReturnValue(
+ ctx.stepHydrationCache,
+ completedEventId,
+ () =>
+ hydrateStepReturnValue(
+ serializedResult,
+ ctx.runId,
+ ctx.encryptionKey,
+ ctx.globalThis
+ )
);
resolve(hydratedResult as Result);
} catch (error) {
diff --git a/packages/core/src/step/context-storage.ts b/packages/core/src/step/context-storage.ts
index d6f3a0f56f..b5c71c1231 100644
--- a/packages/core/src/step/context-storage.ts
+++ b/packages/core/src/step/context-storage.ts
@@ -23,6 +23,33 @@ export type StepContext = {
/** Deployment that owns the current workflow run, used for forwarded streams. */
workflowDeploymentId?: string;
ops: Promise[];
+ /**
+ * Operations that MUST be durably committed before the step's
+ * `step_completed`/`step_failed` event is written, because the workflow
+ * continuation triggered by that event depends on them.
+ *
+ * The canonical case is a step-initiated `AbortController.abort()`: the
+ * durable `hook_received` event records the cancellation in the workflow's
+ * event log. If it is flushed in the background (like `ops`), the workflow
+ * continuation enqueued by `step_completed` can run — and advance past the
+ * abort, dispatching a later step with a stale, non-aborted `signal` — before
+ * the `hook_received` event exists. Awaiting these inline before completion
+ * guarantees the abort is ordered ahead of any continuation that observes the
+ * step's result. Unlike these, `ops` holds best-effort real-time stream
+ * writes that should fire ASAP and are intentionally left in the background.
+ *
+ * Contract: producers MUST NOT push a promise that can reject — these are
+ * awaited only to enforce ordering, never to surface an outcome. A rejection
+ * here would propagate as an infra error (queue re-delivery), not the
+ * user-code failure path, so each producer swallows its own errors (see
+ * `reviveAbortController` in serialization.ts). The await sites also
+ * defensively `.catch()` so ordering is all this bucket can ever enforce.
+ *
+ * Required (not optional) so a new step-context construction site that
+ * forgets to wire it fails at compile time, rather than silently regressing
+ * the ordering guarantee back to background-flush behavior.
+ */
+ preCompletionOps: Promise[];
closureVars?: Record;
encryptionKey?: CryptoKey;
writables?: Map;
diff --git a/packages/core/src/workflow.test.ts b/packages/core/src/workflow.test.ts
index 3a51969164..12aebe329a 100644
--- a/packages/core/src/workflow.test.ts
+++ b/packages/core/src/workflow.test.ts
@@ -219,6 +219,85 @@ describe('runWorkflow', () => {
).toEqual(3);
});
+ it('regenerates step correlation IDs independent of startedAt (turbo replay-stability)', async () => {
+ // Turbo's first delivery synthesizes `startedAt` from the local clock,
+ // while later (non-turbo) deliveries load the server-canonical `startedAt`.
+ // Replay matching must NOT depend on `startedAt`: correlation IDs come from
+ // `generateUlid`, keyed off the run-ID-recovered `fixedTimestamp`, not
+ // `startedAt`. Here the recorded `add` event uses the createdAt-derived
+ // correlation ID, but `startedAt` is months away — replay must still
+ // regenerate the same ID and consume the completion rather than throwing
+ // ReplayDivergenceError. Reverting `generateUlid` to `ulid(+startedAt)`
+ // fails this test.
+ const ops: Promise[] = [];
+ const workflowRunId = 'wrun_123';
+ const workflowRun: WorkflowRun = {
+ runId: workflowRunId,
+ workflowName: 'workflow',
+ status: 'running',
+ input: await dehydrateWorkflowArguments(
+ [],
+ 'wrun_123',
+ noEncryptionKey,
+ ops
+ ),
+ createdAt: new Date('2024-01-01T00:00:00.000Z'),
+ updatedAt: new Date('2024-01-01T00:00:00.000Z'),
+ // Diverges from createdAt by months — replay must ignore it.
+ startedAt: new Date('2024-06-01T12:34:56.000Z'),
+ deploymentId: 'test-deployment',
+ };
+
+ const events: Event[] = [
+ {
+ eventId: 'event-0',
+ runId: workflowRunId,
+ eventType: 'step_started',
+ correlationId: 'step_01HK153X00SFW49DWMQP3J810S',
+ eventData: {
+ stepName: 'add',
+ },
+ createdAt: new Date('2024-01-01T00:00:01.000Z'),
+ },
+ {
+ eventId: 'event-1',
+ runId: workflowRunId,
+ eventType: 'step_completed',
+ correlationId: 'step_01HK153X00SFW49DWMQP3J810S',
+ eventData: {
+ stepName: 'add',
+ result: await dehydrateStepReturnValue(
+ 3,
+ 'wrun_123',
+ noEncryptionKey,
+ ops
+ ),
+ },
+ createdAt: new Date('2024-01-01T00:00:02.000Z'),
+ },
+ ];
+
+ const result = await runWorkflow(
+ `const add = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("add");
+ async function workflow() {
+ const a = await add(1, 2);
+ return a;
+ }${getWorkflowTransformCode('workflow')}`,
+ workflowRun,
+ events,
+ noEncryptionKey
+ );
+
+ expect(
+ await hydrateWorkflowReturnValue(
+ result as any,
+ 'wrun_123',
+ noEncryptionKey,
+ ops
+ )
+ ).toEqual(3);
+ });
+
// Test that timestamps update correctly as events are consumed
it('should update the timestamp in the vm context as events are replayed', async () => {
const ops: Promise[] = [];
@@ -978,7 +1057,7 @@ describe('runWorkflow', () => {
const events: Event[] = [
{
eventType: 'step_started',
- correlationId: 'step_01HK153X00WAVWBK9YGJQC6R7M',
+ correlationId: 'step_01K75533W5WAVWBK9YGJQC6R7M',
runId: 'wrun_01K75533W56DAE35VY3082DN3P',
eventId: 'evnt_01K755385N02MMWXYHFCQSP9P0',
eventData: {
@@ -988,7 +1067,7 @@ describe('runWorkflow', () => {
},
{
eventType: 'step_started',
- correlationId: 'step_01HK153X00WAVWBK9YGJQC6R7N',
+ correlationId: 'step_01K75533W5WAVWBK9YGJQC6R7N',
runId: 'wrun_01K75533W56DAE35VY3082DN3P',
eventId: 'evnt_01K755386GHGAFYYDC58V17E3T',
eventData: {
@@ -998,7 +1077,7 @@ describe('runWorkflow', () => {
},
{
eventType: 'step_started',
- correlationId: 'step_01HK153X00WAVWBK9YGJQC6R7P',
+ correlationId: 'step_01K75533W5WAVWBK9YGJQC6R7P',
runId: 'wrun_01K75533W56DAE35VY3082DN3P',
eventId: 'evnt_01K75538D4Q4X8PJ1ZNDZD5R0W',
eventData: {
@@ -1008,7 +1087,7 @@ describe('runWorkflow', () => {
},
{
eventType: 'step_started',
- correlationId: 'step_01HK153X00WAVWBK9YGJQC6R7Q',
+ correlationId: 'step_01K75533W5WAVWBK9YGJQC6R7Q',
runId: 'wrun_01K75533W56DAE35VY3082DN3P',
eventId: 'evnt_01K75538Y9GEHXJQXT3JB89M4C',
eventData: {
@@ -1018,7 +1097,7 @@ describe('runWorkflow', () => {
},
{
eventType: 'step_started',
- correlationId: 'step_01HK153X00WAVWBK9YGJQC6R7R',
+ correlationId: 'step_01K75533W5WAVWBK9YGJQC6R7R',
runId: 'wrun_01K75533W56DAE35VY3082DN3P',
eventId: 'evnt_01K75539CD2PAH419SKJ2X5V5T',
eventData: {
@@ -1028,7 +1107,7 @@ describe('runWorkflow', () => {
},
{
eventType: 'step_completed',
- correlationId: 'step_01HK153X00WAVWBK9YGJQC6R7R',
+ correlationId: 'step_01K75533W5WAVWBK9YGJQC6R7R',
eventData: {
stepName: 'promiseRaceStressTestDelayStep',
result: await dehydrateStepReturnValue(
@@ -1044,7 +1123,7 @@ describe('runWorkflow', () => {
},
{
eventType: 'step_completed',
- correlationId: 'step_01HK153X00WAVWBK9YGJQC6R7Q',
+ correlationId: 'step_01K75533W5WAVWBK9YGJQC6R7Q',
eventData: {
stepName: 'promiseRaceStressTestDelayStep',
result: await dehydrateStepReturnValue(
@@ -1060,7 +1139,7 @@ describe('runWorkflow', () => {
},
{
eventType: 'step_completed',
- correlationId: 'step_01HK153X00WAVWBK9YGJQC6R7P',
+ correlationId: 'step_01K75533W5WAVWBK9YGJQC6R7P',
eventData: {
stepName: 'promiseRaceStressTestDelayStep',
result: await dehydrateStepReturnValue(
@@ -1076,7 +1155,7 @@ describe('runWorkflow', () => {
},
{
eventType: 'step_completed',
- correlationId: 'step_01HK153X00WAVWBK9YGJQC6R7N',
+ correlationId: 'step_01K75533W5WAVWBK9YGJQC6R7N',
eventData: {
stepName: 'promiseRaceStressTestDelayStep',
result: await dehydrateStepReturnValue(
@@ -1092,7 +1171,7 @@ describe('runWorkflow', () => {
},
{
eventType: 'step_completed',
- correlationId: 'step_01HK153X00WAVWBK9YGJQC6R7M',
+ correlationId: 'step_01K75533W5WAVWBK9YGJQC6R7M',
eventData: {
stepName: 'promiseRaceStressTestDelayStep',
result: await dehydrateStepReturnValue(
diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts
index d342f90b0c..8d6269c737 100644
--- a/packages/core/src/workflow.ts
+++ b/packages/core/src/workflow.ts
@@ -25,6 +25,7 @@ import {
hydrateWorkflowArguments,
} from './serialization.js';
import { createUseStep } from './step.js';
+import type { StepHydrationCache } from './step-hydration-cache.js';
import {
BODY_INIT_SYMBOL,
STABLE_ULID,
@@ -125,7 +126,15 @@ export async function runWorkflow(
workflowCode: string,
workflowRun: WorkflowRun,
events: Event[],
- encryptionKey: CryptoKey | undefined
+ encryptionKey: CryptoKey | undefined,
+ /**
+ * Optional per-run cache for hydrated step return values, owned by the inline
+ * replay loop so it survives across the loop's iterations (each of which
+ * creates a fresh context). Memoizes the decrypt + devalue-parse of completed
+ * step results to turn O(N²) replay hydration into O(N). Omitted by callers
+ * that replay only once (then there is nothing to reuse).
+ */
+ stepHydrationCache?: StepHydrationCache
): Promise {
return trace(`workflow.run ${workflowRun.workflowName}`, async (span) => {
span?.setAttributes({
@@ -211,7 +220,16 @@ export async function runWorkflow(
globalThis: vmGlobalThis,
onWorkflowError: workflowDiscontinuation.reject,
eventsConsumer,
- generateUlid: () => ulid(+startedAt),
+ // Correlation IDs (step_/wait_/hook_) are derived from `generateUlid`, so
+ // the time prefix fed to `ulid()` MUST be replay-stable across every
+ // delivery — otherwise a redelivery regenerates different correlation IDs
+ // and replay throws ReplayDivergenceError. `startedAt` is NOT safe here:
+ // under turbo the first delivery synthesizes `startedAt` from the local
+ // clock, but later (non-turbo) deliveries load the server-canonical
+ // `startedAt`, which differs by >=1ms. Use the same replay-stable value
+ // that already seeds the RNG and the VM clock (`fixedTimestamp`, recovered
+ // from the run ID's ULID and known the instant the message arrives).
+ generateUlid: () => ulid(fixedTimestamp),
generateNanoid,
invocationsQueue: new Map(),
// Use getter/setter so the EventsConsumer's getPromiseQueue() always
@@ -224,6 +242,7 @@ export async function runWorkflow(
},
pendingDeliveries: 0,
pendingDeliveryBarriers: new Map(),
+ stepHydrationCache,
};
// Consume run lifecycle events - these are structural events that don't
diff --git a/packages/core/src/workflow/abort-controller.ts b/packages/core/src/workflow/abort-controller.ts
index 663bc965ff..1774b34a11 100644
--- a/packages/core/src/workflow/abort-controller.ts
+++ b/packages/core/src/workflow/abort-controller.ts
@@ -178,31 +178,47 @@ export function createCreateAbortController(ctx: WorkflowOrchestratorContext) {
// dehydration, so `'reason' in payload` is false and reason
// ends up undefined on replay.
const rawPayload = event.eventData?.payload;
+ // Account this abort as a pending delivery, exactly like step
+ // results (step.ts) and hook payloads (workflow/hook.ts) do. The
+ // suspension handler dehydrates queued step arguments only once
+ // `scheduleWhenIdle` observes `pendingDeliveries === 0`. Without
+ // this counter, a step dispatched right after the abort — e.g. one
+ // that receives `controller.signal` — can have its arguments
+ // serialized while the abort is still in flight behind
+ // `await hydrateStepReturnValue`, capturing `signal.aborted === false`
+ // (and a missing reason). Bumping the counter holds the suspension
+ // until `_setAborted` has landed, so downstream serialization is
+ // deterministic regardless of reason-hydration (decryption) latency.
+ ctx.pendingDeliveries++;
ctx.promiseQueue = ctx.promiseQueue.then(async () => {
let reason: unknown;
- if (rawPayload !== undefined) {
- try {
- const hydrated = (await hydrateStepReturnValue(
- rawPayload,
- ctx.runId,
- ctx.encryptionKey,
- ctx.globalThis
- )) as { reason?: unknown } | undefined;
- if (
- hydrated &&
- typeof hydrated === 'object' &&
- 'reason' in hydrated
- ) {
- reason = hydrated.reason;
+ try {
+ if (rawPayload !== undefined) {
+ try {
+ const hydrated = (await hydrateStepReturnValue(
+ rawPayload,
+ ctx.runId,
+ ctx.encryptionKey,
+ ctx.globalThis
+ )) as { reason?: unknown } | undefined;
+ if (
+ hydrated &&
+ typeof hydrated === 'object' &&
+ 'reason' in hydrated
+ ) {
+ reason = hydrated.reason;
+ }
+ } catch {
+ // Best-effort: if hydration fails, fall back to undefined
+ // reason. The signal still aborts; the user just won't see
+ // the original reason. Matches WorkflowAbortSignal's spec
+ // fallback (DOMException AbortError).
}
- } catch {
- // Best-effort: if hydration fails, fall back to undefined
- // reason. The signal still aborts; the user just won't see
- // the original reason. Matches WorkflowAbortSignal's spec
- // fallback (DOMException AbortError).
}
+ this.signal._setAborted(reason);
+ } finally {
+ ctx.pendingDeliveries--;
}
- this.signal._setAborted(reason);
});
ctx.invocationsQueue.delete(correlationId);
diff --git a/packages/nest/CHANGELOG.md b/packages/nest/CHANGELOG.md
index 727e4ba6f4..59190578aa 100644
--- a/packages/nest/CHANGELOG.md
+++ b/packages/nest/CHANGELOG.md
@@ -1,5 +1,12 @@
# @workflow/nest
+## 5.0.0-beta.21
+
+### Patch Changes
+
+- Updated dependencies [[`5291f15`](https://github.com/vercel/workflow/commit/5291f1549fee4d8b042cc03b6696fd8b6cb798fc), [`b713d84`](https://github.com/vercel/workflow/commit/b713d8417b1334abd1e30bdc50701d0d96dee39d), [`57cccaf`](https://github.com/vercel/workflow/commit/57cccaf3734f4afa8218e1ea729a9bb886c691f3)]:
+ - @workflow/builders@5.0.0-beta.21
+
## 5.0.0-beta.20
### Patch Changes
diff --git a/packages/nest/package.json b/packages/nest/package.json
index d40cf1dcd7..47c8a3142e 100644
--- a/packages/nest/package.json
+++ b/packages/nest/package.json
@@ -1,6 +1,6 @@
{
"name": "@workflow/nest",
- "version": "5.0.0-beta.20",
+ "version": "5.0.0-beta.21",
"description": "NestJS integration for Workflow SDK",
"type": "module",
"main": "dist/index.js",
diff --git a/packages/next/CHANGELOG.md b/packages/next/CHANGELOG.md
index 9fb3f46686..4c68c58ca9 100644
--- a/packages/next/CHANGELOG.md
+++ b/packages/next/CHANGELOG.md
@@ -1,5 +1,21 @@
# @workflow/next
+## 5.0.0-beta.21
+
+### Minor Changes
+
+- [#2545](https://github.com/vercel/workflow/pull/2545) [`57cccaf`](https://github.com/vercel/workflow/commit/57cccaf3734f4afa8218e1ea729a9bb886c691f3) Thanks [@ijjk](https://github.com/ijjk)! - Remove the Next.js lazy discovery/deferred builder path and the `workflows.lazyDiscovery` option.
+
+ Fall back to direct generated-file overwrites on Windows when atomic rename is blocked by Next.js dev server file handles.
+
+### Patch Changes
+
+- [#2546](https://github.com/vercel/workflow/pull/2546) [`5291f15`](https://github.com/vercel/workflow/commit/5291f1549fee4d8b042cc03b6696fd8b6cb798fc) Thanks [@ijjk](https://github.com/ijjk)! - Optimize eager workflow discovery and improve default eager build compatibility.
+
+- Updated dependencies [[`6de5ea5`](https://github.com/vercel/workflow/commit/6de5ea5c2f32b474274f5dabe5f3663e03622ac5), [`5291f15`](https://github.com/vercel/workflow/commit/5291f1549fee4d8b042cc03b6696fd8b6cb798fc), [`66ca0dc`](https://github.com/vercel/workflow/commit/66ca0dcc096440f39dd234e04669e1fc7bf2d615), [`b713d84`](https://github.com/vercel/workflow/commit/b713d8417b1334abd1e30bdc50701d0d96dee39d), [`57cccaf`](https://github.com/vercel/workflow/commit/57cccaf3734f4afa8218e1ea729a9bb886c691f3), [`3e82a12`](https://github.com/vercel/workflow/commit/3e82a12712b1efe229ac2b1623dc6c8fc7be7055)]:
+ - @workflow/core@5.0.0-beta.21
+ - @workflow/builders@5.0.0-beta.21
+
## 5.0.0-beta.20
### Patch Changes
diff --git a/packages/next/package.json b/packages/next/package.json
index 748791ea44..811b7cf4c0 100644
--- a/packages/next/package.json
+++ b/packages/next/package.json
@@ -1,6 +1,6 @@
{
"name": "@workflow/next",
- "version": "5.0.0-beta.20",
+ "version": "5.0.0-beta.21",
"description": "Next.js integration for Workflow SDK",
"type": "commonjs",
"main": "dist/index.js",
@@ -28,6 +28,7 @@
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
+ "test": "vitest run src",
"clean": "tsc --build --clean && rm -rf dist docs",
"prepack": "mkdir -p docs && cp ../../docs/content/docs/v5/getting-started/next.mdx ./docs/ && cp -r ../../docs/content/docs/v5/api-reference/workflow-next ./docs/api-reference",
"postpack": "rm -rf docs"
@@ -45,7 +46,8 @@
"@types/semver": "7.7.1",
"@types/watchpack": "2.4.4",
"@workflow/tsconfig": "workspace:*",
- "next": "16.2.1"
+ "next": "16.2.1",
+ "vitest": "catalog:"
},
"peerDependencies": {
"next": ">13"
diff --git a/packages/next/src/builder-deferred.test.ts b/packages/next/src/builder-deferred.test.ts
deleted file mode 100644
index 22fcedbd56..0000000000
--- a/packages/next/src/builder-deferred.test.ts
+++ /dev/null
@@ -1,191 +0,0 @@
-import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-import { join, relative } from 'node:path';
-import { afterEach, describe, expect, it, vi } from 'vitest';
-import { getNextBuilderDeferred } from './builder-deferred.js';
-
-const tempDirs: string[] = [];
-// biome-ignore lint/security/noGlobalEval: The test preserves the builder's dynamic import shim while stubbing one import.
-const originalEval = globalThis.eval;
-
-afterEach(async () => {
- await Promise.all(
- tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))
- );
- tempDirs.length = 0;
- vi.unstubAllGlobals();
-});
-
-describe('NextDeferredBuilder', () => {
- it('lets Next bundle step registrations from source imports', async () => {
- const workingDir = await mkdtemp(join(tmpdir(), 'workflow-next-deferred-'));
- tempDirs.push(workingDir);
- vi.stubGlobal('eval', (source: string) => {
- if (source === 'import("@workflow/builders")') {
- return import('@workflow/builders');
- }
- return originalEval(source);
- });
-
- const NextDeferredBuilder = await getNextBuilderDeferred();
- const builder = new NextDeferredBuilder({
- dirs: [],
- workingDir,
- buildTarget: 'next',
- workflowsBundlePath: '',
- stepsBundlePath: '',
- webhookBundlePath: '',
- }) as any;
-
- const workflowFile = join(workingDir, 'workflows/example.ts');
- const routeDir = join(workingDir, 'app/.well-known/workflow/v1/flow');
- const flowOutfile = join(routeDir, 'route.js.temp');
- const stepManifest = {
- steps: {
- 'workflows/example.ts': {
- step: { stepId: 'step//./workflows/example//step' },
- },
- },
- };
- const workflowManifest = {
- workflows: {
- 'workflows/example.ts': {
- run: { workflowId: 'workflow//./workflows/example//run' },
- },
- },
- };
-
- builder.createStepsBundle = vi.fn();
- builder.createWorkflowsBundle = vi.fn(async () => ({
- interimBundleText: 'globalThis.__private_workflows = new Map();',
- manifest: workflowManifest,
- }));
- builder.createDeferredStepManifest = vi.fn(async () => stepManifest);
-
- const result = await builder.createDeferredFlowRoute({
- inputFiles: [workflowFile],
- flowOutfile,
- discoveredEntries: {
- discoveredSteps: new Set([workflowFile]),
- discoveredWorkflows: new Set([workflowFile]),
- discoveredSerdeFiles: new Set(),
- },
- });
-
- expect(builder.createStepsBundle).not.toHaveBeenCalled();
- expect(result.manifest).toEqual({
- ...stepManifest,
- workflows: workflowManifest.workflows,
- classes: {},
- });
-
- const routeCode = await readFile(flowOutfile, 'utf8');
- const expectedStepImport = relative(routeDir, workflowFile).replace(
- /\\/g,
- '/'
- );
- expect(routeCode).toContain("import 'workflow/internal/builtins';");
- expect(routeCode).toContain(`import "${expectedStepImport}";`);
- expect(routeCode).toContain(
- "import { workflowEntrypoint } from 'workflow/runtime';"
- );
- expect(routeCode).not.toContain('__step_registrations');
- });
-
- it('loads workflow code from disk for dev deferred flow routes', async () => {
- const workingDir = await mkdtemp(join(tmpdir(), 'workflow-next-deferred-'));
- tempDirs.push(workingDir);
- vi.stubGlobal('eval', (source: string) => {
- if (source === 'import("@workflow/builders")') {
- return import('@workflow/builders');
- }
- return originalEval(source);
- });
-
- const NextDeferredBuilder = await getNextBuilderDeferred();
- const builder = new NextDeferredBuilder({
- dirs: [],
- workingDir,
- buildTarget: 'next',
- workflowsBundlePath: '',
- stepsBundlePath: '',
- webhookBundlePath: '',
- watch: true,
- }) as any;
-
- const workflowCode = 'globalThis.__private_workflows = new Map();';
- const workflowFile = join(workingDir, 'workflows/example.ts');
- const routeDir = join(workingDir, 'app/.well-known/workflow/v1/flow');
- const flowOutfile = join(routeDir, 'route.js.temp');
-
- builder.createWorkflowsBundle = vi.fn(async () => ({
- interimBundleText: workflowCode,
- manifest: { workflows: {}, classes: {} },
- }));
- builder.createDeferredStepManifest = vi.fn(async () => ({}));
-
- await builder.createDeferredFlowRoute({
- inputFiles: [workflowFile],
- flowOutfile,
- discoveredEntries: {
- discoveredSteps: new Set(),
- discoveredWorkflows: new Set([workflowFile]),
- discoveredSerdeFiles: new Set(),
- },
- });
-
- const routeCode = await readFile(flowOutfile, 'utf8');
- expect(routeCode).toContain(
- "import { readFile, stat } from 'node:fs/promises';"
- );
- expect(routeCode).toContain('async function getWorkflowHandler()');
- expect(routeCode).toContain('export async function POST(req)');
- expect(routeCode).not.toContain('const workflowCode = `');
- await expect(
- readFile(join(routeDir, '__workflow_code.txt'), 'utf8')
- ).resolves.toBe(workflowCode);
- });
-
- it('imports workspace package step sources from dist output outside packages directories', async () => {
- const workingDir = await mkdtemp(join(tmpdir(), 'workflow-next-deferred-'));
- tempDirs.push(workingDir);
- vi.stubGlobal('eval', (source: string) => {
- if (source === 'import("@workflow/builders")') {
- return import('@workflow/builders');
- }
- return originalEval(source);
- });
-
- const NextDeferredBuilder = await getNextBuilderDeferred();
- const builder = new NextDeferredBuilder({
- dirs: [],
- workingDir,
- buildTarget: 'next',
- workflowsBundlePath: '',
- stepsBundlePath: '',
- webhookBundlePath: '',
- }) as any;
-
- const packageDir = join(workingDir, '../libs/demo');
- const packageSourceFile = join(packageDir, 'src/runtime/run.ts');
- const packageDistFile = join(packageDir, 'dist/runtime/run.js');
- const routeDir = join(workingDir, 'app/.well-known/workflow/v1/flow');
- await mkdir(join(packageDir, 'src/runtime'), { recursive: true });
- await mkdir(join(packageDir, 'dist/runtime'), { recursive: true });
- await writeFile(
- join(packageDir, 'package.json'),
- JSON.stringify({ name: '@demo/workflow-steps', type: 'module' })
- );
- await writeFile(packageSourceFile, 'export async function step() {}');
- await writeFile(packageDistFile, 'export async function step() {}');
-
- const importSpecifier = await builder.getDeferredRouteImportSpecifier(
- packageSourceFile,
- routeDir
- );
-
- expect(importSpecifier).toBe(
- relative(routeDir, packageDistFile).replace(/\\/g, '/')
- );
- });
-});
diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts
deleted file mode 100644
index 0b62c6cd8d..0000000000
--- a/packages/next/src/builder-deferred.ts
+++ /dev/null
@@ -1,2123 +0,0 @@
-import { createHash } from 'node:crypto';
-import { constants, existsSync, readFileSync } from 'node:fs';
-import {
- access,
- mkdir,
- open,
- readdir,
- readFile,
- rm,
- stat,
- writeFile,
-} from 'node:fs/promises';
-import { createRequire } from 'node:module';
-import os from 'node:os';
-import {
- basename,
- dirname,
- extname,
- isAbsolute,
- join,
- relative,
- resolve,
-} from 'node:path';
-import type { WorkflowManifest } from '@workflow/builders';
-import {
- cleanupStaleSocketInfoFiles,
- createSocketServer,
- SOCKET_INFO_FILENAME,
- type SocketIO,
- type SocketServerConfig,
-} from './socket-server.js';
-
-const ROUTE_STUB_FILE_MARKER = 'WORKFLOW_ROUTE_STUB_FILE';
-const ROUTE_STUB_MARKER_SCAN_BYTES = 4 * 1024;
-const DEV_WORKFLOW_CODE_FILENAME = '__workflow_code.txt';
-
-let CachedNextBuilderDeferred: any;
-
-// Create the deferred Next builder dynamically by extending the ESM BaseBuilder.
-// Exported as getNextBuilderDeferred() to allow CommonJS modules to import from
-// the ESM @workflow/builders package via dynamic import at runtime.
-export async function getNextBuilderDeferred() {
- if (CachedNextBuilderDeferred) {
- return CachedNextBuilderDeferred;
- }
-
- // V2: STEP_QUEUE_TRIGGER and enhanced-resolve infrastructure
- // were removed because the V2 combined handler eliminates the separate step
- // route/topic. The step copy import rewriting (getRelativeImportSpecifier,
- // getStepCopyFileName, rewriteRelativeImportsForCopiedStep) from main was also
- // removed — V2 doesn't use step copies. If step copy support is needed, it
- // should land as a complete feature set.
- const {
- BaseBuilder: BaseBuilderClass,
- WORKFLOW_QUEUE_TRIGGER,
- createWorkflowEntrypointOptionsCode,
- detectWorkflowPatterns,
- applySwcTransform,
- getImportPath,
- resolveWorkflowAliasRelativePath,
- // biome-ignore lint/security/noGlobalEval: Need to use eval here to avoid TypeScript from transpiling the import statement into `require()`
- } = (await eval(
- 'import("@workflow/builders")'
- )) as typeof import('@workflow/builders');
-
- class NextDeferredBuilder extends BaseBuilderClass {
- private socketIO?: SocketIO;
- private readonly discoveredWorkflowFiles = new Set();
- private readonly discoveredStepFiles = new Set();
- private readonly discoveredSerdeFiles = new Set();
- private trackedDependencyFiles = new Set();
- private deferredBuildQueue = Promise.resolve();
- private cacheInitialized = false;
- private cacheWriteTimer: NodeJS.Timeout | null = null;
- private deferredRebuildTimer: NodeJS.Timeout | null = null;
- private lastDeferredBuildSignature: string | null = null;
-
- async build() {
- const outputDir = await this.findAppDirectory();
-
- await this.initializeDiscoveryState();
- await this.cleanupGeneratedArtifactsOnBoot(outputDir);
-
- await this.writeStubFiles(outputDir);
- await this.createDiscoverySocketServer();
- }
-
- async onBeforeDeferredEntries(): Promise {
- await this.initializeDiscoveryState();
- await this.validateDiscoveredEntryFiles();
- const implicitStepFiles = await this.resolveImplicitStepFiles();
-
- const pendingBuild = this.deferredBuildQueue.then(() =>
- this.buildDeferredEntriesUntilStable(implicitStepFiles)
- );
-
- // Keep the queue chain alive even when the current build fails so future
- // callbacks can enqueue another attempt without triggering unhandled
- // rejection warnings.
- this.deferredBuildQueue = pendingBuild.catch(() => {
- // Error is surfaced through `pendingBuild` below.
- });
-
- await pendingBuild;
- }
-
- private getCurrentInputFiles(implicitStepFiles: string[]): string[] {
- return Array.from(
- new Set([
- ...this.discoveredWorkflowFiles,
- ...this.discoveredStepFiles,
- ...this.discoveredSerdeFiles,
- ...implicitStepFiles,
- ])
- ).sort();
- }
-
- private async buildDeferredEntriesUntilStable(
- implicitStepFiles: string[]
- ): Promise {
- // A successful build can discover additional transitive dependency files
- // (via source maps), which changes the signature and may require one more
- // build pass to include newly discovered serde files.
- const maxBuildPasses = 3;
-
- for (let buildPass = 0; buildPass < maxBuildPasses; buildPass++) {
- const inputFiles = this.getCurrentInputFiles(implicitStepFiles);
- const buildSignature =
- await this.createDeferredBuildSignature(inputFiles);
- const shouldForceBuildForGeneratedRoutes =
- await this.shouldForceBuildForGeneratedRoutes();
- if (
- buildSignature === this.lastDeferredBuildSignature &&
- !shouldForceBuildForGeneratedRoutes
- ) {
- return;
- }
-
- try {
- await this.buildDiscoveredFiles(inputFiles, implicitStepFiles);
- } catch (error) {
- if (this.config.watch) {
- await this.validateDiscoveredEntryFiles();
- const recoveredInputFiles =
- this.getCurrentInputFiles(implicitStepFiles);
- const recoveredSignature =
- await this.createDeferredBuildSignature(recoveredInputFiles);
- if (recoveredSignature !== buildSignature) {
- // A file was added/removed while this build was running; retry
- // immediately with the refreshed discovered-entry state.
- continue;
- }
- console.warn(
- '[workflow] Deferred entries build failed. Will retry only after inputs change.',
- error
- );
- this.lastDeferredBuildSignature = buildSignature;
- return;
- } else {
- throw error;
- }
- }
- this.lastDeferredBuildSignature = buildSignature;
-
- if (!this.config.watch) {
- // Production builds can persist newly discovered deferred-entry files to
- // the cache after the first pass completes. Reload that cache before we
- // decide whether the input signature stabilized so staged tarball builds
- // can immediately replay with the expanded step set.
- await new Promise((resolve) => setTimeout(resolve, 250));
- await this.loadWorkflowsCache();
- }
-
- const postBuildInputFiles =
- this.getCurrentInputFiles(implicitStepFiles);
- const postBuildSignature =
- await this.createDeferredBuildSignature(postBuildInputFiles);
- if (postBuildSignature === buildSignature) {
- return;
- }
- }
-
- console.warn(
- '[workflow] Deferred entries build signature did not stabilize after 3 passes.'
- );
- }
-
- private async resolveImplicitStepFiles(): Promise {
- const workflowStdlibPath = this.resolveWorkflowStdlibStepFilePath();
- return workflowStdlibPath ? [workflowStdlibPath] : [];
- }
-
- 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/v1/webhook/[token]/route.js'),
- ];
-
- for (const routeFilePath of generatedRouteFiles) {
- const routeState = await this.getGeneratedRouteState(routeFilePath);
- if (routeState === 'missing' || routeState === 'stub') {
- return true;
- }
- }
-
- return false;
- }
-
- private async getGeneratedRouteState(
- routeFilePath: string
- ): Promise<'missing' | 'stub' | 'generated'> {
- let routeStats: Awaited>;
- try {
- routeStats = await stat(routeFilePath);
- } catch {
- return 'missing';
- }
- if (!routeStats.isFile()) {
- return 'missing';
- }
-
- try {
- const routeFileHandle = await open(routeFilePath, 'r');
- try {
- const markerScanBuffer = Buffer.alloc(ROUTE_STUB_MARKER_SCAN_BYTES);
- const { bytesRead } = await routeFileHandle.read(
- markerScanBuffer,
- 0,
- ROUTE_STUB_MARKER_SCAN_BYTES,
- 0
- );
- const markerScanSource = markerScanBuffer.toString(
- 'utf8',
- 0,
- bytesRead
- );
- return markerScanSource.includes(ROUTE_STUB_FILE_MARKER)
- ? 'stub'
- : 'generated';
- } finally {
- await routeFileHandle.close();
- }
- } catch {
- return 'missing';
- }
- }
-
- private resolveWorkflowStdlibStepFilePath(): string | null {
- let workflowCjsEntry: string;
- try {
- workflowCjsEntry = require.resolve('workflow', {
- paths: [this.config.workingDir],
- });
- } catch {
- return null;
- }
-
- const workflowDistDir = dirname(workflowCjsEntry);
- const workflowStdlibPath = this.normalizeDiscoveredFilePath(
- join(workflowDistDir, 'stdlib.js')
- );
- return existsSync(workflowStdlibPath) ? workflowStdlibPath : null;
- }
-
- private async reconcileDiscoveredEntries({
- workflowCandidates,
- stepCandidates,
- serdeCandidates,
- validatePatterns,
- }: {
- workflowCandidates: Iterable;
- stepCandidates: Iterable;
- serdeCandidates?: Iterable;
- validatePatterns: boolean;
- }): Promise<{
- workflowFiles: Set;
- stepFiles: Set;
- serdeFiles: Set;
- }> {
- const candidatesByFile = new Map<
- string,
- {
- hasWorkflowCandidate: boolean;
- hasStepCandidate: boolean;
- hasSerdeCandidate: boolean;
- }
- >();
-
- for (const filePath of workflowCandidates) {
- const normalizedPath = this.normalizeDiscoveredFilePath(filePath);
- const existing = candidatesByFile.get(normalizedPath);
- if (existing) {
- existing.hasWorkflowCandidate = true;
- } else {
- candidatesByFile.set(normalizedPath, {
- hasWorkflowCandidate: true,
- hasStepCandidate: false,
- hasSerdeCandidate: false,
- });
- }
- }
-
- for (const filePath of stepCandidates) {
- const normalizedPath = this.normalizeDiscoveredFilePath(filePath);
- const existing = candidatesByFile.get(normalizedPath);
- if (existing) {
- existing.hasStepCandidate = true;
- } else {
- candidatesByFile.set(normalizedPath, {
- hasWorkflowCandidate: false,
- hasStepCandidate: true,
- hasSerdeCandidate: false,
- });
- }
- }
-
- if (serdeCandidates) {
- for (const filePath of serdeCandidates) {
- const normalizedPath = this.normalizeDiscoveredFilePath(filePath);
- const existing = candidatesByFile.get(normalizedPath);
- if (existing) {
- existing.hasSerdeCandidate = true;
- } else {
- candidatesByFile.set(normalizedPath, {
- hasWorkflowCandidate: false,
- hasStepCandidate: false,
- hasSerdeCandidate: true,
- });
- }
- }
- }
-
- const fileEntries = Array.from(candidatesByFile.entries()).sort(
- ([a], [b]) => a.localeCompare(b)
- );
- const validatedEntries = await Promise.all(
- fileEntries.map(async ([filePath, candidates]) => {
- try {
- const fileStats = await stat(filePath);
- if (!fileStats.isFile()) {
- return null;
- }
-
- if (!validatePatterns) {
- return {
- filePath,
- hasUseWorkflow: candidates.hasWorkflowCandidate,
- hasUseStep: candidates.hasStepCandidate,
- hasSerde: candidates.hasSerdeCandidate,
- };
- }
-
- const source = await readFile(filePath, 'utf-8');
- const patterns = detectWorkflowPatterns(source);
- return {
- filePath,
- hasUseWorkflow: patterns.hasUseWorkflow,
- hasUseStep: patterns.hasUseStep,
- hasSerde: patterns.hasSerde,
- };
- } catch {
- return null;
- }
- })
- );
-
- const workflowFiles = new Set();
- const stepFiles = new Set();
- const serdeFiles = new Set();
- for (const entry of validatedEntries) {
- if (!entry) {
- continue;
- }
- if (entry.hasUseWorkflow) {
- workflowFiles.add(entry.filePath);
- }
- if (entry.hasUseStep) {
- stepFiles.add(entry.filePath);
- }
- if (entry.hasSerde) {
- serdeFiles.add(entry.filePath);
- }
- }
-
- return { workflowFiles, stepFiles, serdeFiles };
- }
-
- private async validateDiscoveredEntryFiles(): Promise {
- const workflowCandidates = new Set(this.discoveredWorkflowFiles);
- const stepCandidates = new Set(this.discoveredStepFiles);
- const serdeCandidates = new Set(this.discoveredSerdeFiles);
- const { workflowFiles, stepFiles, serdeFiles } =
- await this.reconcileDiscoveredEntries({
- workflowCandidates,
- stepCandidates,
- serdeCandidates,
- validatePatterns: true,
- });
-
- // Reconcile validated entries against the snapshot we started with so
- // file discoveries that arrive during validation are preserved.
- let workflowsChanged = false;
- let stepsChanged = false;
- let serdeChanged = false;
-
- for (const filePath of workflowCandidates) {
- if (!workflowFiles.has(filePath)) {
- workflowsChanged =
- this.discoveredWorkflowFiles.delete(filePath) || workflowsChanged;
- }
- }
- for (const filePath of workflowFiles) {
- if (!this.discoveredWorkflowFiles.has(filePath)) {
- this.discoveredWorkflowFiles.add(filePath);
- workflowsChanged = true;
- }
- }
-
- for (const filePath of stepCandidates) {
- if (!stepFiles.has(filePath)) {
- stepsChanged =
- this.discoveredStepFiles.delete(filePath) || stepsChanged;
- }
- }
- for (const filePath of stepFiles) {
- if (!this.discoveredStepFiles.has(filePath)) {
- this.discoveredStepFiles.add(filePath);
- stepsChanged = true;
- }
- }
-
- for (const filePath of serdeCandidates) {
- if (!serdeFiles.has(filePath)) {
- serdeChanged =
- this.discoveredSerdeFiles.delete(filePath) || serdeChanged;
- }
- }
- for (const filePath of serdeFiles) {
- if (!this.discoveredSerdeFiles.has(filePath)) {
- this.discoveredSerdeFiles.add(filePath);
- serdeChanged = true;
- }
- }
-
- if (workflowsChanged || stepsChanged) {
- this.scheduleWorkflowsCacheWrite();
- }
- }
-
- private async buildDiscoveredFiles(
- inputFiles: string[],
- implicitStepFiles: string[]
- ) {
- const outputDir = await this.findAppDirectory();
- const workflowGeneratedDir = 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');
- const tempRouteFileName = 'route.js.temp';
- const trackedDiscoveredEntries =
- await this.collectTrackedDiscoveredEntries();
- const discoveredStepFileCandidates = Array.from(
- new Set([
- ...this.discoveredStepFiles,
- ...trackedDiscoveredEntries.discoveredSteps,
- ...implicitStepFiles,
- ])
- ).sort();
- const discoveredWorkflowFileCandidates = Array.from(
- new Set([
- ...this.discoveredWorkflowFiles,
- ...trackedDiscoveredEntries.discoveredWorkflows,
- ])
- ).sort();
- const discoveredSerdeFileCandidates = Array.from(
- new Set([
- ...this.discoveredSerdeFiles,
- ...trackedDiscoveredEntries.discoveredSerdeFiles,
- ])
- ).sort();
- const discoveredWorkflowFiles = await this.filterExistingFiles(
- discoveredWorkflowFileCandidates
- );
- const existingStepFileCandidates = await this.filterExistingFiles(
- discoveredStepFileCandidates
- );
- const discoveredStepFiles = await this.collectTransitiveStepFiles({
- entryFiles: [...existingStepFileCandidates, ...discoveredWorkflowFiles],
- stepFiles: existingStepFileCandidates,
- });
- const existingSerdeFileCandidates = await this.filterExistingFiles(
- discoveredSerdeFileCandidates
- );
- const discoveredSerdeFiles = await this.collectTransitiveSerdeFiles({
- entryFiles: [...discoveredStepFiles, ...discoveredWorkflowFiles],
- serdeFiles: existingSerdeFileCandidates,
- });
- const existingInputFiles = await this.filterExistingFiles(inputFiles);
- const buildInputFiles = Array.from(
- new Set([
- ...existingInputFiles,
- ...discoveredStepFiles,
- ...discoveredWorkflowFiles,
- ...discoveredSerdeFiles,
- ])
- ).sort();
- const discoveredEntries = {
- discoveredSteps: new Set(discoveredStepFiles),
- discoveredWorkflows: new Set(discoveredWorkflowFiles),
- discoveredSerdeFiles: new Set(discoveredSerdeFiles),
- };
-
- // Ensure output directories exist
- await mkdir(workflowGeneratedDir, { recursive: true });
-
- await this.writeFileIfChanged(
- join(workflowGeneratedDir, '.gitignore'),
- '*'
- );
-
- const tsconfigPath = await this.findTsConfigPath();
-
- const flowRouteDir = join(workflowGeneratedDir, 'flow');
- await mkdir(flowRouteDir, { recursive: true });
-
- const combinedResult = await this.createDeferredFlowRoute({
- inputFiles: buildInputFiles,
- flowOutfile: join(flowRouteDir, tempRouteFileName),
- tsconfigPath,
- discoveredEntries,
- });
- await this.buildWebhookRoute({
- workflowGeneratedDir,
- routeFileName: tempRouteFileName,
- });
- await this.refreshTrackedDependencyFiles(
- workflowGeneratedDir,
- tempRouteFileName
- );
-
- const manifest = {
- steps: { ...combinedResult?.manifest?.steps },
- workflows: { ...combinedResult?.manifest?.workflows },
- classes: { ...combinedResult?.manifest?.classes },
- };
-
- const manifestFilePath = join(workflowGeneratedDir, 'manifest.json');
- const manifestBuildPath = join(manifestBuildDir, 'manifest.json');
- const workflowBundlePath = join(
- workflowGeneratedDir,
- `flow/${tempRouteFileName}`
- );
- const manifestJson = await this.createManifest({
- workflowBundlePath,
- manifestDir: manifestBuildDir,
- manifest,
- });
- if (manifestJson) {
- await this.rewriteJsonFileWithStableKeyOrder(manifestBuildPath);
- await this.copyFileIfChanged(manifestBuildPath, manifestFilePath);
- } else {
- await rm(manifestBuildPath, { force: true });
- await rm(manifestFilePath, { force: true });
- }
-
- await this.writeFunctionsConfig(outputDir);
-
- // V2: Combined route (flow) — step registrations already at final path
- await this.copyFileIfChanged(
- join(workflowGeneratedDir, `flow/${tempRouteFileName}`),
- join(workflowGeneratedDir, 'flow/route.js')
- );
- await this.copyFileIfChanged(
- join(workflowGeneratedDir, `webhook/[token]/${tempRouteFileName}`),
- join(workflowGeneratedDir, '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.
- if (this.shouldExposePublicManifest && manifestJson) {
- const publicManifestDir = join(
- this.config.workingDir,
- 'public/.well-known/workflow/v1'
- );
- await mkdir(publicManifestDir, { recursive: true });
- if (process.env.VERCEL_DEPLOYMENT_ID === undefined) {
- await this.writeFileIfChanged(
- join(publicManifestDir, '.gitignore'),
- '*'
- );
- }
- await this.copyFileIfChanged(
- manifestFilePath,
- join(publicManifestDir, 'manifest.json')
- );
- }
-
- // Notify deferred entry loaders waiting on route.js stubs.
- this.socketIO?.emit('build-complete');
- }
-
- private async createDeferredFlowRoute({
- inputFiles,
- flowOutfile,
- tsconfigPath,
- discoveredEntries,
- }: {
- inputFiles: string[];
- flowOutfile: string;
- tsconfigPath?: string;
- discoveredEntries: {
- discoveredSteps: Set;
- discoveredWorkflows: Set;
- discoveredSerdeFiles: Set;
- };
- }): Promise<{ manifest: WorkflowManifest }> {
- const workflowResult = await this.createWorkflowsBundle({
- inputFiles,
- outfile: `${flowOutfile}.__wf_tmp.js`,
- format: 'esm',
- bundleFinalOutput: false,
- tsconfigPath,
- discoveredEntries,
- });
-
- const workflowVMCode = workflowResult.interimBundleText;
- if (!workflowVMCode) {
- throw new Error(
- 'createWorkflowsBundle did not return interimBundleText'
- );
- }
-
- try {
- await rm(`${flowOutfile}.__wf_tmp.js`, { force: true });
- } catch {
- // Ignore cleanup errors.
- }
-
- const stepAndSerdeFiles = Array.from(
- new Set([
- ...discoveredEntries.discoveredSteps,
- ...discoveredEntries.discoveredSerdeFiles,
- ])
- ).sort();
- const stepImports = await this.createDeferredStepSideEffectImports(
- stepAndSerdeFiles,
- dirname(flowOutfile)
- );
- const stepManifest =
- await this.createDeferredStepManifest(stepAndSerdeFiles);
- const escapedVMCode = workflowVMCode.replace(/[\\`$]/g, '\\$&');
- const workflowEntrypointOptionsCode =
- createWorkflowEntrypointOptionsCode();
- let routeCode: string;
-
- if (this.config.watch) {
- const workflowCodePath = join(
- dirname(flowOutfile),
- DEV_WORKFLOW_CODE_FILENAME
- );
- await this.writeFileIfChanged(workflowCodePath, workflowVMCode);
- routeCode = `// biome-ignore-all lint: generated file
-/* eslint-disable */
-import 'workflow/internal/builtins';
-${stepImports}
-import { readFile, stat } from 'node:fs/promises';
-import { workflowEntrypoint } from 'workflow/runtime';
-
-const workflowCodePath = ${JSON.stringify(workflowCodePath)};
-let cachedWorkflowHandler;
-let cachedWorkflowCodeSignature;
-
-async function getWorkflowHandler() {
- const workflowCodeStats = await stat(workflowCodePath);
- const workflowCodeSignature = \`\${workflowCodeStats.size}:\${workflowCodeStats.mtimeMs}\`;
- if (!cachedWorkflowHandler || cachedWorkflowCodeSignature !== workflowCodeSignature) {
- const workflowCode = await readFile(workflowCodePath, 'utf8');
- cachedWorkflowHandler = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCode});
- cachedWorkflowCodeSignature = workflowCodeSignature;
- }
- return cachedWorkflowHandler;
-}
-
-export async function POST(req) {
- return (await getWorkflowHandler())(req);
-}`;
- } else {
- routeCode = `// biome-ignore-all lint: generated file
-/* eslint-disable */
-import 'workflow/internal/builtins';
-${stepImports}
-import { workflowEntrypoint } from 'workflow/runtime';
-
-const workflowCode = \`${escapedVMCode}\`;
-
-export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCode});`;
- }
-
- await this.writeFileIfChanged(flowOutfile, routeCode);
-
- return {
- manifest: {
- ...stepManifest,
- workflows: {
- ...stepManifest.workflows,
- ...workflowResult.manifest.workflows,
- },
- classes: {
- ...stepManifest.classes,
- ...workflowResult.manifest.classes,
- },
- },
- };
- }
-
- private async createDeferredStepSideEffectImports(
- files: string[],
- routeDir: string
- ): Promise {
- const importSpecifiers = await Promise.all(
- files.map((file) =>
- this.getDeferredRouteImportSpecifier(file, routeDir)
- )
- );
-
- return importSpecifiers
- .map((specifier) => `import ${JSON.stringify(specifier)};`)
- .join('\n');
- }
-
- private async getDeferredRouteImportSpecifier(
- file: string,
- routeDir: string
- ): Promise {
- const { importPath, isPackage } = getImportPath(
- file,
- this.config.workingDir
- );
-
- if (isPackage) {
- return importPath;
- }
-
- const absolutePath = await this.resolveDeferredRouteImportFilePath(
- resolve(this.config.workingDir, importPath)
- );
- let relativePath = relative(routeDir, absolutePath).replace(/\\/g, '/');
- if (!relativePath.startsWith('./') && !relativePath.startsWith('../')) {
- relativePath = `./${relativePath}`;
- }
- return relativePath;
- }
-
- private async resolveDeferredRouteImportFilePath(
- filePath: string
- ): Promise {
- const packageDistPath =
- this.resolvePackageSourceDistFilePathForRouteImport(filePath);
- if (packageDistPath) {
- return packageDistPath;
- }
-
- return filePath;
- }
-
- private resolvePackageSourceDistFilePathForRouteImport(
- filePath: string
- ): string | null {
- const relativeToWorkingDir = relative(this.config.workingDir, filePath);
- if (
- !relativeToWorkingDir.startsWith('..') &&
- !isAbsolute(relativeToWorkingDir)
- ) {
- return null;
- }
-
- const packageRoot = this.findPackageRootForRouteImportSource(filePath);
- if (!packageRoot) {
- return null;
- }
-
- const relativeToPackageRoot = relative(packageRoot, filePath).replace(
- /\\/g,
- '/'
- );
- const srcPrefix = 'src/';
- if (!relativeToPackageRoot.startsWith(srcPrefix)) {
- return null;
- }
-
- const extension = extname(relativeToPackageRoot);
- const outputExtension =
- extension === '.mts' ? '.mjs' : extension === '.cts' ? '.cjs' : '.js';
- const withoutExtension = relativeToPackageRoot.slice(
- srcPrefix.length,
- relativeToPackageRoot.length - extension.length
- );
- const distPath = join(
- packageRoot,
- 'dist',
- `${withoutExtension}${outputExtension}`
- );
-
- return existsSync(distPath) ? distPath : null;
- }
-
- private findPackageRootForRouteImportSource(
- filePath: string
- ): string | null {
- let currentDir = dirname(filePath);
-
- while (true) {
- if (existsSync(join(currentDir, 'package.json'))) {
- return currentDir;
- }
-
- const parentDir = dirname(currentDir);
- if (parentDir === currentDir) {
- return null;
- }
- currentDir = parentDir;
- }
- }
-
- private async createDeferredStepManifest(
- files: string[]
- ): Promise {
- const manifest: WorkflowManifest = {};
- const manifestFiles = Array.from(
- new Set([
- ...files,
- ...(await this.resolveBuiltInStepFilesForManifest()),
- ])
- ).sort();
-
- for (const file of manifestFiles) {
- const source = await readFile(file, 'utf8').catch(() => undefined);
- if (!source) {
- continue;
- }
-
- const relativeFilepath = await this.getRelativeFilenameForSwc(file);
- const { workflowManifest } = await applySwcTransform(
- relativeFilepath,
- source,
- 'step',
- file,
- this.transformProjectRoot,
- this.moduleSpecifierRoot
- );
- this.mergeWorkflowManifest(manifest, workflowManifest);
- }
-
- return manifest;
- }
-
- private async resolveBuiltInStepFilesForManifest(): Promise {
- try {
- const workflowRequire = createRequire(
- join(this.config.workingDir, 'package.json')
- );
- return [
- this.normalizeDiscoveredFilePath(
- workflowRequire.resolve('workflow/internal/builtins')
- ),
- ];
- } catch {
- return [];
- }
- }
-
- private mergeWorkflowManifest(
- target: WorkflowManifest,
- source: WorkflowManifest
- ): void {
- target.workflows = Object.assign(
- target.workflows || {},
- source.workflows
- );
- target.steps = Object.assign(target.steps || {}, source.steps);
- target.classes = Object.assign(target.classes || {}, source.classes);
- }
-
- private async cleanupGeneratedArtifactsOnBoot(
- outputDir: string
- ): Promise {
- const workflowGeneratedDir = join(outputDir, '.well-known/workflow/v1');
- const flowRouteDir = join(workflowGeneratedDir, 'flow');
- const stepRouteDir = join(workflowGeneratedDir, 'step');
- const webhookRouteDir = join(workflowGeneratedDir, 'webhook/[token]');
-
- const staleArtifactPaths = [
- join(flowRouteDir, 'route.js.temp'),
- join(flowRouteDir, 'route.js.temp.debug.json'),
- join(flowRouteDir, 'route.js.debug.json'),
- join(flowRouteDir, DEV_WORKFLOW_CODE_FILENAME),
- 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,
- join(webhookRouteDir, 'route.js.temp'),
- join(workflowGeneratedDir, 'manifest.json'),
- ];
-
- await Promise.all([
- ...staleArtifactPaths.map((stalePath) =>
- rm(stalePath, { recursive: true, force: true })
- ),
- cleanupStaleSocketInfoFiles(
- join(this.config.workingDir, this.getDistDir())
- ),
- this.removeStaleDeferredTempFiles(flowRouteDir),
- this.removeStaleDeferredTempFiles(webhookRouteDir),
- ]);
- }
-
- private async removeStaleDeferredTempFiles(
- routeDir: string
- ): Promise {
- const routeEntries = await readdir(routeDir, {
- withFileTypes: true,
- }).catch(() => []);
- await Promise.all(
- routeEntries
- .filter(
- (entry) =>
- entry.isFile() &&
- entry.name.startsWith('route.js.') &&
- entry.name.endsWith('.tmp')
- )
- .map((entry) =>
- rm(join(routeDir, entry.name), {
- force: true,
- })
- )
- );
- }
-
- private async createDiscoverySocketServer(): Promise {
- if (this.socketIO || process.env.WORKFLOW_SOCKET_PORT) {
- return;
- }
-
- process.env.WORKFLOW_SOCKET_INFO_PATH = this.getSocketInfoFilePath();
- const config: SocketServerConfig = {
- isDevServer: Boolean(this.config.watch),
- socketInfoFilePath: this.getSocketInfoFilePath(),
- onFileDiscovered: (
- filePath: string,
- hasWorkflow: boolean,
- hasStep: boolean,
- hasSerde: boolean
- ) => {
- const normalizedFilePath = this.normalizeDiscoveredFilePath(filePath);
- let hasCacheTrackingChange = false;
- const wasTrackedDependency =
- this.trackedDependencyFiles.has(normalizedFilePath);
-
- if (hasWorkflow) {
- if (!this.discoveredWorkflowFiles.has(normalizedFilePath)) {
- this.discoveredWorkflowFiles.add(normalizedFilePath);
- hasCacheTrackingChange = true;
- }
- } else {
- const wasDeleted =
- this.discoveredWorkflowFiles.delete(normalizedFilePath);
- hasCacheTrackingChange = wasDeleted || hasCacheTrackingChange;
- }
-
- if (hasStep) {
- if (!this.discoveredStepFiles.has(normalizedFilePath)) {
- this.discoveredStepFiles.add(normalizedFilePath);
- hasCacheTrackingChange = true;
- }
- } else {
- const wasDeleted =
- this.discoveredStepFiles.delete(normalizedFilePath);
- hasCacheTrackingChange = wasDeleted || hasCacheTrackingChange;
- }
-
- if (hasSerde) {
- if (!this.discoveredSerdeFiles.has(normalizedFilePath)) {
- hasCacheTrackingChange = true;
- }
- this.discoveredSerdeFiles.add(normalizedFilePath);
- } else {
- const wasDeleted =
- this.discoveredSerdeFiles.delete(normalizedFilePath);
- hasCacheTrackingChange = wasDeleted || hasCacheTrackingChange;
- }
-
- if (hasCacheTrackingChange) {
- this.scheduleWorkflowsCacheWrite();
- }
-
- if (
- hasWorkflow ||
- hasStep ||
- hasSerde ||
- hasCacheTrackingChange ||
- wasTrackedDependency
- ) {
- this.scheduleDeferredRebuild();
- }
- },
- onTriggerBuild: () => {
- this.scheduleDeferredRebuild();
- },
- };
-
- this.socketIO = await createSocketServer(config);
- }
-
- private async initializeDiscoveryState(): Promise {
- if (this.cacheInitialized) {
- return;
- }
-
- await this.loadWorkflowsCache();
- // Deferred mode must not run eager input-graph discovery; entries are
- // discovered via loader->socket notifications during Next's build.
- this.cacheInitialized = true;
- }
-
- private getDistDir(): string {
- return (this.config as { distDir?: string }).distDir || '.next';
- }
-
- private getWorkflowsCacheFilePath(): string {
- return join(
- this.config.workingDir,
- this.getDistDir(),
- 'cache',
- 'workflows.json'
- );
- }
-
- private getSocketInfoFilePath(): string {
- return join(
- this.config.workingDir,
- this.getDistDir(),
- SOCKET_INFO_FILENAME
- );
- }
-
- private findPackageJsonPath(filePath: string): string | null {
- let currentDir = dirname(filePath);
- let previousDir = '';
-
- while (currentDir !== previousDir) {
- const packageJsonPath = join(currentDir, 'package.json');
- if (existsSync(packageJsonPath)) {
- return packageJsonPath;
- }
- previousDir = currentDir;
- currentDir = dirname(currentDir);
- }
-
- return null;
- }
-
- private shouldPreferSourceBackedPackagePath(filePath: string): boolean {
- const normalizedPath = filePath.replace(/\\/g, '/');
- // Only prefer source for workspace packages (not in node_modules).
- // For tarball-installed packages, using source-backed paths causes
- // esbuild to bundle the full source tree (including world.ts with
- // process.cwd()) instead of externalizing properly.
- if (
- normalizedPath.includes('/packages/') &&
- !normalizedPath.includes('/node_modules/')
- ) {
- return true;
- }
-
- if (!normalizedPath.includes('/node_modules/')) {
- return false;
- }
-
- const packageJsonPath = this.findPackageJsonPath(filePath);
- if (!packageJsonPath) {
- return false;
- }
-
- try {
- const packageJson = JSON.parse(
- readFileSync(packageJsonPath, 'utf-8')
- ) as { name?: unknown };
- return (
- packageJson.name === 'workflow' ||
- (typeof packageJson.name === 'string' &&
- packageJson.name.startsWith('@workflow/'))
- );
- } catch {
- return false;
- }
- }
-
- private normalizeDiscoveredFilePath(filePath: string): string {
- return isAbsolute(filePath)
- ? filePath
- : resolve(this.config.workingDir, filePath);
- }
-
- private async filterExistingFiles(filePaths: string[]): Promise {
- const normalizedFilePaths = Array.from(
- new Set(
- filePaths.map((filePath) =>
- this.normalizeDiscoveredFilePath(filePath)
- )
- )
- ).sort();
-
- const existingFiles = await Promise.all(
- normalizedFilePaths.map(async (filePath) => {
- try {
- const fileStats = await stat(filePath);
- return fileStats.isFile() ? filePath : null;
- } catch {
- return null;
- }
- })
- );
-
- return existingFiles.filter((filePath): filePath is string =>
- Boolean(filePath)
- );
- }
-
- private async createDeferredBuildSignature(
- inputFiles: string[]
- ): Promise {
- const normalizedFiles = Array.from(
- new Set([
- ...inputFiles.map((filePath) =>
- this.normalizeDiscoveredFilePath(filePath)
- ),
- ...this.trackedDependencyFiles,
- ])
- ).sort();
-
- const signatureParts = await Promise.all(
- normalizedFiles.map(async (filePath) => {
- try {
- const fileStats = await stat(filePath);
- return `${filePath}:${fileStats.size}:${Math.trunc(fileStats.mtimeMs)}`;
- } catch {
- return `${filePath}:missing`;
- }
- })
- );
-
- const signatureHash = createHash('sha256');
- for (const signaturePart of signatureParts) {
- signatureHash.update(signaturePart);
- signatureHash.update('\n');
- }
-
- return signatureHash.digest('hex');
- }
-
- private async collectTrackedDiscoveredEntries(): Promise<{
- discoveredSteps: string[];
- discoveredWorkflows: string[];
- discoveredSerdeFiles: string[];
- }> {
- if (this.trackedDependencyFiles.size === 0) {
- return {
- discoveredSteps: [],
- discoveredWorkflows: [],
- discoveredSerdeFiles: [],
- };
- }
-
- const { workflowFiles, stepFiles, serdeFiles } =
- await this.reconcileDiscoveredEntries({
- workflowCandidates: this.trackedDependencyFiles,
- stepCandidates: this.trackedDependencyFiles,
- serdeCandidates: this.trackedDependencyFiles,
- validatePatterns: true,
- });
-
- return {
- discoveredSteps: Array.from(stepFiles).sort(),
- discoveredWorkflows: Array.from(workflowFiles).sort(),
- discoveredSerdeFiles: Array.from(serdeFiles).sort(),
- };
- }
-
- private async refreshTrackedDependencyFiles(
- workflowGeneratedDir: string,
- routeFileName: string
- ): Promise {
- const bundleFiles = [
- join(workflowGeneratedDir, `step/${routeFileName}`),
- join(workflowGeneratedDir, `flow/${routeFileName}`),
- ];
- const trackedFiles = new Set();
-
- for (const bundleFile of bundleFiles) {
- const bundleSources = await this.extractBundleSourceFiles(bundleFile);
- for (const sourceFile of bundleSources) {
- trackedFiles.add(sourceFile);
- }
- }
-
- if (trackedFiles.size > 0) {
- this.trackedDependencyFiles = trackedFiles;
- }
- }
-
- private async extractBundleSourceFiles(
- bundleFilePath: string
- ): Promise {
- let bundleContents: string;
- try {
- bundleContents = await readFile(bundleFilePath, 'utf-8');
- } catch {
- return [];
- }
-
- const baseDirectory = dirname(bundleFilePath);
- const localSourceFiles = new Set();
-
- // Extract inline base64 sourcemaps via string scanning. A previous
- // implementation used `bundleContents.matchAll(/...[A-Za-z0-9+/=]+.../g)`
- // which overflows V8's regex stack
- // (`RangeError: Maximum call stack size exceeded at RegExpStringIterator.next`)
- // on bundles with large inline sourcemaps — V8's irregexp uses
- // recursion for greedy character-class quantifiers and certain long
- // base64 inputs exhaust the call stack.
- const sourceMapPrefix = '//# sourceMappingURL=data:application/json';
- const base64Marker = ';base64,';
- const sourceMapMatches: Array<{ base64Value: string }> = [];
- let scanFrom = 0;
- while (scanFrom < bundleContents.length) {
- const prefixIdx = bundleContents.indexOf(sourceMapPrefix, scanFrom);
- if (prefixIdx === -1) break;
- const base64Start = bundleContents.indexOf(
- base64Marker,
- prefixIdx + sourceMapPrefix.length
- );
- if (base64Start === -1) {
- scanFrom = prefixIdx + sourceMapPrefix.length;
- continue;
- }
- // Bail if a comma appears before `;base64,` — that means this
- // wasn't a `data:application/json[;params];base64,...` URL after
- // all (the comma would terminate the data URL parameters).
- const commaBefore = bundleContents.indexOf(',', prefixIdx);
- if (commaBefore !== -1 && commaBefore < base64Start) {
- scanFrom = base64Start;
- continue;
- }
- const valueStart = base64Start + base64Marker.length;
- // Base64 payload terminates at first non-base64 char (newline,
- // closing `*/`, etc.). Scanning a small alphabet by char is far
- // cheaper than backtracking a regex over a multi-MB capture.
- let valueEnd = valueStart;
- while (valueEnd < bundleContents.length) {
- const code = bundleContents.charCodeAt(valueEnd);
- // A-Z 0x41-0x5A, a-z 0x61-0x7A, 0-9 0x30-0x39, '+' 0x2B,
- // '/' 0x2F, '=' 0x3D
- if (
- !(
- (code >= 0x41 && code <= 0x5a) ||
- (code >= 0x61 && code <= 0x7a) ||
- (code >= 0x30 && code <= 0x39) ||
- code === 0x2b ||
- code === 0x2f ||
- code === 0x3d
- )
- ) {
- break;
- }
- valueEnd++;
- }
- if (valueEnd > valueStart) {
- sourceMapMatches.push({
- base64Value: bundleContents.slice(valueStart, valueEnd),
- });
- }
- scanFrom = valueEnd;
- }
-
- for (const match of sourceMapMatches) {
- const base64Value = match.base64Value;
- if (!base64Value) {
- continue;
- }
-
- let sourceMap: { sourceRoot?: unknown; sources?: unknown };
- try {
- sourceMap = JSON.parse(
- Buffer.from(base64Value, 'base64').toString('utf-8')
- ) as { sourceRoot?: unknown; sources?: unknown };
- } catch {
- continue;
- }
-
- const sourceRoot =
- typeof sourceMap.sourceRoot === 'string' ? sourceMap.sourceRoot : '';
- const sources = Array.isArray(sourceMap.sources)
- ? sourceMap.sources.filter(
- (source): source is string => typeof source === 'string'
- )
- : [];
-
- for (const source of sources) {
- if (source.startsWith('webpack://') || source.startsWith('<')) {
- continue;
- }
-
- let resolvedSourcePath: string;
- if (source.startsWith('file://')) {
- try {
- resolvedSourcePath = decodeURIComponent(new URL(source).pathname);
- } catch {
- continue;
- }
- } else if (isAbsolute(source)) {
- resolvedSourcePath = source;
- } else {
- resolvedSourcePath = resolve(baseDirectory, sourceRoot, source);
- }
-
- const normalizedSourcePath =
- this.normalizeDiscoveredFilePath(resolvedSourcePath);
- const normalizedSourcePathForCheck = normalizedSourcePath.replace(
- /\\/g,
- '/'
- );
- const isSourceBackedPackagePath =
- this.shouldPreferSourceBackedPackagePath(normalizedSourcePath);
- if (
- normalizedSourcePathForCheck.includes('/.well-known/workflow/') ||
- (!isSourceBackedPackagePath &&
- normalizedSourcePathForCheck.includes('/node_modules/')) ||
- (!isSourceBackedPackagePath &&
- normalizedSourcePathForCheck.includes('/.pnpm/')) ||
- normalizedSourcePathForCheck.includes('/.next/') ||
- normalizedSourcePathForCheck.endsWith('/virtual-entry.js')
- ) {
- continue;
- }
-
- localSourceFiles.add(normalizedSourcePath);
- }
- }
-
- return Array.from(localSourceFiles);
- }
-
- private scheduleWorkflowsCacheWrite(): void {
- if (this.cacheWriteTimer) {
- clearTimeout(this.cacheWriteTimer);
- }
-
- this.cacheWriteTimer = setTimeout(() => {
- this.cacheWriteTimer = null;
- void this.writeWorkflowsCache().catch((error) => {
- console.warn('Failed to write workflow discovery cache', error);
- });
- }, 50);
- }
-
- private scheduleDeferredRebuild(): void {
- if (this.deferredRebuildTimer) {
- clearTimeout(this.deferredRebuildTimer);
- }
-
- this.deferredRebuildTimer = setTimeout(() => {
- this.deferredRebuildTimer = null;
- void this.onBeforeDeferredEntries().catch((error) => {
- console.warn(
- '[workflow] Deferred rebuild after source update failed.',
- error
- );
- });
- }, 75);
- }
-
- private async readWorkflowsCache(): Promise<{
- workflowFiles: string[];
- stepFiles: string[];
- } | null> {
- const cacheFilePath = this.getWorkflowsCacheFilePath();
-
- try {
- const cacheContents = await readFile(cacheFilePath, 'utf-8');
- const parsed = JSON.parse(cacheContents) as {
- workflowFiles?: unknown;
- stepFiles?: unknown;
- };
-
- const workflowFiles = Array.isArray(parsed.workflowFiles)
- ? parsed.workflowFiles.filter(
- (item): item is string => typeof item === 'string'
- )
- : [];
- const stepFiles = Array.isArray(parsed.stepFiles)
- ? parsed.stepFiles.filter(
- (item): item is string => typeof item === 'string'
- )
- : [];
-
- return { workflowFiles, stepFiles };
- } catch {
- return null;
- }
- }
-
- private async loadWorkflowsCache(): Promise {
- const cachedData = await this.readWorkflowsCache();
- if (!cachedData) {
- return;
- }
- const { workflowFiles, stepFiles, serdeFiles } =
- await this.reconcileDiscoveredEntries({
- workflowCandidates: cachedData.workflowFiles,
- stepCandidates: cachedData.stepFiles,
- serdeCandidates: this.discoveredSerdeFiles,
- validatePatterns: true,
- });
-
- this.discoveredWorkflowFiles.clear();
- this.discoveredStepFiles.clear();
- this.discoveredSerdeFiles.clear();
- for (const filePath of workflowFiles) {
- this.discoveredWorkflowFiles.add(filePath);
- }
- for (const filePath of stepFiles) {
- this.discoveredStepFiles.add(filePath);
- }
- for (const filePath of serdeFiles) {
- this.discoveredSerdeFiles.add(filePath);
- }
- }
-
- private async writeWorkflowsCache(): Promise {
- const cacheFilePath = this.getWorkflowsCacheFilePath();
- const cacheDir = join(this.config.workingDir, this.getDistDir(), 'cache');
- await mkdir(cacheDir, { recursive: true });
-
- const cacheData = {
- workflowFiles: Array.from(this.discoveredWorkflowFiles).sort(),
- stepFiles: Array.from(this.discoveredStepFiles).sort(),
- };
-
- await writeFile(cacheFilePath, JSON.stringify(cacheData, null, 2));
- }
-
- private async writeStubFiles(outputDir: string): Promise {
- // Turbopack currently has a worker-concurrency limitation for pending
- // virtual entries. Warn if parallelism is too low to reliably discover.
- const parallelismCount = os.availableParallelism();
- if (process.env.TURBOPACK && parallelismCount < 4) {
- console.warn(
- `Available parallelism of ${parallelismCount} is less than needed 4. This can cause workflows/steps to fail to discover properly in turbopack`
- );
- }
-
- const routeStubContent = [
- `// ${ROUTE_STUB_FILE_MARKER}`,
- 'export const __workflowRouteStub = true;',
- ].join('\n');
- const workflowGeneratedDir = join(outputDir, '.well-known/workflow/v1');
-
- await mkdir(join(workflowGeneratedDir, 'flow'), { recursive: true });
- await mkdir(join(workflowGeneratedDir, 'webhook/[token]'), {
- recursive: true,
- });
-
- await this.writeFileIfChanged(
- join(workflowGeneratedDir, '.gitignore'),
- '*'
- );
-
- // V2: Only flow + webhook stubs needed (no separate step route).
- // Stubs are replaced by generated output once discovery finishes.
- await this.writeFileIfChanged(
- join(workflowGeneratedDir, 'flow/route.js'),
- routeStubContent
- );
- await this.writeFileIfChanged(
- join(workflowGeneratedDir, 'webhook/[token]/route.js'),
- routeStubContent
- );
- }
-
- protected async getInputFiles(): Promise {
- // Read Next.js's app-paths-manifest.json from a previous build to
- // determine which files are actual route entrypoints. This avoids
- // predicting Next.js conventions with regexes and instead reads
- // from Next.js's own output.
- const nextDir = join(this.config.workingDir, '.next');
- const manifestPath = join(nextDir, 'app-paths-manifest.json');
- try {
- const manifestContent = readFileSync(manifestPath, 'utf-8');
- const manifest = JSON.parse(manifestContent) as Record;
- // The manifest maps route paths to their source files.
- // Extract the source file paths and resolve them.
- const manifestFiles = new Set();
- for (const sourcePath of Object.values(manifest)) {
- const resolved = resolve(nextDir, 'server', sourcePath);
- // The manifest points to built output; find the source file
- // by matching against the base builder's full file list.
- manifestFiles.add(resolved);
- }
-
- // Use the manifest route paths to filter the input files.
- // A file is included if it matches a known route segment from
- // the manifest (e.g., app/api/route contains 'app/api/route').
- const inputFiles = await super.getInputFiles();
- const routeSegments = Object.keys(manifest).map((route) =>
- route.replace(/^\//, '').replace(/\/route$/, '')
- );
- return inputFiles.filter((item) =>
- routeSegments.some((segment) => item.includes(segment))
- );
- } catch {
- // No manifest from a previous build — fall back to the base
- // builder's full file scan. This is safe but slower; subsequent
- // builds will use the manifest.
- return super.getInputFiles();
- }
- }
-
- private async writeFunctionsConfig(outputDir: string) {
- // we don't run this in development mode as it's not needed
- if (process.env.NODE_ENV === 'development') {
- return;
- }
- // V2: Single combined trigger handles both workflow and step execution
- const generatedConfig = {
- version: '0',
- workflows: {
- maxDuration: 'max',
- experimentalTriggers: [WORKFLOW_QUEUE_TRIGGER],
- },
- };
-
- await this.writeFileIfChanged(
- join(outputDir, '.well-known/workflow/v1/config.json'),
- JSON.stringify(generatedConfig, null, 2)
- );
- }
-
- private async writeFileIfChanged(
- filePath: string,
- contents: string | Buffer
- ): Promise {
- const nextBuffer = Buffer.isBuffer(contents)
- ? contents
- : Buffer.from(contents);
-
- try {
- const currentBuffer = await readFile(filePath);
- if (currentBuffer.equals(nextBuffer)) {
- return false;
- }
- } catch {
- // File does not exist yet or cannot be read; write a fresh copy.
- }
-
- await mkdir(dirname(filePath), { recursive: true });
- await writeFile(filePath, nextBuffer);
- return true;
- }
-
- private async copyFileIfChanged(
- sourcePath: string,
- destinationPath: string
- ): Promise {
- const sourceContents = await readFile(sourcePath);
- return this.writeFileIfChanged(destinationPath, sourceContents);
- }
-
- private sortJsonValue(value: unknown): unknown {
- if (Array.isArray(value)) {
- return value.map((item) => this.sortJsonValue(item));
- }
- if (value && typeof value === 'object') {
- const sortedEntries = Object.entries(value as Record)
- .sort(([a], [b]) => a.localeCompare(b))
- .map(([key, entryValue]) => [key, this.sortJsonValue(entryValue)]);
- return Object.fromEntries(sortedEntries);
- }
- return value;
- }
-
- private async rewriteJsonFileWithStableKeyOrder(
- filePath: string
- ): Promise {
- try {
- const contents = await readFile(filePath, 'utf-8');
- const parsed = JSON.parse(contents) as unknown;
- const normalized = this.sortJsonValue(parsed);
- await this.writeFileIfChanged(
- filePath,
- `${JSON.stringify(normalized, null, 2)}\n`
- );
- } catch {
- // Manifest may not exist (e.g. manifest generation failed); ignore.
- }
- }
-
- private extractRelativeImportSpecifiers(source: string): string[] {
- return this.extractImportSpecifiers(source).filter((specifier) =>
- specifier.startsWith('.')
- );
- }
-
- private extractImportSpecifiers(source: string): string[] {
- const relativeSpecifiers = new Set();
- const importPatterns = [
- /from\s+['"]([^'"]+)['"]/g,
- /import\s+['"]([^'"]+)['"]/g,
- /import\(\s*['"]([^'"]+)['"]\s*\)/g,
- /require\(\s*['"]([^'"]+)['"]\s*\)/g,
- ];
-
- for (const importPattern of importPatterns) {
- for (const match of source.matchAll(importPattern)) {
- const specifier = match[1];
- if (specifier) {
- relativeSpecifiers.add(specifier);
- }
- }
- }
-
- return Array.from(relativeSpecifiers);
- }
-
- private async getRelativeFilenameForSwc(filePath: string): Promise {
- const workingDir = this.config.workingDir;
- const normalizedWorkingDir = workingDir
- .replace(/\\/g, '/')
- .replace(/\/$/, '');
- const normalizedFilepath = filePath.replace(/\\/g, '/');
-
- // Windows fix: Use case-insensitive comparison to work around drive letter casing issues.
- const lowerWd = normalizedWorkingDir.toLowerCase();
- const lowerPath = normalizedFilepath.toLowerCase();
-
- let relativeFilename: string;
- if (lowerPath.startsWith(`${lowerWd}/`)) {
- relativeFilename = normalizedFilepath.substring(
- normalizedWorkingDir.length + 1
- );
- } else if (lowerPath === lowerWd) {
- relativeFilename = '.';
- } else {
- relativeFilename = relative(workingDir, filePath).replace(/\\/g, '/');
- if (relativeFilename.startsWith('../')) {
- const aliasedRelativePath = await resolveWorkflowAliasRelativePath(
- filePath,
- workingDir
- );
- if (aliasedRelativePath) {
- relativeFilename = aliasedRelativePath;
- } else {
- relativeFilename = relativeFilename
- .split('/')
- .filter((part) => part !== '..')
- .join('/');
- }
- }
- }
-
- if (relativeFilename.includes(':') || relativeFilename.startsWith('/')) {
- relativeFilename = basename(normalizedFilepath);
- }
-
- return relativeFilename;
- }
-
- private resolveImportTargetWithExtensionFallbacks(
- targetPath: string
- ): string {
- if (existsSync(targetPath)) {
- return targetPath;
- }
-
- const extensionMatch = targetPath.match(/(\.[^./\\]+)$/);
- const extension = extensionMatch?.[1]?.toLowerCase();
- if (!extension) {
- return targetPath;
- }
-
- const extensionFallbacks =
- extension === '.js'
- ? ['.ts', '.tsx', '.mts', '.cts']
- : extension === '.mjs'
- ? ['.mts']
- : extension === '.cjs'
- ? ['.cts']
- : extension === '.jsx'
- ? ['.tsx']
- : [];
-
- if (extensionFallbacks.length === 0) {
- return targetPath;
- }
-
- const targetWithoutExtension = targetPath.slice(0, -extension.length);
- for (const fallbackExtension of extensionFallbacks) {
- const fallbackPath = `${targetWithoutExtension}${fallbackExtension}`;
- if (existsSync(fallbackPath)) {
- return fallbackPath;
- }
- }
-
- return targetPath;
- }
-
- private shouldSkipTransitiveStepFile(filePath: string): boolean {
- const normalizedPath = filePath.replace(/\\/g, '/');
- const isSourceBackedPackagePath =
- this.shouldPreferSourceBackedPackagePath(filePath);
- return (
- normalizedPath.includes('/.well-known/workflow/') ||
- normalizedPath.includes('/.next/') ||
- (!isSourceBackedPackagePath &&
- (normalizedPath.includes('/node_modules/') ||
- normalizedPath.includes('/.pnpm/')))
- );
- }
-
- private async resolveTransitiveStepImportTargetPath(
- sourceFilePath: string,
- specifier: string
- ): Promise {
- const specifierMatch = specifier.match(/^([^?#]+)(.*)$/);
- const importPath = specifierMatch?.[1] ?? specifier;
-
- if (!importPath.startsWith('.')) {
- if (importPath !== 'workflow' && !importPath.startsWith('@workflow/')) {
- return null;
- }
-
- try {
- const resolvedPath =
- createRequire(sourceFilePath).resolve(importPath);
- const normalizedResolvedPath =
- this.normalizeDiscoveredFilePath(resolvedPath);
- if (this.shouldSkipTransitiveStepFile(normalizedResolvedPath)) {
- return null;
- }
-
- const fileStats = await stat(normalizedResolvedPath);
- return fileStats.isFile() ? normalizedResolvedPath : null;
- } catch {
- return null;
- }
- }
-
- const absoluteTargetPath = resolve(dirname(sourceFilePath), importPath);
-
- const candidatePaths = new Set([
- this.resolveImportTargetWithExtensionFallbacks(absoluteTargetPath),
- ]);
-
- if (!extname(absoluteTargetPath)) {
- const extensionCandidates = [
- '.ts',
- '.tsx',
- '.mts',
- '.cts',
- '.js',
- '.jsx',
- '.mjs',
- '.cjs',
- ];
- for (const extensionCandidate of extensionCandidates) {
- candidatePaths.add(`${absoluteTargetPath}${extensionCandidate}`);
- candidatePaths.add(
- join(absoluteTargetPath, `index${extensionCandidate}`)
- );
- }
- }
-
- for (const candidatePath of candidatePaths) {
- const resolvedPath =
- this.resolveImportTargetWithExtensionFallbacks(candidatePath);
- const normalizedResolvedPath =
- this.normalizeDiscoveredFilePath(resolvedPath);
-
- if (this.shouldSkipTransitiveStepFile(normalizedResolvedPath)) {
- continue;
- }
-
- try {
- const fileStats = await stat(normalizedResolvedPath);
- if (fileStats.isFile()) {
- return normalizedResolvedPath;
- }
- } catch {
- // Try the next candidate path.
- }
- }
-
- return null;
- }
-
- private async collectTransitiveStepFiles({
- entryFiles,
- stepFiles,
- }: {
- entryFiles: string[];
- stepFiles: string[];
- }): Promise {
- const normalizedEntryFiles = Array.from(
- new Set(
- entryFiles.map((entryFile) =>
- this.normalizeDiscoveredFilePath(entryFile)
- )
- )
- ).sort();
- const normalizedStepSeedFiles = Array.from(
- new Set(
- stepFiles.map((stepFile) =>
- this.normalizeDiscoveredFilePath(stepFile)
- )
- )
- ).sort();
- const discoveredStepFiles = new Set(normalizedStepSeedFiles);
- const queuedFiles = Array.from(
- new Set([...normalizedEntryFiles, ...normalizedStepSeedFiles])
- );
- const visitedFiles = new Set();
- const sourceCache = new Map();
- const patternCache = new Map<
- string,
- ReturnType | null
- >();
-
- const getSource = async (filePath: string): Promise => {
- if (sourceCache.has(filePath)) {
- return sourceCache.get(filePath) ?? null;
- }
- try {
- const source = await readFile(filePath, 'utf-8');
- sourceCache.set(filePath, source);
- return source;
- } catch {
- sourceCache.set(filePath, null);
- return null;
- }
- };
-
- const getPatterns = async (
- filePath: string
- ): Promise | null> => {
- if (patternCache.has(filePath)) {
- return patternCache.get(filePath) ?? null;
- }
- const source = await getSource(filePath);
- if (source === null) {
- patternCache.set(filePath, null);
- return null;
- }
- const patterns = detectWorkflowPatterns(source);
- patternCache.set(filePath, patterns);
- return patterns;
- };
-
- while (queuedFiles.length > 0) {
- const currentFile = queuedFiles.pop();
- if (!currentFile || visitedFiles.has(currentFile)) {
- continue;
- }
- visitedFiles.add(currentFile);
-
- const currentSource = await getSource(currentFile);
- if (currentSource === null) {
- continue;
- }
-
- const importSpecifiers = this.extractImportSpecifiers(currentSource);
- for (const specifier of importSpecifiers) {
- const resolvedImportPath =
- await this.resolveTransitiveStepImportTargetPath(
- currentFile,
- specifier
- );
- if (!resolvedImportPath) {
- continue;
- }
-
- if (!visitedFiles.has(resolvedImportPath)) {
- queuedFiles.push(resolvedImportPath);
- }
-
- const importPatterns = await getPatterns(resolvedImportPath);
- if (importPatterns?.hasUseStep) {
- discoveredStepFiles.add(resolvedImportPath);
- }
- }
- }
-
- return Array.from(discoveredStepFiles).sort();
- }
-
- private async collectTransitiveSerdeFiles({
- entryFiles,
- serdeFiles,
- }: {
- entryFiles: string[];
- serdeFiles: string[];
- }): Promise {
- const normalizedEntryFiles = Array.from(
- new Set(
- entryFiles.map((entryFile) =>
- this.normalizeDiscoveredFilePath(entryFile)
- )
- )
- ).sort();
- const normalizedSerdeSeedFiles = Array.from(
- new Set(
- serdeFiles.map((serdeFile) =>
- this.normalizeDiscoveredFilePath(serdeFile)
- )
- )
- ).sort();
- // Intentionally re-validate serde seeds against source patterns.
- // This keeps previously discovered/manual seed entries from sticking when
- // files no longer match serde patterns.
- const discoveredSerdeFiles = new Set();
- const queuedFiles = Array.from(
- new Set([...normalizedEntryFiles, ...normalizedSerdeSeedFiles])
- );
- const visitedFiles = new Set();
- const sourceCache = new Map();
- const patternCache = new Map<
- string,
- ReturnType | null
- >();
-
- const getSource = async (filePath: string): Promise => {
- if (sourceCache.has(filePath)) {
- return sourceCache.get(filePath) ?? null;
- }
- try {
- const source = await readFile(filePath, 'utf-8');
- sourceCache.set(filePath, source);
- return source;
- } catch {
- sourceCache.set(filePath, null);
- return null;
- }
- };
-
- const getPatterns = async (
- filePath: string
- ): Promise | null> => {
- if (patternCache.has(filePath)) {
- return patternCache.get(filePath) ?? null;
- }
- const source = await getSource(filePath);
- if (source === null) {
- patternCache.set(filePath, null);
- return null;
- }
- const patterns = detectWorkflowPatterns(source);
- patternCache.set(filePath, patterns);
- return patterns;
- };
-
- for (const serdeSeedFile of normalizedSerdeSeedFiles) {
- const seedPatterns = await getPatterns(serdeSeedFile);
- if (seedPatterns?.hasSerde) {
- discoveredSerdeFiles.add(serdeSeedFile);
- }
- }
-
- while (queuedFiles.length > 0) {
- const currentFile = queuedFiles.pop();
- if (!currentFile || visitedFiles.has(currentFile)) {
- continue;
- }
- visitedFiles.add(currentFile);
-
- const currentSource = await getSource(currentFile);
- if (currentSource === null) {
- continue;
- }
-
- const relativeImportSpecifiers =
- this.extractRelativeImportSpecifiers(currentSource);
- for (const specifier of relativeImportSpecifiers) {
- const resolvedImportPath =
- await this.resolveTransitiveStepImportTargetPath(
- currentFile,
- specifier
- );
- if (!resolvedImportPath) {
- continue;
- }
-
- if (!visitedFiles.has(resolvedImportPath)) {
- queuedFiles.push(resolvedImportPath);
- }
-
- const importPatterns = await getPatterns(resolvedImportPath);
- if (importPatterns?.hasSerde) {
- discoveredSerdeFiles.add(resolvedImportPath);
- }
- }
- }
-
- // AST-level verification: run SWC detect mode on regex-matched candidates
- // to confirm they actually define serde classes. This prevents SDK internal
- // files (which match serde regex patterns but define no classes) from being
- // bundled into the workflow sandbox.
- const projectRoot = this.config.projectRoot || this.config.workingDir;
- const verifiedSerdeFiles: string[] = [];
- await Promise.all(
- Array.from(discoveredSerdeFiles).map(async (filePath) => {
- const source = await getSource(filePath);
- if (!source) return;
- try {
- const relativeFilename =
- await this.getRelativeFilenameForSwc(filePath);
- const { workflowManifest } = await applySwcTransform(
- relativeFilename,
- source,
- 'detect',
- filePath,
- projectRoot,
- this.moduleSpecifierRoot
- );
- // Only include files that actually define serde classes
- const hasClasses =
- workflowManifest.classes &&
- Object.values(workflowManifest.classes).some(
- (entries) => Object.keys(entries).length > 0
- );
- if (hasClasses) {
- verifiedSerdeFiles.push(filePath);
- }
- } catch {
- // If detect fails, include the file to be safe
- verifiedSerdeFiles.push(filePath);
- }
- })
- );
-
- return verifiedSerdeFiles.sort();
- }
-
- private async buildWebhookRoute({
- workflowGeneratedDir,
- routeFileName = 'route.js',
- }: {
- workflowGeneratedDir: string;
- routeFileName?: string;
- }): Promise {
- const webhookRouteFile = join(
- workflowGeneratedDir,
- `webhook/[token]/${routeFileName}`
- );
- await this.createWebhookBundle({
- outfile: webhookRouteFile,
- bundle: false, // Next.js doesn't need bundling
- });
- }
-
- private async findAppDirectory(): Promise {
- const appDir = resolve(this.config.workingDir, 'app');
- const srcAppDir = resolve(this.config.workingDir, 'src/app');
- const pagesDir = resolve(this.config.workingDir, 'pages');
- const srcPagesDir = resolve(this.config.workingDir, 'src/pages');
-
- // Helper to check if a path exists and is a directory
- const isDirectory = async (path: string): Promise => {
- try {
- await access(path, constants.F_OK);
- const stats = await stat(path);
- if (!stats.isDirectory()) {
- throw new Error(`Path exists but is not a directory: ${path}`);
- }
- return true;
- } catch (e) {
- if (e instanceof Error && e.message.includes('not a directory')) {
- throw e;
- }
- return false;
- }
- };
-
- // Check if app directory exists
- if (await isDirectory(appDir)) {
- return appDir;
- }
-
- // Check if src/app directory exists
- if (await isDirectory(srcAppDir)) {
- return srcAppDir;
- }
-
- // If no app directory exists, check for pages directory and create app next to it
- if (await isDirectory(pagesDir)) {
- // Create app directory next to pages directory
- await mkdir(appDir, { recursive: true });
- return appDir;
- }
-
- if (await isDirectory(srcPagesDir)) {
- // Create src/app directory next to src/pages directory
- await mkdir(srcAppDir, { recursive: true });
- return srcAppDir;
- }
-
- throw new Error(
- 'Could not find Next.js app or pages directory. Expected one of: "app", "src/app", "pages", or "src/pages" to exist.'
- );
- }
- }
-
- CachedNextBuilderDeferred = NextDeferredBuilder;
- return NextDeferredBuilder;
-}
diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts
index 93d587d951..fa1a78e74e 100644
--- a/packages/next/src/builder-eager.ts
+++ b/packages/next/src/builder-eager.ts
@@ -3,7 +3,6 @@ import { access, copyFile, mkdir, stat, writeFile } from 'node:fs/promises';
import { extname, join, resolve } from 'node:path';
import type { WorkflowManifest } from '@workflow/builders';
import Watchpack from 'watchpack';
-import { cleanupStaleSocketInfoFiles } from './socket-server.js';
let CachedNextBuilderEager: any;
@@ -25,16 +24,6 @@ export async function getNextBuilderEager() {
class NextBuilder extends BaseBuilderClass {
async build() {
- // Eager mode never starts a discovery socket server, so any leftover
- // workflow-socket.json is from a previous deferred-mode build and
- // would make the webpack loader connect to a dead port.
- await cleanupStaleSocketInfoFiles(
- join(
- this.config.workingDir,
- (this.config as { distDir?: string }).distDir || '.next'
- )
- );
-
const outputDir = await this.findAppDirectory();
const workflowGeneratedDir = join(outputDir, '.well-known/workflow/v1');
@@ -96,26 +85,31 @@ export async function getNextBuilderEager() {
if (this.config.watch) {
// TODO: implement watch mode for combined bundle
// For now, fall back to full rebuild on file changes
- let stepsCtx = combinedResult?.stepsContext;
- if (!stepsCtx) {
+ if (!combinedResult?.interimBundleCtx || !combinedResult.bundleFinal) {
throw new Error(
- 'Invariant: expected steps build context in watch mode'
+ 'Invariant: expected workflow build context in watch mode'
);
}
- // Use stepsCtx for the watch rebuild (workflow interim ctx from combined)
+ // Step registrations may be emitted as source imports without an
+ // esbuild context when externalizeNonSteps is enabled.
+ let stepsCtx = combinedResult.stepsContext;
let workflowsCtx = {
- interimBundleCtx: combinedResult?.interimBundleCtx!,
- bundleFinal: combinedResult?.bundleFinal!,
+ interimBundleCtx: combinedResult.interimBundleCtx,
+ bundleFinal: combinedResult.bundleFinal,
};
const normalizePath = (pathname: string) =>
pathname.replace(/\\/g, '/');
- const knownFiles = new Set();
type WatchpackTimeInfoEntry = {
safeTime: number;
timestamp?: number;
};
+ type FileChanges = {
+ addedFiles: string[];
+ modifiedFiles: string[];
+ removedFiles: string[];
+ };
let previousTimeInfo = new Map();
const watchableExtensions = new Set([
@@ -195,18 +189,14 @@ export async function getNextBuilderEager() {
};
const fullRebuild = async () => {
+ this.clearDiscoveredEntriesCache();
const newInputFiles = await this.getInputFiles();
options.inputFiles = newInputFiles;
- await stepsCtx!.dispose();
+ await stepsCtx?.dispose();
await workflowsCtx.interimBundleCtx.dispose();
const newCombined = await this.buildCombinedFunction(options);
- if (!newCombined?.stepsContext) {
- throw new Error(
- 'Invariant: expected steps build context after rebuild'
- );
- }
stepsCtx = newCombined.stepsContext;
if (!newCombined?.interimBundleCtx || !newCombined?.bundleFinal) {
@@ -222,62 +212,26 @@ export async function getNextBuilderEager() {
await writeManifest(newCombined.manifest);
};
- const logBuildMessages = (
- result: {
- errors?: import('esbuild').Message[];
- warnings?: import('esbuild').Message[];
- },
- label: string
- ) => {
- const logByType = (
- messages: import('esbuild').Message[] | undefined,
- method: 'error' | 'warn'
- ) => {
- if (!messages || messages.length === 0) {
- return;
- }
- const descriptor = method === 'error' ? 'errors' : 'warnings';
- console[method](`${descriptor} while rebuilding ${label}`);
- for (const message of messages) {
- console[method](message);
- }
- };
-
- logByType(result.errors, 'error');
- logByType(result.warnings, 'warn');
- };
-
- const rebuildExistingFiles = async () => {
- const rebuiltStepStart = Date.now();
- const stepsResult = await stepsCtx!.rebuild();
- logBuildMessages(stepsResult, 'steps bundle');
- console.log(
- 'Rebuilt steps bundle',
- `${Date.now() - rebuiltStepStart}ms`
- );
+ const isWatchableFile = (path: string) =>
+ watchableExtensions.has(extname(path));
- const rebuiltWorkflowStart = Date.now();
- const workflowResult = await workflowsCtx.interimBundleCtx.rebuild();
- logBuildMessages(workflowResult, 'workflows bundle');
+ const normalizeWatchpackPaths = (paths?: Iterable) => {
+ const normalizedPaths: string[] = [];
+ if (!paths) {
+ return normalizedPaths;
+ }
- if (
- !workflowResult.outputFiles ||
- workflowResult.outputFiles.length === 0
- ) {
- console.error(
- 'No output generated while rebuilding workflows bundle'
- );
- return;
+ for (const path of paths) {
+ const normalizedPath = normalizePath(path);
+ if (isWatchableFile(normalizedPath)) {
+ normalizedPaths.push(normalizedPath);
+ }
}
- await workflowsCtx.bundleFinal(workflowResult.outputFiles[0].text);
- console.log(
- 'Rebuilt workflow bundle',
- `${Date.now() - rebuiltWorkflowStart}ms`
- );
+
+ return normalizedPaths;
};
- const isWatchableFile = (path: string) =>
- watchableExtensions.has(extname(path));
+ const unique = (paths: string[]) => [...new Set(paths)];
const getComparableTimestamp = (entry: WatchpackTimeInfoEntry) =>
entry.timestamp ?? entry.safeTime;
@@ -326,7 +280,7 @@ export async function getNextBuilderEager() {
const determineFileChanges = (
currentEntries: Map,
previousEntries: Map
- ) => {
+ ): FileChanges => {
const removedFiles = findRemovedFiles(
currentEntries,
previousEntries
@@ -343,50 +297,90 @@ export async function getNextBuilderEager() {
};
};
- let isInitial = true;
-
- watcher.on('aggregated', () => {
- const currentEntries = readTimeInfoEntries();
- const { addedFiles, modifiedFiles, removedFiles } =
- determineFileChanges(currentEntries, previousTimeInfo);
+ const mergeFileChanges = ({
+ currentEntries,
+ previousEntries,
+ timestampChanges,
+ eventChangedFiles,
+ eventRemovedFiles,
+ }: {
+ currentEntries: Map;
+ previousEntries: Map;
+ timestampChanges: FileChanges;
+ eventChangedFiles: string[];
+ eventRemovedFiles: string[];
+ }): FileChanges => ({
+ addedFiles: unique([
+ ...timestampChanges.addedFiles,
+ ...eventChangedFiles.filter(
+ (path) => currentEntries.has(path) && !previousEntries.has(path)
+ ),
+ ]),
+ modifiedFiles: unique([
+ ...timestampChanges.modifiedFiles,
+ ...eventChangedFiles,
+ ]),
+ removedFiles: unique([
+ ...timestampChanges.removedFiles,
+ ...eventRemovedFiles,
+ ]),
+ });
- previousTimeInfo = currentEntries;
+ const hasFileChanges = ({
+ addedFiles,
+ modifiedFiles,
+ removedFiles,
+ }: FileChanges) =>
+ addedFiles.length > 0 ||
+ modifiedFiles.length > 0 ||
+ removedFiles.length > 0;
- if (isInitial) {
- isInitial = false;
- return;
- }
+ let isInitial = true;
- if (
- addedFiles.length === 0 &&
- modifiedFiles.length === 0 &&
- removedFiles.length === 0
- ) {
- return;
- }
+ watcher.on(
+ 'aggregated',
+ (changes?: Set, removals?: Set) => {
+ const currentEntries = readTimeInfoEntries();
+ const eventChangedFiles = normalizeWatchpackPaths(changes);
+ const eventRemovedFiles = normalizeWatchpackPaths(removals);
+ const timestampChanges = determineFileChanges(
+ currentEntries,
+ previousTimeInfo
+ );
- for (const removal of removedFiles) {
- knownFiles.delete(removal);
- }
- for (const added of addedFiles) {
- knownFiles.add(added);
- }
+ const fileChanges = mergeFileChanges({
+ currentEntries,
+ previousEntries: previousTimeInfo,
+ timestampChanges,
+ eventChangedFiles,
+ eventRemovedFiles,
+ });
+
+ previousTimeInfo = currentEntries;
+
+ if (isInitial) {
+ isInitial = false;
+ if (
+ eventChangedFiles.length === 0 &&
+ eventRemovedFiles.length === 0
+ ) {
+ return;
+ }
+ }
- enqueue(async () => {
- if (addedFiles.length > 0 || removedFiles.length > 0) {
- await fullRebuild();
+ if (!hasFileChanges(fileChanges)) {
return;
}
- if (modifiedFiles.length > 0) {
- await rebuildExistingFiles();
- }
- });
- });
+ enqueue(async () => {
+ await fullRebuild();
+ });
+ }
+ );
watcher.watch({
directories: [this.config.workingDir],
- startTime: 0,
+ startTime: Date.now(),
});
}
}
@@ -456,6 +450,7 @@ export async function getNextBuilderEager() {
flowOutfile: join(flowRouteDir, 'route.js'),
bundleFinalOutput: false,
externalizeNonSteps: true,
+ sourceStepRegistrationImports: true,
tsconfigPath,
});
}
diff --git a/packages/next/src/builder.test.ts b/packages/next/src/builder.test.ts
deleted file mode 100644
index 6e2d92035e..0000000000
--- a/packages/next/src/builder.test.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { afterEach, describe, expect, it } from 'vitest';
-import { shouldUseDeferredBuilder } from './builder.js';
-import { parseEnvironmentFlag } from './environment-flag.js';
-
-const originalLazyDiscoveryEnv = process.env.WORKFLOW_NEXT_LAZY_DISCOVERY;
-
-afterEach(() => {
- if (originalLazyDiscoveryEnv === undefined) {
- delete process.env.WORKFLOW_NEXT_LAZY_DISCOVERY;
- } else {
- process.env.WORKFLOW_NEXT_LAZY_DISCOVERY = originalLazyDiscoveryEnv;
- }
-});
-
-describe('shouldUseDeferredBuilder', () => {
- it('treats WORKFLOW_NEXT_LAZY_DISCOVERY=0 as disabled', () => {
- process.env.WORKFLOW_NEXT_LAZY_DISCOVERY = '0';
-
- expect(shouldUseDeferredBuilder('16.2.1')).toBe(false);
- });
-
- it('treats WORKFLOW_NEXT_LAZY_DISCOVERY=false as disabled', () => {
- process.env.WORKFLOW_NEXT_LAZY_DISCOVERY = 'false';
-
- expect(shouldUseDeferredBuilder('16.2.1')).toBe(false);
- });
-
- it('treats WORKFLOW_NEXT_LAZY_DISCOVERY=off as disabled', () => {
- process.env.WORKFLOW_NEXT_LAZY_DISCOVERY = 'off';
-
- expect(parseEnvironmentFlag(process.env.WORKFLOW_NEXT_LAZY_DISCOVERY)).toBe(
- false
- );
- expect(shouldUseDeferredBuilder('16.2.1')).toBe(false);
- });
-
- it('enables deferred mode for compatible versions when the env is enabled', () => {
- process.env.WORKFLOW_NEXT_LAZY_DISCOVERY = '1';
-
- expect(shouldUseDeferredBuilder('16.2.1')).toBe(true);
- });
-});
diff --git a/packages/next/src/builder.ts b/packages/next/src/builder.ts
index e4a4e9ec56..b8574d714f 100644
--- a/packages/next/src/builder.ts
+++ b/packages/next/src/builder.ts
@@ -1,39 +1,5 @@
-import semver from 'semver';
-import { getNextBuilderDeferred } from './builder-deferred.js';
import { getNextBuilderEager } from './builder-eager.js';
-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/v1/webhook/[token]',
-] as const;
-
-let warnedAboutFlagAndVersion = false;
-
-export function shouldUseDeferredBuilder(nextVersion: string): boolean {
- const flagEnabled =
- parseEnvironmentFlag(process.env.WORKFLOW_NEXT_LAZY_DISCOVERY) ?? false;
- const versionCompatible = semver.gte(
- nextVersion,
- DEFERRED_BUILDER_MIN_VERSION
- );
-
- if (flagEnabled && !versionCompatible && !warnedAboutFlagAndVersion) {
- warnedAboutFlagAndVersion = true;
- console.warn(
- `lazyDiscovery requires Next.js >= ${DEFERRED_BUILDER_MIN_VERSION} (found ${nextVersion}); falling back to eager workflow discovery.`
- );
- }
-
- return flagEnabled && versionCompatible;
-}
-
-export async function getNextBuilder(nextVersion: string) {
- if (shouldUseDeferredBuilder(nextVersion)) {
- return getNextBuilderDeferred();
- }
+export async function getNextBuilder(_nextVersion: string) {
return getNextBuilderEager();
}
diff --git a/packages/next/src/environment-flag.ts b/packages/next/src/environment-flag.ts
deleted file mode 100644
index 3436a7303c..0000000000
--- a/packages/next/src/environment-flag.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'off']);
-
-export function parseEnvironmentFlag(
- rawValue: string | undefined
-): boolean | undefined {
- const normalizedValue = rawValue?.trim().toLowerCase();
- if (!normalizedValue) {
- return undefined;
- }
-
- if (FALSE_ENV_VALUES.has(normalizedValue)) {
- return false;
- }
-
- return true;
-}
diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts
index b9419893f4..d60dc14692 100644
--- a/packages/next/src/index.test.ts
+++ b/packages/next/src/index.test.ts
@@ -14,7 +14,7 @@ const {
buildMock,
builderConfigs,
getNextBuilderMock,
- shouldUseDeferredBuilderMock,
+ prewarmWorkflowSwcPluginCacheMock,
} = vi.hoisted(() => {
const buildMock = vi.fn(async () => {});
const builderConfigs: Record[] = [];
@@ -27,35 +27,27 @@ const {
}
};
});
- const shouldUseDeferredBuilderMock = vi.fn(() => false);
+ const prewarmWorkflowSwcPluginCacheMock = vi.fn();
return {
buildMock,
builderConfigs,
getNextBuilderMock,
- shouldUseDeferredBuilderMock,
+ prewarmWorkflowSwcPluginCacheMock,
};
});
vi.mock('./builder.js', () => ({
getNextBuilder: getNextBuilderMock,
- shouldUseDeferredBuilder: shouldUseDeferredBuilderMock,
- WORKFLOW_DEFERRED_ENTRIES: [
- '/.well-known/workflow/v1/flow',
- '/.well-known/workflow/v1/step',
- '/.well-known/workflow/v1/webhook/[token]',
- ],
+}));
+
+vi.mock('./swc-plugin-cache.js', () => ({
+ prewarmWorkflowSwcPluginCache: prewarmWorkflowSwcPluginCacheMock,
}));
import { withWorkflow } from './index.js';
-const loaderStubPath = join(
- process.cwd(),
- 'packages',
- 'next',
- 'src',
- 'loader.js'
-);
+const loaderStubPath = join(__dirname, 'loader.js');
const hadLoaderStub = existsSync(loaderStubPath);
const realTmpDir = realpathSync(tmpdir());
@@ -70,7 +62,6 @@ describe('withWorkflow builder config', () => {
PORT: process.env.PORT,
VERCEL_DEPLOYMENT_ID: process.env.VERCEL_DEPLOYMENT_ID,
WORKFLOW_LOCAL_DATA_DIR: process.env.WORKFLOW_LOCAL_DATA_DIR,
- WORKFLOW_NEXT_LAZY_DISCOVERY: process.env.WORKFLOW_NEXT_LAZY_DISCOVERY,
WORKFLOW_NEXT_PRIVATE_BUILT: process.env.WORKFLOW_NEXT_PRIVATE_BUILT,
WORKFLOW_TARGET_WORLD: process.env.WORKFLOW_TARGET_WORLD,
};
@@ -79,7 +70,7 @@ describe('withWorkflow builder config', () => {
buildMock.mockClear();
builderConfigs.length = 0;
getNextBuilderMock.mockClear();
- shouldUseDeferredBuilderMock.mockClear();
+ prewarmWorkflowSwcPluginCacheMock.mockClear();
if (!hadLoaderStub) {
writeFileSync(loaderStubPath, 'module.exports = {};\n', 'utf-8');
@@ -88,7 +79,6 @@ describe('withWorkflow builder config', () => {
delete process.env.PORT;
delete process.env.VERCEL_DEPLOYMENT_ID;
delete process.env.WORKFLOW_LOCAL_DATA_DIR;
- delete process.env.WORKFLOW_NEXT_LAZY_DISCOVERY;
delete process.env.WORKFLOW_NEXT_PRIVATE_BUILT;
delete process.env.WORKFLOW_TARGET_WORLD;
});
@@ -130,19 +120,26 @@ describe('withWorkflow builder config', () => {
});
});
- it('enables lazyDiscovery by default', async () => {
- withWorkflow({});
- expect(process.env.WORKFLOW_NEXT_LAZY_DISCOVERY).toBe('1');
- });
+ it.each([
+ 'phase-production-build',
+ 'phase-development-server',
+ ])('prewarms the SWC plugin cache during %s', async (phase) => {
+ const config = withWorkflow({});
- it('enables lazyDiscovery when explicitly set to true', async () => {
- withWorkflow({}, { workflows: { lazyDiscovery: true } });
- expect(process.env.WORKFLOW_NEXT_LAZY_DISCOVERY).toBe('1');
+ await config(phase, { defaultConfig: {} });
+
+ expect(prewarmWorkflowSwcPluginCacheMock).toHaveBeenCalledOnce();
+ expect(prewarmWorkflowSwcPluginCacheMock).toHaveBeenCalledWith(
+ process.cwd()
+ );
});
- it('disables lazyDiscovery when explicitly set to false', async () => {
- withWorkflow({}, { workflows: { lazyDiscovery: false } });
- expect(process.env.WORKFLOW_NEXT_LAZY_DISCOVERY).toBeUndefined();
+ it('does not prewarm the SWC plugin cache for the production server', async () => {
+ const config = withWorkflow({});
+
+ await config('phase-production-server', { defaultConfig: {} });
+
+ expect(prewarmWorkflowSwcPluginCacheMock).not.toHaveBeenCalled();
});
it('configures diagnostics inside the default Next.js dist dir', async () => {
@@ -219,36 +216,6 @@ describe('withWorkflow builder config', () => {
expect(webpackConfig?.externals).toEqual([{ react: 'commonjs react' }]);
});
- it('preserves an explicit lazyDiscovery disable override', () => {
- process.env.WORKFLOW_NEXT_LAZY_DISCOVERY = '0';
-
- withWorkflow(
- {},
- {
- workflows: {
- lazyDiscovery: true,
- },
- }
- );
-
- expect(process.env.WORKFLOW_NEXT_LAZY_DISCOVERY).toBe('0');
- });
-
- it('treats an empty lazyDiscovery env override as unset', () => {
- process.env.WORKFLOW_NEXT_LAZY_DISCOVERY = '';
-
- withWorkflow(
- {},
- {
- workflows: {
- lazyDiscovery: true,
- },
- }
- );
-
- expect(process.env.WORKFLOW_NEXT_LAZY_DISCOVERY).toBe('1');
- });
-
it('removes workflow packages from serverExternalPackages for this build', async () => {
const projectDir = mkdtempSync(
join(realTmpDir, 'workflow-next-server-external-')
diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts
index dcaaf26cf0..12033ee6d0 100644
--- a/packages/next/src/index.ts
+++ b/packages/next/src/index.ts
@@ -3,12 +3,7 @@ import { copyFile, mkdir, readFile } from 'node:fs/promises';
import { dirname, isAbsolute, join } from 'node:path';
import type { NextConfig } from 'next';
import semver from 'semver';
-import {
- getNextBuilder,
- shouldUseDeferredBuilder,
- WORKFLOW_DEFERRED_ENTRIES,
-} from './builder.js';
-import { parseEnvironmentFlag } from './environment-flag.js';
+import { getNextBuilder } from './builder.js';
const VERCEL_WORLD_PACKAGE = '@workflow/world-vercel';
const VERCEL_WORLD_DEPENDENCY_PACKAGES = [
@@ -326,7 +321,6 @@ export function withWorkflow(
workflows,
}: {
workflows?: {
- lazyDiscovery?: boolean;
local?: {
port?: number;
};
@@ -341,25 +335,6 @@ export function withWorkflow(
};
} = {}
) {
- // lazyDiscovery defaults to true; pass `lazyDiscovery: false` to force eager
- // discovery (scanning the project at startup) instead of deferring workflow
- // discovery until files are requested. The `WORKFLOW_NEXT_LAZY_DISCOVERY`
- // environment variable, if set, takes precedence over the option.
- const lazyDiscoveryOverride = parseEnvironmentFlag(
- process.env.WORKFLOW_NEXT_LAZY_DISCOVERY
- );
- if (lazyDiscoveryOverride === undefined) {
- if (workflows?.lazyDiscovery === false) {
- delete process.env.WORKFLOW_NEXT_LAZY_DISCOVERY;
- } else {
- process.env.WORKFLOW_NEXT_LAZY_DISCOVERY = '1';
- }
- } else {
- process.env.WORKFLOW_NEXT_LAZY_DISCOVERY = lazyDiscoveryOverride
- ? '1'
- : '0';
- }
-
if (!process.env.VERCEL_DEPLOYMENT_ID) {
if (!process.env.WORKFLOW_TARGET_WORLD) {
process.env.WORKFLOW_TARGET_WORLD = 'local';
@@ -379,9 +354,18 @@ export function withWorkflow(
phase: string,
ctx: { defaultConfig: NextConfig }
) {
- const loaderPath = require.resolve('./loader');
- let runDeferredBuildFromCallback: (() => Promise) | undefined;
+ if (
+ phase === 'phase-development-server' ||
+ phase === 'phase-production-build'
+ ) {
+ const { prewarmWorkflowSwcPluginCache } = await import(
+ './swc-plugin-cache.js'
+ );
+ // Loader workers inherit this cwd and read from the same SWC cache.
+ prewarmWorkflowSwcPluginCache(process.cwd());
+ }
+ const loaderPath = require.resolve('./loader');
let nextConfig: NextConfig;
if (typeof nextConfigOrFn === 'function') {
@@ -460,7 +444,6 @@ export function withWorkflow(
const existingRules = nextConfig.turbopack.rules as any;
const nextVersion = resolveNextVersion(process.cwd());
const supportsTurboCondition = semver.gte(nextVersion, 'v16.0.0');
- const useDeferredBuilder = shouldUseDeferredBuilder(nextVersion);
const shouldWatch = process.env.NODE_ENV === 'development';
let workflowBuilderPromise: Promise | undefined;
@@ -495,10 +478,6 @@ export function withWorkflow(
stepsBundlePath: '', // not used in base
webhookBundlePath: '', // node used in base
sourcemap: workflows?.sourcemap,
- suppressCreateWorkflowsBundleLogs: useDeferredBuilder,
- suppressCreateWorkflowsBundleWarnings: useDeferredBuilder,
- suppressCreateWebhookBundleLogs: useDeferredBuilder,
- suppressCreateManifestLogs: useDeferredBuilder,
externalPackages: [
// server-only and client-only are pseudo-packages handled by Next.js
// during its build process. We mark them as external to prevent esbuild
@@ -515,50 +494,6 @@ export function withWorkflow(
return workflowBuilderPromise;
};
- if (useDeferredBuilder) {
- runDeferredBuildFromCallback = async () => {
- const workflowBuilder = await getWorkflowBuilder();
- if (typeof workflowBuilder.onBeforeDeferredEntries === 'function') {
- await workflowBuilder.onBeforeDeferredEntries();
- }
- };
-
- const existingExperimental = (nextConfig.experimental ?? {}) as Record<
- string,
- any
- >;
- const existingDeferredEntries = Array.isArray(
- existingExperimental.deferredEntries
- )
- ? existingExperimental.deferredEntries
- : [];
- const existingOnBeforeDeferredEntries =
- typeof existingExperimental.onBeforeDeferredEntries === 'function'
- ? existingExperimental.onBeforeDeferredEntries
- : undefined;
-
- nextConfig.experimental = {
- ...existingExperimental,
-
- // biome-ignore lint/suspicious/noTsIgnore: expect-error is wrong as it will work on valid version
- // @ts-ignore this is only available in canary Next.js
- deferredEntries: [
- ...new Set([
- ...existingDeferredEntries,
- ...WORKFLOW_DEFERRED_ENTRIES,
- ]),
- ],
- onBeforeDeferredEntries: async (...args: unknown[]) => {
- if (existingOnBeforeDeferredEntries) {
- await existingOnBeforeDeferredEntries(...args);
- }
- if (runDeferredBuildFromCallback) {
- await runDeferredBuildFromCallback();
- }
- },
- };
- }
-
for (const key of [
'*.tsx',
'*.ts',
diff --git a/packages/next/src/loader.test.ts b/packages/next/src/loader.test.ts
deleted file mode 100644
index b13ee68561..0000000000
--- a/packages/next/src/loader.test.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { shouldNotifySocketForDiscoveredPattern } from './loader.js';
-
-describe('workflow loader discovery notifications', () => {
- it('notifies for unchanged files that still contain workflow patterns', () => {
- expect(
- shouldNotifySocketForDiscoveredPattern(false, {
- hasWorkflow: true,
- hasStep: false,
- hasSerde: false,
- })
- ).toBe(true);
- expect(
- shouldNotifySocketForDiscoveredPattern(false, {
- hasWorkflow: false,
- hasStep: true,
- hasSerde: false,
- })
- ).toBe(true);
- expect(
- shouldNotifySocketForDiscoveredPattern(false, {
- hasWorkflow: false,
- hasStep: false,
- hasSerde: true,
- })
- ).toBe(true);
- });
-
- it('only notifies for plain files when pattern state changed', () => {
- const plainPatternState = {
- hasWorkflow: false,
- hasStep: false,
- hasSerde: false,
- };
-
- expect(
- shouldNotifySocketForDiscoveredPattern(false, plainPatternState)
- ).toBe(false);
- expect(
- shouldNotifySocketForDiscoveredPattern(true, plainPatternState)
- ).toBe(true);
- });
-});
diff --git a/packages/next/src/loader.ts b/packages/next/src/loader.ts
index 60d38c79a7..11895225a8 100644
--- a/packages/next/src/loader.ts
+++ b/packages/next/src/loader.ts
@@ -1,14 +1,6 @@
import { existsSync } from 'node:fs';
-import { readFile } from 'node:fs/promises';
-import { connect, type Socket } from 'node:net';
import { dirname, join, relative } from 'node:path';
import { transform } from '@swc/core';
-import {
- parseMessage,
- SOCKET_INFO_FILENAME,
- type SocketMessage,
- serializeMessage,
-} from './socket-server.js';
type DecoratorOptionsWithConfigPath =
import('@workflow/builders').DecoratorOptionsWithConfigPath;
@@ -27,75 +19,6 @@ type LoaderStaticDependencies = {
};
let cachedLoaderStaticDependencies: LoaderStaticDependencies | null = null;
-type DiscoveredPatternState = {
- hasWorkflow: boolean;
- hasStep: boolean;
- hasSerde: boolean;
-};
-const discoveredPatternStateByFilePath = new Map<
- string,
- DiscoveredPatternState
->();
-
-export function shouldNotifySocketForDiscoveredPattern(
- patternStateChanged: boolean,
- patternState: DiscoveredPatternState
-): boolean {
- return (
- patternStateChanged ||
- patternState.hasWorkflow ||
- patternState.hasStep ||
- patternState.hasSerde
- );
-}
-
-// Cache socket connection to avoid reconnecting on every file.
-let socketClientPromise: Promise | null = null;
-let socketClient: Socket | null = null;
-let socketClientKey: string | null = null;
-
-type SocketCredentials = {
- port: number;
- authToken: string;
-};
-
-/**
- * Wrap a TCP connect failure with context about where the connection was
- * attempted, where the credentials came from, and which source file was
- * being processed when it failed. ECONNREFUSED is the common case here, and
- * the message points the user at the most likely root cause (stale
- * socket-info file).
- */
-function annotateConnectionError(
- originalError: unknown,
- credentials: SocketCredentials
-): Error {
- const errorCode =
- originalError instanceof Error &&
- 'code' in originalError &&
- typeof (originalError as { code?: unknown }).code === 'string'
- ? ((originalError as { code: string }).code as string)
- : undefined;
- const errorMessage =
- originalError instanceof Error
- ? originalError.message
- : String(originalError);
-
- const lines = [
- `Workflow discovery socket connect failed: ${errorCode ?? errorMessage} (127.0.0.1:${credentials.port})`,
- ];
-
- const annotated = new Error(lines.join('\n'));
- if (originalError instanceof Error) {
- (annotated as { cause?: unknown }).cause = originalError;
- }
- return annotated;
-}
-
-const ROUTE_STUB_FILE_MARKER = 'WORKFLOW_ROUTE_STUB_FILE';
-const ROUTE_STUB_BUILD_WAIT_TIMEOUT_MS = 120_000;
-let pendingDeferredRouteStubBuildPromise: Promise | null = null;
-
function registerFileDependency(
loaderContext: WorkflowLoaderContext,
dependencyPath: string
@@ -104,35 +27,6 @@ function registerFileDependency(
loaderContext.addBuildDependency?.(dependencyPath);
}
-function updateDiscoveredPatternState(
- filePath: string,
- nextState: DiscoveredPatternState
-): { shouldNotify: boolean; previousState?: DiscoveredPatternState } {
- const previousState = discoveredPatternStateByFilePath.get(filePath);
- const hasAnyPattern =
- nextState.hasWorkflow || nextState.hasStep || nextState.hasSerde;
-
- if (!hasAnyPattern) {
- if (!previousState) {
- return { shouldNotify: false };
- }
- discoveredPatternStateByFilePath.delete(filePath);
- return { shouldNotify: true, previousState };
- }
-
- if (
- previousState &&
- previousState.hasWorkflow === nextState.hasWorkflow &&
- previousState.hasStep === nextState.hasStep &&
- previousState.hasSerde === nextState.hasSerde
- ) {
- return { shouldNotify: false, previousState };
- }
-
- discoveredPatternStateByFilePath.set(filePath, nextState);
- return { shouldNotify: true, previousState };
-}
-
function addIfExists(files: Set, dependencyPath: string): void {
if (existsSync(dependencyPath)) {
files.add(dependencyPath);
@@ -160,7 +54,6 @@ function resolveLoaderStaticDependencies(): LoaderStaticDependencies {
const files = new Set([
__filename,
- require.resolve('./socket-server'),
swcPluginPath,
swcPluginBuildHashPath,
workflowBuildersPath,
@@ -186,354 +79,6 @@ function registerTransformDependencies(
return staticDependencies.swcPluginPath;
}
-function resetSocketClient(cachedSocket?: Socket): void {
- if (cachedSocket && socketClient && socketClient !== cachedSocket) {
- return;
- }
-
- socketClientPromise = null;
- socketClient = null;
- socketClientKey = null;
-}
-
-async function writeSocketMessage(
- socket: Socket,
- message: string
-): Promise {
- await new Promise((resolve, reject) => {
- socket.write(message, (error?: Error | null) => {
- if (error) {
- reject(error);
- return;
- }
- resolve();
- });
- });
-}
-
-function getSocketInfoFilePath(): string | null {
- const configuredPath = process.env.WORKFLOW_SOCKET_INFO_PATH;
- if (configuredPath) {
- return configuredPath;
- }
-
- // Fallback for worker processes that don't inherit dynamic env updates
- // from the process that created the socket server.
- const distDir = process.env.WORKFLOW_NEXT_DIST_DIR || '.next';
- const cwdFallbackPath = join(process.cwd(), distDir, SOCKET_INFO_FILENAME);
- const projectRoot = process.env.WORKFLOW_PROJECT_ROOT;
- if (projectRoot) {
- const projectRootFallbackPath = join(
- projectRoot,
- distDir,
- SOCKET_INFO_FILENAME
- );
- if (existsSync(projectRootFallbackPath)) {
- return projectRootFallbackPath;
- }
- }
- return cwdFallbackPath;
-}
-
-function getSocketCredentialsFromEnv(): SocketCredentials | null {
- const socketPort = process.env.WORKFLOW_SOCKET_PORT;
- const authToken = process.env.WORKFLOW_SOCKET_AUTH;
- if (!socketPort || !authToken) {
- return null;
- }
-
- const port = Number.parseInt(socketPort, 10);
- if (Number.isNaN(port)) {
- return null;
- }
- return { port, authToken };
-}
-
-async function getSocketCredentialsFromFile(): Promise {
- const socketInfoFilePath = getSocketInfoFilePath();
- if (!socketInfoFilePath) {
- return null;
- }
- if (!existsSync(socketInfoFilePath)) {
- return null;
- }
-
- try {
- const raw = await readFile(socketInfoFilePath, 'utf8');
- const parsed = JSON.parse(raw) as {
- port?: unknown;
- authToken?: unknown;
- };
- const authToken =
- typeof parsed.authToken === 'string' ? parsed.authToken : null;
- const numericPort =
- typeof parsed.port === 'number'
- ? parsed.port
- : Number.parseInt(String(parsed.port), 10);
-
- if (!authToken || Number.isNaN(numericPort)) {
- return null;
- }
- return {
- port: numericPort,
- authToken,
- };
- } catch {
- return null;
- }
-}
-
-async function getSocketCredentials(): Promise {
- const envCredentials = getSocketCredentialsFromEnv();
- if (envCredentials) {
- return envCredentials;
- }
- return await getSocketCredentialsFromFile();
-}
-
-async function getSocketClient(): Promise {
- const socketCredentials = await getSocketCredentials();
- if (!socketCredentials) {
- return null;
- }
-
- if (socketClient?.destroyed) {
- resetSocketClient(socketClient);
- }
-
- const currentSocketKey = `${socketCredentials.port}:${socketCredentials.authToken}`;
- if (socketClientKey && socketClientKey !== currentSocketKey) {
- if (socketClient) {
- resetSocketClient(socketClient);
- } else {
- resetSocketClient();
- }
- }
-
- if (!socketClientPromise) {
- socketClientPromise = (async () => {
- try {
- const socket = connect({
- port: socketCredentials.port,
- host: '127.0.0.1',
- });
-
- // Wait for connection
- await new Promise((resolve, reject) => {
- const onConnect = () => {
- socket.setNoDelay(true);
- cleanup();
- resolve();
- };
- const onError = (error: Error) => {
- cleanup();
- reject(annotateConnectionError(error, socketCredentials));
- };
- const timeout = setTimeout(() => {
- cleanup();
- socket.destroy();
- reject(
- annotateConnectionError(
- new Error('Socket connection timeout'),
- socketCredentials
- )
- );
- }, 1000);
- const cleanup = () => {
- clearTimeout(timeout);
- socket.off('connect', onConnect);
- socket.off('error', onError);
- };
-
- socket.on('connect', onConnect);
- socket.on('error', onError);
- });
-
- socket.on('close', () => {
- resetSocketClient(socket);
- });
- socket.on('error', () => {
- resetSocketClient(socket);
- });
-
- socketClient = socket;
- socketClientKey = currentSocketKey;
- return socket;
- } catch (error) {
- resetSocketClient();
- throw error;
- }
- })();
- }
- return socketClientPromise;
-}
-
-async function notifySocketServer(
- filename: string,
- hasWorkflow: boolean,
- hasStep: boolean,
- hasSerde: boolean
-): Promise {
- const socketCredentials = await getSocketCredentials();
- if (!socketCredentials) {
- return;
- }
-
- const socket = await getSocketClient();
- if (!socket) {
- throw new Error('Invariant: missing workflow socket connection');
- }
-
- const message: SocketMessage = {
- type: 'file-discovered',
- filePath: filename,
- hasWorkflow,
- hasStep,
- hasSerde,
- };
- const serializedMessage = serializeMessage(
- message,
- socketCredentials.authToken
- );
-
- try {
- await writeSocketMessage(socket, serializedMessage);
- } catch (error) {
- resetSocketClient(socket);
- const reconnectedSocket = await getSocketClient();
- if (!reconnectedSocket) {
- throw error;
- }
- await writeSocketMessage(reconnectedSocket, serializedMessage);
- }
-}
-
-function isWorkflowRouteStubSource(source: string): boolean {
- return source.includes(ROUTE_STUB_FILE_MARKER);
-}
-
-async function createSocketConnection(
- socketCredentials: SocketCredentials,
- timeoutMs = 1_000
-): Promise {
- return await new Promise((resolve, reject) => {
- const socket = connect({
- port: socketCredentials.port,
- host: '127.0.0.1',
- });
- const timeout = setTimeout(() => {
- cleanup();
- socket.destroy();
- reject(
- annotateConnectionError(
- new Error('Socket connection timeout'),
- socketCredentials
- )
- );
- }, timeoutMs);
- const cleanup = () => {
- clearTimeout(timeout);
- socket.off('connect', onConnect);
- socket.off('error', onError);
- };
- const onConnect = () => {
- socket.setNoDelay(true);
- cleanup();
- resolve(socket);
- };
- const onError = (error: Error) => {
- cleanup();
- socket.destroy();
- reject(annotateConnectionError(error, socketCredentials));
- };
-
- socket.on('connect', onConnect);
- socket.on('error', onError);
- });
-}
-
-async function waitForDeferredBuildComplete(
- socket: Socket,
- authToken: string,
- timeoutMs = ROUTE_STUB_BUILD_WAIT_TIMEOUT_MS
-): Promise {
- await new Promise((resolve, reject) => {
- let buffer = '';
- const timeout = setTimeout(() => {
- cleanup();
- reject(
- new Error('Timed out waiting for deferred route build completion')
- );
- }, timeoutMs);
- const cleanup = () => {
- clearTimeout(timeout);
- socket.off('data', onData);
- socket.off('error', onError);
- socket.off('close', onClose);
- socket.off('end', onClose);
- };
- const onError = (error: Error) => {
- cleanup();
- reject(error);
- };
- const onClose = () => {
- cleanup();
- reject(new Error('Socket closed before deferred route build completed'));
- };
- const onData = (chunk: Buffer) => {
- buffer += chunk.toString();
- let newlineIndex = buffer.indexOf('\n');
- while (newlineIndex !== -1) {
- const line = buffer.slice(0, newlineIndex);
- buffer = buffer.slice(newlineIndex + 1);
- newlineIndex = buffer.indexOf('\n');
-
- const message = parseMessage(line, authToken);
- if (message?.type === 'build-complete') {
- cleanup();
- resolve();
- return;
- }
- }
- };
-
- socket.on('data', onData);
- socket.on('error', onError);
- socket.on('close', onClose);
- socket.on('end', onClose);
- });
-}
-
-async function triggerDeferredRouteStubBuildAndWait(): Promise {
- const socketCredentials = await getSocketCredentials();
- if (!socketCredentials) {
- return;
- }
- const socket = await createSocketConnection(socketCredentials);
- try {
- await writeSocketMessage(
- socket,
- serializeMessage({ type: 'trigger-build' }, socketCredentials.authToken)
- );
- await waitForDeferredBuildComplete(socket, socketCredentials.authToken);
- } finally {
- socket.destroy();
- }
-}
-
-async function ensureDeferredRouteStubBuildAndWait(): Promise {
- if (pendingDeferredRouteStubBuildPromise) {
- return pendingDeferredRouteStubBuildPromise;
- }
- const pendingPromise = triggerDeferredRouteStubBuildAndWait();
- pendingDeferredRouteStubBuildPromise = pendingPromise.finally(() => {
- if (pendingDeferredRouteStubBuildPromise === pendingPromise) {
- pendingDeferredRouteStubBuildPromise = null;
- }
- });
- return pendingDeferredRouteStubBuildPromise;
-}
-
async function getBuildersModule(): Promise<
typeof import('@workflow/builders')
> {
@@ -680,52 +225,12 @@ export default function workflowLoader(
const isGeneratedWorkflowFile = await checkGeneratedFile(filename);
// Skip generated workflow route files to avoid re-processing them.
- // Deferred route stubs are a special case: wait for the generated route
- // output to become available before returning.
if (isGeneratedWorkflowFile) {
- if (
- process.env.WORKFLOW_NEXT_LAZY_DISCOVERY === '1' &&
- isWorkflowRouteStubSource(normalizedSource)
- ) {
- try {
- await ensureDeferredRouteStubBuildAndWait();
- const refreshedSource = await readFile(filename, 'utf8');
- if (!isWorkflowRouteStubSource(refreshedSource)) {
- return { code: refreshedSource, map: sourceMap };
- }
- } catch (error) {
- console.warn(
- `[workflow] Failed waiting for deferred route build for ${filename}, using stub output`,
- error
- );
- }
- }
return { code: normalizedSource, map: sourceMap };
}
// Detect workflow patterns in the source code.
const patterns = await detectPatterns(sourceForTransform);
- // Always notify discovery tracking, even for `false/false`, so files that
- // previously had workflow/step usage are removed from the tracked sets.
- const nextPatternState: DiscoveredPatternState = {
- hasWorkflow: patterns.hasUseWorkflow,
- hasStep: patterns.hasUseStep,
- hasSerde: patterns.hasSerde,
- };
- const { shouldNotify } = updateDiscoveredPatternState(
- filename,
- nextPatternState
- );
- if (
- shouldNotifySocketForDiscoveredPattern(shouldNotify, nextPatternState)
- ) {
- await notifySocketServer(
- filename,
- nextPatternState.hasWorkflow,
- nextPatternState.hasStep,
- nextPatternState.hasSerde
- );
- }
// Check if file needs transformation based on patterns and path
if (!(await checkShouldTransform(filename, patterns))) {
@@ -775,6 +280,7 @@ export default function workflowLoader(
},
target: 'es2022',
experimental: {
+ cacheRoot: join(workingDir, '.swc'),
plugins: [[swcPluginPath, { mode, moduleSpecifier }]],
},
transform: {
diff --git a/packages/next/src/socket-server.ts b/packages/next/src/socket-server.ts
deleted file mode 100644
index 2f82c219c5..0000000000
--- a/packages/next/src/socket-server.ts
+++ /dev/null
@@ -1,258 +0,0 @@
-import { randomBytes } from 'node:crypto';
-import { mkdir, rm, writeFile } from 'node:fs/promises';
-import { createServer, type Server, type Socket } from 'node:net';
-import { dirname, join } from 'node:path';
-
-/**
- * Magic preamble that must prefix all messages to authenticate them as workflow messages.
- * This prevents accidental processing of messages from port scanners or other local processes.
- */
-const MESSAGE_PREAMBLE = 'WF:';
-
-/**
- * Generate a random authentication token for this server session.
- * Clients must include this token in all messages.
- */
-function generateAuthToken(): string {
- return randomBytes(16).toString('hex');
-}
-
-/**
- * Message types that can be sent between loader and builder
- */
-export type SocketMessage =
- | {
- type: 'file-discovered';
- filePath: string;
- hasWorkflow: boolean;
- hasStep: boolean;
- hasSerde: boolean;
- }
- | { type: 'trigger-build' }
- | { type: 'build-complete' };
-
-/**
- * Configuration for the socket server
- */
-export interface SocketServerConfig {
- isDevServer: boolean;
- onFileDiscovered: (
- filePath: string,
- hasWorkflow: boolean,
- hasStep: boolean,
- hasSerde: boolean
- ) => void;
- onTriggerBuild: () => void;
- socketInfoFilePath?: string;
-}
-
-/**
- * Interface for the socket IO instance returned by createSocketServer
- */
-export interface SocketIO {
- emit(event: 'build-complete'): void;
- getAuthToken(): string;
-}
-
-/**
- * Filename for the socket-info file.
- */
-export const SOCKET_INFO_FILENAME = 'workflow-socket.json';
-
-/**
- * Previous filesystem location for the socket-info file. This file lives
- * inside `.next/cache/`, which Vercel and Turborepo preserve across builds —
- * a stale file from a prior build would cause the loader to attempt to
- * connect to a dead port (ECONNREFUSED). The current location is a sibling
- * of `cache/` so it isn't preserved.
- *
- * Exported so the builders can unlink the legacy path at boot, cleaning up
- * any leftover file written by older versions of the SDK.
- */
-export const LEGACY_SOCKET_INFO_RELATIVE_PATH = join(
- 'cache',
- SOCKET_INFO_FILENAME
-);
-
-function getDefaultSocketInfoFilePath(): string {
- return join(process.cwd(), '.next', SOCKET_INFO_FILENAME);
-}
-
-/**
- * Remove any stale socket-info files at boot.
- * @param distDir absolute path to the project's `.next` directory.
- */
-export async function cleanupStaleSocketInfoFiles(
- distDir: string
-): Promise {
- await Promise.all([
- rm(join(distDir, SOCKET_INFO_FILENAME), { force: true }),
- rm(join(distDir, LEGACY_SOCKET_INFO_RELATIVE_PATH), { force: true }),
- ]);
-}
-
-/**
- * Serialize a message with authentication preamble
- */
-export function serializeMessage(
- message: SocketMessage,
- authToken: string
-): string {
- return `${MESSAGE_PREAMBLE}${authToken}:${JSON.stringify(message)}\n`;
-}
-
-/**
- * Parse and authenticate a message from the socket
- * Returns the parsed message if valid, null otherwise
- */
-export function parseMessage(
- line: string,
- authToken: string
-): SocketMessage | null {
- const trimmed = line.trim();
- if (!trimmed) {
- return null;
- }
-
- // Check for preamble
- if (!trimmed.startsWith(MESSAGE_PREAMBLE)) {
- console.warn('Received message without valid preamble, ignoring');
- return null;
- }
-
- // Extract auth token and payload
- const withoutPreamble = trimmed.slice(MESSAGE_PREAMBLE.length);
- const colonIndex = withoutPreamble.indexOf(':');
- if (colonIndex === -1) {
- console.warn('Received message without auth token separator, ignoring');
- return null;
- }
-
- const messageToken = withoutPreamble.slice(0, colonIndex);
- const payload = withoutPreamble.slice(colonIndex + 1);
-
- // Verify auth token
- if (messageToken !== authToken) {
- console.warn('Received message with invalid auth token, ignoring');
- return null;
- }
-
- // Parse JSON payload
- try {
- return JSON.parse(payload) as SocketMessage;
- } catch (error) {
- console.error('Failed to parse socket message JSON:', error);
- return null;
- }
-}
-
-/**
- * Create a TCP socket server for loader<->builder communication.
- * Returns a SocketIO interface for broadcasting messages and the auth token.
- *
- * SECURITY: Server listens on 127.0.0.1 (localhost only) and uses
- * message authentication to prevent processing of unauthorized messages.
- */
-export async function createSocketServer(
- config: SocketServerConfig
-): Promise {
- const authToken = generateAuthToken();
- const clients = new Set();
- let buildTriggered = false;
-
- const server: Server = createServer((socket: Socket) => {
- socket.setNoDelay(true);
- clients.add(socket);
-
- // Send build-complete if build already finished (production mode)
- if (buildTriggered && !config.isDevServer) {
- socket.write(serializeMessage({ type: 'build-complete' }, authToken));
- }
-
- let buffer = '';
-
- socket.on('data', (data: Buffer) => {
- buffer += data.toString();
-
- // Process complete messages (newline-delimited)
- let newlineIndex = buffer.indexOf('\n');
- while (newlineIndex !== -1) {
- const line = buffer.slice(0, newlineIndex);
- buffer = buffer.slice(newlineIndex + 1);
- newlineIndex = buffer.indexOf('\n');
-
- const message = parseMessage(line, authToken);
- if (!message) {
- continue;
- }
-
- if (message.type === 'file-discovered') {
- config.onFileDiscovered(
- message.filePath,
- message.hasWorkflow,
- message.hasStep,
- message.hasSerde
- );
- } else if (message.type === 'trigger-build') {
- config.onTriggerBuild();
- }
- }
- });
-
- socket.on('end', () => {
- clients.delete(socket);
- });
-
- socket.on('error', (err: Error) => {
- console.error('Socket error:', err);
- clients.delete(socket);
- });
- });
-
- // Listen on random available port (localhost only)
- await new Promise((resolve, reject) => {
- server.once('error', reject);
- server.listen(0, '127.0.0.1', () => {
- const address = server.address();
- if (address && typeof address === 'object') {
- const socketInfoFilePath =
- config.socketInfoFilePath || getDefaultSocketInfoFilePath();
- void (async () => {
- try {
- await mkdir(dirname(socketInfoFilePath), { recursive: true });
- await writeFile(
- socketInfoFilePath,
- JSON.stringify(
- {
- port: address.port,
- authToken,
- },
- null,
- 2
- )
- );
- process.env.WORKFLOW_SOCKET_INFO_PATH = socketInfoFilePath;
- process.env.WORKFLOW_SOCKET_PORT = String(address.port);
- process.env.WORKFLOW_SOCKET_AUTH = authToken;
- resolve();
- } catch (error) {
- reject(error);
- }
- })();
- return;
- }
- reject(new Error('Failed to obtain workflow socket server address'));
- });
- });
-
- return {
- emit: (_event: 'build-complete') => {
- buildTriggered = true;
- const message = serializeMessage({ type: 'build-complete' }, authToken);
- for (const client of clients) {
- client.write(message);
- }
- },
- getAuthToken: () => authToken,
- };
-}
diff --git a/packages/next/src/swc-plugin-cache.integration.test.ts b/packages/next/src/swc-plugin-cache.integration.test.ts
new file mode 100644
index 0000000000..168077618a
--- /dev/null
+++ b/packages/next/src/swc-plugin-cache.integration.test.ts
@@ -0,0 +1,62 @@
+import { execFile } from 'node:child_process';
+import { mkdtemp, readdir, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { promisify } from 'node:util';
+import { describe, expect, it, onTestFinished } from 'vitest';
+import { prewarmWorkflowSwcPluginCache } from './swc-plugin-cache.js';
+
+const execFileAsync = promisify(execFile);
+
+const WORKER_SOURCE = `
+const [swcCorePath, pluginPath, cacheRoot, workerId] = process.argv.slice(1);
+const { transformSync } = require(swcCorePath);
+
+transformSync(
+ \`export async function worker\${workerId}() { 'use step'; }\`,
+ {
+ filename: \`worker-\${workerId}.js\`,
+ swcrc: false,
+ jsc: {
+ experimental: {
+ cacheRoot,
+ plugins: [[pluginPath, { mode: 'step' }]],
+ },
+ },
+ }
+);
+`;
+
+describe('Workflow SWC plugin cache prewarming integration', () => {
+ it('makes the compiled plugin safe for parallel worker reads', async () => {
+ const projectRoot = await mkdtemp(
+ join(tmpdir(), 'workflow-swc-plugin-cache-')
+ );
+ onTestFinished(() => rm(projectRoot, { recursive: true }));
+
+ prewarmWorkflowSwcPluginCache(projectRoot);
+
+ const cacheRoot = join(projectRoot, '.swc');
+ const cacheFiles = await readdir(cacheRoot, { recursive: true });
+ expect(cacheFiles.some((file) => file.endsWith('.wasmer-v7'))).toBe(true);
+
+ const swcCorePath = require.resolve('@swc/core');
+ const swcPluginPath = require.resolve('@workflow/swc-plugin');
+ await Promise.all(
+ Array.from({ length: 16 }, (_, workerId) =>
+ execFileAsync(
+ process.execPath,
+ [
+ '-e',
+ WORKER_SOURCE,
+ swcCorePath,
+ swcPluginPath,
+ cacheRoot,
+ String(workerId),
+ ],
+ { cwd: projectRoot }
+ )
+ )
+ );
+ }, 30_000);
+});
diff --git a/packages/next/src/swc-plugin-cache.ts b/packages/next/src/swc-plugin-cache.ts
new file mode 100644
index 0000000000..e731bc486f
--- /dev/null
+++ b/packages/next/src/swc-plugin-cache.ts
@@ -0,0 +1,20 @@
+import { join } from 'node:path';
+import { transformSync } from '@swc/core';
+
+/**
+ * SWC does not atomically write its Wasmer cache. Compile the plugin before
+ * Next.js starts parallel loader workers.
+ * @see https://github.com/swc-project/swc/issues/10065
+ */
+export function prewarmWorkflowSwcPluginCache(projectRoot: string): void {
+ transformSync(`async function step() { 'use step'; }`, {
+ filename: '__workflow_swc_cache_warmup__.js',
+ swcrc: false,
+ jsc: {
+ experimental: {
+ cacheRoot: join(projectRoot, '.swc'),
+ plugins: [[require.resolve('@workflow/swc-plugin'), { mode: 'step' }]],
+ },
+ },
+ });
+}
diff --git a/packages/nitro/CHANGELOG.md b/packages/nitro/CHANGELOG.md
index cc3b48fb2b..baea111a92 100644
--- a/packages/nitro/CHANGELOG.md
+++ b/packages/nitro/CHANGELOG.md
@@ -1,5 +1,16 @@
# @workflow/nitro
+## 5.0.0-beta.21
+
+### Patch Changes
+
+- Updated dependencies [[`6de5ea5`](https://github.com/vercel/workflow/commit/6de5ea5c2f32b474274f5dabe5f3663e03622ac5), [`5291f15`](https://github.com/vercel/workflow/commit/5291f1549fee4d8b042cc03b6696fd8b6cb798fc), [`66ca0dc`](https://github.com/vercel/workflow/commit/66ca0dcc096440f39dd234e04669e1fc7bf2d615), [`b713d84`](https://github.com/vercel/workflow/commit/b713d8417b1334abd1e30bdc50701d0d96dee39d), [`57cccaf`](https://github.com/vercel/workflow/commit/57cccaf3734f4afa8218e1ea729a9bb886c691f3), [`3e82a12`](https://github.com/vercel/workflow/commit/3e82a12712b1efe229ac2b1623dc6c8fc7be7055)]:
+ - @workflow/core@5.0.0-beta.21
+ - @workflow/builders@5.0.0-beta.21
+ - @workflow/web@5.0.0-beta.21
+ - @workflow/rollup@5.0.0-beta.21
+ - @workflow/vite@5.0.0-beta.21
+
## 5.0.0-beta.20
### Patch Changes
diff --git a/packages/nitro/package.json b/packages/nitro/package.json
index ae180abc6e..7e7f48f6b4 100644
--- a/packages/nitro/package.json
+++ b/packages/nitro/package.json
@@ -1,6 +1,6 @@
{
"name": "@workflow/nitro",
- "version": "5.0.0-beta.20",
+ "version": "5.0.0-beta.21",
"description": "Nitro integration for Workflow SDK",
"type": "module",
"main": "dist/index.js",
diff --git a/packages/nuxt/CHANGELOG.md b/packages/nuxt/CHANGELOG.md
index b0de7f53ba..efee740703 100644
--- a/packages/nuxt/CHANGELOG.md
+++ b/packages/nuxt/CHANGELOG.md
@@ -1,5 +1,12 @@
# @workflow/nuxt
+## 5.0.0-beta.21
+
+### Patch Changes
+
+- Updated dependencies []:
+ - @workflow/nitro@5.0.0-beta.21
+
## 5.0.0-beta.20
### Patch Changes
diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json
index 745d49d0ed..6ac1996de8 100644
--- a/packages/nuxt/package.json
+++ b/packages/nuxt/package.json
@@ -1,6 +1,6 @@
{
"name": "@workflow/nuxt",
- "version": "5.0.0-beta.20",
+ "version": "5.0.0-beta.21",
"description": "Nuxt integration for Workflow SDK",
"license": "Apache-2.0",
"type": "module",
diff --git a/packages/rollup/CHANGELOG.md b/packages/rollup/CHANGELOG.md
index a012c54787..1c89c5f74c 100644
--- a/packages/rollup/CHANGELOG.md
+++ b/packages/rollup/CHANGELOG.md
@@ -1,5 +1,12 @@
# @workflow/rollup
+## 5.0.0-beta.21
+
+### Patch Changes
+
+- Updated dependencies [[`5291f15`](https://github.com/vercel/workflow/commit/5291f1549fee4d8b042cc03b6696fd8b6cb798fc), [`b713d84`](https://github.com/vercel/workflow/commit/b713d8417b1334abd1e30bdc50701d0d96dee39d), [`57cccaf`](https://github.com/vercel/workflow/commit/57cccaf3734f4afa8218e1ea729a9bb886c691f3)]:
+ - @workflow/builders@5.0.0-beta.21
+
## 5.0.0-beta.20
### Patch Changes
diff --git a/packages/rollup/package.json b/packages/rollup/package.json
index f3096bc619..a497b1a570 100644
--- a/packages/rollup/package.json
+++ b/packages/rollup/package.json
@@ -1,6 +1,6 @@
{
"name": "@workflow/rollup",
- "version": "5.0.0-beta.20",
+ "version": "5.0.0-beta.21",
"description": "Rollup plugin for Workflow SDK",
"type": "module",
"main": "dist/index.js",
diff --git a/packages/sveltekit/CHANGELOG.md b/packages/sveltekit/CHANGELOG.md
index cafda7065d..6c86cafecb 100644
--- a/packages/sveltekit/CHANGELOG.md
+++ b/packages/sveltekit/CHANGELOG.md
@@ -1,5 +1,14 @@
# @workflow/sveltekit
+## 5.0.0-beta.21
+
+### Patch Changes
+
+- Updated dependencies [[`5291f15`](https://github.com/vercel/workflow/commit/5291f1549fee4d8b042cc03b6696fd8b6cb798fc), [`b713d84`](https://github.com/vercel/workflow/commit/b713d8417b1334abd1e30bdc50701d0d96dee39d), [`57cccaf`](https://github.com/vercel/workflow/commit/57cccaf3734f4afa8218e1ea729a9bb886c691f3)]:
+ - @workflow/builders@5.0.0-beta.21
+ - @workflow/rollup@5.0.0-beta.21
+ - @workflow/vite@5.0.0-beta.21
+
## 5.0.0-beta.20
### Patch Changes
diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json
index a8a19f3ef5..2aed017600 100644
--- a/packages/sveltekit/package.json
+++ b/packages/sveltekit/package.json
@@ -1,6 +1,6 @@
{
"name": "@workflow/sveltekit",
- "version": "5.0.0-beta.20",
+ "version": "5.0.0-beta.21",
"description": "SvelteKit integration for Workflow SDK",
"type": "module",
"main": "dist/index.js",
diff --git a/packages/vite/CHANGELOG.md b/packages/vite/CHANGELOG.md
index 7dcec55f60..65e6e701e3 100644
--- a/packages/vite/CHANGELOG.md
+++ b/packages/vite/CHANGELOG.md
@@ -1,5 +1,12 @@
# @workflow/vite
+## 5.0.0-beta.21
+
+### Patch Changes
+
+- Updated dependencies [[`5291f15`](https://github.com/vercel/workflow/commit/5291f1549fee4d8b042cc03b6696fd8b6cb798fc), [`b713d84`](https://github.com/vercel/workflow/commit/b713d8417b1334abd1e30bdc50701d0d96dee39d), [`57cccaf`](https://github.com/vercel/workflow/commit/57cccaf3734f4afa8218e1ea729a9bb886c691f3)]:
+ - @workflow/builders@5.0.0-beta.21
+
## 5.0.0-beta.20
### Patch Changes
diff --git a/packages/vite/package.json b/packages/vite/package.json
index 567b87e76e..2ce49da1c2 100644
--- a/packages/vite/package.json
+++ b/packages/vite/package.json
@@ -1,7 +1,7 @@
{
"name": "@workflow/vite",
"description": "Vite plugin for Workflow SDK",
- "version": "5.0.0-beta.20",
+ "version": "5.0.0-beta.21",
"type": "module",
"main": "dist/index.js",
"files": [
diff --git a/packages/vitest/CHANGELOG.md b/packages/vitest/CHANGELOG.md
index 981bcb3c36..5927184b53 100644
--- a/packages/vitest/CHANGELOG.md
+++ b/packages/vitest/CHANGELOG.md
@@ -1,5 +1,16 @@
# @workflow/vitest
+## 5.0.0-beta.21
+
+### Patch Changes
+
+- [#2351](https://github.com/vercel/workflow/pull/2351) [`047ebd0`](https://github.com/vercel/workflow/commit/047ebd0368454ba1c4c9e25c0b57ce7a63bef5db) Thanks [@VaguelySerious](https://github.com/VaguelySerious)! - Bundle project-local imports into the test step bundle instead of externalizing them, fixing module resolution errors when bundles are loaded by Node's native ESM loader
+
+- Updated dependencies [[`6de5ea5`](https://github.com/vercel/workflow/commit/6de5ea5c2f32b474274f5dabe5f3663e03622ac5), [`5291f15`](https://github.com/vercel/workflow/commit/5291f1549fee4d8b042cc03b6696fd8b6cb798fc), [`66ca0dc`](https://github.com/vercel/workflow/commit/66ca0dcc096440f39dd234e04669e1fc7bf2d615), [`b713d84`](https://github.com/vercel/workflow/commit/b713d8417b1334abd1e30bdc50701d0d96dee39d), [`57cccaf`](https://github.com/vercel/workflow/commit/57cccaf3734f4afa8218e1ea729a9bb886c691f3), [`3e82a12`](https://github.com/vercel/workflow/commit/3e82a12712b1efe229ac2b1623dc6c8fc7be7055)]:
+ - @workflow/core@5.0.0-beta.21
+ - @workflow/builders@5.0.0-beta.21
+ - @workflow/rollup@5.0.0-beta.21
+
## 5.0.0-beta.20
### Patch Changes
diff --git a/packages/vitest/package.json b/packages/vitest/package.json
index a6d4ffcc1f..fe3f727e57 100644
--- a/packages/vitest/package.json
+++ b/packages/vitest/package.json
@@ -1,6 +1,6 @@
{
"name": "@workflow/vitest",
- "version": "5.0.0-beta.20",
+ "version": "5.0.0-beta.21",
"description": "Vitest plugin for testing Workflow SDK workflows",
"type": "module",
"main": "./dist/index.js",
diff --git a/packages/web-shared/CHANGELOG.md b/packages/web-shared/CHANGELOG.md
index 93ae5c576e..1daafbadbd 100644
--- a/packages/web-shared/CHANGELOG.md
+++ b/packages/web-shared/CHANGELOG.md
@@ -1,5 +1,12 @@
# @workflow/web-shared
+## 5.0.0-beta.21
+
+### Patch Changes
+
+- Updated dependencies [[`6de5ea5`](https://github.com/vercel/workflow/commit/6de5ea5c2f32b474274f5dabe5f3663e03622ac5), [`66ca0dc`](https://github.com/vercel/workflow/commit/66ca0dcc096440f39dd234e04669e1fc7bf2d615), [`3e82a12`](https://github.com/vercel/workflow/commit/3e82a12712b1efe229ac2b1623dc6c8fc7be7055)]:
+ - @workflow/core@5.0.0-beta.21
+
## 5.0.0-beta.20
### Patch Changes
diff --git a/packages/web-shared/package.json b/packages/web-shared/package.json
index 917d3f271e..f7491208ce 100644
--- a/packages/web-shared/package.json
+++ b/packages/web-shared/package.json
@@ -1,7 +1,7 @@
{
"name": "@workflow/web-shared",
"description": "Shared components for Workflow Observability UI",
- "version": "5.0.0-beta.20",
+ "version": "5.0.0-beta.21",
"private": false,
"files": [
"dist",
diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md
index 89a3542682..2869eb9a58 100644
--- a/packages/web/CHANGELOG.md
+++ b/packages/web/CHANGELOG.md
@@ -1,5 +1,7 @@
# @workflow/web
+## 5.0.0-beta.21
+
## 5.0.0-beta.20
## 5.0.0-beta.19
diff --git a/packages/web/package.json b/packages/web/package.json
index 92cc1033a0..77221c1b7a 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -1,7 +1,7 @@
{
"name": "@workflow/web",
"description": "Workflow Observability UI",
- "version": "5.0.0-beta.20",
+ "version": "5.0.0-beta.21",
"type": "module",
"private": false,
"files": [
diff --git a/packages/workflow/CHANGELOG.md b/packages/workflow/CHANGELOG.md
index 84c948fb8a..0bca0845bb 100644
--- a/packages/workflow/CHANGELOG.md
+++ b/packages/workflow/CHANGELOG.md
@@ -1,5 +1,27 @@
# workflow
+## 5.0.0-beta.21
+
+### Minor Changes
+
+- [#2526](https://github.com/vercel/workflow/pull/2526) [`3e82a12`](https://github.com/vercel/workflow/commit/3e82a12712b1efe229ac2b1623dc6c8fc7be7055) Thanks [@VaguelySerious](https://github.com/VaguelySerious)! - Add turbo mode (on by default, disable with `WORKFLOW_TURBO=0`): on the first delivery of a run's first invocation the runtime backgrounds `run_started`, skips the initial event-log load, and forces optimistic inline start so the run reaches its first steps with no preceding network round-trips. It is safe there because the first delivery has no concurrent handler to race; turbo mode deactivates once a hook or sleep is encountered.
+
+### Patch Changes
+
+- [#2472](https://github.com/vercel/workflow/pull/2472) [`66ca0dc`](https://github.com/vercel/workflow/commit/66ca0dcc096440f39dd234e04669e1fc7bf2d615) Thanks [@pranaygp](https://github.com/pranaygp)! - Memoize hydrated step return values across inline replay iterations, turning the per-invocation step-result decrypt+parse cost from O(N²) to O(N) for sequential workflows. Only primitive results are cached, so deterministic replay is preserved.
+
+- Updated dependencies [[`6de5ea5`](https://github.com/vercel/workflow/commit/6de5ea5c2f32b474274f5dabe5f3663e03622ac5), [`5291f15`](https://github.com/vercel/workflow/commit/5291f1549fee4d8b042cc03b6696fd8b6cb798fc), [`66ca0dc`](https://github.com/vercel/workflow/commit/66ca0dcc096440f39dd234e04669e1fc7bf2d615), [`57cccaf`](https://github.com/vercel/workflow/commit/57cccaf3734f4afa8218e1ea729a9bb886c691f3), [`3e82a12`](https://github.com/vercel/workflow/commit/3e82a12712b1efe229ac2b1623dc6c8fc7be7055)]:
+ - @workflow/core@5.0.0-beta.21
+ - @workflow/next@5.0.0-beta.21
+ - @workflow/cli@5.0.0-beta.21
+ - @workflow/nitro@5.0.0-beta.21
+ - @workflow/typescript-plugin@5.0.0-beta.4
+ - @workflow/astro@5.0.0-beta.21
+ - @workflow/nest@5.0.0-beta.21
+ - @workflow/rollup@5.0.0-beta.21
+ - @workflow/sveltekit@5.0.0-beta.21
+ - @workflow/nuxt@5.0.0-beta.21
+
## 5.0.0-beta.20
### Minor Changes
diff --git a/packages/workflow/package.json b/packages/workflow/package.json
index db51f54c44..af4b184d16 100644
--- a/packages/workflow/package.json
+++ b/packages/workflow/package.json
@@ -1,6 +1,6 @@
{
"name": "workflow",
- "version": "5.0.0-beta.20",
+ "version": "5.0.0-beta.21",
"description": "Workflow SDK - Build durable, resilient, and observable workflows",
"main": "dist/typescript-plugin.cjs",
"type": "module",
diff --git a/packages/world-testing/CHANGELOG.md b/packages/world-testing/CHANGELOG.md
index 847cfa7b2b..ea71aae85f 100644
--- a/packages/world-testing/CHANGELOG.md
+++ b/packages/world-testing/CHANGELOG.md
@@ -1,5 +1,14 @@
# @workflow/world-testing
+## 5.0.0-beta.21
+
+### Patch Changes
+
+- Updated dependencies [[`6de5ea5`](https://github.com/vercel/workflow/commit/6de5ea5c2f32b474274f5dabe5f3663e03622ac5), [`66ca0dc`](https://github.com/vercel/workflow/commit/66ca0dcc096440f39dd234e04669e1fc7bf2d615), [`3e82a12`](https://github.com/vercel/workflow/commit/3e82a12712b1efe229ac2b1623dc6c8fc7be7055)]:
+ - @workflow/core@5.0.0-beta.21
+ - workflow@5.0.0-beta.21
+ - @workflow/cli@5.0.0-beta.21
+
## 5.0.0-beta.20
### Patch Changes
diff --git a/packages/world-testing/package.json b/packages/world-testing/package.json
index a3d2d3b497..d4d140733e 100644
--- a/packages/world-testing/package.json
+++ b/packages/world-testing/package.json
@@ -1,6 +1,6 @@
{
"name": "@workflow/world-testing",
- "version": "5.0.0-beta.20",
+ "version": "5.0.0-beta.21",
"description": "Testing utilities and World implementation for Workflow SDK",
"main": "dist/src/index.mjs",
"files": [
diff --git a/packages/world-vercel/src/events-v4.test.ts b/packages/world-vercel/src/events-v4.test.ts
index 81c54739b5..3a5a1a60da 100644
--- a/packages/world-vercel/src/events-v4.test.ts
+++ b/packages/world-vercel/src/events-v4.test.ts
@@ -5,12 +5,11 @@ import {
TooEarlyError,
WorkflowWorldError,
} from '@workflow/errors';
-import { encode } from 'cbor-x';
+import { decode, encode } from 'cbor-x';
import { MockAgent } from 'undici';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createWorkflowRunEventV4,
- getEventV4,
getWorkflowRunEventsV4,
throwForErrorResponse,
} from './events-v4.js';
@@ -237,52 +236,6 @@ describe('getWorkflowRunEventsV4 over HTTP', () => {
});
});
-/**
- * getEventV4 returns after the first frame. The early return must cancel the
- * response body (releasing its undici socket) without corrupting the returned
- * value or hanging — the trailing frame below is never read.
- */
-describe('getEventV4 over HTTP', () => {
- it('returns the first frame and stops reading the rest', async () => {
- const origin = 'https://vercel-workflow.com';
- const agent = new MockAgent();
- agent.disableNetConnect();
-
- const body = new TextEncoder().encode('event-payload');
- const frames = Buffer.concat([
- encodeFrame(
- {
- eventId: 'evnt_1',
- runId: 'wrun_1',
- eventType: 'run_created',
- createdAt: '2026-06-10T00:00:00.000Z',
- eventData: {},
- },
- body
- ),
- // Trailing bytes the reader must never need.
- encodeFrame({ eventId: 'evnt_unused' }, new Uint8Array(8)),
- ]);
-
- agent
- .get(origin)
- .intercept({ path: '/api/v4/runs/wrun_1/events/evnt_1', method: 'GET' })
- .reply(200, frames, {
- headers: { 'content-type': V4_FRAME_CONTENT_TYPE },
- });
-
- const { event, body: returnedBody } = await getEventV4('wrun_1', 'evnt_1', {
- token: 'test-token',
- dispatcher: agent,
- });
-
- expect(event.eventId).toBe('evnt_1');
- expect(event.eventType).toBe('run_created');
- expect(new Uint8Array(returnedBody)).toEqual(body);
- agent.assertNoPendingInterceptors();
- });
-});
-
/**
* Regression: v4 requests must go through the global `fetch`, not undici's
* `request()`. Vercel's observability log viewer instruments the global
@@ -369,4 +322,110 @@ describe('createWorkflowRunEventV4 over HTTP', () => {
expect(result.body.step).toMatchObject({ stepId: 'step_1' });
agent.assertNoPendingInterceptors();
});
+
+ it('forwards skipPreload in the run_started frame meta (turbo preload opt-out)', async () => {
+ const origin = 'https://vercel-workflow.com';
+ const agent = new MockAgent();
+ agent.disableNetConnect();
+
+ // Decode the posted frame's CBOR meta block:
+ // u32_be(meta_len) || cbor_meta || u32_be(body_len) || body
+ let capturedMeta: Record | undefined;
+ const captureMeta = (rawBody: unknown) => {
+ const bytes =
+ typeof rawBody === 'string'
+ ? new TextEncoder().encode(rawBody)
+ : new Uint8Array(rawBody as ArrayBufferLike);
+ const metaLen = new DataView(
+ bytes.buffer,
+ bytes.byteOffset,
+ bytes.byteLength
+ ).getUint32(0, false);
+ capturedMeta = decode(bytes.subarray(4, 4 + metaLen)) as Record<
+ string,
+ unknown
+ >;
+ };
+
+ agent
+ .get(origin)
+ .intercept({
+ path: '/api/v4/runs/wrun_1/events/run_started',
+ method: 'POST',
+ })
+ .reply(
+ 200,
+ (opts: { body?: unknown }) => {
+ captureMeta(opts.body);
+ return encode({ run: { runId: 'wrun_1', status: 'running' } });
+ },
+ {
+ headers: {
+ 'x-wf-event-id': 'evnt_1',
+ 'x-wf-run-id': 'wrun_1',
+ 'x-wf-created-at': '2026-06-10T00:00:00.000Z',
+ },
+ }
+ );
+
+ await createWorkflowRunEventV4(
+ {
+ runId: 'wrun_1',
+ eventType: 'run_started',
+ specVersion: 5,
+ skipPreload: true,
+ },
+ { token: 'test-token', dispatcher: agent }
+ );
+
+ expect(capturedMeta?.eventType).toBe('run_started');
+ expect(capturedMeta?.skipPreload).toBe(true);
+ agent.assertNoPendingInterceptors();
+ });
+
+ it('omits skipPreload from the frame meta when not set (default / old SDK parity)', async () => {
+ const origin = 'https://vercel-workflow.com';
+ const agent = new MockAgent();
+ agent.disableNetConnect();
+
+ let capturedMeta: Record | undefined;
+ agent
+ .get(origin)
+ .intercept({
+ path: '/api/v4/runs/wrun_1/events/run_started',
+ method: 'POST',
+ })
+ .reply(
+ 200,
+ (opts: { body?: unknown }) => {
+ const bytes = new Uint8Array(opts.body as ArrayBufferLike);
+ const metaLen = new DataView(
+ bytes.buffer,
+ bytes.byteOffset,
+ bytes.byteLength
+ ).getUint32(0, false);
+ capturedMeta = decode(bytes.subarray(4, 4 + metaLen)) as Record<
+ string,
+ unknown
+ >;
+ return encode({ run: { runId: 'wrun_1', status: 'running' } });
+ },
+ {
+ headers: {
+ 'x-wf-event-id': 'evnt_1',
+ 'x-wf-run-id': 'wrun_1',
+ 'x-wf-created-at': '2026-06-10T00:00:00.000Z',
+ },
+ }
+ );
+
+ await createWorkflowRunEventV4(
+ { runId: 'wrun_1', eventType: 'run_started', specVersion: 5 },
+ { token: 'test-token', dispatcher: agent }
+ );
+
+ expect(capturedMeta?.eventType).toBe('run_started');
+ expect('skipPreload' in (capturedMeta ?? {})).toBe(false);
+ agent.assertNoPendingInterceptors();
+ });
});
diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts
index 76e54b6850..9270267981 100644
--- a/packages/world-vercel/src/events-v4.ts
+++ b/packages/world-vercel/src/events-v4.ts
@@ -144,6 +144,11 @@ export interface CreateEventV4Input {
* other event types; older servers ignore it entirely (the runtime then
* falls back to events.list). */
sinceCursor?: string;
+ /** Run-started preload opt-out. Turbo backgrounds run_started as a write
+ * barrier only and never reads the preloaded log, so it asks the server to
+ * skip the list+resolve. Acted on by the server only for run_started;
+ * older servers ignore it and preload as before. */
+ skipPreload?: boolean;
}
export interface CreateEventV4Result {
@@ -211,6 +216,7 @@ function buildPostFrameMeta(
meta.allowReservedAttributes = input.allowReservedAttributes;
}
if (input.sinceCursor !== undefined) meta.sinceCursor = input.sinceCursor;
+ if (input.skipPreload !== undefined) meta.skipPreload = input.skipPreload;
return meta;
}
diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts
index 6be615e967..438ed5af41 100644
--- a/packages/world-vercel/src/events.ts
+++ b/packages/world-vercel/src/events.ts
@@ -605,6 +605,11 @@ async function createWorkflowRunEventInner(
// step_completed/step_failed; older servers ignore it and the runtime
// falls back to events.list.
...(params?.sinceCursor ? { sinceCursor: params.sinceCursor } : {}),
+ // Run-started preload opt-out: turbo backgrounds run_started as a write
+ // barrier only and never reads the preloaded log, so tell the server to
+ // skip the list+resolve. The server only acts on it for run_started;
+ // older servers ignore it and simply preload as before.
+ ...(params?.skipPreload ? { skipPreload: true } : {}),
remoteRefBehavior,
payload,
...meta,
diff --git a/packages/world-vercel/src/frames.test.ts b/packages/world-vercel/src/frames.test.ts
index 72d312272e..966bab97ab 100644
--- a/packages/world-vercel/src/frames.test.ts
+++ b/packages/world-vercel/src/frames.test.ts
@@ -1,4 +1,4 @@
-import { decode } from 'cbor-x';
+import { decode, encode } from 'cbor-x';
import { describe, expect, it } from 'vitest';
import {
type DecodedFrame,
@@ -41,29 +41,6 @@ async function drainFrames(
return out;
}
-/** A stream that stays open after delivering its payload (never signals EOF),
- * like a kept-alive HTTP socket, and records whether cancel() ran — the
- * signal undici uses to release the connection. highWaterMark: 0 suppresses
- * the pull-ahead that would otherwise auto-close a toy stream. */
-function spyStream(payload: Uint8Array) {
- let sent = false;
- let cancelled = false;
- const stream = new ReadableStream(
- {
- pull(controller) {
- if (sent) return;
- controller.enqueue(payload);
- sent = true;
- },
- cancel() {
- cancelled = true;
- },
- },
- { highWaterMark: 0 }
- );
- return { stream, wasCancelled: () => cancelled };
-}
-
describe('encodeFrame', () => {
it('produces the canonical wire layout', () => {
const meta = { eventId: 'evnt_abc', n: 42 };
@@ -233,51 +210,6 @@ describe('decodeFrames from an AsyncIterable source', () => {
});
});
-describe('decodeFrames releases the stream on early exit', () => {
- // Regression: a consumer that stops before EOF (getEventV4 returns after
- // the first frame; consumeListFrameStream breaks at the sentinel) must
- // cancel the body, or its undici socket stays pinned out of the pool.
- function twoFramesThenEnd(): Uint8Array {
- return new Uint8Array([
- ...encodeFrame({ eventId: 'a' }, new Uint8Array([1, 2, 3])),
- ...encodeFrame({ eventId: 'b' }, new Uint8Array([4, 5, 6])),
- ...encodeEndFrame(),
- ]);
- }
-
- it('cancels the underlying stream when the consumer breaks early', async () => {
- const { stream, wasCancelled } = spyStream(twoFramesThenEnd());
- for await (const f of decodeFrames(stream)) {
- expect(f.meta).toEqual({ eventId: 'a' });
- break; // mirrors getEventV4 returning after the first frame
- }
- expect(wasCancelled()).toBe(true);
- });
-
- it('cancels via the reader path when the source is not async-iterable', async () => {
- const { stream, wasCancelled } = spyStream(twoFramesThenEnd());
- // A bare { getReader } object forces the readerToIterator branch (a real
- // ReadableStream is already async-iterable in Node).
- const source = {
- getReader: () => stream.getReader(),
- } as unknown as ReadableStream;
- for await (const f of decodeFrames(source)) {
- expect(f.meta).toEqual({ eventId: 'a' });
- break;
- }
- expect(wasCancelled()).toBe(true);
- });
-
- it('still decodes every frame when fully consumed', async () => {
- const frames = await drainFrames(spyStream(twoFramesThenEnd()).stream);
- expect(frames.map((f) => f.meta)).toEqual([
- { eventId: 'a' },
- { eventId: 'b' },
- { _end: 1 },
- ]);
- });
-});
-
describe('V4_FRAME_CONTENT_TYPE', () => {
it('matches the server-side content type', () => {
expect(V4_FRAME_CONTENT_TYPE).toBe('application/vnd.workflow.v4-frames');
diff --git a/packages/world-vercel/src/frames.ts b/packages/world-vercel/src/frames.ts
index 2d802063bc..59aacd9280 100644
--- a/packages/world-vercel/src/frames.ts
+++ b/packages/world-vercel/src/frames.ts
@@ -79,56 +79,41 @@ export async function* decodeFrames(
return out;
};
- try {
- while (true) {
- if (!(await refill(4))) return;
- const metaLen = new DataView(
- buffer.buffer,
- buffer.byteOffset,
- 4
- ).getUint32(0, false);
- take(4);
+ while (true) {
+ if (!(await refill(4))) return;
+ const metaLen = new DataView(buffer.buffer, buffer.byteOffset, 4).getUint32(
+ 0,
+ false
+ );
+ take(4);
- if (!(await refill(metaLen))) {
- throw new Error('decodeFrames: truncated meta block');
- }
- const meta = decode(take(metaLen)) as Record;
+ if (!(await refill(metaLen))) {
+ throw new Error('decodeFrames: truncated meta block');
+ }
+ const meta = decode(take(metaLen)) as Record;
- if (!(await refill(4))) {
- throw new Error('decodeFrames: truncated body length');
- }
- const bodyLen = new DataView(
- buffer.buffer,
- buffer.byteOffset,
- 4
- ).getUint32(0, false);
- take(4);
+ if (!(await refill(4))) {
+ throw new Error('decodeFrames: truncated body length');
+ }
+ const bodyLen = new DataView(buffer.buffer, buffer.byteOffset, 4).getUint32(
+ 0,
+ false
+ );
+ take(4);
- if (bodyLen > 0 && !(await refill(bodyLen))) {
+ if (bodyLen > 0) {
+ if (!(await refill(bodyLen))) {
throw new Error('decodeFrames: truncated body bytes');
}
- // Slice (not subarray) so the yielded body owns its bytes — later
- // reads into the buffer won't overwrite it; bodyLen 0 yields empty.
+ // Slice (not subarray) so the yielded body owns its bytes —
+ // subsequent reads into the buffer won't overwrite it.
yield { meta, body: buffer.slice(0, bodyLen) };
take(bodyLen);
-
- if (meta._end === 1) return;
+ } else {
+ yield { meta, body: new Uint8Array(0) };
}
- } finally {
- // Release the source when the consumer stops before EOF (early
- // break/return): an unconsumed body pins its undici socket out of the
- // connection pool. No-op once the stream is already drained.
- await closeQuietly(() => chunks.return?.());
- }
-}
-/** Best-effort source cleanup, safe to run from a `finally`: swallows errors
- * so cleanup can't mask the original outcome. */
-async function closeQuietly(close: () => unknown): Promise {
- try {
- await close();
- } catch {
- // best-effort
+ if (meta._end === 1) return;
}
}
@@ -137,14 +122,9 @@ async function closeQuietly(close: () => unknown): Promise {
async function* readerToIterator(
reader: ReadableStreamDefaultReader
): AsyncGenerator {
- try {
- while (true) {
- const { done, value } = await reader.read();
- if (done) return;
- if (value) yield value;
- }
- } finally {
- // Cancel on early exit so the socket is released, not just unlocked.
- await closeQuietly(() => reader.cancel());
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) return;
+ if (value) yield value;
}
}
diff --git a/packages/world-vercel/src/streamer.test.ts b/packages/world-vercel/src/streamer.test.ts
index af44a6c755..515981b784 100644
--- a/packages/world-vercel/src/streamer.test.ts
+++ b/packages/world-vercel/src/streamer.test.ts
@@ -190,7 +190,7 @@ describe('streams.get', () => {
vi.restoreAllMocks();
});
- it('includes runId in the fetch URL', async () => {
+ it('reads the live stream from the v3 endpoint (error-on-timeout)', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(
@@ -202,10 +202,12 @@ describe('streams.get', () => {
expect(fetchSpy).toHaveBeenCalledTimes(1);
const url = new URL(fetchSpy.mock.calls[0][0] as string);
- expect(url.pathname).toBe('/v2/runs/run-123/stream/my-stream');
+ // v3, not v2: the reconnecting reader relies on the server erroring the
+ // body on a max-duration timeout rather than closing it cleanly.
+ expect(url.pathname).toBe('/v3/runs/run-123/stream/my-stream');
});
- it('passes startIndex as a query parameter', async () => {
+ it('passes startIndex as a query parameter on the v3 read', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(
@@ -216,7 +218,7 @@ describe('streams.get', () => {
await streamer.streams.get('run-123', 'my-stream', 5);
const url = new URL(fetchSpy.mock.calls[0][0] as string);
- expect(url.pathname).toBe('/v2/runs/run-123/stream/my-stream');
+ expect(url.pathname).toBe('/v3/runs/run-123/stream/my-stream');
expect(url.searchParams.get('startIndex')).toBe('5');
});
});
diff --git a/packages/world-vercel/src/streamer.ts b/packages/world-vercel/src/streamer.ts
index b74e2ae2e9..9be08bbe9a 100644
--- a/packages/world-vercel/src/streamer.ts
+++ b/packages/world-vercel/src/streamer.ts
@@ -23,12 +23,27 @@ export const MAX_CHUNKS_PER_REQUEST = 1000;
// (partial writes, long-lived reads), and duplex streams are incompatible
// with undici's experimental H2 support.
+// Writes (PUT) and stream completion use the v2 stream endpoint.
function getStreamUrl(name: string, runId: string, httpConfig: HttpConfig) {
return new URL(
`${httpConfig.baseUrl}/v2/runs/${encodeURIComponent(runId)}/stream/${encodeURIComponent(name)}`
);
}
+// The live-read (GET) endpoint is versioned at v3: on a max-duration timeout
+// (or a mid-stream connection drop) the server errors the response body
+// instead of closing it cleanly, which is what lets the reconnecting reader
+// (`createReconnectingFramedStream`) resume from the next chunk rather than
+// treating the timeout as end-of-stream. Reading from v2 would silently
+// truncate long-lived streams at the server's 2-minute limit. Only the live
+// read is affected by the timeout — writes, completion, and snapshot reads
+// (chunks/info/list) stay on v2.
+function getStreamReadUrl(name: string, runId: string, httpConfig: HttpConfig) {
+ return new URL(
+ `${httpConfig.baseUrl}/v3/runs/${encodeURIComponent(runId)}/stream/${encodeURIComponent(name)}`
+ );
+}
+
function createStreamRequestError(
operation: 'write' | 'close',
url: URL,
@@ -190,7 +205,7 @@ export function createStreamer(config?: APIConfig): Streamer {
async get(runId: string, name: string, startIndex?: number) {
const httpConfig = await getHttpConfig(config);
- const url = getStreamUrl(name, runId, httpConfig);
+ const url = getStreamReadUrl(name, runId, httpConfig);
if (typeof startIndex === 'number') {
url.searchParams.set('startIndex', String(startIndex));
}
diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts
index 50efb68a22..5dffd08e91 100644
--- a/packages/world/src/events.ts
+++ b/packages/world/src/events.ts
@@ -475,6 +475,23 @@ export interface CreateEventParams {
* delta is computed atomically against the same log the fetch would read.
*/
sinceCursor?: string;
+ /**
+ * Run-started preload opt-out (advisory). On a `run_started` write a World
+ * MAY preload the run's event log onto the {@link EventResult}
+ * (`events`/`cursor`/`hasMore`) so the runtime can skip its initial
+ * `events.list`. The turbo first invocation backgrounds `run_started`
+ * purely as a write barrier and never reads that preload, so it sets this
+ * to tell the World to skip the wasted list+resolve — trimming the
+ * `run_started` round-trip that the chained first `step_started` waits on.
+ * A World that ignores it (or doesn't preload) remains fully correct: the
+ * runtime falls back to `events.list` whenever it actually needs the log.
+ * Only honored for `run_started`; ignored for other event types.
+ *
+ * Named to match the World boundary, the wire frame meta, and the backend
+ * option end-to-end (cf. {@link sinceCursor}) so the single name greps
+ * across the SDK and the backend.
+ */
+ skipPreload?: boolean;
}
/**
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 030b242008..f58a922fa3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -788,6 +788,9 @@ importers:
next:
specifier: 16.2.1
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ vitest:
+ specifier: 'catalog:'
+ version: 4.0.18(@opentelemetry/api@1.9.1)(@types/node@22.19.0)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)
packages/nitro:
dependencies:
diff --git a/scripts/create-community-worlds-matrix.mjs b/scripts/create-community-worlds-matrix.mjs
index de156bf5d6..4c23fcd692 100644
--- a/scripts/create-community-worlds-matrix.mjs
+++ b/scripts/create-community-worlds-matrix.mjs
@@ -46,14 +46,19 @@ const testableWorlds = manifest.worlds.filter((world) => {
// Build the matrix
const matrix = {
world: testableWorlds.map((world) => {
- // Determine service type based on services array
+ // Determine service type based on services array.
+ // `mongodb` and `redis` have dedicated single-service CI steps; anything
+ // else (or any multi-service world) goes through the generic `docker`
+ // path driven by the manifest's `services` array.
let serviceType = 'none';
if (world.services && world.services.length > 0) {
- // Use the first service's name as the service type
- // Currently supports: mongodb, redis
- const serviceName = world.services[0].name;
- if (['mongodb', 'redis'].includes(serviceName)) {
- serviceType = serviceName;
+ if (world.services.length === 1) {
+ const serviceName = world.services[0].name;
+ serviceType = ['mongodb', 'redis'].includes(serviceName)
+ ? serviceName
+ : 'docker';
+ } else {
+ serviceType = 'docker';
}
}
@@ -61,8 +66,10 @@ const matrix = {
id: world.id,
name: world.name,
package: world.package,
+ version: world.version || '',
'service-type': serviceType,
'env-vars': JSON.stringify(world.env || {}),
+ services: JSON.stringify(world.services || []),
};
}),
};
diff --git a/scripts/create-test-matrix.mjs b/scripts/create-test-matrix.mjs
index be2573ea9f..fdcf726533 100644
--- a/scripts/create-test-matrix.mjs
+++ b/scripts/create-test-matrix.mjs
@@ -109,23 +109,11 @@ for (const app of [
},
]) {
matrix.app.push(
- createMatrixEntry(app.name, app.project, DEV_TEST_CONFIGS[app.name], {
- lazyDiscovery: true,
- runLabel: 'stable lazyDiscovery enabled',
- artifactSuffix: 'stable-lazy-discovery-enabled',
- })
- );
- matrix.app.push(
- createMatrixEntry(app.name, app.project, DEV_TEST_CONFIGS[app.name], {
- lazyDiscovery: false,
- runLabel: 'stable lazyDiscovery disabled',
- artifactSuffix: 'stable-lazy-discovery-disabled',
- })
+ createMatrixEntry(app.name, app.project, DEV_TEST_CONFIGS[app.name])
);
matrix.app.push(
createMatrixEntry(app.name, app.project, DEV_TEST_CONFIGS[app.name], {
canary: true,
- lazyDiscovery: true,
})
);
}
diff --git a/worlds-manifest.json b/worlds-manifest.json
index 041d9bb046..1c29008003 100644
--- a/worlds-manifest.json
+++ b/worlds-manifest.json
@@ -281,6 +281,52 @@
"services": [],
"requiresCredentials": true,
"credentialsNote": "Requires Upstash Redis and QStash credentials from Upstash console (https://console.upstash.com)"
+ },
+ {
+ "id": "platformatic",
+ "type": "community",
+ "package": "@platformatic/world",
+ "version": "0.8.1",
+ "name": "Platformatic",
+ "description": "Platformatic World backed by a centralized workflow service with PostgreSQL for version-safe orchestration on Kubernetes",
+ "repository": "https://github.com/platformatic/platformatic-world",
+ "docs": "https://github.com/platformatic/platformatic-world",
+ "features": [],
+ "env": {
+ "WORKFLOW_TARGET_WORLD": "@platformatic/world",
+ "PLT_WORLD_SERVICE_URL": "http://localhost:3042",
+ "PLT_WORLD_APP_ID": "default",
+ "PLT_WORLD_DEPLOYMENT_VERSION": "local"
+ },
+ "services": [
+ {
+ "name": "postgres",
+ "image": "postgres:18-alpine",
+ "ports": ["5432:5432"],
+ "env": {
+ "POSTGRES_USER": "wf",
+ "POSTGRES_PASSWORD": "wf",
+ "POSTGRES_DB": "workflow"
+ },
+ "healthCheck": {
+ "cmd": "pg_isready -h localhost",
+ "retries": 5
+ }
+ },
+ {
+ "name": "platformatic-workflow",
+ "image": "platformatic/workflow:0.8.1",
+ "ports": ["3042:3042"],
+ "env": {
+ "DATABASE_URL": "postgres://wf:wf@localhost:5432/workflow",
+ "PORT": "3042"
+ },
+ "healthCheck": {
+ "cmd": "curl -sf http://localhost:3042/status",
+ "retries": 10
+ }
+ }
+ ]
}
]
}