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.
npm install quickjs-wasiThe caller is responsible for providing the WASM bytes (or a pre-compiled WebAssembly.Module) — quickjs-wasi does no implicit filesystem or network I/O. The package ships the binary at the quickjs-wasi/quickjs.wasm subpath, which can be resolved by your environment's preferred mechanism.
Node.js (read from disk):
import { readFile } from 'node:fs/promises';
const wasmBytes = await readFile(new URL(import.meta.resolve('quickjs-wasi/quickjs.wasm')));
// or with require.resolve in CJS:
// readFileSync(require.resolve('quickjs-wasi/quickjs.wasm'))Browser (fetch + streaming compile):
const wasmModule = await WebAssembly.compileStreaming(fetch('/quickjs.wasm'));Vite / bundlers using the ?url import suffix:
import wasmUrl from 'quickjs-wasi/quickjs.wasm?url';
const wasmModule = await WebAssembly.compileStreaming(fetch(wasmUrl));Compiling a WebAssembly.Module once and reusing it across many VMs is highly recommended — instantiation from a compiled module is much faster than re-compiling bytes for each call to QuickJS.create().
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({ wasm: wasmBytes });
// Evaluate code — handles are auto-disposed with `using`
using result = vm.evalCode('1 + 2');
console.log(result.toNumber()); // 3
} // vm and result are automatically disposed hereusing 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.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 useful for inline one-liners
const value = vm.evalCode('1 + 2').consume(h => h.toNumber()); // 3Register 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.evalCode('add(3, 4)');
console.log(result.toNumber()); // 7Bridge 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);
}Evaluate code as an ES module with EvalFlags.TYPE_MODULE. Module evaluation
returns a handle to a Promise that resolves to the module's namespace object
(its exports):
import { QuickJS, EvalFlags } from 'quickjs-wasi';
using vm = await QuickJS.create(wasmBytes);
using promise = vm.evalCode(
'export const x = 42; export default "hi";',
'<eval>',
EvalFlags.TYPE_MODULE
);
vm.executePendingJobs();
const result = await vm.resolvePromise(promise);
if ('error' in result) {
console.error(result.error.consume(h => h.toString()));
} else {
using ns = result.value;
ns.getProp('x').consume(h => h.toNumber()); // 42
ns.getProp('default').consume(h => h.toString()); // "hi"
}import statements are resolved through the moduleLoader option:
using vm = await QuickJS.create({
wasm: wasmBytes,
moduleLoader: {
// Optional: resolve relative specifiers against the importing module
normalize: (baseName, specifier) => specifier,
// Return the module source code for a given (normalized) specifier
load: (name) => {
if (name === 'math.js') return 'export const add = (a, b) => a + b;';
throw new Error(`Module not found: ${name}`);
},
},
});
using promise = vm.evalCode(
`import { add } from 'math.js'; export const sum = add(3, 4);`,
'<entry>',
EvalFlags.TYPE_MODULE
);
vm.executePendingJobs();
const result = await vm.resolvePromise(promise);
if ('value' in result) {
result.value.consume(ns => ns.getProp('sum').consume(h => h.toNumber())); // 7
}The load and normalize callbacks are synchronous — the engine calls
them from inside the WASM call stack, which cannot be suspended to await a
Promise (returning one throws a TypeError). To load module sources
asynchronously (e.g. over https://), use the fetch-and-retry pattern:
throw from load on a cache miss, fetch the missing module on the host, and
re-run the eval. Modules already loaded by the runtime are cached and not
re-requested, so each attempt makes progress one module deeper into the
dependency graph:
const cache = new Map<string, string>();
let missing: string | null = null;
using vm = await QuickJS.create({
wasm: wasmBytes,
moduleLoader: {
load: (name) => {
const src = cache.get(name);
if (src === undefined) {
missing = name;
throw new Error(`module not cached: ${name}`);
}
return src;
},
},
});
async function evalModule(code: string, filename: string) {
while (true) {
missing = null;
try {
return vm.evalCode(code, filename, EvalFlags.TYPE_MODULE);
} catch (err) {
if (missing === null) throw err; // a real error, not a cache miss
const response = await fetch(new URL(missing, 'https://example.com/'));
if (!response.ok) throw new Error(`failed to fetch ${missing}`);
cache.set(missing, await response.text());
}
}
}
using promise = await evalModule(`import { x } from 'mod.js'; export { x };`, '<entry>');
vm.executePendingJobs();
const result = await vm.resolvePromise(promise);Alternatively, pre-fetch all module sources up front and serve them from the cache directly.
using vm = await QuickJS.create(wasmBytes);
// evalCode() throws a JSException if the evaluated code throws
try {
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);
}The wasi option lets you override any wasi_snapshot_preview1 host function. It's a factory that receives the WASM linear memory and returns an object of override functions. Overrides apply to both the main module and all loaded extensions.
This is useful for deterministic execution — QuickJS uses a xorshift64* PRNG that is seeded once from the clock value during context creation. Override clock_time_get to control both Date.now() and the Math.random() seed:
const fixedClock = (memory: WebAssembly.Memory) => ({
clock_time_get(_clockId: number, _precision: bigint, resultPtr: number) {
new DataView(memory.buffer).setBigUint64(resultPtr, 1700000000000n * 1_000_000n, true);
return 0;
},
});
using vm1 = await QuickJS.create({ wasm: wasmBytes, wasi: fixedClock });
using vm2 = await QuickJS.create({ wasm: wasmBytes, wasi: fixedClock });
vm1.evalCode('Math.random()').consume(h => h.toNumber());
// => 0.8130834347906803
vm2.evalCode('Math.random()').consume(h => h.toNumber());
// => 0.8130834347906803 (identical)Override random_get to control the crypto extension's RNG:
using vm = await QuickJS.create({
wasm: wasmBytes,
wasi: (memory) => ({
random_get(bufPtr: number, bufLen: number) {
new Uint8Array(memory.buffer, bufPtr, bufLen).fill(0x42); // deterministic
return 0;
},
}),
extensions: [cryptoExtension],
});The time can also be advanced between calls for realistic behavior:
let currentTime = 1700000000000n;
using vm = await QuickJS.create({
wasm: wasmBytes,
wasi: (memory) => ({
clock_time_get(_clockId: number, _precision: bigint, resultPtr: number) {
new DataView(memory.buffer).setBigUint64(resultPtr, currentTime * 1_000_000n, true);
return 0;
},
}),
});
vm.evalCode('Date.now()').consume(h => h.toNumber()); // 1700000000000
currentTime += 1000n; // advance 1 second
vm.evalCode('Date.now()').consume(h => h.toNumber()); // 1700000001000Restrict 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.
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
},
});
try {
vm.evalCode('while (true) {}');
} catch (err) {
// JSException — interrupted
err.dispose();
}
// VM is still usable after an interrupt
vm.evalCode('1 + 2').consume(h => h.toNumber()); // 3The handler is called approximately once per JS bytecode instruction, so it should be fast. When it returns true, the current execution is interrupted and throws a JSException. The VM remains usable after an interrupt.
By default, Date inside the sandbox mirrors the host environment's timezone. You can override this with a fixed offset or a dynamic callback:
// Fixed offset: UTC-8 (480 minutes west of UTC)
using vm = await QuickJS.create({
wasm: wasmBytes,
timezoneOffset: 480,
});
vm.evalCode('new Date().getTimezoneOffset()').consume(h => h.toNumber()); // 480// Force UTC (offset 0)
using vm = await QuickJS.create({
wasm: wasmBytes,
timezoneOffset: 0,
});// Dynamic callback for custom DST-aware logic
using vm = await QuickJS.create({
wasm: wasmBytes,
timezoneOffset: (timeSecs) => {
// Return offset in minutes (getTimezoneOffset convention: positive = west of UTC)
return new Date(timeSecs * 1000).getTimezoneOffset();
},
});The timezoneOffset option accepts:
'host'(default) — mirrors the host's timezone, including DST transitions.- A number — fixed UTC offset in minutes using the
getTimezoneOffset()sign convention (positive values are west of UTC, e.g.480for UTC-8). - A callback
(timeSecs: number) => number— called with seconds since epoch, must return the offset in minutes. Useful for custom timezone logic. The callback is invoked whenever QuickJS needs to convert between UTC and local time (e.g.getHours(),toString(),new Date(year, month, ...),getTimezoneOffset()), so it may be called multiple times per Date operation.
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.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 functions registered with newFunction() are keyed by their name, which gets baked into the snapshot. After restoring, re-register the callbacks by name:
let snapshot: Snapshot;
{
using vm = await QuickJS.create(wasmBytes);
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 by name
using vm = await QuickJS.restore(snapshot, wasmBytes);
vm.registerHostCallback('hostAdd', (...args) => {
return vm.newNumber(args[0].toNumber() + args[1].toNumber());
});
// hostAdd() works again
using result = vm.evalCode('hostAdd(100, 200)');
console.log(result.toNumber()); // 300
}Note: each call to newFunction() must use a unique name. Attempting to register two host functions with the same name will throw an error.
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.
The package ships five pre-built extensions, each available as a subpath export. As with the main quickjs.wasm binary, the caller is responsible for loading the bytes:
| Extension | Subpath | Adds |
|---|---|---|
| URL | quickjs-wasi/url.so |
URL, URLSearchParams (ada-url) |
| Encoding | quickjs-wasi/encoding.so |
TextEncoder, TextDecoder |
| Headers | quickjs-wasi/headers.so |
Headers |
| Crypto | quickjs-wasi/crypto.so |
crypto.subtle, crypto.getRandomValues |
| Structured Clone | quickjs-wasi/structured-clone.so |
structuredClone |
Node.js:
import { readFile } from 'node:fs/promises';
import { QuickJS } from 'quickjs-wasi';
const urlExtBytes = await readFile(
new URL(import.meta.resolve('quickjs-wasi/url.so')),
);
using vm = await QuickJS.create({
wasm: wasmBytes,
extensions: [{ name: 'url', wasm: urlExtBytes }],
});
using result = vm.evalCode(`
const url = new URL('https://example.com:8080/api?key=value#section');
url.hostname // 'example.com'
`);Vite / bundlers:
import urlSoUrl from 'quickjs-wasi/url.so?url';
const urlExtBytes = await fetch(urlSoUrl).then((r) => r.arrayBuffer());Extensions survive snapshot/restore — provide the same extensions when restoring:
const snapshot = vm.snapshot();
using vm2 = await QuickJS.restore(snapshot, {
wasm: wasmBytes,
extensions: [{ name: 'url', wasm: urlExtBytes }],
});
// URL objects created before the snapshot still workSee EXTENSIONS.md for how to build your own extensions, how dynamic linking works, and known limitations.
| Method | Description |
|---|---|
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 (throws JSException on error) |
vm.callFunction(fn, this, ...args) |
Call a QuickJS function (throws JSException on error) |
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(name, fn) |
Re-register a host callback by name after restore |
vm.dispose() |
Free the VM |
vm[Symbol.dispose]() |
Same as dispose() — enables using vm = ... |
| Option | Description |
|---|---|
wasm |
WASM module bytes or pre-compiled WebAssembly.Module |
wasi |
WASI override factory: (memory) => ({ random_get, clock_time_get, ... }). Applies to main module and all extensions |
memoryLimit |
Maximum memory the QuickJS runtime can allocate (bytes) |
interruptHandler |
Callback to interrupt execution (return true to stop) |
extensions |
Array of ExtensionDescriptor objects — native WASM extensions to load |
timezoneOffset |
Timezone for Date inside the VM: 'host' (default), fixed offset in minutes, or (timeSecs) => minutes callback |
| Property | Description |
|---|---|
name |
Identifier string (used in snapshot metadata) |
wasm |
WASM bytes (BufferSource) or pre-compiled WebAssembly.Module |
initFn? |
Init function name (default: qjs_ext_${name}_init) |
wasi? |
Extension-provided WASI overrides: (memory) => ({...}). Layered between built-in defaults and user overrides |
These are singleton handles — do not dispose them:
| Property | Value |
|---|---|
vm.global |
The global object |
vm.undefined |
undefined |
vm.null |
null |
vm.true |
true |
vm.false |
false |
| Method / Property | Description |
|---|---|
handle.isUndefined |
true if this is undefined |
handle.isNull |
true if this is null |
handle.promiseState |
0 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 = ... |
These use engine-level (internal class) checks and cannot be spoofed or broken by guest-side prototype/constructor mutation. This makes them safe to call on hostile or unknown values (e.g. when rendering an inspector UI, or implementing side-effect-free serialization).
The brand checks, classId, and getProxyTarget()/getProxyHandler()
never execute guest code on any value — no proxy traps, no getters, no
Symbol.hasInstance. The two property-inspection helpers
(getOwnPropertyKeys() and getOwnPropertyDescriptor()) never invoke
getters and are guest-code free for ordinary objects, but on a Proxy they
necessarily fire its ownKeys/getOwnPropertyDescriptor traps — check
isProxy first if that matters.
| Method / Property | Description |
|---|---|
handle.isProxy |
true if this is a Proxy exotic object (undetectable from within JS) |
handle.isMap / handle.isSet |
Engine brand checks (a Proxy wrapping a Map is not a Map) |
handle.isDate / handle.isRegExp |
Engine brand checks |
handle.isWeakRef / handle.isWeakMap / handle.isWeakSet |
Engine brand checks |
handle.isDataView |
Engine brand check |
handle.classId |
Internal QuickJS class ID (0 for non-objects) |
handle.getProxyTarget() |
The [[ProxyTarget]] of a Proxy, without firing traps |
handle.getProxyHandler() |
The [[ProxyHandler]] of a Proxy, without firing traps |
handle.getOwnPropertyKeys() |
All own keys (strings and symbols, incl. non-enumerable), à la Reflect.ownKeys() |
handle.getOwnPropertyDescriptor(key) |
Own property descriptor without invoking getters; accessor properties yield get/set handles |
| Property / Method | Description |
|---|---|
deferred.handle |
The QuickJS promise object |
deferred.settled |
Host 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 |
dump() and hostToHandle() automatically convert values between the host and the QuickJS VM. The following types are supported:
| Host Type | QuickJS Type | dump() returns |
hostToHandle() accepts |
|---|---|---|---|
undefined |
undefined | undefined |
undefined |
null |
null | null |
null |
boolean |
boolean | boolean |
boolean |
number |
number | number |
number |
string |
string | string |
string |
bigint |
BigInt | bigint |
bigint |
Symbol.for() |
global Symbol | Symbol.for(description) |
Symbol.for(description) |
Error |
Error | Error (with name, message, stack) |
Error |
Array |
Array | Array (recursive) |
Array (recursive) |
ArrayBuffer |
ArrayBuffer | ArrayBuffer (copy) |
ArrayBuffer |
Uint8Array |
Uint8Array | Uint8Array (copy) |
Uint8Array |
| Other typed arrays | typed array | Corresponding typed array (copy) | ArrayBuffer (via view) |
Promise |
Promise | — | QuickJS Promise (bridged via Deferred) |
| Plain object | Object | Record<string, unknown> (recursive, own enumerable keys) |
Object (recursive) |
Notes:
- Global symbols (
Symbol.for()) round-trip as real hostSymbolvalues viaSymbol.for(description) - Local (anonymous) symbols dump as
undefinedand throw if passed tohostToHandle() - 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)
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.
Unlike quickjs-emscripten which has a two-level model (QuickJSWASMModule → QuickJSContext), 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.
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)
When vm.newFunction(name, fn) is called, a QuickJS C function is created via JS_NewCFunctionData2 with the function name stored as a JS string in func_data[0]. When QuickJS code calls the function, the C trampoline extracts the name and calls the imported host_call(name_ptr, name_len, this_ptr, argc, argv_ptr) function, which dispatches to the registered host callback by name.
This design survives snapshot/restore: the name string is stored in QuickJS's heap (part of the snapshot), and after restore, registerHostCallback(name, fn) re-maps the name to a new host function. Because callbacks are keyed by name rather than sequential integer IDs, the registration order doesn't matter and adding or removing host functions won't silently break restore.
- wasi-sdk v32 (pinned via
WASI_SDK_VERSION_REQUIREDin the Makefile; the build fails on any other version) — setWASI_SDKenv var or defaults to/tmp/wasi-sdk - Node.js >= 22
- pnpm
# Clone with submodules
git clone --recursive https://github.com/vercel-labs/quickjs-wasm.git
cd quickjs-wasm
# Install the pinned wasi-sdk (auto-detects your platform, installs to $WASI_SDK
# or /tmp/wasi-sdk by default)
make setup
# Install dependencies
pnpm install
# Build WASM binary + TypeScript
pnpm run build
# Run tests
pnpm test- Built from quickjs-ng (MIT license)
- Compiled with wasi-sdk targeting
wasm32-wasip1in reactor mode - 1.4 MB uncompressed
- 7 WASM imports: 6 WASI functions + 1
env.host_callfor host callbacks - Exports
memoryand__stack_pointerfor snapshot support
The snapshot captures the entire WASM linear memory, which contains:
- The
JSRuntimestruct (GC state, job queue, module loader state) - The
JSContextstruct (global object, intrinsics, atom table) - All JS objects (via QuickJS's GC heap)
- The promise job queue (pending
.thencallbacks) - The string intern table (atoms)
- The
dlmallocheap metadata - The C interface's
static JSRuntime *rtandstatic JSContext *ctxglobals - Host callback IDs stored in function data
Plus the __stack_pointer WASM global (a single i32).
- 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_SetMaxStackSizeon WASI, so deep recursion causes a WASM trap (not a catchable exception). - ES Modules: Supported via
EvalFlags.TYPE_MODULEand themoduleLoaderoption (see ES Modules). Dynamicimport()resolves through the same loader. - 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.
quickjs-wasi works in browsers — the TypeScript API uses only the standard WebAssembly API and the WASI shim is environment-agnostic. The package does no implicit I/O, so loading the WASM binary is up to you. See Loading the WASM Binary above for fetch() and bundler-based examples.
import { QuickJS } from 'quickjs-wasi';
import wasmUrl from 'quickjs-wasi/quickjs.wasm?url'; // Vite
// Fetch the .wasm file and compile it once
const wasmModule = await WebAssembly.compileStreaming(fetch(wasmUrl));
// 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.