Skip to content

JS/WASM Runtime

The universal runtime (@xriptjs/runtime) executes user scripts inside a secure QuickJS WASM sandbox. It reads a manifest to determine which bindings to expose, enforces capability gates, and prevents access to anything outside the declared surface. It runs in any JavaScript environment: browser, Node.js, Deno, Bun, and Cloudflare Workers.

For Node.js-only applications that need createRuntimeFromFile or native V8 performance, see the Node.js Runtime. For Rust host applications, see the Rust Runtime. For .NET applications, see the C# Runtime. For a comparison of all runtimes, see Choosing a Runtime.

Terminal window
npm install @xriptjs/runtime

The runtime uses QuickJS compiled to WebAssembly for sandboxing. No native dependencies.

The runtime uses a factory pattern. Initialize the WASM module once, then create runtimes from it:

import { initXript } from "@xriptjs/runtime";
const xript = await initXript();
const runtime = xript.createRuntime(manifest, {
hostBindings: { greet: (name) => `Hello, ${name}!` },
});

initXript() loads the QuickJS WASM module asynchronously and returns a factory. createRuntime() on the factory is synchronous, so spin up as many runtimes as you need without paying the async cost again.

For applications with async host bindings, use the async variant:

import { initXriptAsync } from "@xriptjs/runtime";
const xript = await initXriptAsync();
const runtime = await xript.createRuntime(manifest, {
hostBindings: {
getData: async (key) => await db.get(key),
},
});

initXriptAsync() uses the asyncified WASM build, which allows host functions to return Promises that scripts can await.

createRuntime() accepts a RuntimeOptions object:

OptionTypeDefaultDescription
hostBindingsHostBindings(required)Map of binding names to host functions
capabilitiesstring[][]List of capabilities granted to this script
consoleConsoleHandlerno-op functionsConsole output routing (log/info/warn/error/debug/trace, or a single onLog(severity, ...args))
audit(event: AuditEvent) => voidnoneFire-and-forget capability-decision channel with { binding, capability, at, decision, mod, hook }
hardLimitsHardLimitsmanifest limitsHost-side timeout_ms / memory_mb / max_stack_depth caps, applied on top of the manifest’s limits
cancellationCancellationTokennoneCooperative cancellation token; cancelling interrupts in-flight execution and surfaces a CancellationError
rolePreferencesRecord<string, string>nonePer-role provider preference (role → mod name) consulted by resolveRole
commandPreferencesRecord<string, string>nonePer-command or per-slot preference (command id or slot id → mod name) consulted by resolveCommand
validateInputCommandInputValidatornoneOpt-in argument checking at command invoke; the runtime bundles no JSON Schema validator, so a host that wants full input validation passes its own predicate
debugDebugOptionsnoneEnables the DAP-shaped debug session reachable via debugExecute / debugSession

Host bindings are a flat object mapping binding names to functions, or namespace names to objects of functions:

const hostBindings = {
log: (msg) => console.log(msg),
player: {
getName: () => "Hero",
getHealth: () => 80,
setHealth: (value) => { health = value; },
},
};

Every binding declared in the manifest should have a corresponding host function. If a manifest binding has no host function, calling it throws a BindingError.

Capabilities are an opt-in security layer. By default, no capabilities are granted. Pass the capability names the script should have access to:

const runtime = xript.createRuntime(manifest, {
hostBindings,
capabilities: ["modify-player", "storage"],
});

Any call to a binding gated by a capability not in this list throws a CapabilityDeniedError.

Pass a CancellationToken to interrupt long-running scripts. Cancelling the token surfaces a CancellationError (distinct from a timeout ExecutionLimitError) at the next interruption check point:

import { initXriptAsync, CancellationToken, CancellationError } from "@xriptjs/runtime";
const cancellation = new CancellationToken();
const xript = await initXriptAsync();
const runtime = await xript.createRuntime(manifest, { hostBindings, cancellation });
setTimeout(() => cancellation.cancel(), 1000);
try {
await runtime.executeAsync("while (true) {}");
} catch (e) {
// e instanceof CancellationError
}

The optional audit callback fires once per allowed binding invocation, reporting { binding, capability, at }. It is fire-and-forget; a throw from the callback never breaks script execution:

