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 + } + } + ] } ] }