Run Node.js
in the browser.
NanoVM gives you two Node engines over one kernel: nodert, Node's APIs on the browser's own JS engine (fast), and the unmodified Node.js v25.4.0 binary on an emulated RISC-V CPU (faithful). Both run client-side — no server, no container, no install. Every demo below runs in this tab when you press Run.
Node.js at JIT speed, on the browser's engine.
nano.node() with engines: { node: "host" } runs your program on nodert, the node runner: Node's module system and core APIs implemented over the browser's JS engine, reading and writing NanoVM's shared filesystem. It reports process.arch as x64; the full list of differences is in the limitations. hello-node example →
Count primes with Node.js
Edit the program and run it again — it executes in this tab with nano.node() on the host JS engine.
// Pure Node.js — no dependencies. The same file runs on either Node engine: // • the host JS engine (nodert) — fast; reports process.arch "x64" // • the RISC-V VM (the real riscv64 Node ELF) — slow; reports "riscv64" const t0 = Date.now(); let primes = 0; for (let n = 2; n < 60000; n++) { let isPrime = true; for (let d = 2; d * d <= n; d++) { if (n % d === 0) { isPrime = false; break; } } if (isPrime) primes++; } console.log("node :", process.version); console.log("platform :", process.platform, process.arch); console.log("primes :", primes, "below 60000"); console.log("took :", Date.now() - t0, "ms");
node : v25.4.0 platform : linux x64 primes : 6057 below 60000 took : 4 ms
The real Node.js binary, emulated.
The riscv runner executes the upstream riscv64 Node.js ELF instruction by instruction — the same executable you would run on a RISC-V Linux server, so its behaviour is upstream Node's. It is much slower than the host engine and needs about 1.8 GB of guest RAM for V8; use it when fidelity matters more than speed.
The real Node.js binary on the RISC-V VM
Installs the riscv64 node ELF from the signed catalog (a large download on first use, then cached in OPFS) and runs this program on the emulated CPU. The second line shows a known divergence of the emulated floating-point pow: the host engine prints 1024.
console.log("node", process.version, process.platform, process.arch);
console.log("2 ** 10 =", 2 ** 10);node v25.4.0 linux riscv64 2 ** 10 = 1024.0000000000002
Packages from a lockfile, resolved by require().
Dependencies are installed ahead of time with npm ci from the example's package-lock.json; the page writes the resulting node_modules/ into the VFS and the real Node binary on the RISC-V VM resolves them with require(). (This one needs the VM: the host-engine tier cannot load commander 12 yet — see the example for why.) cli-toolkit example →
Install the bundle and run the CLI on the VM
An async function body with nano (the page's booted Nano, 1.8 GB guest) and print() in scope.
const dir = "/work/cli-toolkit"; // 1 · install: the node_modules/ that `npm ci` produced from package-lock.json const bundle = await (await fetch("/demo-files/cli-toolkit/bundle.json")).json(); for (const f of bundle) nano.fs.writeFile(`${dir}/${f.path}`, f.content); nano.fs.writeFile(`${dir}/cli.js`, await (await fetch("/demo-files/cli-toolkit/cli.js")).text()); print(`wrote ${bundle.length} files into ${dir}/node_modules`); // 2 · the real Node.js binary (riscv64) from the signed catalog const node = await nano.installApp("node"); print(`installed ${node.name} ${node.version}`); // 3 · run it on the RISC-V VM const r = await nano.shExec(`cd ${dir} && node cli.js`, { maxSteps: 1e12 }); print(r.stdout);
wrote 42 files into /work/cli-toolkit/node_modules installed node 25.4.0 nano cli-toolkit · npm packages, running in NanoVM PACKAGE VERSION ROLE ──────────────────────────────────────── commander 12.1.0 args chalk 4.1.2 color ansi-styles 4.3.0 color supports-color 7.2.0 tty color-convert 2.0.1 color ✓ 5 packages resolved by require() from node_modules
An http server, previewed in an iframe.
A Node http server listens inside NanoVM; the SDK's service worker routes the preview iframe's requests to it. The same mechanism serves Vite-built React apps — see react-studio and flow-studio. json-api example →
Start the server and open the dashboard
The preview below is served by this Node program, running in your tab.
// A Node.js HTTP server — no dependencies — exposing a JSON API plus a // dashboard that polls it. Runs inside NanoVM (on the host-engine Node tier or // the RISC-V VM); surfaced to the preview iframe through the service-worker bridge. const http = require("http"); const start = Date.now(); let requests = 0; // Requests per second over the last 16 seconds (real counts, drawn as the chart). const perSecond = new Map(); function series() { const now = Math.floor(Date.now() / 1000); return Array.from({ length: 16 }, (_, i) => perSecond.get(now - 15 + i) || 0); } function stats() { return { runtime: "Node.js " + process.version, engine: process.arch === "riscv64" ? "RISC-V VM (riscv64 Node ELF)" : "host JS engine", platform: process.platform + " " + process.arch, uptimeMs: Date.now() - start, requests: requests, series: series(), }; } const PAGE = [ "<!doctype html><html><head><meta charset='utf-8'>", "<meta name='viewport' content='width=device-width, initial-scale=1'>", "<title>nano - json-api</title><style>", "*{box-sizing:border-box}body{margin:0;font-family:ui-monospace,monospace;background:#0e0e10;color:#cfcfda}", ".wrap{max-width:620px;margin:0 auto;padding:28px 20px}", "h1{color:#5fd6d1;font-size:18px;margin:0 0 4px;letter-spacing:.02em}", ".sub{color:#8c8c97;font-size:12px;margin-bottom:18px}", ".grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:16px}", ".card{background:#16161c;border:1px solid #2a2a31;border-radius:10px;padding:12px 14px}", ".k{color:#8c8c97;font-size:10px;text-transform:uppercase;letter-spacing:.12em}", ".v{color:#e8e6f0;font-size:15px;margin-top:4px}", ".v.hi{color:#a984f5}", "canvas{width:100%;height:120px;background:#16161c;border:1px solid #2a2a31;border-radius:10px}", ".tag{display:inline-block;margin-top:14px;font-size:11px;color:#e6b45a;border:1px solid #4a3b1e;border-radius:999px;padding:3px 10px}", "</style></head><body><div class='wrap'>", "<h1>JSON API + dashboard</h1>", "<div class='sub'>Served by a Node.js http server running inside NanoVM, polled live over fetch().</div>", "<div class='grid' id='cards'></div>", "<canvas id='chart' width='580' height='120'></canvas>", "<div class='tag'>node http server · no backend</div>", "</div><script>", "function card(k,v,hi){return \"<div class='card'><div class='k'>\"+k+\"</div><div class='v\"+(hi?' hi':'')+\"'>\"+v+\"</div></div>\"}", "function draw(s){var c=document.getElementById('chart'),x=c.getContext('2d'),w=c.width,h=c.height;x.clearRect(0,0,w,h);", "var max=Math.max.apply(null,s.concat([1])),bw=w/s.length;x.fillStyle='#a984f5';", "for(var i=0;i<s.length;i++){var bh=(s[i]/max)*(h-16);x.globalAlpha=.55+.45*(i/s.length);x.fillRect(i*bw+3,h-bh,bw-6,bh)}x.globalAlpha=1}", "async function tick(){try{var d=await (await fetch('api/stats')).json();", "document.getElementById('cards').innerHTML=card('runtime',d.runtime,1)+card('engine',d.engine)+card('platform',d.platform)+card('requests served',d.requests,1);", "draw(d.series)}catch(e){}}", "setInterval(tick,1000);tick();", "</script></body></html>", ].join(""); const server = http.createServer(function (req, res) { requests++; const sec = Math.floor(Date.now() / 1000); perSecond.set(sec, (perSecond.get(sec) || 0) + 1); perSecond.delete(sec - 60); if (req.url.indexOf("/api/stats") !== -1) { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(stats())); return; } res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(PAGE); }); server.listen(3000, function () { console.log("api listening on 3000"); });
JSON API + dashboard
The wasm runner: image processing with photon.
photon, a Rust image CLI compiled to wasm32-wasip1, runs on the wasm runner — the browser's WebAssembly engine, capability-scoped by WASI — not on the emulated CPU. It reads and writes the same kernel filesystem as Node. photon example →
Apply a sepia filter
Each line runs on the wasm runner with /work as the working directory. Try --filter grayscale, invert, blur, brighten or threshold.
photon in.jpg out.png --filter sepia
$ photon in.jpg out.png --filter sepia photon: sepia 360x240 -> out.png (360x240 PNG)
The Boa runner: scripts with only the powers you grant.
nano.script() evaluates JavaScript in the Boa interpreter (Rust → wasm). The script can touch nothing that expose does not grant — here file access and run, which reaches BusyBox on the RISC-V VM. boa-sandbox example →
Run the sandboxed script
Evaluated by Boa with fs + run granted. Try calling fetch(...) — it is not defined.
// Runs in the Boa sandbox (nano.script). This engine was granted the `fs` and // `run` capabilities plus console — nothing else: no network, no DOM, no host // JavaScript objects. The value of the last expression (awaited if it is a // promise) comes back to the page as JSON. (async () => { const dir = "/work/project"; const files = nano.fs.list(dir).map((f) => f.name).sort(); console.log("files:", files.join(", ")); let total = 0; for (const name of files) { const lines = nano.fs.readText(dir + "/" + name).split("\n").filter(Boolean).length; total += lines; console.log(name.padEnd(12), lines, "lines"); } console.log("total:", total, "lines"); // `run` is a granted capability: a BusyBox applet on the RISC-V VM. const wc = await nano.run("wc -l " + dir + "/readme.md"); console.log("busybox wc -l:", wc.stdout.trim()); // Not granted: the global simply doesn't exist inside the sandbox. console.log("typeof fetch:", typeof fetch); return { files: files.length, total }; })();
files: app.js, readme.md, util.js
app.js 2 lines
readme.md 3 lines
util.js 2 lines
total: 7 lines
busybox wc -l: 3 /work/project/readme.md
typeof fetch: undefined
→ {
"files": 3,
"total": 7
}Cross-tier: Node writes, BusyBox and Boa read.
Runners never call each other directly; they share the kernel's filesystem. Node on the host engine writes report.json, BusyBox cat on the RISC-V VM reads it, and a read-only Boa sandbox reads it again. cross-tier example →
Run the handoff
An async function body with nano and print() in scope. Every step reads the file the previous one wrote.
// Cross-tier handoff over one shared filesystem. This is an SDK snippet: an // async function body with `nano` (a booted Nano, engines.node = "host", // scripting enabled) and `print` in scope. const dir = "/work/handoff"; // 1 · Node.js on the host JS engine (nodert) writes a file… nano.fs.writeFile( `${dir}/producer.js`, `const fs = require("fs"); fs.writeFileSync("${dir}/report.json", JSON.stringify({ from: "node " + process.version + " " + process.arch })); console.log("wrote report.json");`, ); const a = await nano.node([`${dir}/producer.js`]); print("[node · host engine]", a.stdout.trim()); // 2 · …BusyBox on the RISC-V VM reads the same file… const b = await nano.run(`cat ${dir}/report.json`); print("[busybox · riscv VM ]", b.stdout.trim()); const m = await nano.run("uname -m"); print("[busybox · riscv VM ] uname -m:", m.stdout.trim()); // 3 · …and a sandboxed Boa script reads it too (read-only fs, nothing else). const from = await nano.script(`JSON.parse(nano.fs.readText("${dir}/report.json")).from`, { expose: { fs: "readonly" }, }); print("[boa · sandbox ]", from);
[node · host engine] wrote report.json
[busybox · riscv VM ] {"from":"node v25.4.0 x64"}
[busybox · riscv VM ] uname -m: riscv64
[boa · sandbox ] node v25.4.0 x64Host engine or RISC-V VM?
engines: { node: "auto" } tries the host engine and falls back to the VM when a program needs something nodert cannot provide; routing pins individual programs to a tier. See the engines reference.Dev tools from the signed catalog.
Both engines, a few lines each.
import { createNano, ensureCrossOriginIsolated } from "@userland-run/nano-sdk";
await ensureCrossOriginIsolated({ swUrl: "/nano-sw.js" });
// Fast: Node on the browser's own JS engine (nodert)
const nano = await createNano({
image: { wasm: "/nano/console.wasm" }, // RISC-V VM + BusyBox
ramMB: 512,
engines: { node: "host" },
});
nano.fs.writeFile("/app/main.js", 'console.log("hello from", process.version, process.arch)');
const r = await nano.node(["/app/main.js"], { cwd: "/app" });
console.log(r.stdout); // hello from v25.4.0 x64
// Faithful: the real riscv64 Node.js ELF on the RISC-V VM (the default engine)
const vm = await createNano({ image: { wasm: "/nano/console.wasm" } }); // ramMB auto-sized for V8
await vm.installApp("node"); // signed catalog, verified + OPFS-cached
const v = await vm.node(["-e", "console.log(process.arch)"]);
console.log(v.stdout); // riscv64How it differs.
StackBlitz WebContainers also run Node.js client-side, on the browser's JS engine. NanoVM's host-engine tier is the comparable path; what NanoVM adds is the RISC-V VM, which runs the unmodified Node.js binary and other Linux ELF programs, plus WASI apps and a capability-scoped JS sandbox over the same filesystem. NanoVM is open source (AGPL-3.0-only, or a commercial licence; the SDK is MPL-2.0). Both require a cross-origin-isolated page. The detailed, sourced comparison is on NanoVM vs WebContainers.
fetch() (HTTP/HTTPS, subject to CORS; a self-hostable CORS proxy ships with the catalog). And the page must be cross-origin isolated (COOP/COEP) — the SDK's service worker can arrange that for you.Put Node.js in your app.
@userland-run/nano-sdk is a typed, ESM, dependency-free package. Install it, serve the wasm images and the service worker, and run Node in your users' browsers.