userland.rundocs70Try the demo
SDK

Runners and engines

NanoVM has four runners over one kernel and one filesystem. engines decides which one runs nano.node(); the wasm and Boa runners have their own APIs. This page shows each one running and how to choose.

Runner
Runs
Speed
Fidelity
API
riscv
RV64GC Linux ELFs: BusyBox, the Node.js ELF, catalog elf-apps
slow (interpreted CPU)
upstream binaries, unmodified
run, exec, shExec, node() with "vm"
node ("nodert")
Node.js programs on the browser's JS engine
JIT speed
reimplemented built-ins; see Limitations
node() with "host" / "auto"
wasm
wasm32-wasip1 apps
native wasm speed
WASI preview 1 (no sockets, no termios)
registerWasmApp, execWasmApp
boa
untrusted JavaScript
interpreted
ECMAScript, no Node APIs
script, scripting

engines.node

OptionTypeDefaultDescription
engines.node"vm" | "host" | "auto""vm"vm: the RISC-V Node ELF (most faithful, native addons). host: nodert on the browser's engine. auto: nodert, falling back to the VM when the nodert runtime can't be loaded.
engines.routingRecord<string, "vm" | "host">{}Pins by entry-script basename (node_modules/.bin/jest → jest). A pin beats engines.node. Inline -e / -p code has no entry and always uses the default.

nano.resolveNodeEngine(args) returns the runner a node(args) call would use, without running anything. The worker client has the same method.

host: Node.js on the browser's engine

This page boots with engines: { node: "host" }. Programs start in milliseconds and share the VM's filesystem. process.arch reports x64 on purpose: packages special-case linux/x64 (registry entry DIV-001).

SDK · JavaScript

node() on the host engine

javascript
print("resolveNodeEngine:", nano.resolveNodeEngine(["app.js"]));
const r = await nano.node(["-e", "console.log(process.platform, process.arch, process.version)"]);
print(r.stdout.trim());
loading…
Expected output
resolveNodeEngine: host
linux x64 v25.4.0

vm: the RISC-V Node.js ELF

The default. It runs the upstream Node.js v25.4.0 binary on the emulator, so behavior matches Node on Linux exactly, at interpreter speed. The ELF comes from the catalog (62 MB) or from image.node. Give the VM ramMB: 1800; V8 needs about 1.8 GB.

SDK · JavaScript

node() on the RISC-V runner

Boots a slim VM, installs BusyBox and Node.js from the signed catalog, and runs Node on the emulated CPU. The first run downloads about 63 MB (cached in OPFS afterwards). Timings in the expected output are from Chromium on an Apple M3 Max.

javascript
const vm = await sdk.createNano({ image: { wasm: "/nano/nano.wasm" }, ramMB: 1800, engines: { node: "vm" } });
await vm.installApp("busybox");
await vm.installApp("node");
const t0 = performance.now();
const r = await vm.node(["-e", "console.log(process.platform, process.arch, process.version)"]);
print(r.stdout.trim());
print("took", Math.round(performance.now() - t0), "ms on the emulator");
vm.destroy();
loading…
Expected output
linux riscv64 v25.4.0
took 1721 ms on the emulator

auto and routing pins

auto runs on nodert and falls back to the VM only when nodert can't be loaded at all (a missing vendor/runners/node tree, or a CSP without 'unsafe-eval'). A program that loads fine but then calls an API nodert doesn't support is not retried; it fails with that error. Pin such programs to vm with routing.

SDK · JavaScript

auto with a routing pin

The same file contents under two names: the pinned one runs on the emulator, the other on the host engine.

javascript
const vm = await sdk.createNano({
  image: { wasm: "/nano/nano.wasm" },
  ramMB: 1800,
  engines: { node: "auto", routing: { "on-vm.js": "vm" } },
});
await vm.installApp("busybox");
await vm.installApp("node");
for (const f of ["/work/on-host.js", "/work/on-vm.js"]) {
  vm.fs.writeFile(f, "console.log(require('path').basename(__filename), '→', process.arch)");
  print(vm.resolveNodeEngine([f]), "|", (await vm.node([f])).stdout.trim());
}
vm.destroy();
loading…
Expected output
auto | on-host.js → x64
vm | on-vm.js → riscv64

Error codes

`err.code`
When
ERR_NODE_HOST_RUNTIME_UNAVAILABLE
engines.node: "host" and the nodert runtime can't load (missing vendor tree, or CSP blocks 'unsafe-eval'). node() rejects; "auto" falls back to the VM instead.
ERR_NODE_HOST_UNSUPPORTED
a program on nodert used something nodert does not implement (for example http2). It surfaces inside the program as an exception with this code.
SDK · JavaScript

ERR_NODE_HOST_UNSUPPORTED inside a program

javascript
const r = await nano.node(["-e", "try { require('http2').connect('https://example.com') } catch (e) { console.log(e.code + ': ' + e.message) }"]);
print(r.stdout.trim());
loading…
Expected output
ERR_NODE_HOST_UNSUPPORTED: http2 is not supported on nodert; use HTTP/1.1

The wasm runner

wasm32-wasip1 command-line apps run on the browser's WebAssembly engine at native wasm speed, with cwd as their only preopened directory. Register an app from bytes with registerWasmApp(name, bytes), or install a kind: "wasm-app" from the catalog (registered automatically), then run it with execWasmApp(argv, { cwd, timeoutMs }). Details: App catalog.

wasm app

A WASI image tool

Photon is a small wasm32-wasip1 image CLI built in the nano repository (make build-photon).

bash
photon in.jpg out.png --filter grayscale
loading…
Expected output
$ photon in.jpg out.png --filter grayscale
photon: grayscale 360x240 -> out.png (360x240 PNG)

The Boa runner

Untrusted JavaScript runs in the Boa interpreter with no ambient authority; you grant filesystem, command and Node access explicitly. See Scripting.

Boa sandbox

JavaScript in the sandbox

On this site the script samples are granted read-write files, commands and four web APIs; the nano global is the bridge.

javascript
nano.fs.writeFile("/work/from-boa.txt", "written by a sandboxed script");
console.log(nano.fs.readText("/work/from-boa.txt"));
({ engine: "boa", sum: [1, 2, 3].reduce((a, b) => a + b) })
loading…
Expected output
written by a sandboxed script
→ {
  "engine": "boa",
  "sum": 6
}

One filesystem for all runners

All runners read and write the same VFS, so programs on different runners can hand files to each other.

SDK · JavaScript

Node writes, BusyBox reads

javascript
await nano.node(["-e", "require('fs').writeFileSync('/work/handoff.txt', 'from node ' + process.version)"]);
print((await nano.shExec("cat /work/handoff.txt; echo; wc -c < /work/handoff.txt")).stdout.trim());
loading…
Expected output
from node v25.4.0
17
The examples gallery has a complete cross-tier app.