userland.run70Try the demo
Feature · serve + preview

Run a dev server in the browser and preview it in an iframe

A web app is only half a demo until it is served. With NanoVM the server runs inside the visitor's tab: a plain Node.js http server listens on a virtual port, and a service worker turns iframe requests for /sw/<port>/… into connections to that port. Nothing is proxied to a backend and no tunnel is opened.

Serve something now

Each sample below is an ordinary Node program. Run starts it, waits until its port is listening, and loads the preview iframe; it keeps running until you press Stop. These samples run Node on the browser's own JavaScript engine (the fast host-engine tier), so they start in about a second.

dev server

Hello from an in-tab HTTP server

A require("http") server on port 8080. The iframe below the output is served by it.

javascript
const http = require("http");

http
  .createServer((req, res) => {
    res.setHeader("content-type", "text/html; charset=utf-8");
    res.end(`<h1>Served from your tab</h1>
<p>method: ${req.method} · path: ${req.url}</p>`);
  })
  .listen(8080, () => console.log("listening on :8080"));
loading…
Expected output
Served from your tab
dev server

A static file server over the VFS

The server reads files that were written into /work/public — the same in-memory filesystem the shell and the other runners see.

javascript
const http = require("http");
const fs = require("fs");
const path = require("path");

const root = "/work/public";
const types = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript" };

http
  .createServer((req, res) => {
    const file = path.join(root, req.url === "/" ? "index.html" : req.url);
    fs.readFile(file, (err, body) => {
      if (err) return res.writeHead(404).end("not found");
      res.writeHead(200, { "content-type": types[path.extname(file)] || "application/octet-stream" });
      res.end(body);
    });
  })
  .listen(8080, () => console.log("serving", root));
loading…
Expected output
Static site, zero servers
dev server

Any port, server-side rendering

Ports are virtual, so 3000 is as good as 8080. This server renders a small table from data it computes on each request.

javascript
const http = require("http");

const todos = [
  { title: "write the server", done: true },
  { title: "preview it", done: true },
  { title: "deploy it", done: false },
];

http
  .createServer((req, res) => {
    const rows = todos.map((t) => `<li>${t.done ? "✓" : "·"} ${t.title}</li>`).join("");
    const left = todos.filter((t) => !t.done).length;
    res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
    res.end(`<h2>Todo (${left} left)</h2><ul>${rows}</ul><small>port 3000 · node ${process.version}</small>`);
  })
  .listen(3000);
loading…
Expected output
Todo (1 left)

How the preview works

Browsers can't open a listening socket, so the preview is built from three pieces that already exist in every modern browser:

  • A virtual port table. When the guest program calls listen(8080), the kernel records the port. Nothing is bound on the real machine.
  • A service worker. The SDK ships nano-sw.js. It intercepts requests under its scope's sw/<port>/ prefix and forwards each one, as raw HTTP bytes, to the page that hosts the VM.
  • ServeBridge. On the page, the bridge receives the request, injects it as a new connection on that port (injectConnection), reads the HTTP response the server writes back, and hands it to the service worker, which answers the iframe.
text
iframe ── GET /sw/8080/style.css ──▶ service worker (nano-sw.js)
                                         │ postMessage(raw HTTP)
                                         ▼
                                   page: ServeBridge
                                         │ injectConnection(8080, bytes)
                                         ▼
                              server in the VM / host engine
                                         │ HTTP response bytes
iframe ◀──────── Response ───────── service worker ◀──┘

Because the iframe URL is same-origin, relative links, stylesheets, scripts and fetch() calls from the previewed app all route back to the in-tab server. The lower-level details of the in-VM socket injection are in virtual server.

The SDK calls

javascript
import { createNano, ServeBridge, startServer } from "@userland-run/nano-sdk";

const nano = await createNano({ image: { wasm: "/nano/nano.wasm" }, ramMB: 1800 });
await nano.installApp("node");
nano.fs.writeFile("/srv/server.js", serverSource);

const bridge = await ServeBridge.register({ injector: nano.virtualServer, swUrl: "/nano-sw.js" });
const { stop } = await startServer(nano, { node: ["/srv/server.js"] }, { readyPattern: /listening/i });

iframe.src = bridge.previewUrl(8080, "/"); // → "<scope>sw/8080/"
// … later
stop();
bridge.detach();
OptionTypeDefaultDescription
ServeBridge.register({ injector, swUrl, scope? })Promise<ServeBridge>—Registers the service worker and starts answering its requests. scope defaults to the worker's directory.
bridge.previewUrl(port, path?)string"/"The iframe URL for a port: <scope>sw/<port><path>.
bridge.detach() / unregister()void / Promise<void>—Stop answering (keep or remove the registration).
startServer(nano, launch, opts?)Promise<{ stop }>—Launch { node: [...] } or { command: "sh line" } and resolve when output matches readyPattern (default /listening/i).
createHeadless().serve(launch, { port })Promise<{ url(path), stop }>—The same, wired up for you by the headless session.

A server holds the VM

A NanoVM runs one thing at a time, and a listening server never "finishes". While a server launched with startServer runs on the VM, other VM calls on the same instance reject immediately with VM is busy serving instead of queueing forever. Call stop() first, or create a second Nano for concurrent work. This page applies the same rule: while one sample's server is running, other samples ask you to stop it first.

Servers started on the host-engine tier (Node on the browser's JS engine, as in the samples above) don't occupy the RISC-V VM. In SDK 0.2.x the built-in nano.virtualServer injector reaches servers inside the VM; this site's samples add a small injector for host-engine listeners on top of it.

Current limitations

  • BusyBox httpd inside the VM answers only one connection, so use a Node server for previews.
  • A preview needs a service worker, which needs a secure context (HTTPS or localhost) and a same-origin nano-sw.js.
  • Each request is injected as a fresh connection; long-lived streaming responses and WebSockets are not part of the bridge.
  • Outbound network calls from the server go through the host's fetch and are subject to CORS — see networking.

FAQ

Does the preview URL work in another tab or on another device?

No. The server lives in the tab that runs the VM; the service worker routes /sw/<port>/ requests to that page. Close the tab and the server is gone.

Can I run Express, Vite or Next.js?

Anything that ends up in Node's http server listening on a port can be previewed. Larger frameworks need their dependencies installed in the VM first, and run noticeably slower under emulation than natively — see the examples.

Why does my iframe show 502 Bad Gateway?

The bridge answers 502 when it can't reach a listener on that port — usually because the server hasn't started yet, crashed, or listens on a different port than the preview URL.