Scripting
Run untrusted JavaScript in the Boa engine, compiled to WebAssembly, with no ambient authority. expose grants exactly the files, commands and web APIs a script may use; limits and timeoutMs bound it.
Enable scripting with scripting: { wasm: "/nano/boa.wasm" } in createNano(); boa.wasm (2.8 MB) loads on first use. nano.script(source, opts) creates an engine, evaluates once and disposes it. nano.scripting(opts) returns a long-lived ScriptEngine.
A script with file access
Script samples on this site are granted fs: "readwrite", run: true and the console/encoding/url/timers web APIs. The result value is returned as JSON.
const files = nano.fs.list("/").map((e) => e.name);
console.log("root has", files.length, "entries");
({ hasEtc: files.includes("etc"), hostname: nano.fs.readText("/etc/hostname").trim() })root has 12 entries
→ {
"hasEtc": true,
"hostname": "nanovm"
}Options
| Option | Type | Default | Description |
|---|---|---|---|
expose | ExposeConfig | {} | The capability grant; see below. Nothing is granted by default. |
limits | { loopIterations?, recursion? } | — | Interpreter caps for runaway loops and recursion. |
timeoutMs | number | none | Host watchdog, checked while the script waits on async host work. On expiry the engine is disposed and the call rejects. |
syncOnly | boolean | false | Expose only synchronous bridge members; no async job pump. |
globalName | string | "nano" | Name of the bridge global. |
env | Record<string, unknown> | {} | Read-only JSON values at <global>.env. |
onStdout / onStderr | (chunk) => void | — | Receive console.log/info and console.warn/error. Without them output goes to the browser console. |
expose
| Option | Type | Default | Description |
|---|---|---|---|
expose.fs | "none" | "readonly" | "readwrite" | "none" | readonly adds nano.fs.readText, readFile, list, exists; readwrite adds writeFile. |
expose.run | boolean | false | Adds async nano.run, nano.sh and nano.exec (BusyBox on the VM). |
expose.node | boolean | false | Adds async nano.node(args). |
expose.webapis | Array<"console" | "encoding" | "url" | "timers"> | ["console"] | Web API globals to install. |
fs: none, readonly, readwrite
nano.fs.writeFile("/work/a.txt", "AAA");
const probe = '[typeof nano.fs, nano.fs && Object.keys(nano.fs).join("/")].join(" ")';
for (const fs of ["none", "readonly", "readwrite"]) {
print(fs.padEnd(9), "→", await nano.script(probe, { expose: { fs } }));
}
const denied = await nano.script('nano.fs.writeFile("/work/b.txt", "x")', { expose: { fs: "readonly" } }).catch((e) => e.message.split("\n")[0]);
print("readonly write →", denied);none → undefined readonly → object readText/readFile/list/exists readwrite → object readText/readFile/list/exists/writeFile readonly write → TypeError: not a callable function
run and node
const out = await nano.script(
'(async () => { const a = await nano.run("uname -m"); const b = await nano.sh("echo x | tr x y"); const c = await nano.exec(["echo", "a b"]); const d = await nano.node(["-e", "console.log(6 * 7)"]); return [a, b, c, d].map((r) => r.stdout.trim()); })()',
{ expose: { run: true, node: true } },
);
print(JSON.stringify(out));
print(await nano.script("typeof nano.run"));["riscv64","y","a b","42"] undefined
webapis
const probe = "[typeof console, typeof TextEncoder, typeof URL, typeof setTimeout].join(' ')";
print("default:", await nano.script(probe));
print("all four:", await nano.script(probe, { expose: { webapis: ["console", "encoding", "url", "timers"] } }));
print(await nano.script('new URL("https://userland.run/docs/sdk/").pathname + " " + new TextEncoder().encode("héllo").length', { expose: { webapis: ["url", "encoding"] } }));default: object undefined undefined undefined all four: object function function function /docs/sdk/ 6
setTimeout exists with webapis: ["timers"] but its callbacks never run, and awaiting a timer rejects with "script did not settle (no pending host work)". Use a host function for delays.limits and timeoutMs
Loop and recursion caps
const loop = await nano.script("let i = 0; while (true) i++;", { limits: { loopIterations: 100_000 } }).catch((e) => e.message.split("\n")[0]);
print(loop);
const rec = await nano.script("function f(n) { return f(n + 1) } f(0)", { limits: { recursion: 100 } }).catch((e) => e.message.split("\n")[0]);
print(rec);RuntimeLimit: Maximum loop iteration limit 100000 exceeded RuntimeLimit: exceeded maximum number of recursive calls
timeoutMs
The watchdog is checked between async host round-trips: here the script awaits a 50 ms host function forever and is stopped after about 300 ms. It cannot interrupt synchronous code (that would block the page), so always set limits too.
const engine = await nano.scripting({ timeoutMs: 300 });
engine.registerFunction("tick", () => new Promise((r) => setTimeout(r, 50)));
const t0 = performance.now();
const err = await engine.eval("(async () => { for (;;) await tick(); })()").catch((e) => e);
print(err.name + ":", err.message, "| under 1 s:", performance.now() - t0 < 1000);ScriptError: script timed out after 300ms | under 1 s: true
syncOnly, globalName, env
syncOnly, globalName and env
print("syncOnly:", await nano.script("typeof nano.fs.readText + ' ' + typeof nano.run", { syncOnly: true, expose: { fs: "readonly", run: true } }));
print("globalName:", await nano.script("typeof nano + ' ' + typeof host + ' ' + host.env.MODE", { globalName: "host", env: { MODE: "demo" } }));
print("env:", JSON.stringify(await nano.script("nano.env", { env: { MODE: "demo", retries: 3, tags: ["a", "b"] } })));syncOnly: function undefined
globalName: undefined object demo
env: {"MODE":"demo","retries":3,"tags":["a","b"]}A long-lived engine
| Option | Type | Default | Description |
|---|---|---|---|
eval(source) | Promise<unknown> | — | Evaluate a script; globals persist between calls. The value comes back as JSON. |
evalModule(source, specifier?) | Promise<unknown> | — | Evaluate an ES module. Imports are disabled; the call resolves to null, so return data through a host function. |
registerFunction(name, fn) | void | — | Expose a host function (sync or async) as a global. |
defineGlobal(name, value) | void | — | Define a global from a JSON value. |
dispose() | void | — | Free the engine; later calls throw BoaEngine: context disposed. |
Host functions, globals, modules
const engine = await nano.scripting({ onStdout: (c) => print("[log]", c.trim()), onStderr: (c) => print("[err]", c.trim()) });
engine.defineGlobal("CONFIG", { retries: 3 });
engine.registerFunction("double", (x) => x * 2);
engine.registerFunction("fetchRow", async (id) => ({ id, name: "row-" + id }));
const seen = [];
engine.registerFunction("report", (v) => void seen.push(v));
print(await engine.eval('console.log("hi"); console.error("careful"); CONFIG.retries'));
print(await engine.eval("double(21)"), JSON.stringify(await engine.eval("fetchRow(7)")));
await engine.eval("var counter = 0"); await engine.eval("counter++");
print("counter:", await engine.eval("counter"));
print("module →", await engine.evalModule("const xs = [3, 1, 2]; report(xs.toSorted().join(',')); export default 1;"), JSON.stringify(seen));
engine.dispose();
print(await engine.eval("1").catch((e) => e.message));[log] hi
[err] careful
3
42 {"id":7,"name":"row-7"}
counter: 1
module → null ["1,2,3"]
BoaEngine: context disposedErrors
Syntax errors, exceptions and limit violations reject with ScriptError (exported by the SDK); its message carries the JavaScript error and a stack.
ScriptError
for (const src of ["null.x", "let = ;"]) {
try {
await nano.script(src);
} catch (e) {
print(e instanceof sdk.ScriptError, e.name, "-", e.message.split("\n")[0]);
}
}true ScriptError - TypeError: cannot convert 'null' or 'undefined' to object true ScriptError - SyntaxError: unexpected token ';', primary expression at line 1, col 7
What crosses the boundary
Values are marshalled as JSON in both directions: Date becomes {}, NaN becomes null, undefined properties are dropped, functions become plain objects. Bytes from nano.fs.readFile arrive in the script as a Uint8Array.
Scripts can also be started from a Shell line (script <source>) and over the worker transport. See the JS sandbox feature and the boa-sandbox example.