Previews
When a program in the VM listens on a port, for example a small web server on port 3000, vm.exposePort gives you a URL for it. Put that URL in an iframe or open it in a new tab, and the guest server answers. The requests never leave the browser: a Service Worker on a separate preview origin hands them to the server inside the VM.
With the hosted runtime frame, previews work without setup: the SDK serves them from https://<session>.oxvm.dev. With a self-hosted engine, or to use a preview origin of your own, they need two things: a preview option when you create the VM, and a host that serves the SDK’s preview files on that origin. Previews work in both modes.
Try it
Section titled “Try it”This demo writes the Python web server on the left into a Linux VM in this tab, starts it and shows the page it answers with on the right. The requests go from the preview to the server inside your browser. Change the code, then apply your changes to restart the server.
The VM runs a small image of its own, Alpine Linux with Python 3, built with the image builder. It downloads only the files Python reads.
It works like the example below: vm.workspace.writeText puts the file in the workspace, vm.spawn starts python3 server.py, and vm.exposePort(3000) returns the address the preview opens.
Configure the preview origin
Section titled “Configure the preview origin”To use your own preview origin, pass preview to createVM:
| Option | Meaning |
|---|---|
origin |
A bare origin with no path or query: HTTPS, or HTTP on localhost, a name ending in .localhost, 127.0.0.1 or [::1]. It must not be your app’s origin. |
isolation: "per-session" |
Each preview gets its own origin, https://<session>.<preview host>/, so cookies and storage stay apart between previews. The origin needs a host name, not an IP address. Use this for deployed apps. |
isolation: "shared" with developmentOnly: true |
Every preview opens on the preview origin itself, and all of them share cookies and storage. Use it only for local development, for example when you cannot set up wildcard host names. The origin may be an IP address. |
Without preview, a VM in the frame on https://run.oxidevm.dev uses { origin: "https://oxvm.dev", isolation: "per-session" }. Any other VM created without preview reports vm.capabilities.previews as { available: false, reason: "not-configured" }, and exposePort rejects with FEATURE_NOT_AVAILABLE.
Expose a guest server
Section titled “Expose a guest server”This example writes a small Node.js server into the workspace, starts it, and shows it in an iframe:
import { createVM } from "@oxidevm/sdk";
const serverSource = `require("node:http") .createServer((request, response) => { response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); response.end("<h1>Hello from the guest</h1>"); }) .listen(3000, "0.0.0.0");`;
// Read a process stream to the end so the guest never blocks on a full pipe.function drain(stream: ReadableStream<Uint8Array>): void { const decoder = new TextDecoder(); stream .pipeTo(new WritableStream({ write: (bytes) => console.log(decoder.decode(bytes, { stream: true })) })) .catch((error: unknown) => console.error(error));}
export async function startPreview(frame: HTMLIFrameElement): Promise<() => Promise<void>> { const vm = await createVM({ mode: "userspace", runtime: { hosting: "frame" } }); try { await vm.workspace.writeText("server.cjs", serverSource); const server = await vm.spawn({ command: "node", args: ["server.cjs"] }); await server.stdin.close(); drain(server.stdout); drain(server.stderr);
const preview = await vm.exposePort(3000, { signal: AbortSignal.timeout(60_000) }); frame.src = preview.url.href; // Disposing the VM closes its previews and stops the server. return () => vm.dispose(); } catch (error) { await vm.dispose(); throw error; }}With a self-hosted engine, also pass preview: { origin: "https://preview.example.com", isolation: "per-session" } and host the preview files there.
exposePort waits until a guest program listens on the port at an address the SDK can reach, and until the preview origin is connected. Bound that wait with signal; the deadline above is your choice. It then resolves with a PreviewHandle:
| Member | Meaning |
|---|---|
url |
The preview’s URL. Use url.href as an iframe src or a link. |
port |
The guest port. |
isolation |
"per-session" or "shared", as configured. |
close() |
Removes the route. The guest server keeps running. |
Exposing the same port again while its route is open shares that route, which closes when its last handle closes. vm.dispose() closes every preview of the VM.
To keep the preview connected, the SDK adds a hidden iframe from the preview origin to your page. The URL works in your iframe and in other tabs while the page that created the VM stays open.
Find listening ports
Section titled “Find listening ports”vm.listeningPorts() returns the ports guest programs listen on right now. vm.watchListeningPorts(onChange, { signal }) calls onChange with the current list and then after every change, until signal aborts; its promise then resolves. It rejects if reading the ports fails or your callback throws.
import type { VM } from "@oxidevm/sdk";
export function watchPorts(vm: VM, render: (lines: string[]) => void): AbortController { const watching = new AbortController(); vm.watchListeningPorts( (ports) => { render(ports.map((port) => `${port.port} ${port.program ?? "unknown program"} ${port.previewable ? "" : "(unreachable)"}`)); }, { signal: watching.signal }, ).catch((error: unknown) => console.error(error)); return watching;}Each entry is a ListeningPort:
| Field | Meaning |
|---|---|
port |
The port number. |
address |
The address the program bound, such as 0.0.0.0 or ::. |
program |
The listening program, or null when it is not known. |
previewable |
Whether exposePort can reach this listener. |
Bind guest servers to 0.0.0.0 or 127.0.0.1; both modes reach those. Userspace also reaches ::1, System does not. A listener the SDK cannot reach still appears in the list with previewable: false, and exposePort keeps waiting for a reachable one.
What the guest server sees
Section titled “What the guest server sees”The SDK presents each request the way a browser on the same machine sends it to localhost, in both modes. Development servers therefore work without allowedHosts, allowedDevOrigins or similar settings:
Hostislocalhost:<port>for requests and WebSocket upgrades.OriginandRefererheaders that name the preview origin becomehttp://localhost:<port>, andRefererkeeps its path and query. Other origins pass unchanged. A WebSocket upgrade always carriesOrigin: http://localhost:<port>.X-Forwarded-ProtoandX-Forwarded-Hostname the preview origin, for servers that build public URLs behind a proxy.- In responses, absolute
http://localhost:<port>,http://127.0.0.1:<port>andhttp://[::1]:<port>URLs inLocation,Content-Location,RefreshandAccess-Control-Allow-Originpoint back at the preview origin. Response bodies and other ports are not rewritten. - A cookie set with
Domain=localhostis kept as a host-only cookie of the preview origin.Securecookies are kept the way a browser keeps them onlocalhost.
Host the preview files
Section titled “Host the preview files”The preview origin serves a few static files from the SDK package. Requests to the guest app itself are answered inside the browser and never reach this server.
- Serve
index.html,bootstrap.js,sw.js,websocket.jsandlocation.jsfromnode_modules/@oxidevm/sdk/assets/preview/under/__oxidevm/, and answer/__oxidevm/itself withindex.html. Serve the HTML astext/htmland the scripts as JavaScript. - Send the headers from
headers.jsonin the same folder on every response. Among them,Service-Worker-Allowed: /lets/__oxidevm/sw.jscontrol the whole origin, andCross-Origin-Embedder-Policy: require-corpwithCross-Origin-Resource-Policy: cross-originlet your isolated page embed it. - Return 404 for every other path, including a missing transport file. Do not add a fallback page.
- For
per-session, every host below the preview host must serve the same files: wildcard DNS for*.preview.example.comand a TLS certificate that covers it. - Copy the files again when you upgrade the SDK. They form one transport with the SDK version in your app.
If your app sends a Content Security Policy, allow the preview origin in frame-src: https://*.oxvm.dev for the frame’s previews, or https://*.preview.example.com for per-session previews on your own origin. See Deploying.
Preview files during development
Section titled “Preview files during development”Chromium-based browsers and Firefox resolve localhost and every name below it, such as <session>.preview.localhost, to your own computer, so per-session previews work locally without DNS setup. Save this script in your project root. It serves the preview files on http://preview.localhost:4178 and on every session host below it:
import { createServer } from "node:http";import { readFile } from "node:fs/promises";
const directory = new URL("./node_modules/@oxidevm/sdk/assets/preview/", import.meta.url);const headers: Record<string, string> = JSON.parse(await readFile(new URL("headers.json", directory), "utf8"));const files = new Map([ ["/__oxidevm/", "index.html"], ["/__oxidevm/index.html", "index.html"], ["/__oxidevm/bootstrap.js", "bootstrap.js"], ["/__oxidevm/sw.js", "sw.js"], ["/__oxidevm/websocket.js", "websocket.js"], ["/__oxidevm/location.js", "location.js"],]);
createServer((request, response) => { const name = files.get(new URL(request.url ?? "/", "http://localhost").pathname); if (name === undefined) { response.writeHead(404, headers).end(); return; } const type = name.endsWith(".html") ? "text/html; charset=utf-8" : "text/javascript; charset=utf-8"; readFile(new URL(name, directory)).then( (body) => response.writeHead(200, { ...headers, "Content-Type": type }).end(body), () => response.writeHead(500, headers).end(), );}).listen(4178, "127.0.0.1");node serve-preview.tsCreate the VM with preview: { origin: "http://preview.localhost:4178", isolation: "per-session" }. Then check the preview origin from your app’s development URL:
npx oxidevm doctor http://localhost:5173/ --preview-origin http://preview.localhost:4178The preview-origin check loads the preview page in a frame of your app and passes once its Service Worker registers and asks for a connection. Pass the origin exactly, without a trailing slash.
When a preview fails
Section titled “When a preview fails”| Error | Cause |
|---|---|
FEATURE_NOT_AVAILABLE, detail.reason: "not-configured" |
The VM was created without preview, and not in the frame on https://run.oxidevm.dev. |
INVALID_ARGUMENT |
The port is not a whole number from 1 to 65535. createVM also rejects a preview origin with a path or query, HTTP on a host other than the loopback names above, and isolation: "shared" without developmentOnly: true. exposePort rejects a per-session origin whose host is an IP address. |
ABORTED |
Your signal aborted before the listener and the preview were ready. |
PERMISSION_DENIED, detail.reason: "service-worker-blocked" |
The browser refused the preview origin’s Service Worker. Some privacy settings block Service Workers in cross-site frames. |
RELAY_ERROR, detail.reason: "service-worker-failed" |
The preview origin’s Service Worker failed to start. Check the files and headers above. |
RELAY_ERROR without detail.reason |
The preview origin did not finish its startup handshake in time. Check that /__oxidevm/ answers with index.html and the headers from headers.json. |
See Errors for all codes and VM for the full method signatures.