diff --git a/.changeset/quickjs-default-engine.md b/.changeset/quickjs-default-engine.md new file mode 100644 index 0000000000..07d8217f66 --- /dev/null +++ b/.changeset/quickjs-default-engine.md @@ -0,0 +1,5 @@ +--- +'@workflow/core': minor +--- + +The QuickJS WASM VM is now the default workflow engine. Set `WORKFLOW_VM=node` to opt back into the `node:vm` engine. Existing runs keep executing on the engine stamped in their `executionContext` at start. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 84bb27400e..40008638b1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -254,9 +254,9 @@ jobs: strategy: fail-fast: false matrix: - # Workflow VM engines: node:vm (default) and the opt-in QuickJS - # WASM engine (WORKFLOW_VM=quickjs). - vm: [node, quickjs] + # Workflow VM engines: QuickJS WASM (the default — WORKFLOW_VM left + # unset) and the explicit node:vm opt-in (WORKFLOW_VM=node). + vm: [quickjs, node] env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ vars.TURBO_TEAM }} @@ -278,7 +278,8 @@ jobs: run: pnpm test working-directory: workbench/vitest env: - WORKFLOW_VM: ${{ matrix.vm }} + # quickjs is the engine default — leave WORKFLOW_VM unset for it. + WORKFLOW_VM: ${{ matrix.vm == 'node' && 'node' || '' }} e2e-package-build: name: Build Shared E2E Packages @@ -331,12 +332,14 @@ jobs: strategy: fail-fast: false matrix: - # Workflow VM engines: node:vm (default) and the opt-in QuickJS - # WASM engine. The env var is set on the e2e test runner, which is - # the client that starts runs against the deployed app — start() - # stamps executionContext.workflowVm so the deployed handler - # executes each run on the requested engine. - vm: [node, quickjs] + # Workflow VM engines: QuickJS WASM (the default — WORKFLOW_VM left + # unset so the default-selection path is exercised end to end) and + # the explicit node:vm opt-in (WORKFLOW_VM=node). The env var is set + # on the e2e test runner, which is the client that starts runs + # against the deployed app — start() stamps + # executionContext.workflowVm so the deployed handler executes each + # run on the requested engine. + vm: [quickjs, node] app: - name: "example" project-id: "prj_xWq20Dd860HHAfzMjK2Mb6TPVxMa" @@ -437,13 +440,16 @@ jobs: run: echo "ms=$(($(date +%s) * 1000))" >> "$GITHUB_OUTPUT" - name: Run E2E Tests - run: pnpm run test:e2e --reporter=verbose --reporter=json --reporter=./packages/core/e2e/github-reporter.ts "--outputFile=e2e-vercel-prod-$APP_NAME-$WORKFLOW_VM.json" + run: pnpm run test:e2e --reporter=verbose --reporter=json --reporter=./packages/core/e2e/github-reporter.ts "--outputFile=e2e-vercel-prod-$APP_NAME-$MATRIX_VM.json" env: NODE_OPTIONS: "--enable-source-maps" DEPLOYMENT_URL: ${{ steps.waitForDeployment.outputs.deployment-url || steps.prodDeployment.outputs.deployment-url }} VERCEL_DEPLOYMENT_ID: ${{ steps.waitForDeployment.outputs.deployment-id || steps.prodDeployment.outputs.deployment-id }} APP_NAME: ${{ matrix.app.name }} - WORKFLOW_VM: ${{ matrix.vm }} + # quickjs is the engine default — leave WORKFLOW_VM unset for it + # (MATRIX_VM carries the label for file/job naming). + WORKFLOW_VM: ${{ matrix.vm == 'node' && 'node' || '' }} + MATRIX_VM: ${{ matrix.vm }} # changeset-release PRs test main's production deployment, so they # must be treated as a production run everywhere downstream. WORKFLOW_VERCEL_ENV: ${{ (github.ref == 'refs/heads/main' || startsWith(github.head_ref, 'changeset-release/')) && 'production' || 'preview' }} @@ -482,8 +488,8 @@ jobs: if: always() env: APP_NAME: ${{ matrix.app.name }} - WORKFLOW_VM: ${{ matrix.vm }} - run: node .github/scripts/aggregate-e2e-results.js . --job-name "E2E Vercel Prod ($APP_NAME - $WORKFLOW_VM)" >> $GITHUB_STEP_SUMMARY || true + MATRIX_VM: ${{ matrix.vm }} + run: node .github/scripts/aggregate-e2e-results.js . --job-name "E2E Vercel Prod ($APP_NAME - $MATRIX_VM)" >> $GITHUB_STEP_SUMMARY || true - name: Upload E2E results if: always() @@ -925,9 +931,9 @@ jobs: strategy: fail-fast: false matrix: - # Workflow VM engines: node:vm (default) and the opt-in QuickJS - # WASM engine (WORKFLOW_VM=quickjs). - vm: [node, quickjs] + # Workflow VM engines: QuickJS WASM (the default — WORKFLOW_VM left + # unset) and the explicit node:vm opt-in (WORKFLOW_VM=node). + vm: [quickjs, node] env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ vars.TURBO_TEAM }} @@ -1038,7 +1044,9 @@ jobs: DEV_TEST_CONFIG: '{"generatedStepRegistrationPath":"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"}' DEV_SERVER_LOG_PATH: "${{ github.workspace }}/nextjs-server.log" WORKFLOW_DEV_HMR_LOGS: "1" - WORKFLOW_VM: ${{ matrix.vm }} + # quickjs is the engine default — leave WORKFLOW_VM unset for it + # (MATRIX_VM carries the label for reporting). + WORKFLOW_VM: ${{ matrix.vm == 'node' && 'node' || '' }} MATRIX_VM: ${{ matrix.vm }} - name: Print Next.js server logs diff --git a/docs/content/docs/v5/configuration/runtime-tuning.mdx b/docs/content/docs/v5/configuration/runtime-tuning.mdx index 85672dfc86..d3ed3a80b9 100644 --- a/docs/content/docs/v5/configuration/runtime-tuning.mdx +++ b/docs/content/docs/v5/configuration/runtime-tuning.mdx @@ -113,12 +113,12 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL ### `WORKFLOW_VM` -- Default: `node` +- Default: `quickjs` - Values: `node` or `quickjs` - Selects the sandboxed VM engine that executes workflow functions (`"use workflow"`). Step functions are unaffected — they always run with full Node.js access. -- `node` (default) runs workflow code in a [`node:vm`](https://nodejs.org/api/vm.html) context. -- `quickjs` (experimental) runs workflow code in a [QuickJS](https://github.com/quickjs-ng/quickjs) VM compiled to WebAssembly (via [`quickjs-wasi`](https://github.com/vercel-labs/quickjs-wasi)). Both engines implement the same event-replay execution model (seeded PRNG, deterministic clock, and correlation-ID sequences are identical), but the **global surface is not identical** — see the differences below before switching an existing deployment. The QuickJS engine is intended for platforms that do not implement `node:vm`, and is the foundation for future VM-memory snapshotting. -- Global-surface differences under `quickjs` (workflow functions only — step functions always have full Node.js): +- `quickjs` (default) runs workflow code in a [QuickJS](https://github.com/quickjs-ng/quickjs) VM compiled to WebAssembly (via [`quickjs-wasi`](https://github.com/vercel-labs/quickjs-wasi)). It works on platforms that do not implement `node:vm` (e.g. edge runtimes), and is the foundation for VM-memory snapshotting. +- `node` runs workflow code in a [`node:vm`](https://nodejs.org/api/vm.html) context instead. Both engines implement the same event-replay execution model (seeded PRNG, deterministic clock, and correlation-ID sequences are identical), but the **global surface is not identical** — review the differences below before flipping an existing deployment either direction. +- Global-surface differences under `quickjs` relative to `node` (workflow functions only — step functions always have full Node.js): - `crypto.getRandomValues()` and `crypto.randomUUID()` are provided and deterministic (seeded like the node engine's). All `crypto.subtle.*` methods throw with guidance to move to a step function — including `digest`, which the node engine supports. - `Intl` is not available (QuickJS has no ICU). The `Intl.*` constructors throw, and `toLocaleString`-family methods (including `localeCompare`) throw when called **with an explicit locale** — calling them without arguments keeps the engine default. Perform locale-sensitive formatting in a step function. - `WebAssembly` and `Atomics` are not available. @@ -130,7 +130,7 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL - Default: `0` (disabled) - Values: non-negative integer -- Only used by the QuickJS engine (`WORKFLOW_VM=quickjs`). +- Only used by the QuickJS engine (the default; see `WORKFLOW_VM`). - When set above `0`, the runtime persists a **VM-memory snapshot** at a suspension once at least this many events have been processed since the last snapshot. Subsequent invocations restore the VM from the snapshot and replay only the events recorded since — instead of re-executing the workflow from the top against the full event log. - Short-lived runs below the threshold never pay the snapshot cost; long-running or unbounded runs stop scaling their resume cost with total event-log length. `1` snapshots at every qualifying suspension. - Snapshots are an optimization, not a source of truth: the event log remains authoritative, and a missing, corrupt, or incompatible snapshot automatically falls back to a full replay. diff --git a/packages/core/src/runtime-trace-mode.test.ts b/packages/core/src/runtime-trace-mode.test.ts index bf119ac4ac..7f7a0d53f5 100644 --- a/packages/core/src/runtime-trace-mode.test.ts +++ b/packages/core/src/runtime-trace-mode.test.ts @@ -33,6 +33,12 @@ import { workflowEntrypoint } from './runtime.js'; import { dehydrateWorkflowArguments } from './serialization.js'; import { getNextTraceCarrier, getWorkflowTraceMode } from './telemetry.js'; +// These tests exercise the node:vm engine's replay loop internals against +// mock worlds. Pin the engine explicitly — QuickJS is the default, and +// dispatching there would instantiate a WASM VM per entrypoint call (and +// bypass the node replay loop these tests assert on). +process.env.WORKFLOW_VM = 'node'; + vi.mock('@vercel/functions', () => ({ waitUntil: vi.fn((p: Promise) => { p.catch(() => {}); diff --git a/packages/core/src/runtime.test.ts b/packages/core/src/runtime.test.ts index 750cef009f..a0876ec69d 100644 --- a/packages/core/src/runtime.test.ts +++ b/packages/core/src/runtime.test.ts @@ -20,6 +20,12 @@ import { dehydrateWorkflowArguments, } from './serialization.js'; +// These tests exercise the node:vm engine's replay loop internals against +// mock worlds. Pin the engine explicitly — QuickJS is the default, and +// dispatching there would instantiate a WASM VM per entrypoint call (and +// bypass the node replay loop these tests assert on). +process.env.WORKFLOW_VM = 'node'; + // Capture every promise handed to `waitUntil` so tests can assert that // progress-critical sends are never registered on a detached, unconsumed // promise (which would reject → unhandled rejection → process exit 128, and diff --git a/packages/core/src/runtime/precondition-guard-replay.test.ts b/packages/core/src/runtime/precondition-guard-replay.test.ts index fefa5a0bcf..6a1e450152 100644 --- a/packages/core/src/runtime/precondition-guard-replay.test.ts +++ b/packages/core/src/runtime/precondition-guard-replay.test.ts @@ -45,6 +45,12 @@ import { } from './constants.js'; import { setWorld } from './world.js'; +// These tests exercise the node:vm engine's replay loop internals against +// mock worlds. Pin the engine explicitly — QuickJS is the default, and +// dispatching there would instantiate a WASM VM per entrypoint call (and +// bypass the node replay loop these tests assert on). +process.env.WORKFLOW_VM = 'node'; + vi.mock('@vercel/functions', () => ({ waitUntil: vi.fn(), })); diff --git a/packages/core/src/runtime/vm-mode.test.ts b/packages/core/src/runtime/vm-mode.test.ts index 003f3329af..865dcb6772 100644 --- a/packages/core/src/runtime/vm-mode.test.ts +++ b/packages/core/src/runtime/vm-mode.test.ts @@ -78,8 +78,13 @@ describe('useQuickJSVm', () => { delete process.env.WORKFLOW_VM; }); - it('defaults to node:vm (false) when nothing is configured', () => { - expect(useQuickJSVm(makeRun())).toBe(false); + it('defaults to QuickJS (true) when nothing is configured', () => { + expect(useQuickJSVm(makeRun())).toBe(true); + }); + + it('an empty WORKFLOW_VM value also resolves to the QuickJS default', () => { + process.env.WORKFLOW_VM = ''; + expect(useQuickJSVm(makeRun())).toBe(true); }); it('returns true when WORKFLOW_VM=quickjs is set in the environment', () => { diff --git a/packages/core/src/runtime/vm-mode.ts b/packages/core/src/runtime/vm-mode.ts index 8dcecefa13..80e4665136 100644 --- a/packages/core/src/runtime/vm-mode.ts +++ b/packages/core/src/runtime/vm-mode.ts @@ -1,8 +1,8 @@ /** * VM engine selection for workflow execution. * - * The Node.js `node:vm` engine is the default. The QuickJS WASM engine is - * opt-in via the `WORKFLOW_VM` env var or `executionContext.workflowVm`. + * The QuickJS WASM engine is the default. The Node.js `node:vm` engine is + * opt-in via `WORKFLOW_VM=node` or `executionContext.workflowVm`. * * Both engines implement the same event-replay execution model: on every * workflow handler invocation the workflow function is re-executed from the @@ -53,7 +53,8 @@ export function getWorkflowVmFromEnv( * when `WORKFLOW_VM` is set on the client) takes precedence so a run keeps * executing on the engine it started on. When the run doesn't specify an * engine, the `WORKFLOW_VM` env var on the workflow handler decides. - * The default is the `node:vm` engine. + * The default is the QuickJS engine; `WORKFLOW_VM=node` opts back into + * the `node:vm` engine. * * Throws if `WORKFLOW_VM` or `executionContext.workflowVm` is set to an * unknown value. @@ -71,7 +72,7 @@ export function useQuickJSVm(workflowRun: WorkflowRun): boolean { } return vmFromRun === 'quickjs'; } - return getWorkflowVmFromEnv() === 'quickjs'; + return getWorkflowVmFromEnv() !== 'node'; } /** diff --git a/packages/core/src/runtime/wait-completion-replay.test.ts b/packages/core/src/runtime/wait-completion-replay.test.ts index c4ca46982a..e57d47763b 100644 --- a/packages/core/src/runtime/wait-completion-replay.test.ts +++ b/packages/core/src/runtime/wait-completion-replay.test.ts @@ -17,6 +17,12 @@ import { import { createContext } from '../vm/index.js'; import { setWorld } from './world.js'; +// These tests exercise the node:vm engine's replay loop internals against +// mock worlds. Pin the engine explicitly — QuickJS is the default, and +// dispatching there would instantiate a WASM VM per entrypoint call (and +// bypass the node replay loop these tests assert on). +process.env.WORKFLOW_VM = 'node'; + vi.mock('@vercel/functions', () => ({ waitUntil: vi.fn(), })); diff --git a/scripts/create-test-matrix.mjs b/scripts/create-test-matrix.mjs index 27f9456aee..b7285a13be 100644 --- a/scripts/create-test-matrix.mjs +++ b/scripts/create-test-matrix.mjs @@ -165,32 +165,37 @@ matrix.app.push({ }); // Cross-product with the workflow VM engine axis: every app is tested -// against both the default node:vm engine and the opt-in QuickJS WASM -// engine (WORKFLOW_VM=quickjs). Each engine gets its own artifactSuffix -// and runLabel so CI artifacts and job names are unique. The `vm` field -// is surfaced to the workflow dev server via the WORKFLOW_VM env var in -// tests.yml. -const VMS = ['node', 'quickjs']; +// against both the default QuickJS WASM engine and the opt-in node:vm +// engine (WORKFLOW_VM=node) — the QuickJS legs deliberately leave +// WORKFLOW_VM unset so they exercise the default-selection path end to +// end, not just an explicit opt-in. Each engine gets its own +// artifactSuffix and runLabel so CI artifacts and job names are unique. +// The `vm` field is surfaced to the workflow dev server via the +// WORKFLOW_VM env var in tests.yml (empty ⇒ engine default). +const VMS = [ + { vm: '', label: 'quickjs' }, // default engine (QuickJS) + { vm: 'node', label: 'node' }, // explicit node:vm opt-in +]; matrix.app = matrix.app.flatMap((app) => - VMS.map((vm) => ({ + VMS.map(({ vm, label }) => ({ ...app, vm, - runLabel: [app.runLabel, vm].filter(Boolean).join(' '), - artifactSuffix: [app.artifactSuffix, vm].filter(Boolean).join('-'), + runLabel: [app.runLabel, label].filter(Boolean).join(' '), + artifactSuffix: [app.artifactSuffix, label].filter(Boolean).join('-'), })) ); -// QuickJS engine with VM-memory snapshotting at maximum churn -// (WORKFLOW_SNAPSHOT_THRESHOLD=1 snapshots at every qualifying -// suspension) — exercises the save/restore/delete lifecycle and the -// restore + partial-replay determinism on every run. +// QuickJS engine (the default — WORKFLOW_VM left unset) with VM-memory +// snapshotting at maximum churn (WORKFLOW_SNAPSHOT_THRESHOLD=1 snapshots +// at every qualifying suspension) — exercises the save/restore/delete +// lifecycle and the restore + partial-replay determinism on every run. matrix.app.push( createMatrixEntry( 'nextjs-turbopack', 'example-nextjs-workflow-turbopack', DEV_TEST_CONFIGS['nextjs-turbopack'], { - vm: 'quickjs', + vm: '', snapshotThreshold: '1', runLabel: 'quickjs-snapshot', artifactSuffix: 'quickjs-snapshot',