userland.rundocs70Try the demo
SDK

Serve mode

Run an HTTP server inside the VM and show it in a real iframe. The service worker catches the iframe's requests, ServeBridge injects them into the VM's socket layer, and parseHttpResponse hands the reply back.

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

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, "/"); // "/sw/8080/"
// later: stop(); bridge.detach();

ServeBridge

OptionTypeDefaultDescription
injectorConnectionInjector—Anything with injectConnection(port, request): nano.virtualServer (or nano), a worker client, or your own.
swUrlstring—URL of the shipped nano-sw.js.
scopestringthe worker's directoryService-worker scope; preview URLs are <scope>sw/<port>/<path>.
Member
What it does
ServeBridge.register(opts)
registers the service worker, starts listening for its requests and announces this page as the VM host
bridge.previewUrl(port, path = "/")
the iframe URL for a port and path
bridge.detach()
stops answering (the service worker stays registered)
bridge.unregister()
detaches and unregisters the service worker
SDK · JavaScript

A server, a bridge and an iframe

BusyBox httpd in the VM, a ServeBridge for this page and a hidden iframe on previewUrl(). The iframe's request goes through the service worker into the VM and back.

javascript
nano.fs.writeFile("/www/index.html", "<h1>hello through the bridge</h1>");
const bridge = await sdk.ServeBridge.register({ injector: nano.virtualServer, swUrl: "/nano-sw.js" });
const srv = await sdk.startServer(nano, { command: "echo listening on 8081; exec busybox httpd -f -p 8081 -h /www" });
const url = bridge.previewUrl(8081, "/");
const frame = Object.assign(document.createElement("iframe"), { hidden: true, src: url });
const loaded = new Promise((r) => (frame.onload = r));
document.body.append(frame);
await loaded;
print(url, "→", frame.contentDocument.body.textContent);
frame.remove();
srv.stop();
bridge.detach();
print(bridge.previewUrl(3000, "api/items?page=2"));
loading…
Expected output
/sw/8081/ → hello through the bridge
/sw/3000/api/items?page=2
Caution · BusyBox httpd inside the VM answers only one connection (SDK 0.2.10), so it's fine for a single request (as above) but not for a page with assets. Use a Node.js server for real previews.

Node.js servers

The samples below run a Node http server until you press Stop and render it in an iframe. On this site they run on the host engine. Since SDK 0.2.8 nano.injectConnection routes by the kernel port table, so the same bridge reaches a host-engine server (over a kernel loopback connection) and a server inside the RISC-V VM. nano.listeners() lists the table.

SDK · JavaScript

listeners() and a host-engine server

A Node server started with nano.node() on the host engine shows up in the port table and answers injectConnection. Stop it by killing its process; here it stops after the first request.

javascript
nano.fs.writeFile("/work/srv.js", "const s = require('http').createServer((q, r) => { r.end('host-engine server'); s.close(); }).listen(9100, () => console.log('listening'))");
const done = nano.node(["/work/srv.js"]);
while (!nano.listeners().some((l) => l.port === 9100)) await new Promise((r) => setTimeout(r, 50));
print(JSON.stringify(nano.listeners().filter((l) => l.port === 9100).map(({ port, kind }) => ({ port, kind }))));
const res = sdk.parseHttpResponse(await nano.injectConnection(9100, "GET / HTTP/1.1\r\nHost: x\r\n\r\n"));
print(res.status, new TextDecoder().decode(res.body));
print("server exit", (await done).exitCode);
loading…
Expected output
[{"port":9100,"kind":"node"}]
200 host-engine server
server exit 0
dev server

Serve a Node.js app

javascript
const http = require("http");
http
  .createServer((req, res) => {
    res.setHeader("content-type", "text/html");
    res.end(`<h1>Served from NanoVM</h1><p>${req.method} ${req.url}</p>`);
  })
  .listen(8080, () => console.log("listening on 8080"));
loading…
Expected output
Served from NanoVM
GET /
dev server

Sub-resources, routes and a second port

The page loads app.js and api/info from the same server through the service worker; the script is synchronous, so its text is there when the iframe finishes loading.

