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.
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
| Option | Type | Default | Description |
|---|---|---|---|
injector | ConnectionInjector | — | Anything with injectConnection(port, request): nano.virtualServer (or nano), a worker client, or your own. |
swUrl | string | — | URL of the shipped nano-sw.js. |
scope | string | the worker's directory | Service-worker scope; preview URLs are <scope>sw/<port>/<path>. |
ServeBridge.register(opts)bridge.previewUrl(port, path = "/")bridge.detach()bridge.unregister()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.
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"));/sw/8081/ → hello through the bridge /sw/3000/api/items?page=2
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.
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.
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);[{"port":9100,"kind":"node"}]
200 host-engine server
server exit 0Serve a Node.js app
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"));Served from NanoVM GET /
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.
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"));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.
| Option | Type | Default | Description |
|---|---|---|---|
launch | { node: string[] } | { command: string } | — | node args (run on the engine resolveNodeEngine picks) or a shell line. |
readyPattern | RegExp | /listening/i | Output that means "ready". |
onReady | () => void | — | Called once when ready. |
onData | (chunk) => void | — | The server's output. |
Ready detection and early exit
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());
}[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.
Direct injection and the busy VM
injectConnection(port, rawRequest) sends one HTTP/1.1 request straight into the VM, no service worker involved.
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());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.
Parse a chunked response
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)));
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.