userland.rundocs70Try the demo
SDK

Configuration

Every option createNano(config) accepts, with its default and a sample that boots a VM with it. nanoImage() builds the image part for self-hosted binaries.

OptionTypeDefaultDescription
imageImageConfig—Required. The WebAssembly build plus optional separate ELFs and overlay archives. See image.
ramMBnumberautoSize of the VM's linear memory in MB. Omitted: floor(2000 − wasmMB − 20), with a URL wasm assumed to be 138 MB, i.e. 1842. See ramMB.
warmupbooleantrueRun true once after boot so the first real run doesn't pay first-call costs.
scripting{ wasm }noneURL or bytes of boa.wasm. Without it script() and scripting() throw. Loaded lazily on first use.
crossOriginIsolation"assert" | "ignore""assert"assert throws when the page isn't cross-origin isolated; ignore skips the check (Node.js, tests).
engines{ node?, routing? }{ node: "vm" }Which runner backs nano.node(). See Runners & engines.

The samples below each boot a second, short-lived VM next to this page's VM and destroy it at the end, so you can see each option take effect.

image

OptionTypeDefaultDescription
image.wasmstring | ArrayBuffer | Uint8Array—The emulator build. console.wasm embeds BusyBox; slim nano.wasm embeds nothing.
image.busyboxstring | ArrayBuffer | Uint8Array—A BusyBox ELF for builds that don't embed one. Bytes need a browser (they become object URLs).
image.nodestring | ArrayBuffer | Uint8Array—A Node.js RISC-V ELF used by node() on the VM runner.
image.overlaysArray<string | ArrayBuffer | Uint8Array>[].tar.gz archives unpacked into the filesystem at boot, in order.
SDK · JavaScript

image.wasm from a URL or from bytes

A URL is fetched by the SDK; bytes skip the fetch, which helps when you cache the image yourself.

javascript
const bytes = new Uint8Array(await (await fetch("/nano/console.wasm")).arrayBuffer());
const vm = await sdk.createNano({ image: { wasm: bytes }, ramMB: 256 });
print("booted from", bytes.length, "bytes:", (await vm.run("uname -m")).stdout.trim());
vm.destroy();
loading…
Expected output
booted from 3229271 bytes: riscv64
SDK · JavaScript

image.busybox on the slim build

