quickjs-wasm
v0.0.5
Published
Execute javascript in a secure WebAssembly sandbox
Downloads
940
Readme
QuickJS WASM Library
This library provides a WebAssembly wrapper around the QuickJS JavaScript engine, allowing you to run JavaScript code in an isolated environment with bidirectional communication between the host and the sandboxed JavaScript.
Published on npm as quickjs-wasm:
npm install quickjs-wasmFeatures
- Run JavaScript code in a sandboxed environment
- Compile JavaScript to bytecode for faster execution
- Load and execute bytecode
- Call JavaScript functions from the host
- Call host functions from JavaScript
- Support for asynchronous JavaScript code and Promises
- Access JavaScript objects and properties
- Protect the host against runaway guest code with memory limits and eval timeouts
Intended use: one sandbox per execution
This library is built for short-lived, single-use JavaScript environments: create an instance, run one untrusted script, throw the instance away.
There is deliberately no disposal API. Nothing has to be released and nothing has to be tracked: create an instance, run a script, drop it. The bindings free what they allocate as each call returns — sources, bytecode buffers, and the strings copied out to the host — so a single run can evaluate and compile as much as it likes without growing. Whatever is still held when you drop the instance goes with it.
What counts as a leak
Some values are retained for the life of an instance: an object handle you were given, the function object behind a module call, the exception path in the pending-job loop. All of them are bounded per instance and reclaimed when it is dropped. This library does not treat those as bugs. A retention that can only be observed by keeping one instance alive across many scripts is out of scope, because that is precisely the mode this library tells you not to use it in.
What is in scope is anything that scales with the work a single script does — sources, bytecode, strings crossing the boundary. Those are freed at the call boundary, because the wasm heap is a fixed ~16.5MB and is only reclaimed after the run finishes, so a run that leaks competes with itself.
Creating a fresh instance per execution is also the stronger isolation property: no state carries over between untrusted scripts — no polluted prototypes, no globals stashed by a previous run, nothing observable from one script to the next.
Instances are cheap. The wasm module is fetched and compiled once per page or process and then shared, so every createQuickJS() after the first only allocates a fresh linear memory — sharing a compiled WebAssembly.Module shares no state.
Both users of this library work that way. In a NEAR smart contract the protocol instantiates the contract wasm per call and discards it afterwards. In WebAssembly Music createQuickJS() is called once per song compilation.
If you need a long-lived JS environment shared by many scripts over time, use quickjs-emscripten instead — it manages value lifetimes explicitly.
The async model
Guest await suspends at the JavaScript level inside QuickJS: when guest code calls an async host function, QuickJS parks the guest execution as a pending promise and the wasm call returns to the host. There is no Emscripten asyncify (or any stack switching) anywhere — the wasm stack fully unwinds on every host call. The host later resumes the guest by resolving the promise via promise_callback, which also runs QuickJS's pending-job loop. This is why waitForPendingAsyncInvocations() must be awaited before reading a promise result: it drains the host-side async invocations that resume the guest.
Note that the host JS thread is blocked while wasm executes synchronous guest code — eval timeouts (see Sandboxing untrusted code) are enforced by a wall-clock check inside QuickJS's interrupt handler, not by preemption. If you need the host to stay responsive regardless of what the guest does, run the sandbox in a Worker.
Basic Usage
Creating a QuickJS Instance
import { createQuickJS } from "./quickjs.js";
// Create a QuickJS instance
const quickjs = await createQuickJS();
if (!quickjs || typeof quickjs.evalSource !== "function") {
throw new Error("Failed to create QuickJS instance");
}Evaluating JavaScript
// Evaluate JavaScript code directly
const result = quickjs.evalSource("42;"); // returns 42
if (result !== 42) throw new Error("Expected 42, got " + result);Compiling and Running Bytecode
// Compile JavaScript to bytecode
const bytecode = quickjs.compileToByteCode("42;");
if (!bytecode || bytecode.length === 0)
throw new Error("Failed to compile bytecode");
// Execute bytecode
const result = quickjs.evalByteCode(bytecode); // returns 42
if (result !== 42) throw new Error("Expected 42, got " + result);Working with Modules
Compiling and Loading Modules
// Compile a module to bytecode
const bytecode = quickjs.compileToByteCode(
`
export function getNumber() {
return 42;
}
`,
"math.js",
);
// Load the module
const mod = quickjs.loadByteCode(bytecode);
if (typeof mod !== "bigint")
throw new Error("Expected module handle to be bigint");
// Call a function from the module
const result = quickjs.callModFunction(mod, "getNumber"); // returns 42
if (result !== 42) throw new Error("Expected 42, got " + result);Working with Promises
Evaluating Async JavaScript
// Compile an async function
const bytecode = quickjs.compileToByteCode(
`
export async function test() {
const result = await new Promise(resolve => resolve(883));
return result;
}
`,
"test.js",
);
// Load the module
const mod = quickjs.loadByteCode(bytecode);
// Call the async function
const promise = quickjs.callModFunction(mod, "test");
// Get the result of the promise
const result = quickjs.getPromiseResult(promise); // returns 883
if (result !== 883) throw new Error("Expected 883, got " + result);Interacting with Host Functions
Calling Host Functions from JavaScript
// Register a host function
quickjs.hostFunctions["sleep"] = async (params) => {
const duration = quickjs.getObjectPropertyValue(params, "duration");
await new Promise((resolve) => setTimeout(resolve, duration));
return quickjs.allocateJSstring(`Slept for ${duration} ms`);
};
// Compile JavaScript that calls the host function
const bytecode = quickjs.compileToByteCode(
`
export async function test() {
const result = await env.callHostAsync({ function_name: "sleep", duration: 500 });
return result;
}
`,
"test.js",
);
// Load and call the function
const mod = quickjs.loadByteCode(bytecode);
const promise = quickjs.callModFunction(mod, "test");
// Wait for any pending async operations to complete
await quickjs.waitForPendingAsyncInvocations();
// Get the result
const result = quickjs.getPromiseResult(promise); // "Slept for 500 ms"
if (result !== "Slept for 500 ms")
throw new Error("Expected 'Slept for 500 ms', got " + result);The host-function contract
The pieces above fit together like this:
- The host registers functions in the
hostFunctionsregistry:quickjs.hostFunctions["name"] = async (params) => { ... }. - Guest code calls
env.callHostAsync({ function_name: "name", ...params })and awaits the result.function_nameselects the entry inhostFunctions; the whole argument object is passed to the host function as an object handle — read values from it withgetObjectPropertyValue(params, "propertyName"). - The host function returns a QuickJS value handle (e.g. from
allocateJSstring), ornull/undefined. Internally the wrapper resolves the guest's promise via the wasm exportpromise_callback(resolvingFunctions, result), which also runs QuickJS's pending-job loop so the guest continues past itsawait. - Because host functions are async, the host must
await quickjs.waitForPendingAsyncInvocations()before reading results withgetPromiseResult(promise)— this drains all in-flight host invocations (including ones scheduled while draining).
Sandboxing untrusted code
Untrusted guest code can attempt to hang the host (while(true){}) or exhaust memory. Both can be bounded:
const quickjs = await createQuickJS();
// Cap how much memory the QuickJS runtime may allocate (bytes)
quickjs.setMemoryLimit(12 * 1024 * 1024);
// An allocation bomb now fails inside the sandbox instead of killing the host
try {
quickjs.evalSource("new Array(1e9).fill(0);");
throw new Error("expected the allocation to fail");
} catch (e) {
if (!e.message.includes("out of memory")) throw e;
}
// The third parameter of evalSource is a timeout in milliseconds
try {
quickjs.evalSource("while(true){}", "<evalsource>", 100);
throw new Error("expected the eval to be interrupted");
} catch (e) {
if (!e.message.includes("interrupted")) throw e;
}
// The sandbox is still fully usable afterwards
const result = quickjs.evalSource("42;");
if (result !== 42) throw new Error("Expected 42, got " + result);callModFunction(mod, functionName, timeoutMs) and evalByteCode(bytecode, timeoutMs) accept the same timeout parameter. The timeout is enforced by a wall-clock check in QuickJS's interrupt handler, so it also covers pending jobs executed at the end of the call. It does not cover guest code resumed later by an async host-function response; requestInterrupt() can be called from a host function to terminate the guest when it next resumes.
When an eval fails — an exception thrown by guest code, an interrupted eval, or an exceeded memory limit — the wrapper throws an Error whose message is the QuickJS exception message.
The wasm linear memory is a fixed ~16.5MB and cannot grow, so that is the hard ceiling whatever limit you set. Running into it is still reported as a catchable out of memory exception and the instance stays usable, but keeping the limit below the ceiling makes the bound explicit and leaves room for the strings and buffers the host bindings allocate on the guest's behalf.
API Reference
TypeScript declarations ship with the package, so the distinction that matters most is visible at the call site: converted values (number | string | boolean | null | undefined) versus JSHandle, the opaque bigint reference to something still living inside the sandbox.
Core Functions
createQuickJS(): Creates a new QuickJS instanceevalSource(code, filename?, timeoutMs?): Evaluates JavaScript codecompileToByteCode(code, filename?): Compiles JavaScript code to bytecodeevalByteCode(bytecode, timeoutMs?): Executes bytecodeloadByteCode(bytecode): Loads a module from bytecodecallModFunction(module, functionName, timeoutMs?): Calls a function in a module (note: arguments are not currently supported)
Limits and interruption
setMemoryLimit(bytes): Caps QuickJS runtime allocations; exceeding the cap throws an "out of memory" exception inside the sandboxtimeoutMsparameters: interrupt the guest with an "interrupted" exception once the wall-clock deadline passesrequestInterrupt(): Requests that the guest is interrupted at its next resumption (useful from async host functions); cleared when the next timeout-guarded call completes
Promise Handling
getPromiseResult(promise): Gets the result of a settled promisewaitForPendingAsyncInvocations(): Waits for all pending async operations to complete
Object Manipulation
getObjectPropertyValue(object, propertyName): Gets a property value from a JavaScript objectallocateJSstring(string): Creates a JavaScript string in the QuickJS environment
There is one escape hatch, which one-shot users never need: freeValue(handle) releases an object handle early. It exists only for a single run that produces handles in the hundreds of thousands, where they would otherwise fill the instance's heap before the run finishes. If you are wondering whether you should be calling it, you should not.
Host Function Integration
hostFunctions: Object to register functions that can be called from JavaScript viaenv.callHostAsync()(see The host-function contract)
Value conversion
Return values from the sandbox are converted to host values: integers, floats, booleans, strings (UTF-8 safe), null and undefined map to their host equivalents; objects (including promises and module handles) are returned as opaque bigint handles for use with getObjectPropertyValue/getPromiseResult/callModFunction.
Converted values are released as they cross the boundary; handles are reclaimed when the instance is dropped. Neither requires anything from you.
A failed compile throws rather than returning empty bytecode, so a source too large or too broken to compile is reported at compileToByteCode instead of surfacing later as a call on a broken module.
Building from source
./build.sh produces jseval.wasm. It downloads a pinned QuickJS release (2026-06-04) and uses emcc from the PATH, bootstrapping a pinned emsdk if none is installed. The build is reproducible: two clean builds with the same toolchain produce byte-identical wasm.
Examples
Basic Example
const quickjs = await createQuickJS();
const result = quickjs.evalSource("40 + 2"); // returns 42
if (result !== 42) throw new Error("Expected 42, got " + result);Module Example
const quickjs = await createQuickJS();
const bytecode = quickjs.compileToByteCode(
`
export function greet() {
return "Hello, World!";
}
`,
"greet.js",
);
const mod = quickjs.loadByteCode(bytecode);
const greeting = quickjs.callModFunction(mod, "greet"); // returns "Hello, World!"
if (greeting !== "Hello, World!")
throw new Error("Expected 'Hello, World!', got " + greeting);Async Example with Host Functions
const quickjs = await createQuickJS();
// Register a host function
quickjs.hostFunctions["fetchData"] = async (params) => {
const url = quickjs.getObjectPropertyValue(params, "url");
const response = await fetch(url);
const data = await response.json();
return quickjs.allocateJSstring(JSON.stringify(data));
};
// Create and run code that uses the host function
const bytecode = quickjs.compileToByteCode(
`
export async function getData() {
const data = await env.callHostAsync({
function_name: "fetchData",
url: "https://api.example.com/data"
});
return data; // Return as string, objects become bigint references
}
`,
"fetch.js",
);
const mod = quickjs.loadByteCode(bytecode);
const promise = quickjs.callModFunction(mod, "getData");
await quickjs.waitForPendingAsyncInvocations();
const result = quickjs.getPromiseResult(promise); // Returns JSON string
const parsedResult = JSON.parse(result); // Parse on the host side
if (!parsedResult.status || parsedResult.status !== "success") {
throw new Error("Expected success status in result");
}