const runtime = xript.createRuntime(manifest, {
hostBindings,
capabilities: ["storage"],
audit: ({ binding, capability, at }) => {
log.record(`${binding} (${capability ?? "ungated"}) @ ${at}`);
},
});
const result = runtime.execute("2 + 2");
// { value: 4, duration_ms: 0.5 }

execute(code) runs the code synchronously and returns an ExecutionResult:

FieldTypeDescription
valueunknownThe result of the last expression
duration_msnumberWall-clock execution time in milliseconds
const result = await runtime.executeAsync("return await data.get('score');");
// { value: "42", duration_ms: 1.2 }

executeAsync(code) wraps the code in an async function. Use return and await as needed. Returns a Promise resolving to an ExecutionResult.

When createRuntime is given a debug option, runtime.debugExecute(code) runs the script under the DAP-shaped debug protocol and runtime.debugSession() returns the live DebugSession (or null when debugging is off). The session lets the host set/clear breakpoints by source position, pause/resume/step, and inspect scopes, locals, and stack frames. See Debugging for the full protocol and per-engine fidelity notes.

A mod authored as an ES module (entry.format: "module") auto-registers its top-level named function exports as host-invokable. The host calls them by name:

const result = runtime.invokeExport("transcribe", ["input text"]);
const asyncResult = await runtime.invokeExportAsync("fetchAll", [query]);

invokeExport(name, args) runs synchronously; invokeExportAsync(name, args) returns a Promise. If a mod declared a capability on the export, the call is gated — invoking without that capability granted throws CapabilityDeniedError. A missing or throwing export surfaces an InvokeError.

runtime.fireHook(hookName, options?) invokes every handler a mod registered for a named lifecycle hook and returns their results. options carries an optional phase and data:

const results = runtime.fireHook("onTurnStart", { phase: "before", data: { turn: 3 } });

Standalone manifest hooks are deprecated in favor of event-typed slots (a slot whose accepts is the event-handler kind), but host-side firing through fireHook is unchanged.

When mods fill host-declared slots, the host pulls the contributions back out by slot id:

const contributions = runtime.resolveSlot("toolbar"); // priority-ordered SlotContribution[]
const primary = runtime.resolveSlotSingle("status-bar"); // first contribution, or null

Provider roles resolve the same way. A mod fills a role-typed slot, and the host asks for a logical provider by role name:

const provider = runtime.resolveRole("formatter"); // { addon, fns } or null, honoring rolePreferences
const all = runtime.resolveRoleAll("formatter"); // every provider, for building a picker

Declaring a role grants nothing on its own; the named functions stay gated by their own capabilities.

Candidates come back ordered by owning mod name, and rolePreferences outranks that order. Resolution used to return whichever provider was loaded first, so reordering the host’s mod array silently changed the winner; it no longer does.

A command-typed slot (accepts: ["application/x-xript-command"]) collects named, invocable actions. The host lists them and invokes the one the user picked:

const palette = runtime.resolveCommands("palette.commands"); // ordered ResolvedCommand[]
const every = runtime.resolveCommandsAll("palette.commands"); // ignoring slot cardinality
const one = runtime.resolveCommand("wrap.double"); // bare id or "mod:id", or null
const result = runtime.invokeCommand(one, { close: ")" });
const async = await runtime.invokeCommandAsync(one, { close: ")" });

Ordering is priority descending, then command id, then owning mod name, so it does not depend on load order. A multiple: false command slot resolves to exactly one command across all mods, the same way it resolves to one fragment.

invokeCommand merges the fill’s bound args under the caller’s arguments (shallow, caller wins) and routes through the addressed, per-mod export path, so it re-checks the handler’s export capability at call time and can never be ambiguous about which mod’s export it means. A throwing handler propagates its error, unlike a hook handler. Command fills are delivered on mod.commandFills; before v0.8.0 they arrived untyped in mod.dataFills[slotId].

When you’re done with a runtime, call dispose() to free the underlying WASM resources:

runtime.dispose();

Failing to call dispose() leaks WASM memory. In long-running applications, dispose runtimes the moment they’re no longer needed.

The runtime exports its error classes as named imports:

Thrown when the manifest fails structural validation.