javascript
const http = require("http");
const routes = {
  "/api/info": ["application/json", () => JSON.stringify({ runner: "node", version: process.version })],
  "/app.js": ["text/javascript", () => `document.getElementById("msg").textContent = "app.js ran inside the preview";`],
  "/": ["text/html", () => `<h1>Routes demo</h1><p id="msg">…</p><script src="app.js"></script><p><a href="api/info">api/info</a></p>`],
};
http
  .createServer((req, res) => {
    const [type, body] = routes[new URL(req.url, "http://x").pathname] ?? routes["/"];
    res.setHeader("content-type", type);
    res.end(body());
  })
  .listen(3000, () => console.log("listening on 3000"));
loading…
Expected output
Routes demo
app.js ran inside the preview
api/info

startServer

A listening server never exits, so its run never resolves. startServer(nano, launch, opts) starts it and resolves with { stop } once the output matches readyPattern. It rejects if the process exits before that. While it runs, the server holds the VM.

OptionTypeDefaultDescription
launch{ node: string[] } | { command: string }—node args (run on the engine resolveNodeEngine picks) or a shell line.
readyPatternRegExp/listening/iOutput that means "ready".
onReady() => void—Called once when ready.
onData(chunk) => void—The server's output.
SDK · JavaScript

Ready detection and early exit

javascript
const srv = await sdk.startServer(
  nano,
  { command: "echo booting; echo READY on 8083; exec busybox httpd -f -p 8083 -h /www" },
  { readyPattern: /READY on \d+/, onReady: () => print("onReady fired"), onData: (c) => print("[server]", c.trim()) },
);
srv.stop();
await new Promise((r) => setTimeout(r, 300)); // stop() cancels; give the run a moment to settle
try {
  await sdk.startServer(nano, { command: "echo oops; exit 3" });
} catch (e) {
  print(e.message.trim());
}
loading…
Expected output
[server] booting
READY on 8083
onReady fired
nano-sdk: server exited before ready (exit 3): oops

A server holds the VM

Until stop() (which calls nano.cancel()) and the cancelled run settles a moment later, every other VM operation on that instance rejects immediately with "the VM is busy serving" instead of queueing forever. Host-engine Node servers don't hold the VM. For concurrent work, use a second instance or a worker.

SDK · JavaScript

Direct injection and the busy VM

injectConnection(port, rawRequest) sends one HTTP/1.1 request straight into the VM, no service worker involved.

javascript
nano.fs.writeFile("/www/index.html", "<h1>raw inject</h1>");
const srv = await sdk.startServer(nano, { command: "echo listening on 8082; exec busybox httpd -f -p 8082 -h /www" });
const raw = await nano.injectConnection(8082, "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
const res = sdk.parseHttpResponse(raw);
print(res.status, res.statusText, res.headers["content-type"], new TextDecoder().decode(res.body));
const busy = await nano.run("echo hi").then(() => "ran", (e) => e.message.slice(0, 32));
print("while serving:", busy);
srv.stop();
await new Promise((r) => setTimeout(r, 300));
print((await nano.run("echo VM free again")).stdout.trim());
loading…
Expected output
200 OK text/html <h1>raw inject</h1>
while serving: nano-sdk: the VM is busy serving
VM free again

parseHttpResponse

parseHttpResponse(bytes) splits a raw HTTP/1.1 response into { status, statusText, headers, body }. Header names are lower-cased, chunked bodies are decoded and body is an ArrayBuffer.

SDK · JavaScript

Parse a chunked response

javascript
const raw = new TextEncoder().encode(
  "HTTP/1.1 201 Created\r\nContent-Type: text/plain\r\nTransfer-Encoding: chunked\r\nX-Demo: yes\r\n\r\n5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n",
);
const r = sdk.parseHttpResponse(raw);
print(r.status, r.statusText, JSON.stringify(r.headers));
print(JSON.stringify(new TextDecoder().decode(r.body)));
loading…
Expected output
201 Created {"content-type":"text/plain","transfer-encoding":"chunked","x-demo":"yes"}
"hello world"

The service worker

ServeBridge.register() registers nano-sw.js itself. The same worker provides cross-origin isolation; see Requirements for registerNanoServiceWorker and ensureCrossOriginIsolated, and Virtual server for the request path inside the VM.

More: dev servers feature, the json-api example and react-studio.