You're Invited:Meet the Socket Team at RSAC and BSidesSF 2026, March 23–26.RSVP
Socket
Book a DemoSign in
Socket

quickjs-wasi

Package Overview
Dependencies
Maintainers
16
Versions
7
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

quickjs-wasi

Snapshotable JavaScript runtime via WebAssembly. QuickJS-NG compiled to WASM with snapshot/restore support.

latest
Source
npmnpm
Version
1.2.0
Version published
Maintainers
16
Created
Source

quickjs-wasi

A snapshotable JavaScript runtime via WebAssembly. Runs QuickJS compiled to WASM, with the ability to snapshot the entire VM state (including pending promises) and restore it in a fresh WASM instance.

Motivation

The Workflow DevKit project implements durable function execution for TypeScript using an event-replay technique: workflow code is re-executed from the beginning on every resumption, with the full event log used as the source of truth for previously completed work. This approach has scaling limitations:

  • As the event log grows, re-fetching it becomes expensive
  • Replaying the full log takes increasingly longer
  • There is an effective upper bound on how much work a workflow can do
  • Running "forever" workflows is impractical

This project explores a fundamentally different approach: VM snapshotting. Instead of replaying from the beginning, we snapshot the JavaScript execution environment at each suspension point and restore it on resumption. The restored VM already has the correct state — only events since the last snapshot need to be fetched and applied.

Install

npm install quickjs-wasi

Usage

Basic Evaluation

Both QuickJS and JSValueHandle implement Symbol.dispose, so you can use using declarations for automatic cleanup:

import { QuickJS } from 'quickjs-wasi';

{
  using vm = await QuickJS.create(wasmBytes);

  // Evaluate code — handles are auto-disposed with `using`
  using result = vm.unwrapResult(vm.evalCode('1 + 2'));
  console.log(result.toNumber()); // 3
} // vm and result are automatically disposed here

Working with Values

using vm = await QuickJS.create(wasmBytes);

// Create values — `using` ensures they're disposed at end of scope
{
  using str = vm.newString('hello');
  using num = vm.newNumber(42);
  using big = vm.newBigInt(9007199254740993n);
  vm.setProp(vm.global, 'message', str);
}

// Read back the value
using msg = vm.unwrapResult(vm.evalCode('message'));
console.log(msg.toString()); // "hello"

// Convert host values to QuickJS handles (and back)
using handle = vm.hostToHandle({ x: 1, y: [2, 3] });
const dumped = vm.dump(handle); // { x: 1, y: [2, 3] }

// consume() is still useful for inline one-liners
const value = vm.evalCode('1 + 2').consume(h => h.toNumber()); // 3

Host Functions

Register JavaScript functions backed by host (Node.js) callbacks:

using vm = await QuickJS.create(wasmBytes);

// The first argument to the callback is always `this`
{
  using add = vm.newFunction('add', (...args) => {
    return vm.newNumber(args[0].toNumber() + args[1].toNumber());
  });
  vm.setProp(vm.global, 'add', add);
}

using result = vm.unwrapResult(vm.evalCode('add(3, 4)'));
console.log(result.toNumber()); // 7

Promises and Async Host Functions

Bridge async host operations into the QuickJS sandbox:

using vm = await QuickJS.create(wasmBytes);

// Create an async host function that returns a promise to QuickJS
{
  using dnsResolve = vm.newFunction('dnsResolve', (...args) => {
    const hostname = args[0].toString();
    const deferred = vm.newPromise();

    // Do real async work on the host side
    dns.resolve4(hostname).then(
      (addresses) => {
        deferred.resolve(vm.newString(addresses[0]));
        vm.executePendingJobs(); // drain the QuickJS job queue
      },
      (err) => {
        deferred.reject(vm.newError(err));
        vm.executePendingJobs();
      }
    );

    return deferred.handle; // return the QuickJS promise
  });
  vm.setProp(vm.global, 'dnsResolve', dnsResolve);
}

Error Handling

using vm = await QuickJS.create(wasmBytes);

// unwrapResult() throws a host Error if the eval/call produced an exception
try {
  vm.unwrapResult(vm.evalCode('throw new TypeError("bad")'));
} catch (err) {
  console.log(err.name);    // "TypeError"
  console.log(err.message); // "bad"
  console.log(err.stack);   // QuickJS stack trace
}