import { ManifestValidationError } from "@xriptjs/runtime";
try {
xript.createRuntime({}, { hostBindings: {} });
} catch (e) {
// e.name === "ManifestValidationError"
// e.issues === [{ path: "/xript", message: "required field..." }, ...]
}

The issues array contains every problem found, with a path and message for each.

Thrown when a host function throws or is not provided.

import { BindingError } from "@xriptjs/runtime";
// e.name === "BindingError"
// e.binding === "player.getHealth"
// e.message includes the original error message

Thrown when calling a capability-gated binding without the required capability.

import { CapabilityDeniedError } from "@xriptjs/runtime";
// e.name === "CapabilityDeniedError"
// e.capability === "modify-player"
// e.binding === "player.setHealth"

Thrown when the script exceeds configured execution limits (timeout, memory).

import { ExecutionLimitError } from "@xriptjs/runtime";
// e.name === "ExecutionLimitError"
// e.limit === "timeout_ms"

Thrown when execution is interrupted by a cancelled CancellationToken (distinct from a timeout).

import { CancellationError } from "@xriptjs/runtime";
// e.name === "CancellationError"

Thrown when a host-invoked export is missing or throws.

import { InvokeError } from "@xriptjs/runtime";
// e.name === "InvokeError"
// e.export === "transcribe"

