userland.run70Try the demo
Feature · Boa runner

A capability-based JavaScript sandbox in the browser

Plugins, user formulas, automation scripts — sometimes you need to run JavaScript you didn't write. NanoVM's Boa runner evaluates it in Boa, a JavaScript engine written in Rust and compiled to WebAssembly. The script runs in its own engine context, can't see your page's globals, and can touch nothing until you grant a capability.

No powers by default

A fresh engine has no filesystem, no command execution and only console. Everything else is opt-in through expose, and members you don't grant are never registered — they don't exist inside the sandbox, so there is nothing to bypass. Values cross the boundary as JSON.

OptionTypeDefaultDescription
expose.fs"none" | "readonly" | "readwrite""none"readonly adds nano.fs.readText/readFile/list/exists; readwrite adds writeFile.
expose.runbooleanfalseAdds nano.run, nano.sh and nano.exec: BusyBox commands on the RISC-V VM.
expose.nodebooleanfalseAdds nano.node(args).
expose.webapis("console" | "encoding" | "url" | "timers")[]["console"]Which WebAPI globals exist (TextEncoder, URL, setTimeout, …).
limits.loopIterationsnumber—Maximum iterations of any single loop before the script throws.
limits.recursionnumber—Maximum call depth.
timeoutMsnumber—Host watchdog for asynchronous work; on expiry the engine is disposed and the call rejects.
syncOnlybooleanfalseRegister only synchronous bridge members (no run, node or async host functions).
globalNamestring"nano"Name of the bridge global.
envobject—Read-only JSON bag exposed as <global>.env.
SDK · JavaScript

The same probe at three capability levels

One script, evaluated three times with different expose grants. It reports which bridge members exist.

javascript
const probe = `JSON.stringify({
  fs: typeof nano.fs,
  writeFile: typeof (nano.fs && nano.fs.writeFile),
  run: typeof nano.run,
})`;
for (const fs of ["none", "readonly", "readwrite"]) {
  print(fs.padEnd(9), await nano.script(probe, { expose: { fs } }));
}
print("with run:", await nano.script(probe, { expose: { run: true } }));
loading…
Expected output
none      {"fs":"undefined","writeFile":"undefined","run":"undefined"}
readonly  {"fs":"object","writeFile":"undefined","run":"undefined"}
readwrite {"fs":"object","writeFile":"function","run":"undefined"}
with run: {"fs":"undefined","writeFile":"undefined","run":"function"}
SDK · JavaScript

A write attempt with read-only access

The script may read /work/config.json but tries to overwrite it. writeFile isn't there, so it throws inside the sandbox and the file is untouched.

javascript
nano.fs.writeFile("/work/config.json", '{"admin":false}');
try {
  await nano.script(`
    const cfg = JSON.parse(nano.fs.readText("/work/config.json"));
    cfg.admin = true;
    nano.fs.writeFile("/work/config.json", JSON.stringify(cfg));
  `, { expose: { fs: "readonly" } });
} catch (e) {
  print("denied:", e.name, "-", e.message);
}
print("config is still", nano.fs.readText("/work/config.json"));
loading…
Expected output
denied: ScriptError - TypeError: not a callable function
    at <main> (unknown at :4:22)
config is still {"admin":false}

Allowed: scripts that drive the VM

With grants, a script becomes a small automation layer over the VM. The samples below use the page's default sandbox for Boa sandbox samples: fs: "readwrite", run: true, and the console, encoding, url and timers WebAPIs.

Boa sandbox

Plain JavaScript in Boa

The last expression is the result; it comes back as JSON.

javascript
const words = "the quick brown fox jumps over the lazy dog".split(" ");
console.log("words:", words.length);
({ longest: words.reduce((a, b) => (b.length > a.length ? b : a)), sorted: [...words].sort().slice(0, 3) })
loading…
Expected output
words: 9
→ {
  "longest": "quick",
  "sorted": [
    "brown",
    "dog",
    "fox"
  ]
}
Boa sandbox

Read, transform and write files

A seeded CSV is parsed in the sandbox and written back as JSON through the granted nano.fs.

javascript
const csv = nano.fs.readText("/work/sales.csv");
const rows = csv.trim().split("\n").slice(1).map((l) => l.split(","));
const total = rows.reduce((s, [, n]) => s + Number(n), 0);
nano.fs.writeFile("/work/summary.json", JSON.stringify({ rows: rows.length, total }));
console.log(nano.fs.readText("/work/summary.json"));
loading…
Expected output
{"rows":3,"total":245}
Boa sandbox

