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 requests | Goes 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 origin | the real network |
`blob:`, `data:`, any other origin | the 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/54321reaches 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"] });| Patch | Why it exists |
|---|---|
fetch | The main path for app requests. Non-VM URLs fall through to the original. |
xhr | React Native's networking layer uses XMLHttpRequest, not fetch. |
websocket | HMR (Vite/Metro) and realtime subscriptions — which often live on a different port than the preview. |
images | React Native Web builds `/assets/…` URLs at render time, so they can't be rewritten statically. |
fonts | FontFace.load() fetches its url() with the browser, bypassing the fetch patch. |
css | Injected `<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.