loadMod / loadModAsync and module-format mods surface a few more named classes:

  • ModManifestValidationError — the mod manifest fails validation or cross-validation against the app’s slots and capabilities; carries an issues array.
  • ModEntryError — a mod entry script throws while loading; carries modName.
  • ModuleUnsupportedError — a entry.format: "module" mod was loaded through the synchronous loadMod; module mods require loadModAsync (async sandbox).
  • ImportDeniedError — a mod tried to import an external module; carries the offending specifier. The no-external-modules guarantee is unconditional.
  • CommonJSDetectedError — a mod entry contains require(, module.exports, or top-level exports.; carries the detected artifact and a fix-it message pointing at the TypeScript authoring guide.

The sandbox provides a restricted JavaScript environment powered by QuickJS compiled to WebAssembly:

Available: Math, JSON, Date, Number, String, Boolean, Array, Object, Map, Set, WeakMap, WeakSet, Promise, RegExp, Symbol, Proxy, Reflect, typed arrays, parseInt, parseFloat, isNaN, isFinite, and all standard error constructors.

Blocked: eval, new Function, process, require, import, fetch, setTimeout, setInterval, Buffer, __dirname, __filename, and all Node.js-specific globals.

Frozen namespaces: Namespace objects are frozen with Object.freeze. Scripts cannot add, remove, or reassign namespace members.

Execution limits: The timeout_ms field in the manifest’s limits section controls how long a script can run. Default is 5000ms. The memory_mb field controls maximum memory usage.

Since the runtime uses QuickJS WASM, it works in browsers without any Node.js-specific APIs:

import { initXript } from "@xriptjs/runtime";
const xript = await initXript();
const runtime = xript.createRuntime(manifest, { hostBindings });
const result = runtime.execute("greet('World')");
runtime.dispose();

Bundle with any standard bundler (Vite, webpack, esbuild, Rollup). No bundler configuration is needed: under the browser export condition the WASM ships inlined in the JavaScript, so there is no separate .wasm asset for a bundler or dev server to lose. In particular, Vite’s dev server needs no optimizeDeps entries for the runtime or its QuickJS dependencies.

Which QuickJS build the runtime loads by default depends on where it is resolved:

Resolved underinitXript()initXriptAsync()
the browser condition (Vite, webpack, esbuild and Rollup browser builds)@jitl/quickjs-singlefile-browser-release-sync@jitl/quickjs-singlefile-browser-release-asyncify
everything else (Node, Deno, Bun, Cloudflare Workers via workerd)@jitl/quickjs-wasmfile-release-sync@jitl/quickjs-wasmfile-release-asyncify

The browser default trades size for robustness. A wasmfile build fetches its .wasm relative to its own module URL, and anything that moves the JavaScript without the asset breaks that fetch; Vite’s dependency pre-bundling is the common case, and it only shows up in dev. A singlefile build has no sibling to lose. The cost is transfer size and startup: base64 inflates the binary by a third (roughly 312 KB gzipped for the sync build against 246 KB for the wasmfile pair, and 496 KB against 382 KB for asyncify), and an inlined module cannot use streaming compilation. Each build still lives in its own module, so a host that only ever calls initXript() never pulls the asyncify build into its bundle.

Pass variant to override the default, for example to serve the separate .wasm from a browser host that has a bundler configuration it controls:

import { initXript } from "@xriptjs/runtime";
import RELEASE_SYNC_WASMFILE from "@jitl/quickjs-wasmfile-release-sync";
const xript = await initXript({ variant: RELEASE_SYNC_WASMFILE });

The four release builds above ship as dependencies; install any other variant you name (a debug build, say).

Variants must match the core version exactly

Section titled “Variants must match the core version exactly”

A variant’s FFI and quickjs-emscripten-core share a callback protocol that is not versioned, so a variant published at a different version than the core fails on the first host call, deep inside the FFI. The runtime pins the core and its own variants to one exact version, exported as QUICKJS_CORE_VERSION, and checks any variant it loads with a single host-call round trip. A mismatched variant throws QuickJSVariantMismatchError from initXript() (or from the first createRuntime() of an initXriptAsync() factory), naming the version to install, rather than failing later with an opaque QuickJSContext had no callback with id 0.

import { QUICKJS_CORE_VERSION } from "@xriptjs/runtime";
// npm install @jitl/quickjs-wasmfile-debug-sync@<QUICKJS_CORE_VERSION>

The loaders are also importable directly as @xriptjs/runtime/quickjs-sync and @xriptjs/runtime/quickjs-async, each resolving to the browser or default build by the same conditions.

runtime.loadMod(modManifest, options?) validates a mod manifest against the app manifest, sanitizes any fragment HTML, and returns a ModInstance. A mod contributes through a single fills object keyed by host slot id. A fragment is a fill of a fragment-format slot, a provider role is a fill of a role-typed slot, and a lifecycle hook handler is a fill of an event-typed slot. (Legacy fragments[] and contributions still validate but emit a deprecation warning.) If the mod manifest declares an entry classic script, it runs during loading.

const mod = runtime.loadMod(modManifest, { fragmentSources });
console.log(mod.name, mod.version);
console.log(mod.fragments.length);

fragmentSources maps file names to their contents: every entry script the mod’s entry names (modEntryScripts(modManifest.entry) lists them) and every non-inline fragment source. The runtime sanitizes each fragment source before attaching it to the mod.

An entry script missing from fragmentSources throws MissingEntryScriptError, and an entry that throws while loading throws ModEntryError. A host that would rather load the mod anyway passes entryErrors: "report" to createRuntime; the failure then lands on mod.diagnostics instead. Every load-time finding, including warnings such as markup in a source that is not marked inline, also reaches the optional onModDiagnostic callback:

const runtime = xript.createRuntime(manifest, {
hostBindings,
entryErrors: "report",
onModDiagnostic: (d) => console.warn(`[${d.mod}] ${d.code}: ${d.message}`),
});

Mods authored as ES modules (entry.format: "module") must be loaded with the async variant, which runs on the asyncified sandbox:

const xript = await initXriptAsync();
const runtime = await xript.createRuntime(manifest, { hostBindings });
const mod = await runtime.loadModAsync(modManifest, { fragmentSources });

Calling the synchronous loadMod on a module-format mod throws ModuleUnsupportedError. Top-level named function exports auto-register as host-invokable — call them with invokeExport.

runtime.fireFragmentHook(fragmentId, lifecycle, bindings?) fires a lifecycle hook registered by the active mod script and returns any command buffer operations the script issued. Supported lifecycles: mount, unmount, update, suspend, resume.

const ops = runtime.fireFragmentHook("health-bar", "update", { health: 75 });
// ops is an array of command arrays: [["setText", ".hp", "75"], ...]

Each entry in ops is a command array whose first element is the command name followed by its arguments. The host applies these operations to the rendered fragment.

runtime.processFragment(fragmentId, source, bindings) evaluates data-bind and data-if attributes in the fragment HTML against the provided binding data and returns the resolved output.

const { html, visibility } = runtime.processFragment("health-bar", source, {
health: 75,
maxHealth: 100,
});

html is the processed HTML string with data-bind values substituted. visibility is a map of element selectors to boolean values derived from data-if expressions.