// Create errors from host Error objects (preserves name, message, stack)
{
  using errHandle = vm.newError(new RangeError('out of bounds'));
  vm.setProp(vm.global, 'hostError', errHandle);
}

Deterministic Execution

The wasi.now option controls Date.now(), new Date(), and — crucially — the Math.random() PRNG seed. QuickJS uses a xorshift64* PRNG that is seeded once from the clock value during context creation. The now() callback is not called on every Math.random() invocation — it seeds the PRNG at startup, and subsequent calls are purely deterministic from that seed.

This means two VMs created with the same now() value will produce identical Math.random() sequences:

const fixedTime = () => BigInt(1700000000000) * 1_000_000n; // nanoseconds

using vm1 = await QuickJS.create({ wasm: wasmBytes, wasi: { now: fixedTime } });
using vm2 = await QuickJS.create({ wasm: wasmBytes, wasi: { now: fixedTime } });

vm1.evalCode('Math.random()').consume(h => h.toNumber());
// => 0.8130834347906803

vm2.evalCode('Math.random()').consume(h => h.toNumber());
// => 0.8130834347906803 (identical)

The time can also be advanced between calls for realistic behavior:

let currentTime = 1700000000000n;
using vm = await QuickJS.create({
  wasm: wasmBytes,
  wasi: {
    now: () => currentTime * 1_000_000n,
  },
});

vm.evalCode('Date.now()').consume(h => h.toNumber()); // 1700000000000
currentTime += 1000n; // advance 1 second
vm.evalCode('Date.now()').consume(h => h.toNumber()); // 1700000001000

Memory Limits

Restrict how much memory the QuickJS runtime can allocate. When exceeded, allocations fail and surface as JS exceptions:

using vm = await QuickJS.create({
  wasm: wasmBytes,
  memoryLimit: 4 * 1024 * 1024, // 4 MB
});

vm.evalCode(`
  try {
    const huge = new Array(10000000).fill("x".repeat(1000));
  } catch (e) {
    console.log(e.message); // allocation failure
  }
`);

The limit is re-applied after QuickJS.restore(), so you can use a different limit for restored VMs than the original.

Interrupt Handler

Prevent infinite loops and enforce execution timeouts:

const start = Date.now();
using vm = await QuickJS.create({
  wasm: wasmBytes,
  interruptHandler: () => {
    // Return true to interrupt — called periodically during JS execution
    return Date.now() - start > 5000; // 5 second timeout
  },
});

const result = vm.evalCode('while (true) {}');
result.isException; // true — interrupted
result.dispose();

// VM is still usable after an interrupt
vm.evalCode('1 + 2').consume(h => h.toNumber()); // 3

The handler is called approximately once per JS bytecode instruction, so it should be fast. When it returns true, the current execution is interrupted and returns an exception result. The VM remains usable after an interrupt.

Snapshot and Restore

The key differentiator — snapshot the entire VM state and restore it later:

let snapshot: Snapshot;

{
  using vm = await QuickJS.create(wasmBytes);

  // Build up some state, including a pending promise
  vm.unwrapResult(vm.evalCode(`
    globalThis.counter = 0;

    let __resolve;
    globalThis.pendingWork = new Promise(r => { __resolve = r; });
    globalThis.__resolve = __resolve;

    globalThis.pendingWork.then(value => {
      globalThis.counter = value;
    });
  `)).dispose();
  vm.executePendingJobs();

  // Take a snapshot
  snapshot = vm.snapshot();
}

// Serialize to a binary buffer for storage (apply gzip on top for best compression)
const bytes = QuickJS.serializeSnapshot(snapshot);
await storage.put('snapshots/run-123', bytes);

// ... time passes, maybe a different process entirely ...

// Deserialize and restore
const loaded = await storage.get('snapshots/run-123');
const restored = QuickJS.deserializeSnapshot(loaded);

{
  using vm = await QuickJS.restore(restored, wasmBytes);

  // The pending promise still exists — resolve it
  using resolve = vm.global.getProp('__resolve');
  using arg = vm.newNumber(42);
  vm.callFunction(resolve, vm.undefined, arg).dispose();
  vm.executePendingJobs();

  // The .then handler ran in the restored VM
  using counter = vm.global.getProp('counter');
  console.log(counter.toNumber()); // 42
}

Host Callbacks After Restore

Host functions registered with newFunction() are assigned integer IDs that get baked into the snapshot. After restoring, re-register the callbacks:

