Skip to content

Snapshot Runtime: QuickJS WASM VM with snapshot/restore for workflow execution#1300

Draft
TooTallNate wants to merge 62 commits intoserialization-migrationfrom
snapshot-runtime
Draft

Snapshot Runtime: QuickJS WASM VM with snapshot/restore for workflow execution#1300
TooTallNate wants to merge 62 commits intoserialization-migrationfrom
snapshot-runtime

Conversation

@TooTallNate
Copy link
Member

Summary

Implements the snapshot-based workflow runtime described in RFC #1298. Instead of replaying the full event log on every invocation, workflows run in a QuickJS WASM VM that is snapshotted at suspension points and restored on resumption.

Verified end-to-end with the nextjs-turbopack workbench — a multi-step workflow completes successfully using WORKFLOW_RUNTIME=snapshot.

How it works

  1. Workflow code runs in a QuickJS WASM VM (via quickjs-wasi)
  2. When the workflow calls a step, the VM suspends and is snapshotted
  3. The step executes in Node.js (unchanged from the existing model)
  4. On re-invocation, the VM is restored from the snapshot
  5. Only events since the last snapshot are fetched and processed
  6. The workflow continues from the exact suspension point

Key components

Component Description
snapshot-runtime.ts Core runtime: VM bootstrap, event processing, snapshot/restore
snapshot-entrypoint.ts Integration with workflowEntrypoint: event fetching, step queuing, snapshot storage
vm-serde-bundle.generated.ts Bundled devalue serializer (16.6 KB) that runs inside QuickJS
workflow-vm.ts VM-compatible serializer (no Node.js deps, pure-JS base64)
polyfills/text-encoder.ts TextEncoder polyfill for QuickJS (adapted from nx.js)
polyfills/text-decoder.ts TextDecoder polyfill for QuickJS (adapted from nx.js)

What's included

  • World interface: snapshots.save(), snapshots.load(), snapshots.delete()
  • world-local: filesystem snapshot storage (.bin + .json sidecar)
  • Snapshot runtime with 7 unit tests
  • VM-compatible devalue serializer with cross-compat tests (14 tests)
  • TextEncoder/TextDecoder polyfills
  • Feature flag: WORKFLOW_RUNTIME=snapshot
  • End-to-end verification with nextjs-turbopack workbench

What's NOT included (future work)

  • createHook / webhook support in snapshot runtime
  • Encryption support (step args/results)
  • Workflow arguments hydration
  • Cleanup of debug logging
  • Performance benchmarks vs event-replay
  • world-vercel / world-postgres snapshot storage

How to test

# Set the env var in workbench/nextjs-turbopack/.env.local
echo 'WORKFLOW_RUNTIME=snapshot' >> workbench/nextjs-turbopack/.env.local

# Start the dev server
cd workbench/nextjs-turbopack
pnpm dev

# Start the simple workflow
curl -X POST http://localhost:3000/api/workflows/start \
  -H "Content-Type: application/json" \
  -d '{"workflowName":"simple","args":[10]}'

Depends on #1299 (serialization refactor) via the serialization-migration branch.

