Skip to content

Rust Runtime

The Rust runtime (xript-runtime) executes user scripts inside a native QuickJS sandbox powered by rquickjs. It provides the same manifest-driven binding model and capability enforcement as the JS/WASM and Node.js runtimes, but as a Rust crate with no WASM overhead.

For applications that need to run in browsers or other JavaScript environments, use the JS/WASM Runtime. For Node.js-only applications, see the Node.js Runtime. For .NET applications, see the C# Runtime. For a comparison of all runtimes, see Choosing a Runtime.

Add xript-runtime to your Cargo.toml:

[dependencies]
xript-runtime = "0.6"
use xript_runtime::{create_runtime, RuntimeOptions, HostBindings, ConsoleHandler};
let manifest_json = r#"{
"xript": "0.1",
"name": "my-app",
"bindings": {
"greet": {
"description": "Returns a greeting.",
"params": [{ "name": "name", "type": "string" }]
}
}
}"#;
let mut bindings = HostBindings::new();
bindings.add_function("greet", |args: &[serde_json::Value]| {
let name = args.first()
.and_then(|v| v.as_str())
.unwrap_or("World");
Ok(serde_json::json!(format!("Hello, {}!", name)))
});
let runtime = create_runtime(manifest_json, RuntimeOptions {
host_bindings: bindings,
capabilities: vec![],
console: ConsoleHandler::default(),
..Default::default()
})?;
use std::path::Path;
use xript_runtime::{create_runtime_from_file, RuntimeOptions, HostBindings, ConsoleHandler};
let runtime = create_runtime_from_file(
Path::new("manifest.json"),
RuntimeOptions {
host_bindings: HostBindings::new(),
capabilities: vec![],
console: ConsoleHandler::default(),
..Default::default()
},
)?;

create_runtime_from_file resolves the manifest’s extends chain relative to the file’s directory before constructing the runtime, so an inheriting manifest loads as its fully merged form.

use xript_runtime::{create_runtime_from_value, RuntimeOptions, HostBindings, ConsoleHandler};
let manifest = serde_json::json!({
"xript": "0.1",
"name": "my-app"
});
let runtime = create_runtime_from_value(manifest, RuntimeOptions {
host_bindings: HostBindings::new(),
capabilities: vec![],
console: ConsoleHandler::default(),
..Default::default()
})?;

RuntimeOptions derives Default, so only the fields you care about need to be set; spread ..Default::default() for the rest. The full set:

FieldTypeDescription
host_bindingsHostBindingsMap of binding names to host functions
capabilitiesVec<String>List of capabilities granted to this script
consoleConsoleHandlerConsole output routing (log, warn, error, plus an optional severity sink)
cancellationOption<CancellationToken>Host-driven cooperative cancellation token; None to disable
auditOption<AuditSink>Per-capability audit channel fired once per allowed binding invocation; None to disable
hard_limitsOption<HardLimits>Host-imposed ceilings clamping the manifest’s limits; None for manifest-only limits
role_preferencesHashMap<String, String>Per-role provider preference (role → preferred mod name) for resolve_role
debugOption<DebugOptions>DAP-shaped debug session options; None for zero-overhead off

Host bindings map binding names to Rust closures. Each closure receives a slice of serde_json::Value arguments and returns Result<serde_json::Value, String>:

let mut bindings = HostBindings::new();
bindings.add_function("add", |args: &[serde_json::Value]| {
let a = args.get(0).and_then(|v| v.as_f64()).unwrap_or(0.0);
let b = args.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);
Ok(serde_json::json!(a + b))
});

For namespace bindings, use add_namespace with a HashMap of member functions:

use std::collections::HashMap;
use std::sync::Arc;
use xript_runtime::HostFn;
let mut members: HashMap<String, HostFn> = HashMap::new();
members.insert("getName".into(), Arc::new(|_| Ok(serde_json::json!("Hero"))));
members.insert("getHealth".into(), Arc::new(|_| Ok(serde_json::json!(100))));
bindings.add_namespace("player", members);

Route console.log, console.warn, and console.error from scripts to Rust callbacks:

use xript_runtime::ConsoleHandler;
let console = ConsoleHandler {
log: Box::new(|msg| println!("[LOG] {}", msg)),
warn: Box::new(|msg| eprintln!("[WARN] {}", msg)),
error: Box::new(|msg| eprintln!("[ERROR] {}", msg)),
on: None,
};