let snapshot: Snapshot;

{
  using vm = await QuickJS.create(wasmBytes);
  // fn is assigned callback ID 1 (first registered callback)
  using fn = vm.newFunction('hostAdd', (...args) => {
    return vm.newNumber(args[0].toNumber() + args[1].toNumber());
  });
  vm.setProp(vm.global, 'hostAdd', fn);
  snapshot = vm.snapshot();
}

{
  // After restore — re-register with the same ID
  using vm = await QuickJS.restore(snapshot, wasmBytes);
  vm.registerHostCallback(1, (...args) => {
    return vm.newNumber(args[0].toNumber() + args[1].toNumber());
  });

  // hostAdd() works again
  using result = vm.unwrapResult(vm.evalCode('hostAdd(100, 200)'));
  console.log(result.toNumber()); // 300
}

Native WASM Extensions

Load C-based extensions compiled as WASM shared libraries. Extensions link directly against the QuickJS C API with zero marshalling overhead — they share the same linear memory and can register custom classes, prototypes, and globals.

import { QuickJS } from 'quickjs-wasi';
import { readFileSync } from 'fs';

const urlExt = readFileSync('./extensions/url/url.so');

using vm = await QuickJS.create({
  extensions: [{ name: 'url', wasm: urlExt }],
});

using result = vm.unwrapResult(vm.evalCode(`
  const url = new URL('https://example.com:8080/api?key=value#section');
  url.hostname // 'example.com'
`));

Extensions survive snapshot/restore — provide the same extensions when restoring:

const snapshot = vm.snapshot();

using vm2 = await QuickJS.restore(snapshot, {
  extensions: [{ name: 'url', wasm: urlExt }],
});
// URL objects created before the snapshot still work

See EXTENSIONS.md for how to build extensions, how dynamic linking works, and known limitations.

API Reference

QuickJS (VM Instance)

MethodDescription
QuickJS.create(options?)Create a fresh VM instance
QuickJS.restore(snapshot, options?)Restore a VM from a snapshot
QuickJS.serializeSnapshot(snapshot)Serialize a snapshot to a versioned binary Uint8Array
QuickJS.deserializeSnapshot(data)Deserialize a snapshot from a binary Uint8Array
vm.evalCode(code, filename?)Evaluate JS code, returns JSValueHandle
vm.unwrapResult(handle)Returns the handle if not an exception, otherwise throws
vm.callFunction(fn, this, ...args)Call a QuickJS function
vm.executePendingJobs()Drain the promise microtask queue
vm.newString(str)Create a string value
vm.newNumber(num)Create a number value
vm.newBigInt(val)Create a BigInt value
vm.newObject()Create an empty object
vm.newArray()Create an empty array
vm.newSymbolFor(description)Create a global symbol (Symbol.for(description))
vm.newArrayBuffer(data)Create an ArrayBuffer from host ArrayBuffer or Uint8Array
vm.newUint8Array(data)Create a Uint8Array from host Uint8Array
vm.newFunction(name, callback)Create a function backed by a host callback
vm.newPromise()Create a Deferred (promise + resolve/reject)
vm.newError(messageOrError)Create an Error from a string or native Error
vm.resolvePromise(handle)Await a QuickJS promise from the host side
vm.setProp(obj, key, value)Set a property (key: string or handle, including symbols)
vm.getProp(obj, key)Get a property using a handle key (including symbols)
vm.typeof(handle)Get the typeof as a string
vm.dump(handle)Convert a QuickJS value to a host value
vm.hostToHandle(value)Convert a host value to a QuickJS handle
vm.snapshot()Capture the entire VM state (including extension metadata)
vm.registerHostCallback(id, fn)Re-register a host callback after restore
vm.dispose()Free the VM
vm[Symbol.dispose]()Same as dispose() — enables using vm = ...

QuickJSOptions

OptionDescription
wasmWASM module bytes or pre-compiled WebAssembly.Module
wasiCustom WASI function implementations (now, stdout)
memoryLimitMaximum memory the QuickJS runtime can allocate (bytes)
interruptHandlerCallback to interrupt execution (return true to stop)
extensionsArray of ExtensionDescriptor objects — native WASM extensions to load

ExtensionDescriptor

PropertyDescription
nameIdentifier string (used in snapshot metadata)
wasmWASM bytes (BufferSource) or pre-compiled WebAssembly.Module
initFn?Init function name (default: qjs_ext_${name}_init)

