Previews without a service worker

A blob-iframe preview transport for browsers where service workers are unreliable — and how the injected fetch/XHR/WebSocket shims route to the right in-VM port.

Live previews rely on a service worker controlling `/`. Where that isn't dependable — iOS Chrome, some cross-origin embeds, a locked-down host — the same preview can run with no service worker at all.

Instead of intercepting requests in a worker, the host fetches the app's entry document from the in-VM server, serves it to the iframe as a blob: URL, and injects shims that tunnel the app's runtime requests to the parent window over postMessage. The parent answers them from the kernel's port registry using the same message protocol the service worker speaks, so both transports are interchangeable.

import { mountNoSwPreview } from "@lifo-sh/ui";

const handle = await mountNoSwPreview(iframe, sandbox.kernel, 8081);
// later
handle.destroy();

The fourth argument is the entry path, which matters more than it looks: a server's root is not necessarily its app. tinbase answers `/` with a JSON health check and serves its dashboard at `/_/`, so mounting the dashboard means asking for it:

await mountNoSwPreview(iframe, sandbox.kernel, 54321, "/_/");

One preview, several servers

A preview is rarely one server. An Expo app on 8081 talks to a backend on 54321, and that backend may serve its own dashboard on the same port. Every request carries its own port, so the shims resolve which in-VM server should answer each URL:

URL the app requestsGoes to
`/index.bundle?platform=web`the preview port
`/_sw/54321/rest/v1/todos`port 54321, as `/rest/v1/todos`
`/_sw/box_ab12/54321/…`port 54321
`http://localhost:54321/rest/v1/todos`port 54321
the embedding page's own originthe real network
`blob:`, `data:`, any other originthe real network

The `/_sw/<port>/` forms are the service worker's URL scheme, understood here too. That's deliberate: an app written for the SW path keeps working with no service worker present. An Expo project whose .env says

EXPO_PUBLIC_SUPABASE_URL=/_sw/54321

reaches its backend under both transports, unchanged.

Gotcha

During local development the embedding page is on loopback too (say a dev server on localhost:5173). Its port is not an in-VM port, so URLs pointing at it must go to the real network — the shims are told the host's port and exclude it. Otherwise requests for the page's own assets (or its CORS proxy) get routed into the port registry, where nothing is listening.

What gets patched

A blob: document has no server to answer `/api/…`, so the shims patch the browser APIs an app uses to make requests. Each patch is separately selectable, so an embedder takes only what it needs:

import { buildPreviewShim } from "@lifo-sh/ui/preview-shims";

// everything (what mountNoSwPreview uses)
buildPreviewShim({ port: 8081, hostPort: location.port });

// HTTP only — no WebSocket, no asset interception
buildPreviewShim({ port: 3000, include: ["fetch", "xhr"] });
PatchWhy it exists
fetchThe main path for app requests. Non-VM URLs fall through to the original.
xhrReact Native's networking layer uses XMLHttpRequest, not fetch.
websocketHMR (Vite/Metro) and realtime subscriptions — which often live on a different port than the preview.
imagesReact Native Web builds `/assets/…` URLs at render time, so they can't be rewritten statically.
fontsFontFace.load() fetches its url() with the browser, bypassing the fetch patch.
cssInjected `<style>@font-face{src:url(…)}` is loaded by the browser, bypassing both of the above.

The asset patches route through the patched fetch, so asking for them without fetch throws rather than silently hitting the network.

The routing rules are a plain function you can use on its own:

import { resolveVmTarget } from "@lifo-sh/ui/vm-routing";

resolveVmTarget("/_sw/54321/rest/v1/todos", 8081, "5173", "");
// → { port: 54321, path: "/rest/v1/todos" }

It's the same function the shim inlines into the preview document, so the code under test and the code in the sandbox can't drift.

Routing inside the app

A blob: document can't change location.pathname, so a client-side router would see the blob's UUID and render "Unmatched Route". A router shim virtualizes document.URL, the URL constructor and the History stack, carrying the real route in the fragment — the app reads a clean path.

That only works for routers that read a virtualizable source. window.location is [Unforgeable]: no embedder can patch it. An app (or dashboard) that reads window.location.pathname directly will always see the blob URL — reading new URL(document.URL).pathname instead costs nothing and makes it embeddable.

Trade-offs versus the service worker

  • No worker registration, no scope header, no Service-Worker-Allowed. Works where a worker can't be installed at all.
  • Response headers are not preserved on the entry document. It is re-served as a blob, so headers on the original response do not apply. Both engines additionally drop anti-framing headers on the preview path, so the difference here is only that the blob drops everything — caching, content-type nuances and the rest.
  • The entry document is fetched up front, so a large single-file app is loaded in full before the iframe renders.
  • HMR still works, over the WebSocket patch rather than the worker.

Both engines are switchable in the playground, so an example can be checked under each.