userland.run70Try the demo
Node.js · npm packages · http servers — in a tab

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 v25.4.0·2 Node engines·100% client-side·open source
Engine 1 · host JS engine

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 →

Node.js

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.

javascript
// 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");
loading…
Expected output
node     : v25.4.0
platform : linux x64
primes   : 6057 below 60000
took     : 4 ms
Engine 2 · RISC-V VM

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.

Node.js

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.

javascript
console.log("node", process.version, process.platform, process.arch);
console.log("2 ** 10 =", 2 ** 10);
loading…
Expected output
node v25.4.0 linux riscv64
2 ** 10 = 1024.0000000000002
npm packages

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 →

SDK · JavaScript

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.

javascript
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);
loading…
Expected output
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
Servers + preview

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 →

dev server

Start the server and open the dashboard

The preview below is served by this Node program, running in your tab.

javascript
// 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 &middot; 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");
});
loading…
Expected output
JSON API + dashboard
A different runner

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 →

wasm app

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.

bash
photon in.jpg out.png --filter sepia
loading…
Expected output
$ photon in.jpg out.png --filter sepia
photon: sepia 360x240 -> out.png (360x240 PNG)
Sandboxed JavaScript

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 →

Boa sandbox

Run the sandboxed script

Evaluated by Boa with fs + run granted. Try calling fetch(...) — it is not defined.

javascript
// 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 };
})();
loading…
Expected output
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
}
One kernel, one filesystem

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 →

SDK · JavaScript

Run the handoff

An async function body with nano and print() in scope. Every step reads the file the previous one wrote.

javascript
// 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);
loading…
Expected output
[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 x64
Choosing an engine

Host engine or RISC-V VM?

host engine (nodert)
RISC-V VM
What runs
nodert: Node's APIs implemented over the browser's JS engine
the unmodified riscv64 Node.js v25.4.0 ELF, emulated
Speed
JIT speed of the host engine
emulated instruction by instruction — much slower
process.arch
x64 (for package compatibility)
riscv64
Fidelity
documented divergences (lean http, crypto subset, …)
byte-for-byte upstream Node — the reference
Native addons
no
yes (riscv64 builds)
Guest RAM
no V8 guest needed
~1.8 GB for V8
Select with
engines: { node: "host" }
engines: { node: "vm" } (the SDK default)
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.
In code

Both engines, a few lines each.

javascript
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); // riscv64
userland.run vs WebContainers

How 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.

The honest trade-offs
The RISC-V VM emulates a CPU, so Node there is far slower than native — fine for tooling, tests and demos, not for heavy compute. The host engine is fast but is a reimplementation with documented divergences. Outbound networking goes through the host's 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.