The default ConsoleHandler silently discards all output.

For finer-grained routing, set the on field to a single severity sink that receives a LogSeverity (Trace, Debug, Info, Warn, Error) for every console call. When on is Some, it supersedes the legacy log/warn/error boxes; when it is None, the runtime falls back to them (Info/Debug/Tracelog, Warnwarn, Errorerror):

use xript_runtime::{ConsoleHandler, LogSeverity};
let console = ConsoleHandler {
on: Some(Box::new(|severity: LogSeverity, msg: &str| {
eprintln!("[{:?}] {}", severity, msg);
})),
..ConsoleHandler::default()
};
let result = runtime.execute("2 + 2")?;
// result.value == serde_json::json!(4)
// result.duration_ms == 0.1 (approx)

execute runs the code synchronously and returns an ExecutionResult:

FieldTypeDescription
valueserde_json::ValueThe result of the last expression
duration_msf64Wall-clock execution time in milliseconds

All errors are variants of XriptError:

VariantWhen
ManifestValidation { issues }Manifest fails structural validation
Binding { binding, message }Host function throws or is missing
CapabilityDenied { binding, capability }Calling a gated binding (or invoking a gated export) without the required capability
ExecutionLimit { limit }Script exceeds timeout or resource limits
CancelledHost cancelled execution via the CancellationToken (distinct from a timeout)
Invoke { export, message }A host-invoked export was missing/unregistered or threw
Script(String)Script throws an uncaught error
ModEntry { mod_name, message }Mod entry script (classic or module) threw an uncaught error
ImportDenied { mod_name, specifier }A mod attempted to import an external module
CommonJsDetected { mod_name, artifact }A mod entry used CommonJS (require / module.exports) instead of ES modules
Engine(String)QuickJS engine error
Json(serde_json::Error)Manifest JSON parsing failed
Io(std::io::Error)File I/O failed (for create_runtime_from_file)

The sandbox provides a restricted JavaScript environment powered by QuickJS (native, via rquickjs):

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

Blocked: eval, new Function, process, require, import, fetch, setTimeout, setInterval, and all Node.js/browser-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 5000ms). The memory_mb field controls maximum heap size. The max_stack_depth field controls the maximum call stack size.

pub fn create_runtime(manifest_json: &str, options: RuntimeOptions) -> Result<XriptRuntime>;
pub fn create_runtime_from_file(path: &Path, options: RuntimeOptions) -> Result<XriptRuntime>;
pub fn create_runtime_from_value(manifest: Value, options: RuntimeOptions) -> Result<XriptRuntime>;
pub struct XriptRuntime { /* ... */ }
pub struct RuntimeOptions { /* host_bindings, capabilities, console, cancellation, audit, hard_limits, role_preferences, debug */ }
pub struct HostBindings { /* ... */ }
pub struct ConsoleHandler { /* log, warn, error, on */ }
pub struct ExecutionResult { pub value: Value, pub duration_ms: f64 }
pub struct Manifest { pub xript: String, pub name: String, /* ... */ }
pub enum XriptError { /* ManifestValidation, Binding, CapabilityDenied, Cancelled, Invoke, ModEntry, ImportDenied, CommonJsDetected, ... */ }
pub type HostFn = Arc<dyn Fn(&[Value]) -> Result<Value, String> + Send + Sync>;
pub type AsyncHostFn = Arc<dyn Fn(&[Value]) -> Pin<Box<dyn Future<Output = Result<Value, String>> + Send>> + Send + Sync>;
pub type Result<T> = std::result::Result<T, XriptError>;
// Lifecycle, audit, and limits
pub struct CancellationToken { /* new(), cancel(), is_cancelled() */ }
pub struct AuditEvent { pub binding: String, pub capability: Option<String>, pub at_ms: f64 }
pub type AuditSink = Arc<dyn Fn(AuditEvent) + Send + Sync>;
pub enum LogSeverity { Trace, Debug, Info, Warn, Error }
pub struct HardLimits { pub timeout_ms: Option<u64>, pub memory_mb: Option<u64>, pub max_stack_depth: Option<usize> }
// Slot and provider-role resolution
pub struct SlotContribution { pub mod_name: String, pub fragment_id: String, pub slot: String, pub format: String, pub priority: i32 }
pub struct RoleResolution { pub addon: String, pub role: String, pub fns: std::collections::BTreeMap<String, String> }
pub struct XriptHandle { /* ... */ } // Send + Sync wrapper; see below
pub struct FragmentInstance { pub id: String, pub slot: String, pub format: String, pub priority: i32, /* ... */ }
pub struct ModInstance {
pub id: String,
pub name: String,
pub version: String,
pub fragments: Vec<FragmentInstance>,
pub provides: Vec<ProviderRole>,
}