Phase 1 of the VM snapshot runtime (RFC #1298).

World interface changes (packages/world):
- Add SnapshotMetadata type (lastEventId, createdAt) with zod schema
- Add snapshots sub-interface to Storage: save(), load(), delete()
- Export new types and schema from @workflow/world

world-local implementation (packages/world-local):
- Filesystem-based snapshot storage in {dataDir}/snapshots/
- {runId}.bin for serialized VM snapshot data
- {runId}.json for metadata (lastEventId, createdAt)
- save() overwrites existing snapshots (atomic via ensureDir + write)
- load() returns null if no snapshot exists
- delete() removes both files
- Wired into createStorage() with tracing instrumentation
Phase 2 of the VM snapshot runtime (RFC #1298).

- Add quickjs-wasi dependency to @workflow/core
- Create snapshot-runtime.ts with the basic structure:
  - runSnapshotWorkflow() entry point
  - Fresh VM creation with deterministic WASI clock and seeded Math.random
  - Snapshot restore path (TODO: event processing)
  - Host function stubs for useStep, sleep, createHook via Symbol.for()
  - Interrupt handler (30s timeout)
  - Memory limit (64MB)
  - Snapshot serialization on suspension

The useStep, sleep, and createHook host functions are stubs with TODO
markers — the basic VM lifecycle and snapshot/restore flow is in place.
Demonstrates the core snapshot/restore mechanism with a compiled
workflow pattern:
- useStep implemented inside QuickJS as JS code (not host functions)
- Pending step resolve/reject functions stored on globalThis.__resolvers
- Step metadata (stepId, args) preserved across snapshot/restore
- Multi-step workflow: snapshot at each suspension, restore and resolve,
  workflow continues from exact suspension point
- Both tests pass: simple workflow + metadata preservation
The snapshot runtime (runSnapshotWorkflow) now handles the complete
workflow lifecycle:

- First run: bootstrap VM with workflow primitives, evaluate compiled
  workflow bundle, start workflow function, process any existing events
- Snapshot: capture VM state when workflow suspends on step/sleep
- Restore: deserialize snapshot, process delta events to resolve/reject
  pending promises, execute pending jobs
- Completion: detect workflow result or error

Workflow primitives (useStep, sleep) are implemented as JavaScript code
inside the QuickJS VM, not as host function callbacks. This keeps the
implementation simple — the host communicates by evaluating small JS
snippets to resolve/reject promises.

7 tests covering: simple completion, step suspension, snapshot/restore
with step completion, multi-step across 3 snapshots, sleep suspension
and wake, step failure with try/catch.
…napshot flag

- Add snapshot-entrypoint.ts that handles the full lifecycle:
  snapshot load → event fetching → runSnapshotWorkflow → result handling
  (create events, queue steps, save/delete snapshots)
- Add feature flag: set WORKFLOW_RUNTIME=snapshot to use the new runtime
- When enabled, the snapshot path runs before the event-replay path
- Step queuing matches the existing step handler's expected payload format
- Wait handling includes timeout calculation for delayed re-queuing
- Extract workflow ID from SWC-compiled bundle's manifest comment
The snapshot runtime now successfully:
1. Evaluates the compiled workflow bundle in QuickJS
2. Suspends on the first step call
3. Snapshots the VM state
4. Creates step_created events and queues step execution

Web API stubs added for TransformStream, ReadableStream, WritableStream,
TextEncoder, TextDecoder, Headers, URL, console — these are referenced
by the compiled bundle but not needed for basic step/sleep workflows.

Remaining issue: step_created events use raw JSON for step input args,
but the step handler expects devalue-serialized data. This is the data
serialization boundary that needs to be resolved (RFC #1298 discusses
moving devalue inside the QuickJS VM).
…untime

The step_created events now contain properly devalue-serialized input
data (Uint8Array with 'devl' format prefix) instead of raw JSON.
This makes the step handler's hydrateStepArguments() work correctly.

When processing step_completed events, the output is deserialized
via workflow.deserialize() on the host side before passing to the
QuickJS VM as JSON. This handles the devalue format prefix correctly.

Also properly serializes the run_completed output.
Step arguments are now wrapped in { args: [...], closureVars?: {...} }
before being serialized with workflow.serialize(), matching the format
expected by the step handler's hydrateStepArguments().

The step handler successfully:
- Receives the step message
- Deserializes the step arguments
- Executes the step function (add(10, 7))
- Handles retry on retryable errors
- Completes the step and re-queues the workflow
New files:
- serialization/base64.ts — pure-JS base64 encode/decode (no Buffer)
- serialization/reducers/common-vm.ts — VM-compatible reducers using
  instanceof Error instead of types.isNativeError(), pure-JS base64
  instead of Buffer
- serialization/codec-devalue-vm.ts — devalue codec using VM reducers
- serialization/workflow-vm.ts — VM workflow serialize/deserialize

The VM serializer produces the EXACT same wire format as the Node.js
serializer (devl-prefixed devalue data). Verified by 14 tests including
critical cross-compatibility:
- VM serialize → Node.js hydrateStepArguments (step handler path)
- Node.js dehydrateStepReturnValue → VM deserialize (step result path)
- Pure-JS base64 matches Node.js Buffer base64

Sub-path export: @workflow/core/serialization/workflow-vm
Re-export: workflow/internal/serialization now points to workflow-vm
Data now flows as format-prefixed devalue bytes (devl + devalue.stringify)
across the VM boundary, with no JSON conversion in the middle:

Step args: VM __wdk_serialize({args}) → Uint8Array → event input
Step results: event output Uint8Array → VM __wdk_deserialize → value
Workflow result: VM __wdk_serialize(result) → Uint8Array → event output

Host functions __wdk_serialize/__wdk_deserialize are installed on
globalThis and use the VM-compatible workflow serializer (pure JS,
no Node.js deps). They are re-installed after snapshot restore since
host callbacks don't survive the snapshot.

VM-compatible serializer (workflow-vm.ts) produces the EXACT same
wire format as the Node.js serializer — verified by cross-compatibility
tests.
The serializer (devalue + reducers + TextEncoder/TextDecoder polyfills)
is now bundled as a 16.6KB IIFE that's evaluated inside the QuickJS VM
during bootstrap. The serialize/deserialize functions are real JS
functions running inside the VM, operating on QuickJS-native values
(Date, Map, Set, etc.) that can't cross the VM boundary via dump().

Architecture:
- vm-bundle-entry.ts is bundled by esbuild into a self-contained IIFE
- esbuild inject option ensures TextEncoder/TextDecoder polyfills run
  before any module-level code
- The host only passes opaque Uint8Array blobs (devl-prefixed devalue)
  across the VM boundary
- On snapshot restore, the serde functions survive in the QuickJS heap
  (no re-registration needed)

New files:
- polyfills/text-encoder.ts — pure JS TextEncoder (from nx.js)
- polyfills/text-decoder.ts — pure JS TextDecoder (from nx.js)
- polyfills/install-text-coding.ts — installs polyfills on globalThis
- serialization/vm-bundle-entry.ts — esbuild entry for VM serde bundle
- runtime/vm-serde-bundle.generated.ts — auto-generated bundle string
- scripts/build-vm-serde-bundle.js — build script (runs during pnpm build)

Removed: installSerdeHostFunctions (no longer needed — serde is in-VM)
…ecution

The snapshot metadata now stores eventsCursor (the pagination cursor from
events.list()) instead of lastEventId (the raw event ID). The world-local
pagination expects cursors in 'timestamp|id' format, not raw event IDs.

This fix enables the full workflow lifecycle:
1. First invocation: QuickJS VM evaluates workflow, suspends on step_0
2. Step handler executes add(10, 7) = 17
3. Second invocation: snapshot restored, step_0 resolved, suspends on step_1
4. Step handler executes add(17, 8) = 25
5. Third invocation: snapshot restored, both steps resolved, workflow completes
6. run_completed event created, snapshot cleaned up

Verified end-to-end with the nextjs-turbopack workbench:
- All events created correctly (run_created → run_completed)
- Step retries work (the add function throws on first attempt)
- Snapshots are saved/restored/deleted at correct lifecycle points
- Run status transitions to 'completed'
@changeset-bot
Copy link

changeset-bot bot commented Mar 9, 2026

⚠️ No Changeset found

Latest commit: 89521e5

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link
Contributor

vercel bot commented Mar 9, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview, Comment Mar 19, 2026 1:10am
example-nextjs-workflow-webpack Ready Ready Preview, Comment Mar 19, 2026 1:10am
example-workflow Ready Ready Preview, Comment Mar 19, 2026 1:10am
workbench-astro-workflow Ready Ready Preview, Comment Mar 19, 2026 1:10am
workbench-express-workflow Ready Ready Preview, Comment Mar 19, 2026 1:10am
workbench-fastify-workflow Ready Ready Preview, Comment Mar 19, 2026 1:10am
workbench-hono-workflow Ready Ready Preview, Comment Mar 19, 2026 1:10am
workbench-nitro-workflow Ready Ready Preview, Comment Mar 19, 2026 1:10am
workbench-nuxt-workflow Ready Ready Preview, Comment Mar 19, 2026 1:10am
workbench-sveltekit-workflow Ready Ready Preview, Comment Mar 19, 2026 1:10am
workbench-vite-workflow Ready Ready Preview, Comment Mar 19, 2026 1:10am
workflow-docs Ready Ready Preview, Comment, Open in v0 Mar 19, 2026 1:10am
workflow-nest Ready Ready Preview, Comment Mar 19, 2026 1:10am
workflow-swc-playground Ready Ready Preview, Comment Mar 19, 2026 1:10am

@github-actions
Copy link
Contributor

github-actions bot commented Mar 9, 2026

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 758 0 67 825
✅ 💻 Local Development 782 0 118 900
✅ 📦 Local Production 782 0 118 900
✅ 🐘 Local Postgres 782 0 118 900
✅ 🪟 Windows 72 0 3 75
❌ 🌍 Community Worlds 118 56 15 189
❌ 📋 Other 336 7 32 375
Total 3630 63 471 4164

❌ Failed Tests

🌍 Community Worlds (56 failed)

mongodb (3 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KM1T9FK2VTVGJSWXNK9N8RTG
  • webhookWorkflow | wrun_01KM1T9R8QXCNNZ5TZTE8G6SKX
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KM1TF3Y2D85VGW0XM7WR8A4C

redis (2 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KM1T9FK2VTVGJSWXNK9N8RTG
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KM1TF3Y2D85VGW0XM7WR8A4C

turso (51 failed):

  • addTenWorkflow | wrun_01KM1T88E2XNN0ES3QJHGVRC1Q
  • addTenWorkflow | wrun_01KM1T88E2XNN0ES3QJHGVRC1Q
  • wellKnownAgentWorkflow (.well-known/agent) | wrun_01KM1T9X1C69HRGKSYJV108QK1
  • should work with react rendering in step
  • promiseAllWorkflow | wrun_01KM1T8F8YQTW6KVWVC188JJWG
  • promiseRaceWorkflow | wrun_01KM1T8NBV43PSTYNASDNC6NCT
  • promiseAnyWorkflow | wrun_01KM1T8QFWY1Q0FA33ZVCPVBS0
  • importedStepOnlyWorkflow | wrun_01KM1TAAR2NA23V818PTX9J33Z
  • hookWorkflow | wrun_01KM1T94QV3VZNDW69J2ZB9WY2
  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KM1T9FK2VTVGJSWXNK9N8RTG
  • webhookWorkflow | wrun_01KM1T9R8QXCNNZ5TZTE8G6SKX
  • sleepingWorkflow | wrun_01KM1T9YQ3FADP81KG8R158GQT
  • parallelSleepWorkflow | wrun_01KM1TAAGWG8BKEY4DWEH0187A
  • nullByteWorkflow | wrun_01KM1TAF0K8KMF2JTTHZAFVGVZ
  • workflowAndStepMetadataWorkflow | wrun_01KM1TAHGJ4Z5J1691W4SNM51Y
  • fetchWorkflow | wrun_01KM1TBE5JX1RBCFC5Y0KSZVYM
  • promiseRaceStressTestWorkflow | wrun_01KM1TBHKZXZ05Y3DXNJCFC2ET
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion | wrun_01KM1TEEHJRRGBAJRSK9605MYR
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KM1TF3Y2D85VGW0XM7WR8A4C
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running | wrun_01KM1TFQVDNSZ9C345SQPAGTSG
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars) | wrun_01KM1TGCCCMPKBFDGGSV71DB6S
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument | wrun_01KM1TGN5DEXTNJBTSBJRAT87G
  • closureVariableWorkflow - nested step functions with closure variables | wrun_01KM1TGTPJTR4RYS6K0R91WHN0
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step | wrun_01KM1TGXC4P782ZVQ2TDJJ76S0
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly | wrun_01KM1THCCN70X2DWWRXPHA5EXE
  • Calculator.calculate - static workflow method using static step methods from another class | wrun_01KM1THJ1GJ9PMKX5PFVYTAWEB
  • AllInOneService.processNumber - static workflow method using sibling static step methods | wrun_01KM1THS2ZERZQEV7DVHNRKK2D
  • ChainableService.processWithThis - static step methods using this to reference the class | wrun_01KM1THZPTZJPX5ZB6N54VMP7Y
  • thisSerializationWorkflow - step function invoked with .call() and .apply() | wrun_01KM1TJ6EHTPKAT5KD5DPRDZ44
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE | wrun_01KM1TJDE599P87Z37J11Y9KZR
  • instanceMethodStepWorkflow - instance methods with "use step" directive | wrun_01KM1TJM3NJCXPP7957YHMXGBS
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context | wrun_01KM1TJXTEDG06K7TTYX3S7CSJ
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument | wrun_01KM1TK5NK486H9930PYQ3TT2W
  • cancelRun - cancelling a running workflow | wrun_01KM1TKCAC0J30424ZDH0K2HXT
  • cancelRun via CLI - cancelling a running workflow | wrun_01KM1TKNKPBNMWFQQ1FAB009EJ
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep | wrun_01KM1TM29FRJ6YZHBJKY9S8R1K
  • sleepInLoopWorkflow - sleep inside loop with steps actually delays each iteration | wrun_01KM1TMPPPPXB96XTRNSABPQMW
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control) | wrun_01KM1TN12VDCEJS7XT4C9T52GG
📋 Other (7 failed)

e2e-snapshot-runtime-vercel (7 failed):

  • hookWorkflow | wrun_01KM1T94QV3VZNDW69J2ZB9WY2
  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KM1T9FK2VTVGJSWXNK9N8RTG
  • webhookWorkflow | wrun_01KM1T9R8QXCNNZ5TZTE8G6SKX
  • hookCleanupTestWorkflow - hook token reuse after workflow completion | wrun_01KM1TEEHJRRGBAJRSK9605MYR
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KM1TF3Y2D85VGW0XM7WR8A4C
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running | wrun_01KM1TFQVDNSZ9C345SQPAGTSG
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep | wrun_01KM1TM29FRJ6YZHBJKY9S8R1K

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 68 0 7
✅ example 68 0 7
✅ express 68 0 7
✅ fastify 68 0 7
✅ hono 68 0 7
✅ nextjs-turbopack 73 0 2
✅ nextjs-webpack 73 0 2
✅ nitro 68 0 7
✅ nuxt 68 0 7
✅ sveltekit 68 0 7
✅ vite 68 0 7
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 66 0 9
✅ express-stable 66 0 9
✅ fastify-stable 66 0 9
✅ hono-stable 66 0 9
✅ nextjs-turbopack-canary 55 0 20
✅ nextjs-turbopack-stable 72 0 3
✅ nextjs-webpack-canary 55 0 20
✅ nextjs-webpack-stable 72 0 3
✅ nitro-stable 66 0 9
✅ nuxt-stable 66 0 9
✅ sveltekit-stable 66 0 9
✅ vite-stable 66 0 9
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 66 0 9
✅ express-stable 66 0 9
✅ fastify-stable 66 0 9
✅ hono-stable 66 0 9
✅ nextjs-turbopack-canary 55 0 20
✅ nextjs-turbopack-stable 72 0 3
✅ nextjs-webpack-canary 55 0 20
✅ nextjs-webpack-stable 72 0 3
✅ nitro-stable 66 0 9
✅ nuxt-stable 66 0 9
✅ sveltekit-stable 66 0 9
✅ vite-stable 66 0 9
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 66 0 9
✅ express-stable 66 0 9
✅ fastify-stable 66 0 9
✅ hono-stable 66 0 9
✅ nextjs-turbopack-canary 55 0 20
✅ nextjs-turbopack-stable 72 0 3
✅ nextjs-webpack-canary 55 0 20
✅ nextjs-webpack-stable 72 0 3
✅ nitro-stable 66 0 9
✅ nuxt-stable 66 0 9
✅ sveltekit-stable 66 0 9
✅ vite-stable 66 0 9
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 72 0 3
❌ 🌍 Community Worlds
App Passed Failed Skipped
✅ mongodb-dev 3 0 2
❌ mongodb 52 3 3
✅ redis-dev 3 0 2
❌ redis 53 2 3
✅ turso-dev 3 0 2
❌ turso 4 51 3
❌ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 66 0 9
✅ e2e-local-postgres-nest-stable 66 0 9
✅ e2e-local-prod-nest-stable 66 0 9
❌ e2e-snapshot-runtime-vercel 66 7 2
✅ e2e-snapshot-runtime 72 0 3

📋 View full workflow run


Snapshot runtime tests (local) (non-blocking): success
⚠️ Snapshot runtime tests (vercel) (non-blocking): failure

Copy link
Contributor

@vercel vercel bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestion:

The Storage interface requires a snapshots property but packages/world-vercel/src/storage.ts does not implement it, causing TypeScript build failures (TS2741).

Fix on Vercel

- Extract workflow arguments from run_created event and pass to the
  workflow function via __wdk_deserialize()
- Call executePendingJobs() after each step_completed/step_failed/
  wait_completed event to allow async function await resumptions
  to unwind one step at a time
- Add debug logging for workflow result bytes

The addTenWorkflow e2e test is still failing: the workflow result bytes
are 'devl-1' (devalue for undefined) even though all steps complete
successfully. The issue appears to be that the async function return
value is not propagating through the SWC-compiled workflow bundle's
promise chain. This needs investigation — the unit tests with simple
inline workflow code work correctly.
Check if the hook entity already exists before calling events.create for
hook_created. This prevents concurrent stale-snapshot invocations from
creating spurious hook_conflict events that pollute the event log and
interfere with workflow progression.
Replace the runId-prefixed counter approach (wrun_xxx_step_0) with proper
ULIDs (step_01ABCDEF...) matching the event-replay runtime format. The
ulid package is bundled into the VM serde bundle via esbuild, using the
seeded Math.random PRNG for deterministic generation.

Also fixes snapshot-runtime unit tests: update makeRun() to match current
WorkflowRun type, use dynamic correlationId capture instead of hardcoded
step_0/step_1/wait_0 values, and fix eventData.output → eventData.result.
Object.getPrototypeOf(null) throws TypeError. Add early null checks
before instanceof/getPrototypeOf in stream reducers so null/undefined
values are correctly rejected.
The minified bundle contains patterns like typeof x<"u" whose escaped
quotes inside the string literal confuse downstream esbuild when it
re-processes the compiled JS (e.g., Nitro prod builds). Encoding the
bundle as base64 avoids all escaping issues — it's decoded at import
time via Buffer.from() or atob().
@socket-security
Copy link

socket-security bot commented Mar 10, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatednpm/​@​vercel/​analytics@​2.0.0 ⏵ 2.0.1991008391 +4100

View full report

Write the esbuild output as a standalone .js file and read it from disk
at runtime with readFileSync. This avoids the escaping issues that arose
when embedding minified JS (containing patterns like typeof x<"u")
inside a JS string literal that gets re-processed by downstream esbuild
(e.g., Nitro prod builds).
Revert the readFileSync approach which broke in CJS bundled contexts
(world-testing embedded tests) where import.meta.url is undefined.

Instead, use a template literal to embed the bundle string. Template
literals avoid the escaping issues that broke Nitro builds — esbuild's
minifier produces patterns like typeof x<"u" whose escaped quotes
inside a JSON-stringified regular string literal confuse downstream
esbuild, but template literals use backticks which don't conflict.
…s, debug logging

- Fix VM serde using wrong symbol names for WritableStream/ReadableStream
  (Symbol.for('STREAM_NAME') -> Symbol.for('WORKFLOW_STREAM_NAME')),
  causing streams to serialize as '__empty' instead of the correct name
- Add WORKFLOW_GET_STREAM_ID implementation to VM bootstrap so
  getWritable() works inside the QuickJS VM
- Include error stack traces in snapshot runtime failure logs
- Fix elapsed waits from prior invocations not firing on snapshot
  restore — pending waits with hasCreatedEvent:true now get
  wait_completed events created and immediate re-queue
- Add debug logging across the execution path (start, queue handler,
  snapshot entrypoint) for visibility with DEBUG=workflow:*
Propagate WORKFLOW_RUNTIME into executionContext.workflowRuntime at
start() so the server can select the snapshot runtime on a per-run
basis. This allows the same Vercel deployment to serve both replay
and snapshot runtime runs — the test runner opts in by setting the
env var, and the queue handler reads it from the run entity.

Add e2e-snapshot-runtime-vercel CI job that runs the full e2e suite
against the nextjs-turbopack Vercel deployment with the snapshot
runtime (non-blocking, like the local snapshot job).
- evalCode() now throws JSException directly instead of returning a
  result union — remove all unwrapResult() calls
- Replace isException checks with try/catch using JSException
- Update extractError() to extract error details from JSException.handle
- Update raw QuickJS proof-of-concept test for new API
The QuickJS VM only understands the 'devl' serialization format.
When encryption is enabled (Vercel deployments), event payloads
have the 'encr' prefix. Resolve the encryption key in the snapshot
entrypoint and decrypt run inputs and step results on the host
side before passing them into the VM.
- Use the real nanoid package (via host function) for webhook tokens
  instead of 'tok_' + ULID, matching the event-replay runtime
- Fix host callback ID mismatch on snapshot restore: quickjs-wasi
  starts nextCallbackId at 1 (not 0), so Math.random is ID 1 and
  __generateNanoid is ID 2. Previously registered as 0 and 1, causing
  Math.random to invoke nanoid and nanoid to return undefined.
- Re-register host callbacks (Math.random, __generateNanoid) after
  QuickJS.restore() so they survive snapshot/restore
- Add error stack trace logging to world-local queue handler
- Add debug logging for hook_created event creation
- Update quickjs-wasi to v2.0.0 which uses string-based host callback
  names instead of numeric IDs for registerHostCallback()
- Add native C extensions: encoding (TextEncoder/TextDecoder), base64
  (btoa/atob), headers (Headers), url (URL/URLSearchParams), and
  structuredClone
- Delete packages/core/src/polyfills/ entirely — TextEncoder,
  TextDecoder, and Headers polyfills replaced by native extensions
- Remove polyfill injection from serde bundle build
- Replace manual base64url implementation with native btoa()
- Serde bundle size reduced from 22.3 KB to 18.0 KB
Compress VM snapshot data with gzip before writing to disk. The
metadata JSON includes a dataFile field with the binary filename
(e.g. '{runId}.bin.gz') so the correct compression format can be
determined on load. Backward compatible with existing uncompressed
.bin snapshots via fallback when dataFile is absent.
…dles

- Use dynamic import with variable indirection for snapshot-entrypoint
  to prevent esbuild from pulling quickjs-wasi into the workflow bundle
  (which is CJS and breaks import.meta.url)
- Externalize quickjs-wasi from Nitro's server bundle (rollup/rolldown)
- Add quickjs-wasi to Next.js serverExternalPackages automatically
The relative path './runtime/snapshot-entrypoint.js' breaks when
Turbopack chunks @workflow/core into a different output directory.
Use '@workflow/core/runtime/snapshot-entrypoint' package specifier
instead, which bundlers resolve correctly regardless of chunking.
…dFileSync

Instead of importing quickjs-wasi subpath modules (quickjs-wasi/base64,
quickjs-wasi/encoding, etc.) which use import.meta.url internally and
break when bundled to CJS, resolve the package directory via
createRequire + require.resolve and read the .wasm and .so files
directly with readFileSync. This is compatible with nft file tracing
and avoids all bundler issues.

- Remove quickjs-wasi subpath imports entirely
- Construct ExtensionDescriptor objects manually with pre-read bytes
- Remove unused wasm option from SnapshotRuntimeOptions
- Add initFn for structured-clone extension (hyphen → underscore)
The dynamic import was unnecessary — the flow bundle runs in Node.js
(not the QuickJS VM), so quickjs-wasi being in the bundle is correct.
Remove the subpath export that was added for the dynamic import.
Use require.resolve directly when available (CJS bundles), falling
back to createRequire(import.meta.url) for ESM contexts.
Revert to importing quickjs-wasi subpath modules directly (base64,
encoding, headers, url, structured-clone) since they work correctly
when externalized. Add quickjs-wasi and quickjs-wasi/* to the
external list in both the final workflow bundle and steps bundle
esbuild configs, so the extension modules stay as require() calls
and their import.meta.url-based .so loading works at runtime.
Generate quickjs-assets.generated.ts at build time containing
base64-encoded quickjs.wasm and extension .so files. Import the
decoded buffers directly in snapshot-runtime.ts and pass to
QuickJS.create()/restore(). This eliminates all runtime filesystem
access, import.meta.url resolution, and require.resolve calls —
it's just JavaScript importing JavaScript.

Remove all quickjs-wasi externalizations from builders, nitro,
and next since they're no longer needed.
Match the event-replay runtime's refactor (dcb0761) where builtin
step functions use this instead of an explicit parameter. Assign
useStep() proxies directly to Response/Request prototypes via
Object.defineProperties so the this binding provides the instance,
which gets serialized as thisVal by WORKFLOW_USE_STEP.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant