Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quickjs-default-engine.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 26 additions & 18 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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' }}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions docs/content/docs/v5/configuration/runtime-tuning.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
TooTallNate marked this conversation as resolved.
- `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.
Expand All @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/runtime-trace-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>) => {
p.catch(() => {});
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment thread
TooTallNate marked this conversation as resolved.

// 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
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/runtime/precondition-guard-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/runtime/vm-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/runtime/vm-mode.ts
Original file line number Diff line number Diff line change
@@ -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`.
Comment thread
TooTallNate marked this conversation as resolved.
*
* Both engines implement the same event-replay execution model: on every
* workflow handler invocation the workflow function is re-executed from the
Expand Down Expand Up @@ -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.
Expand All @@ -71,7 +72,7 @@ export function useQuickJSVm(workflowRun: WorkflowRun): boolean {
}
return vmFromRun === 'quickjs';
}
return getWorkflowVmFromEnv() === 'quickjs';
return getWorkflowVmFromEnv() !== 'node';
}

/**
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/runtime/wait-completion-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));
Expand Down
33 changes: 19 additions & 14 deletions scripts/create-test-matrix.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading