Skip to main content

Three ways to isolate a preview, and the Cloudflare wildcard trick

· 8 min read

When an in-browser IDE runs your dev server and shows you the result, the result has to be served from somewhere. The easy answer is the origin you already have: put the preview at /preview/5173/ on the IDE's own domain, let the Service Worker route by path, ship it.

That is what we did, and it is a security problem.

Same origin means the same cookie jar, the same localStorage, the same IndexedDB, the same OPFS, the same Cache Storage, the same Service Worker scope. Preview code, which includes every npm package the project installed and anything an AI assistant just generated, sits inside the IDE's origin. It can read the editor's session state, corrupt its persistence, and call its same-origin APIs. Previews are not isolated from the IDE, and they are not isolated from each other.

Fixing this properly took three attempts, and each one ran into a different piece of web platform trivia.

Mode A: same origin

The default, and the one to move away from.

https://ide.example.com/preview/5173/

The Service Worker intercepts requests under /preview/<port>/, strips the prefix, and relays them to the kernel, which it can find directly because it is same-origin with the tab holding it.

Zero extra infrastructure, and it works. But beyond the storage problem, path routing quietly breaks things that a real server would get right:

Keyed on originBreaks under shared-origin path routing
Cookie jar (session, CSRF, SameSite)frontend and backend cookies collide at /
localStorage / IndexedDB / OPFS / Cacheservices share one store, state bleeds
CORS and fetch credentialscross-service calls look same-origin, wrongly
Service Worker scopethe app's own SW registrations collide
Absolute pathsrouter basename, /asset.png and <base> all break

That last row is the one users actually report. Any app that assumes it is served from / needs a prefix hack somewhere: in the SW, in the URL rewriter, or in the app's own config.

Mode B: a second origin

Move previews to a different origin entirely and the browser's same-origin policy does the work for you. The kernel still lives in the IDE tab, so the preview's Service Worker reaches it through a hidden bridge iframe and a MessagePort.

Here the hosting platform pushes back. A Cloudflare Pages project only gets <project>.pages.dev, and you cannot mint preview.myproject.pages.dev. A second origin means a second Pages project.

Which sounds fine, and introduces the first real trap.

pages.dev is on the Public Suffix List. That makes myproject.pages.dev and myproject-preview.pages.dev different sites, not merely different origins. As an isolation boundary that is stronger than you asked for: cookies cannot be shared even deliberately.

It also breaks popping a preview out into its own tab, in a way that cannot be worked around.

Chrome storage-partitions cross-site contexts. The kernel is reached through a bridge iframe living in the editor tab; for a standalone preview tab to use that bridge's Service Worker registration and MessagePort, both have to be in the same storage partition. Cross-site, they are not. And requestStorageAccess() un-partitions cookies, not Service Worker registrations, so the "connect this tab to its project" gate can never actually bridge the two partitions. The gate appears, you grant it, and nothing changes.

Two subdomains of one registrable domain fix it. Serve the IDE at ide.example.com and previews at preview.example.com, both CNAMEd to their Pages projects, and the two are same-site: no partition wall exists, so the popped-out tab shares the bridge's Service Worker and reaches the kernel with no gate at all. Storage is still origin-scoped, so preview code still cannot touch IDE storage.

DeploySame-site?Partitioned?Pop-out
Two *.pages.dev projectsNo, PSL cuts pages.devYesGate appears, cannot be granted
Two subdomains of one domainYesNoConnects immediately, no gate
StackBlitz (stackblitz.com / webcontainer.io)No, different domainsYesGate, accepted deliberately

The residual leak with same-site subdomains is domain-wide cookies: anything set with Domain=example.com is visible to both. So do not set domain-wide cookies on the IDE. For trusted first-party code this is the right trade. For untrusted code at scale you want the cross-site boundary and you accept the gate, which is exactly the choice StackBlitz made, and it is a reasonable one for their threat model, not an oversight.

