userland.rundocs70Try the demo
SDK

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.

Boa sandbox

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.

javascript
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() })
loading…
Expected output
root has 12 entries
→ {
  "hasEtc": true,
  "hostname": "nanovm"
}

Options

OptionTypeDefaultDescription
exposeExposeConfig{}The capability grant; see below. Nothing is granted by default.
limits{ loopIterations?, recursion? }—Interpreter caps for runaway loops and recursion.
timeoutMsnumbernoneHost watchdog, checked while the script waits on async host work. On expiry the engine is disposed and the call rejects.
syncOnlybooleanfalseExpose only synchronous bridge members; no async job pump.
globalNamestring"nano"Name of the bridge global.
envRecord<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

OptionTypeDefaultDescription
expose.fs"none" | "readonly" | "readwrite""none"readonly adds nano.fs.readText, readFile, list, exists; readwrite adds writeFile.
expose.runbooleanfalseAdds async nano.run, nano.sh and nano.exec (BusyBox on the VM).
expose.nodebooleanfalseAdds async nano.node(args).
expose.webapisArray<"console" | "encoding" | "url" | "timers">["console"]Web API globals to install.
SDK · JavaScript

fs: none, readonly, readwrite

javascript
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);
loading…
Expected output
none      → undefined 
readonly  → object readText/readFile/list/exists
readwrite → object readText/readFile/list/exists/writeFile
readonly write → TypeError: not a callable function
SDK · JavaScript

run and node

javascript
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"));
loading…
Expected output
["riscv64","y","a b","42"]
undefined
SDK · JavaScript

webapis

javascript
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"] } }));
loading…
Expected output
default: object undefined undefined undefined
all four: object function function function
/docs/sdk/ 6
Caution · In SDK 0.2.10 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

SDK · JavaScript

Loop and recursion caps

javascript
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);
loading…
Expected output
RuntimeLimit: Maximum loop iteration limit 100000 exceeded
RuntimeLimit: exceeded maximum number of recursive calls
SDK · JavaScript

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.

javascript
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);
loading…
Expected output
ScriptError: script timed out after 300ms | under 1 s: true

syncOnly, globalName, env

SDK · JavaScript

syncOnly, globalName and env

javascript
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"] } })));
loading…
Expected output
syncOnly: function undefined
globalName: undefined object demo
env: {"MODE":"demo","retries":3,"tags":["a","b"]}

A long-lived engine

OptionTypeDefaultDescription
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.
SDK · JavaScript

Host functions, globals, modules

javascript
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));
loading…
Expected output
[log] hi
[err] careful
3
42 {"id":7,"name":"row-7"}
counter: 1
module → null ["1,2,3"]
BoaEngine: context disposed

Errors

Syntax errors, exceptions and limit violations reject with ScriptError (exported by the SDK); its message carries the JavaScript error and a stack.

SDK · JavaScript

ScriptError

javascript
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]);
  }
}
loading…
Expected output
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.