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.
elf-appsrun, exec, shExec, node() with "vm"node() with "host" / "auto"registerWasmApp, execWasmAppscript, scriptingengines.node
| Option | Type | Default | Description |
|---|---|---|---|
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.routing | Record<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).
node() on the host engine
print("resolveNodeEngine:", nano.resolveNodeEngine(["app.js"]));
const r = await nano.node(["-e", "console.log(process.platform, process.arch, process.version)"]);
print(r.stdout.trim());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.
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.
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();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.
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.
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();auto | on-host.js → x64 vm | on-vm.js → riscv64
Error codes
ERR_NODE_HOST_RUNTIME_UNAVAILABLEengines.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_UNSUPPORTEDhttp2). It surfaces inside the program as an exception with this code.ERR_NODE_HOST_UNSUPPORTED inside a program
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());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.
A WASI image tool
Photon is a small wasm32-wasip1 image CLI built in the nano repository (make build-photon).
photon in.jpg out.png --filter grayscale
$ 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.
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.
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) })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.
Node writes, BusyBox reads
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());from node v25.4.0 17