There is one important caveat: you have to open the editor at ide.example.com. Loading it via the raw .pages.dev hostname reintroduces cross-site and the gate comes back.

Mode C: one origin per port

Mode B isolates previews from the IDE, but every preview still shares one origin with every other preview, and the port is still in the path. Mode C gives each in-VM port its own origin:

https://k3f9a2xh--5173-vv.example.com/

The port moves into the hostname, so each preview gets genuine localhost:<port> semantics, with its own cookies, its own storage and its own CORS behaviour, and previews are isolated from each other as well as from the IDE. Everything on the "breaks under path routing" list above stops being a problem, and the prefix hacks come out of the codebase.

This is the model StackBlitz uses, and their URLs decode neatly:

https://vitejsvitelqrjey5b-c0kn--5173--87cf54cd.local-credentialless.webcontainer.io/
└────── instance / project id ────┘ └port┘ └session hash┘ └ COEP mode ┘ └base┘

Getting there involves three constraints that each look arbitrary until they bite.

Cloudflare Pages cannot do wildcard custom domains. They are exact hostnames only. So the wildcard origin has to be a Worker bound to a route, serving the static Service Worker runtime and the bridge document. It runs no kernel and no IDE; it is pure static hosting for an origin that has to exist per port.

The wildcard must be a prefix, so the tag must be a suffix. Cloudflare routes only allow * at the start of a hostname. vv-*.example.com is an infix wildcard and is rejected. That is why the marker is a suffix and the route reads *-vv.example.com/*, which has the pleasant side effect of being narrow: it matches Vivari preview hosts and nothing else on the zone. Anything else that reaches the Worker is passed straight through untouched.

Free TLS covers exactly one label. Cloudflare's Universal SSL issues a certificate for the apex plus a single-level wildcard: *.example.com matches abc.example.com but not abc.def.example.com. So a scheme like *.preview.example.com is two levels deep, is not covered, and produces a TLS error rather than a helpful message. Paid Advanced Certificate Manager fixes it; staying free means keeping preview hostnames one level under the apex and packing the port into that single label, hence <token>--<port>-vv, all in one label.

Each preview response is then stamped with COOP: same-origin, COEP: credentialless and CORP: cross-origin, so the IDE (which is require-corp) can embed the bridge iframe, and the Service Worker is allowed to claim root scope.

The limitation nobody can engineer away

One thing worth being honest about, because it applies to every client-side container including this one.

A preview URL is not a network address. It is a capability ticket that only works inside the browser holding a kernel-connected Service Worker. The Service Worker is a per-origin proxy running in your browser, and its live link to the kernel is a MessagePort held in its memory.

The consequences are observable on StackBlitz and we inherit all of them:

  • Paste the URL into a new tab on the same browser and it works, because that tab is claimed by the same Service Worker, which already holds the port.
  • "Open in new tab" sometimes needs a popup and a reload. That happens when the Service Worker's in-memory port has lapsed because it was killed while idle, and the popup provides a window.opener channel to re-handshake.
  • Open it on another machine and it fails, even with the project still open elsewhere. The kernel lives in the first machine's tab RAM. postMessage does not cross the network.
  • Close the editor tab and the preview dies.

None of that is fixable within the no-server model, because the thing serving the preview genuinely is not a server. A persistent, shareable preview URL requires a real backend, which is a different product decision, not a bug to file.

Choosing

A. same-originB. shared preview originC. wildcard per port
Extra infrastructurenoneone more Pages projectcustom domain + wildcard DNS + Worker
Port encoded inpathpathhostname
Isolates IDE from previewnoyesyes
Isolates previews from each othernonoyes
Real per-port semanticsnonoyes

The modes are a deploy-time choice rather than a runtime toggle, which keeps the core simple. Start at A if you are running your own trusted code and want zero infrastructure. Move to B with same-site subdomains as soon as anyone else's code runs in your previews. Go to C when previews need to be isolated from each other, or when apps genuinely need to believe they are on their own host.

The deployment guide has the full setup for each.