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). The WASM binary is loaded automatically from @jitl/quickjs-wasmfile-release-sync.

The runtime defaults to the release builds: @jitl/quickjs-wasmfile-release-sync for initXript() and @jitl/quickjs-wasmfile-release-asyncify for initXriptAsync(). Each lives in its own module, so a host that only ever calls initXript() never pulls the asyncify build into its sync bundle.

Pass variant to override. The main reason to do so is a singlefile-* build, which inlines the WASM as base64 rather than resolving a separate .wasm asset — useful when shipping into a bundler configuration you do not control:

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

Install any variant you name; only the two release builds ship as dependencies.

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 is an object mapping fragment IDs to their raw HTML strings. The runtime sanitizes each source before attaching it to the mod.

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.