XriptRuntime holds a QuickJS context that is !Send. It can only be used from the thread that created it. For multi-threaded hosts (Tauri, Actix, Axum, etc.), use XriptHandle instead; it owns the runtime on a dedicated thread and exposes the same API over channels. See XriptHandle below.

add_async_function() registers an async host function. From the script’s perspective the binding returns a Promise, so scripts can await it. The bridge uses pollster::block_on() internally to drive the future.

use xript_runtime::{HostBindings};
let mut bindings = HostBindings::new();
bindings.add_async_function("fetchData", |args: &[serde_json::Value]| {
let key = args.get(0).and_then(|v| v.as_str()).unwrap_or("default").to_string();
async move { Ok(serde_json::json!(format!("data for {}", key))) }
});

Script side:

const data = await fetchData("users");

Promise is available in the sandbox; standard QuickJS async/await works without any extra setup.

load_mod() validates a mod manifest against the app manifest, sanitizes any fragment HTML, and optionally executes the mod’s entry script before returning.

use std::collections::{HashMap, HashSet};
let fragment_sources: HashMap<String, String> = HashMap::new(); // source path -> raw HTML
let mut granted: HashSet<String> = HashSet::new(); // capabilities approved for this mod
granted.insert("ui-mount".into());
let mod_instance = rt.load_mod(
mod_manifest_json, // &str — mod manifest JSON
fragment_sources, // HashMap<String, String> — fragment source path -> raw HTML
&granted, // &HashSet<String> — capabilities approved for this mod
None, // Option<&str> — entry script, or None
)?;

Fragments declared inline: true carry their HTML in the manifest directly; non-inline fragments resolve their source path against the fragment_sources map. entry_source runs after validation but before load_mod returns; if it throws, load_mod returns XriptError::ModEntry { mod_name, message }. The returned ModInstance exposes the mod’s id, name, version, sanitized fragments, and any provides (provider-role declarations; see Slot & Role Resolution).

A mod entry may be a classic script (using xript.exports.register(...)) or an ES module (entry.format: "module"); top-level named function exports auto-register as host-invokable. A mod that attempts an external import fails with XriptError::ImportDenied, and a mod entry carrying CommonJS (require / module.exports) fails with XriptError::CommonJsDetected.

fire_fragment_hook() fires a lifecycle event for a mounted fragment and returns the command-buffer operations the mod’s registered handlers emitted in response.

let ops_per_handler = rt.fire_fragment_hook(
fragment_id, // &str
lifecycle, // &str — "mount", "unmount", "update", "suspend", or "resume"
Some(&serde_json::json!({ "health": 30 })), // Option<&serde_json::Value> — current bindings
)?;

lifecycle is one of "mount", "unmount", "update", "suspend", or "resume". The return is a Vec<serde_json::Value>: one entry per registered handler, each entry an array of command objects that handler emitted. Each command is a plain JSON object keyed by op:

for handler_ops in &ops_per_handler {
for op in handler_ops.as_array().into_iter().flatten() {
match op["op"].as_str() {
Some("toggle") => { /* op["selector"], op["value"] (bool) */ }
Some("addClass") => { /* op["selector"], op["value"] (class) */ }
Some("removeClass") => { /* op["selector"], op["value"] (class) */ }
Some("setText") => { /* op["selector"], op["value"] (text) */ }
Some("setAttr") => { /* op["selector"], op["attr"], op["value"] */ }
Some("replaceChildren") => { /* op["selector"], op["value"] (html) */ }
_ => {}
}
}
}

The host walks the operations and applies each mutation to its own UI layer.

A mod’s entry can register host-invokable functions two ways: by calling xript.exports.register(name, fn) in a classic-script entry, or by declaring top-level named function exports in a module-format entry (entry.format: "module"). The host calls them through invoke_export:

let result = rt.invoke_export("computeDamage", &[
serde_json::json!(10),
serde_json::json!("fire"),
])?;

