Node.js
Run Node.js v25.4.0 programs in the page: on the browser's JS engine for speed, or as the real RISC-V binary for fidelity, with NodeRuntime snapshots to skip V8's boot on repeated runs.
Which runner executes nano.node() is set by engines. This page boots with engines: { node: "host" }, so the plain samples run on the host engine and start in milliseconds.
Node on the host engine
Built-in modules
events, Buffer, crypto hashes, timers and microtasks behave as in Node. Upstream events, path, assert, querystring, punycode and string_decoder run verbatim.
const { EventEmitter } = require("events");
const crypto = require("crypto");
const bus = new EventEmitter();
bus.on("ready", (v) => console.log("event", v));
bus.emit("ready", 42);
console.log(Buffer.from("nano").toString("base64"));
console.log(crypto.createHash("sha256").update("nano").digest("hex").slice(0, 12));
setTimeout(() => console.log("timer fired"), 10);
Promise.resolve().then(() => console.log("microtask"));event 42 bmFubw== f7a5936c485e microtask timer fired
fs on the shared filesystem
The program's fs is the VM's filesystem, so BusyBox and other runners see what it writes.
const fs = require("fs");
fs.mkdirSync("/work/out", { recursive: true });
fs.writeFileSync("/work/out/report.json", JSON.stringify({ ok: true, at: "/work/out" }));
console.log(fs.readdirSync("/work/out"), JSON.parse(fs.readFileSync("/work/out/report.json", "utf8")).ok);[ 'report.json' ] true
Server samples (http.createServer) are on Serve mode. What the host engine does not implement yet (zlib, child_process without a VM delegate, vm contexts, http2, ESM entry files, node:sqlite in the browser) is listed under Limitations.
Node on the RISC-V runner
With engines.node: "vm" (the default) node() runs the upstream Node.js ELF on the emulator. Install it with installApp("node") and give the VM ramMB: 1800. The sample is on Runners & engines.
NodeRuntime snapshots
On the RISC-V runner most of a short Node run is V8 booting. nano.nodeRuntime() boots V8 once, snapshots the VM at a sentinel, and restores that snapshot for every run, so each run is isolated (it starts from the same state) and skips the boot.
| Option | Type | Default | Description |
|---|---|---|---|
warmup(opts?) | Promise<void> | maxSteps 2e9 | Boot Node and capture the snapshot. Concurrent callers share one warmup. |
run(source, opts?) | Promise<ExecResult> | — | Restore the snapshot and evaluate source (as the body of a Function, so use process.mainModule.require instead of require). Warms up first if needed. |
run opts.extraFiles | Array<{ path, content }> | — | Files written into the restored VM before the run (per-run inputs). |
run opts.onData / maxSteps | | 2e9 | As in ExecOptions. |
reset() | void | — | Drop the snapshot; the next run warms up again (from the current files). |
isWarm | boolean | — | Whether a snapshot is held. |
warmup, run, extraFiles, reset
Boots a slim VM with BusyBox and Node.js from the catalog (about 63 MB the first time), warms a snapshot, then runs three scripts against it. The second script sets a global; the third doesn't see it, because each run restores the same snapshot. 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 });
await vm.installApp("busybox");
await vm.installApp("node");
const rt = vm.nodeRuntime();
let t0 = performance.now();
await rt.warmup();
print("warm:", rt.isWarm, "after", Math.round(performance.now() - t0), "ms");
t0 = performance.now();
const r = await rt.run("console.log(process.mainModule.require('fs').readFileSync('/tmp/in.json', 'utf8'))", {
extraFiles: [{ path: "/tmp/in.json", content: '{"value":42}' }],
});
print(r.stdout.trim(), "in", Math.round(performance.now() - t0), "ms");
await rt.run("globalThis.leak = 1");
print("isolated:", (await rt.run("console.log(typeof globalThis.leak)")).stdout.trim());
rt.reset();
print("after reset:", rt.isWarm);
vm.destroy();warm: true after 1489 ms
{"value":42} in 579 ms
isolated: undefined
after reset: falsestdout, not exitCode, after a warm run: Node's shutdown can exit 134 after printing correct output. The catalog's node recipe marks 134 as benign for provision().console.wasm, which embeds only BusyBox, SDK 0.2.10 snapshots the embedded BusyBox instead and warmup() fails with "App did not reach the snapshot sentinel within budget". Use the slim nano.wasm with installApp("node") or image.node, as above.Where Node runs, compared
process.archx64riscv64riscv64ramMB ≈ 1800ramMB ≈ 1800For complete Node apps (npm tools, dev servers), see Node.js in the browser and the hello-node example.