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.
| Option | Type | Default | Description |
|---|---|---|---|
image | ImageConfig | — | Required. The WebAssembly build plus optional separate ELFs and overlay archives. See image. |
ramMB | number | auto | Size 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. |
warmup | boolean | true | Run true once after boot so the first real run doesn't pay first-call costs. |
scripting | { wasm } | none | URL 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
| Option | Type | Default | Description |
|---|---|---|---|
image.wasm | string | ArrayBuffer | Uint8Array | — | The emulator build. console.wasm embeds BusyBox; slim nano.wasm embeds nothing. |
image.busybox | string | ArrayBuffer | Uint8Array | — | A BusyBox ELF for builds that don't embed one. Bytes need a browser (they become object URLs). |
image.node | string | ArrayBuffer | Uint8Array | — | A Node.js RISC-V ELF used by node() on the VM runner. |
image.overlays | Array<string | ArrayBuffer | Uint8Array> | [] | .tar.gz archives unpacked into the filesystem at boot, in order. |
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.
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();booted from 3229271 bytes: riscv64
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.
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();busybox ELF: 1149392 bytes slim build + separate busybox 67
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.
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();riscv64 v25.4.0
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.
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();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().
| Option | Type | Default | Description |
|---|---|---|---|
baseUrl | string | — | Directory holding nano.wasm (a trailing slash is added). |
withBusybox | boolean | true | Add images/busybox as image.busybox. |
withNode | boolean | false | Add images/node as image.node. |
withDevenv | boolean | false | Add build/devenv.tar.gz as an overlay. |
What nanoImage() returns
print(JSON.stringify(sdk.nanoImage({ baseUrl: "/nano" })));
print(JSON.stringify(sdk.nanoImage({ baseUrl: "https://cdn.example/nano/", withBusybox: false, withNode: true, withDevenv: true })));{"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.
Guest memory follows ramMB
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);
}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.
Boot with and without warmup
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();
}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.
Scripting on and off
This page's VM was booted without scripting, so nano.script() throws. A VM booted with it evaluates the script.
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();page VM: nano-sdk: scripting is not configured. Pass `scripting: { wasm }` to createNano().
with scripting: 2,4,6crossOriginIsolation
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.
crossOriginIsolation: "ignore"
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();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.
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.
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();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.