Cached Properties

These are singleton handles — do not dispose them:

PropertyValue
vm.globalThe global object
vm.undefinedundefined
vm.nullnull
vm.truetrue
vm.falsefalse

JSValueHandle

Method / PropertyDescription
handle.isExceptiontrue if this is an exception result
handle.isUndefinedtrue if this is undefined
handle.isNulltrue if this is null
handle.promiseState0 pending, 1 fulfilled, 2 rejected
handle.toNumber()Extract as a number
handle.toBigInt()Extract as a bigint
handle.toString()Extract as a string
handle.toArrayBuffer()Extract as an ArrayBuffer (copy from WASM memory)
handle.toUint8Array()Extract as a Uint8Array (copy from WASM memory)
handle.getProp(name)Get a property by name
handle.setProp(name, value)Set a property by name
handle.consume(fn)Call fn(handle), then dispose, return result
handle.dup()Duplicate the handle (increment refcount)
handle.dispose()Free the handle
handle[Symbol.dispose]()Same as dispose() — enables using handle = ...

Deferred (from vm.newPromise())

Property / MethodDescription
deferred.handleThe QuickJS promise object
deferred.settledHost Promise<void> that resolves on settlement
deferred.resolve(handle)Resolve the promise with a QuickJS value
deferred.reject(handle)Reject the promise with a QuickJS value

Data Marshalling

dump() and hostToHandle() automatically convert values between the host and the QuickJS VM. The following types are supported:

Host TypeQuickJS Typedump() returnshostToHandle() accepts
undefinedundefinedundefinedundefined
nullnullnullnull
booleanbooleanbooleanboolean
numbernumbernumbernumber
stringstringstringstring
bigintBigIntbigintbigint
Symbol.for()global SymbolSymbol.for(description)Symbol.for(description)
ErrorErrorError (with name, message, stack)Error
ArrayArrayArray (recursive)Array (recursive)
ArrayBufferArrayBufferArrayBuffer (copy)ArrayBuffer
Uint8ArrayUint8ArrayUint8Array (copy)Uint8Array
Other typed arraystyped arrayCorresponding typed array (copy)ArrayBuffer (via view)
PromisePromiseQuickJS Promise (bridged via Deferred)
Plain objectObjectRecord<string, unknown> (recursive, own enumerable keys)Object (recursive)

Notes:

  • Global symbols (Symbol.for()) round-trip as real host Symbol values via Symbol.for(description)
  • Local (anonymous) symbols dump as undefined and throw if passed to hostToHandle()
  • Functions dump as undefined (cannot be meaningfully serialized)
  • Circular and shared references are preserved — dump() returns the same host object for the same QuickJS object pointer
  • Only own enumerable string properties are included when dumping objects
  • Binary data is always copied between host and WASM memory — there is no zero-copy view API
  • dump() for typed arrays determines the host constructor from bytes-per-element (1 → Uint8Array, 2 → Uint16Array, 4 → Uint32Array, 8 → Float64Array)

How It Works

The Core Insight

WebAssembly linear memory is a flat byte array. Everything QuickJS allocates — the runtime struct, all contexts, all JS objects, the GC heap, the atom table, the promise job queue, pending promises — lives in this linear memory. There are no external pointers, file handles, or OS resources. When you copy the memory wholesale to a new WASM instance, all internal pointer relationships are preserved because they reference the same linear address space.

One VM = One WASM Instance

Unlike quickjs-emscripten which has a two-level model (QuickJSWASMModuleQuickJSContext), quickjs-wasm uses a simpler one-level model: each QuickJS.create() call instantiates its own WASM module with its own linear memory, runtime, and context. This gives stronger isolation (no shared memory between VMs) and makes snapshotting clean — one instance, one context, one snapshot.

Architecture

Host (Node.js / Deno / Bun / Browser)
 |
 +-- QuickJS class (ts/index.ts)
 |    |-- evalCode(), callFunction(), newFunction(), ...
 |    |-- snapshot() -> Snapshot { memory, stackPointer, runtimePtr, contextPtr }
 |    +-- restore(snapshot) -> QuickJS
 |
 +-- WASI Shim (ts/wasi-shim.ts)
 |    |-- clock_time_get, fd_write, random_get
 |    +-- fd_close, fd_fdstat_get, fd_seek (stubs)
 |
 +-- quickjs.wasm (1.4 MB)
      |-- QuickJS-NG engine
      +-- C interface layer (c/interface.c)
           |-- Lifecycle, eval, value creation/extraction
           |-- Host callback trampoline (imported host_call)
           +-- Snapshot support (get/set runtime and context pointers)