Run BusyBox commands from a script

nano.run (granted by run: true) executes a command on the RISC-V VM and resolves with its result. Boa evaluates scripts, not modules, so top-level await goes inside an async function.

javascript
(async () => {
  const r = await nano.run("uname -m");
  console.log("arch:", r.stdout.trim(), "exit:", r.exitCode);
})()
loading…
Expected output
arch: riscv64 exit: 0

Limits and timeouts

Capabilities stop a script from reaching things; limits stop it from hogging the tab. loopIterations and recursion are enforced by the engine on synchronous code. timeoutMs is a host-side watchdog checked while the engine waits on asynchronous host work (such as nano.run); when it fires, the engine is disposed and the call rejects.

SDK · JavaScript

An infinite loop, stopped

while (true) {} would freeze a naive eval. With a loop limit it throws after 100 000 iterations.

javascript
try {
  await nano.script("let n = 0; while (true) { n++; }", { limits: { loopIterations: 100_000 } });
} catch (e) {
  print("stopped:", e.message.split("\n")[0]);
}
loading…
Expected output
stopped: RuntimeLimit: Maximum loop iteration limit 100000 exceeded
SDK · JavaScript

Runaway recursion, stopped

A recursion cap turns unbounded recursion into a catchable error instead of a crashed engine.

javascript
try {
  await nano.script("function f(n) { return f(n + 1) + 1; } f(0)", { limits: { recursion: 200 } });
} catch (e) {
  print("stopped:", e.message.split("\n")[0]);
}
loading…
Expected output
stopped: RuntimeLimit: exceeded maximum number of recursive calls
SDK · JavaScript

A watchdog timeout on async work

The script keeps the VM busy forever. timeoutMs: 1000 disposes the engine about a second in.

javascript
const t0 = Date.now();
try {
  await nano.script("(async () => { for (;;) await nano.run('true'); })()", { expose: { run: true }, timeoutMs: 1000 });
} catch (e) {
  print(e.message, "after ~" + Math.round((Date.now() - t0) / 1000) + "s");
}
loading…
Expected output
script timed out after 1000ms after ~1s

Host functions and globals

For plugin systems, nano.scripting(opts) returns a long-lived engine. registerFunction exposes a host callback (async by default; results come back as JSON) and defineGlobal injects plain data. Call dispose() when you are done to free the engine's heap.

SDK · JavaScript

A plugin calling back into the host

The script sees only PRICES and tax() — your page decides what tax does.

javascript
const sandbox = await nano.scripting({ onStdout: (s) => print(s.trimEnd()) });
sandbox.defineGlobal("PRICES", { apple: 3, pear: 5 });
sandbox.registerFunction("tax", (x) => Math.round(x * 1.2));

const total = await sandbox.eval(`
  (async () => {
    let sum = 0;
    for (const k of Object.keys(PRICES)) sum += await tax(PRICES[k]);
    console.log("items:", Object.keys(PRICES).length);
    return sum;
  })()
`);
print("total with tax:", total);
sandbox.dispose();
loading…
Expected output
items: 2
total with tax: 10

When to use which runner

You need to…Use
Run code you don't trust, with explicit grantsBoa runner — nano.script() / nano.scripting()
Run a Node.js program or npm toolNode on the host engine or the RISC-V VM
Run a compiled CLI toolThe wasm runner or the RISC-V VM
Boa is an interpreter, so it is slower than the browser's JIT for heavy computation. It is built for policy, glue and plugin code, where isolation matters more than raw speed. Language coverage is whatever Boa supports, so test unusual syntax before relying on it.
javascript
import { createNano } from "@userland-run/nano-sdk";

const nano = await createNano({
  image: { wasm: "/nano/console.wasm" },
  ramMB: 512,
  scripting: { wasm: "/nano/boa.wasm" }, // fetched lazily, on the first script
});
const out = await nano.script(userCode, {
  expose: { fs: "readonly", webapis: ["console"] },
  limits: { loopIterations: 1e6, recursion: 256 },
  timeoutMs: 2000,
});

FAQ

Can a sandboxed script access the page's DOM, cookies or window?

No. The script runs inside a separate Boa engine compiled to WebAssembly. Its only links to the outside are the bridge members you grant with expose, functions you register and globals you define.

What happens when a script throws or times out?

The call rejects with a ScriptError carrying the message. On a watchdog timeout the engine is also disposed, so a misbehaving script can't continue in the background.

Is boa.wasm downloaded for every page?

Only when scripting is configured and the first script runs: scripting: { wasm } is loaded lazily, so apps that never script pay nothing.