The slim nano.wasm has no BusyBox, so run() would fail with "No busybox ELF loaded". Here the ELF comes from the signed catalog (installed into this page's VM first) and is passed as bytes.

javascript
await nano.installApp("busybox");
const busybox = nano.fs.readFile("/bin/busybox");
const vm = await sdk.createNano({ image: { wasm: "/nano/nano.wasm", busybox }, ramMB: 256 });
print("busybox ELF:", busybox.length, "bytes");
print((await vm.shExec("echo slim build + separate busybox; ls /bin | wc -l")).stdout.trim());
vm.destroy();
loading…
Expected output
busybox ELF: 1149392 bytes
slim build + separate busybox
67
SDK · JavaScript

image.node: a Node.js ELF for the VM runner

Installs the Node.js v25.4.0 RISC-V ELF (62 MB, cached in OPFS after the first download), passes it as image.node and runs it on the emulator. Needs ramMB: 1800; the first run takes a few seconds.

javascript
await nano.installApp("busybox");
await nano.installApp("node");
const vm = await sdk.createNano({
  image: { wasm: "/nano/nano.wasm", busybox: nano.fs.readFile("/bin/busybox"), node: nano.fs.readFile("/usr/bin/node") },
  ramMB: 1800,
  engines: { node: "vm" },
});
const r = await vm.node(["-e", "console.log(process.arch, process.version)"]);
print(r.stdout.trim());
vm.destroy();
loading…
Expected output
riscv64 v25.4.0
SDK · JavaScript

image.overlays: unpack archives at boot

Builds a .tar.gz inside this page's VM (tar cf then gzip), then boots a new VM with it as an overlay. Use overlays to ship a project or toolchain with the image.

javascript
await nano.shExec("mkdir -p /tmp/ov/etc/demo && echo from-the-overlay > /tmp/ov/etc/demo/motd && cd /tmp/ov && tar cf /tmp/ov.tar etc && gzip -f /tmp/ov.tar");
const archive = nano.fs.readFile("/tmp/ov.tar.gz");
const vm = await sdk.createNano({ image: { wasm: "/nano/console.wasm", overlays: [archive] }, ramMB: 256 });
print(vm.fs.readText("/etc/demo/motd").trim());
vm.destroy();
loading…
Expected output
from-the-overlay

nanoImage()

nanoImage(opts) builds an ImageConfig from a directory laid out like the nano repository: <baseUrl>/nano.wasm, images/busybox, images/node and build/devenv.tar.gz. It only builds URLs; nothing is fetched until createNano().

OptionTypeDefaultDescription
baseUrlstring—Directory holding nano.wasm (a trailing slash is added).
withBusyboxbooleantrueAdd images/busybox as image.busybox.
withNodebooleanfalseAdd images/node as image.node.
withDevenvbooleanfalseAdd build/devenv.tar.gz as an overlay.
SDK · JavaScript

What nanoImage() returns

javascript
print(JSON.stringify(sdk.nanoImage({ baseUrl: "/nano" })));
print(JSON.stringify(sdk.nanoImage({ baseUrl: "https://cdn.example/nano/", withBusybox: false, withNode: true, withDevenv: true })));
loading…
Expected output
{"wasm":"/nano/nano.wasm","busybox":"/nano/images/busybox"}
{"wasm":"https://cdn.example/nano/nano.wasm","node":"https://cdn.example/nano/images/node","overlays":["https://cdn.example/nano/build/devenv.tar.gz"]}

ramMB

ramMB sets the VM's linear memory. The images declare 192 MiB initial memory, so smaller values fail to instantiate; the ceiling is the 2 GiB WebAssembly memory maximum. BusyBox-only work runs in 256 to 512; Node.js on the RISC-V runner needs about 1800. The guest sees the value in /proc/meminfo.

SDK · JavaScript

Guest memory follows ramMB

javascript
for (const ramMB of [256, 384]) {
  const vm = await sdk.createNano({ image: { wasm: "/nano/console.wasm" }, ramMB });
  print(ramMB, "→", (await vm.run("head -1 /proc/meminfo")).stdout.trim());
  vm.destroy();
}
try {
  await sdk.createNano({ image: { wasm: "/nano/console.wasm" }, ramMB: 64 });
} catch (e) {
  print(64, "→", e.message.includes("smaller than the declared initial") ? "rejected: below the 192 MiB the image declares" : e.message);
}
loading…
Expected output
256 → MemTotal:       262144 kB
384 → MemTotal:       393216 kB
64 → rejected: below the 192 MiB the image declares

warmup

With warmup: true (the default) createNano() runs true once before resolving. It costs one tiny run at boot; warmup: false returns sooner and the first run pays instead. Results don't change.

SDK · JavaScript

Boot with and without warmup

javascript
for (const warmup of [true, false]) {
  const t0 = performance.now();
  const vm = await sdk.createNano({ image: { wasm: "/nano/console.wasm" }, ramMB: 256, warmup });
  const t1 = performance.now();
  const r = await vm.run("echo ok");
  print(`warmup ${warmup}: boot ${Math.round(t1 - t0)} ms, first run ${Math.round(performance.now() - t1)} ms → ${r.stdout.trim()}`);
  vm.destroy();
}
loading…
Expected output
warmup true: boot 43 ms, first run 0 ms → ok
warmup false: boot 32 ms, first run 0 ms → ok

scripting

Pass scripting: { wasm } to enable the Boa sandbox. The 2.8 MB boa.wasm is fetched on the first script() or scripting() call, not at boot.

SDK · JavaScript

Scripting on and off

This page's VM was booted without scripting, so nano.script() throws. A VM booted with it evaluates the script.

javascript
try {
  await nano.script("1 + 1");
} catch (e) {
  print("page VM:", e.message);
}
const vm = await sdk.createNano({ image: { wasm: "/nano/console.wasm" }, ramMB: 256, scripting: { wasm: "/nano/boa.wasm" } });
print("with scripting:", await vm.script("[1, 2, 3].map((x) => x * 2).join(',')"));
vm.destroy();
loading…
Expected output
page VM: nano-sdk: scripting is not configured. Pass `scripting: { wasm }` to createNano().
with scripting: 2,4,6

crossOriginIsolation

With the default "assert", createNano() throws before allocating memory when globalThis.crossOriginIsolated is false, with a message naming the two headers. "ignore" skips that check, which is what you want under Node.js or in test runners where shared memory is available without the flag. On a page that is isolated, as this one, both values boot the same way.

SDK · JavaScript

crossOriginIsolation: "ignore"

javascript
const vm = await sdk.createNano({ image: { wasm: "/nano/console.wasm" }, ramMB: 256, crossOriginIsolation: "ignore" });
print("page isolated:", crossOriginIsolated, "| booted:", (await vm.run("echo yes")).stdout.trim());
vm.destroy();
loading…
Expected output
page isolated: true | booted: yes

engines

engines.node picks the runner behind nano.node(): "vm" (default, the RISC-V Node ELF), "host" (the browser's JS engine) or "auto" (host, falling back to the VM when the host runtime can't load). engines.routing pins programs by entry-script basename. Full details and samples: Runners & engines.

SDK · JavaScript

engines and routing pins

resolveNodeEngine(args) reports which runner a call would use without running anything. Inline -e code has no entry script, so it always gets the default.

javascript
const vm = await sdk.createNano({
  image: { wasm: "/nano/console.wasm" },
  ramMB: 256,
  engines: { node: "auto", routing: { jest: "vm", tsc: "host" } },
});
print("node_modules/.bin/jest →", vm.resolveNodeEngine(["node_modules/.bin/jest", "--ci"]));
print("/x/tsc →", vm.resolveNodeEngine(["/x/tsc"]));
print("app.js →", vm.resolveNodeEngine(["app.js"]));
print("-e →", vm.resolveNodeEngine(["-e", "1"]));
vm.destroy();
loading…
Expected output
node_modules/.bin/jest → vm
/x/tsc → host
app.js → auto
-e → auto

Apps from the catalog (BusyBox, Node.js, ripgrep and others) install into any of these VMs at runtime; see App catalog.

Destroying a VM

nano.destroy() frees the VM. Any later call on that instance fails, so drop your references. nano.cancel() only stops the current run; see Running commands.