Host Callback Mechanism

When vm.newFunction() is called, an integer ID is allocated and a QuickJS C function is created via JS_NewCFunctionData2 with that ID stored as function data. When QuickJS code calls the function, the C trampoline extracts the ID and calls the imported host_call(func_id, this_ptr, argc, argv_ptr) function, which dispatches to the registered host callback by ID.

This design survives snapshot/restore: the ID is stored in QuickJS's heap (part of the snapshot), and after restore, registerHostCallback(id, fn) re-maps the ID to a new host function.

Implications for Durable Workflows

Event Replay (current)VM Snapshot (this project)
Resumption costO(n) — replay full event logO(1) — restore snapshot + fetch delta
Event log growthUnbounded, all events neededCan be trimmed after snapshot
Long-running workflowsImpractical at scaleNo degradation over time
State representationImplicit (derived from log)Explicit (WASM memory snapshot)
Snapshot sizeN/A~256 KB baseline, grows with JS heap
Determinism requirementYes (seeded PRNG, frozen time)No (state is captured, not re-derived)

Development

Prerequisites

  • wasi-sdk (tested with v30) — set WASI_SDK env var or defaults to /tmp/wasi-sdk
  • Node.js >= 22
  • pnpm

Building Locally

# Clone with submodules
git clone --recursive https://github.com/vercel-labs/quickjs-wasm.git
cd quickjs-wasm

# Install wasi-sdk (macOS arm64 — adjust URL for your platform)
curl -sL "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-30/wasi-sdk-30.0-arm64-macos.tar.gz" \
  | tar xz -C /tmp --strip-components=1 --one-top-level=wasi-sdk

# Install dependencies
pnpm install

# Build WASM binary + TypeScript
pnpm run build

# Run tests
pnpm test

Technical Details

WASM Binary

  • Built from quickjs-ng (MIT license)
  • Compiled with wasi-sdk targeting wasm32-wasip1 in reactor mode
  • 1.4 MB uncompressed
  • 7 WASM imports: 6 WASI functions + 1 env.host_call for host callbacks
  • Exports memory and __stack_pointer for snapshot support

What Gets Snapshotted

The snapshot captures the entire WASM linear memory, which contains:

  • The JSRuntime struct (GC state, job queue, module loader state)
  • The JSContext struct (global object, intrinsics, atom table)
  • All JS objects (via QuickJS's GC heap)
  • The promise job queue (pending .then callbacks)
  • The string intern table (atoms)
  • The dlmalloc heap metadata
  • The C interface's static JSRuntime *rt and static JSContext *ctx globals
  • Host callback IDs stored in function data

Plus the __stack_pointer WASM global (a single i32).

Limitations and Future Work

  • Snapshot size: Snapshots capture the entire WASM linear memory (~256 KB baseline, grows with heap). Use serializeSnapshot() to get a binary buffer, then apply your own compression (gzip/zstd) — the memory compresses very well due to large zero regions.
  • Stack size limit: QuickJS-ng disables JS_SetMaxStackSize on WASI, so deep recursion causes a WASM trap (not a catchable exception).
  • ES Modules: Only script-mode eval is supported. import/export and module loaders are not yet wired through.
  • Extension ABI: Native WASM extensions use an experimental dynamic linking ABI that is not yet stabilized. All extensions must be compiled with the same wasi-sdk version as the main module. See EXTENSIONS.md for details.

Browser Usage

quickjs-wasi works in browsers — the TypeScript API uses only the standard WebAssembly API and the WASI shim is environment-agnostic. The only Node.js-specific code is the default WASM loading fallback (which uses node:fs). In the browser, pass the WASM bytes directly:

import { QuickJS } from 'quickjs-wasi';

// Fetch the .wasm file and compile it once
const response = await fetch('/quickjs.wasm');
const wasmModule = await WebAssembly.compileStreaming(response);

// Create VMs from the pre-compiled module (fast — no re-compilation)
using vm = await QuickJS.create({ wasm: wasmModule });

See examples/browser/ for a complete Vite demo app.

FAQs

Package last updated on 12 Mar 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts