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
- The page registers
`/sw.js`and connects the bridge to the box. - An
<iframe>points at`/_sw/<boxId>/<port>/`— the entry URL for the server listening on that port inside the VM. - The service worker intercepts requests under
`/_sw/<boxId>/<port>/`and forwards each as aVirtualRequestinto the VM's handler for<port>. - 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 — call the port directly with
sandbox.fetch(), or expose it through a
tunnel. After the sw.js changes, a client needs one reload for
the new worker to take control.
Servers that refuse to be framed
A preview renders the in-VM server's document in an iframe, so a server that sends
X-Frame-Options: DENY — or a CSP with frame-ancestors 'none' — would make the
preview blank. helmet() sets that X-Frame-Options by default, so this is common
rather than exotic.
On the preview path those headers are dropped: x-frame-options,
content-security-policy and content-security-policy-report-only. Nothing is
lost by doing so — the "server" is a JavaScript object in the very tab doing the
framing, so there is no cross-origin embedding to defend against. CSP goes too,
not only its frame-ancestors, because the path shim above is an inline script
that a script-src 'self' policy would block.
This applies only to previews. sandbox.fetch(),
curl inside the box, and tunnels — which really can expose a
server to other people — all see exactly what the server sent.
When a worker isn't available
Some browsers won't reliably give you one (iOS Chrome), and some embeds can't register one at all. The same preview can run with no service worker — see Previews without a service worker. Both transports speak the same message protocol and are switchable per preview.