Skip to content

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.

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.

A web server in the VMUserspaceNot started
localhost:3000
Downloads Linux into this tab when you click.

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.

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.

This example writes a small Node.js server into the workspace, starts it, and shows it in an iframe:

preview.ts
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.

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.

ports.ts
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.

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:

  • Host is localhost:<port> for requests and WebSocket upgrades.
  • Origin and Referer headers that name the preview origin become http://localhost:<port>, and Referer keeps its path and query. Other origins pass unchanged. A WebSocket upgrade always carries Origin: http://localhost:<port>.
  • X-Forwarded-Proto and X-Forwarded-Host name the preview origin, for servers that build public URLs behind a proxy.
  • In responses, absolute http://localhost:<port>, http://127.0.0.1:<port> and http://[::1]:<port> URLs in Location, Content-Location, Refresh and Access-Control-Allow-Origin point back at the preview origin. Response bodies and other ports are not rewritten.
  • A cookie set with Domain=localhost is kept as a host-only cookie of the preview origin. Secure cookies are kept the way a browser keeps them on localhost.

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.js and location.js from node_modules/@oxidevm/sdk/assets/preview/ under /__oxidevm/, and answer /__oxidevm/ itself with index.html. Serve the HTML as text/html and the scripts as JavaScript.
  • Send the headers from headers.json in the same folder on every response. Among them, Service-Worker-Allowed: / lets /__oxidevm/sw.js control the whole origin, and Cross-Origin-Embedder-Policy: require-corp with Cross-Origin-Resource-Policy: cross-origin let 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.com and 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.

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:

serve-preview.ts
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");
Terminal window
node serve-preview.ts

Create the VM with preview: { origin: "http://preview.localhost:4178", isolation: "per-session" }. Then check the preview origin from your app’s development URL:

Terminal window
npx oxidevm doctor http://localhost:5173/ --preview-origin http://preview.localhost:4178

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

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.