Args are JSON-serializable and the return value is honored. An undeclared/unregistered export, or one that throws, surfaces as XriptError::Invoke. If the export declares a gating capability the runtime was not granted, the call surfaces as XriptError::CapabilityDenied before the function runs.

When a host declares typed slots, loaded mods fill them with fragment contributions. resolve_slot returns the contributions targeting a slot, ordered by priority descending then fragment id ascending; single-cardinality slots (the default, with multiple unset) return at most the winner:

let contributions = rt.resolve_slot("sidebar.left"); // Vec<SlotContribution>
let winner = rt.resolve_slot_single("main.overlay"); // Option<SlotContribution>

Provider roles are resolved the same way. A mod that declares contributions.provides offers a logical role with a logical→concrete fn map; the host picks a winner with resolve_role (first-installed-wins, unless role_preferences names a present candidate) or enumerates every candidate with resolve_role_all to build its own picker:

let provider = rt.resolve_role("storage"); // Option<RoleResolution>
let all = rt.resolve_role_all("storage"); // Vec<RoleResolution>

The runtime never invokes the named fns itself; it returns the mapping, and the host calls them through its own export/binding path, so each named fn stays gated by its own capability.

A CancellationToken on RuntimeOptions lets a host interrupt an in-flight execution at the next interrupt-check point. Cancellation is sticky and idempotent, and surfaces as XriptError::Cancelled (distinct from an ExecutionLimit timeout):

use xript_runtime::CancellationToken;
let token = CancellationToken::new();
let rt = create_runtime(manifest_json, RuntimeOptions {
cancellation: Some(token.clone()),
..Default::default()
})?;
// from another thread / a timer / a UI cancel button:
token.cancel();

An opt-in AuditSink fires once per allowed host-binding invocation, before the host function runs, reporting { binding, capability, at_ms }. Emission is best-effort and never propagates errors into the sandbox:

use std::sync::Arc;
use xript_runtime::{AuditEvent, AuditSink};
let sink: AuditSink = Arc::new(|event: AuditEvent| {
eprintln!("called {} (cap: {:?}) at {}", event.binding, event.capability, event.at_ms);
});
let rt = create_runtime(manifest_json, RuntimeOptions {
audit: Some(sink),
..Default::default()
})?;

RuntimeOptions::with_audit_channel(tx) is a convenience that wraps an mpsc::Sender<AuditEvent> as a sink.

A host can impose ceilings the manifest’s limits cannot exceed. The effective value per field is min(manifest, hard); an over-requesting manifest is clamped silently rather than rejected:

use xript_runtime::HardLimits;
let rt = create_runtime(manifest_json, RuntimeOptions {
hard_limits: Some(HardLimits {
timeout_ms: Some(2000),
memory_mb: Some(64),
max_stack_depth: Some(256),
}),
..Default::default()
})?;

When RuntimeOptions::debug is set, the runtime attaches a DAP-shaped debug session reachable via rt.debug_session() (None when debug is off, zero overhead when absent). The session uses Debug Adapter Protocol vocabulary (breakpoints, stack frames, scopes, variables, stop reasons) shared across all four runtimes; per-engine fidelity is surfaced through DebugFidelity rather than papered over. See the Debugging spec for the protocol shape.

XriptRuntime is !Send. For Tauri commands, Actix handlers, Axum routes, or any context where the runtime crosses thread boundaries, use XriptHandle:

use xript_runtime::XriptHandle;
let handle = XriptHandle::new(manifest_json, options)?;
// XriptHandle is Send + Sync — safe to put in Arc<Mutex<T>>, tauri::State, etc.
let result = handle.execute("2 + 2")?;

XriptHandle starts a dedicated owner thread, moves the XriptRuntime onto it, and forwards every call through a channel pair. All methods mirror XriptRuntime (execute, load_mod, fire_fragment_hook, and so on). The channel overhead is negligible for typical scripting workloads.

Tauri example:

use std::sync::Mutex;
use tauri::State;
use xript_runtime::XriptHandle;
struct AppState {
xript: Mutex<XriptHandle>,
}
#[tauri::command]
fn run_script(state: State<AppState>, code: &str) -> Result<serde_json::Value, String> {
let handle = state.xript.lock().unwrap();
handle.execute(code)
.map(|r| r.value)
.map_err(|e| e.to_string())
}