Live previews

How a service worker turns an in-VM port into a live preview in the browser, with HMR and clean in-app routing.

In the browser, a dev server running inside a box is served to an <iframe> by a service worker. The ServiceWorkerBridge connects the page's service worker to the VM, so a request to a preview URL is answered by the in-VM HTTP handler — no network hop leaves the tab.

The request path

  1. The page registers `/sw.js` and connects the bridge to the box.
  2. An <iframe> points at `/_sw/<boxId>/<port>/` — the entry URL for the server listening on that port inside the VM.
  3. The service worker intercepts requests under `/_sw/<boxId>/<port>/` and forwards each as a VirtualRequest into the VM's handler for <port>.
  4. The response streams back through the worker to the iframe.

The <boxId> in the path is what lets multiple boxes coexist on one page: each box owns its own kernel and ServiceWorkerBridge with a stable id, so the worker routes `/_sw/<boxId>/<port>/` to the right box's handler. Because the worker then routes a client's subsequent requests by its client id (not by re-parsing the path), only the entry URL needs the prefix — the app itself can fetch from `/` normally.

Clean routing at /

Serving an app under a `/_sw/<boxId>/<port>` prefix would break client-side routers (Expo Router, React Router) that expect to live at `/`. The service worker injects a small path shim into every VM-served HTML document: before app code runs, it history.replaceStates the location to strip the prefix. The app then matches `/` like it would on a real dev server — with zero app changes.

To keep top-level preview-tab reloads working, the shim also saves the current in-VM path to sessionStorage on pagehide (top-level windows only, so an iframe doesn't hijack its parent), and the playground restores it on boot.

HMR

Dev servers push hot updates over a WebSocket. Those upgrades ride the same bridge: the worker proxies the WebSocket to the in-VM server's socket, so Vite/Metro HMR and Fast Refresh work inside the preview. See Protocols for the framing.

Production note

Previews rely on the service worker controlling `/`. In production the host site must serve the worker with a Service-Worker-Allowed: / header so it can claim that scope. Locally (and in the playground) this is already set up.

Gotcha

A service worker only exists in the browser. Server-side in Node there's no worker — you reach an in-VM port directly through the network layer or a tunnel. After the sw.js changes, a client needs one reload for the new worker to take control.