Skip to main content

Deploying on Cloudflare Pages

This project ships four surfaces on a single origin so cross-links stay root-relative:

PathAppCross-origin isolated?
/Landing (Vite + React)No
/docs/Docs (this site — Docusaurus)Yes
/studio/Studio (the IDE)Yes
/embed/Live doc examples (Vite + React)Yes

The studio and the /embed/ playground run the Vivari runtime, so they need COOP/COEP. The docs are isolated too, because they host the /embed/ playground in an <iframe> and an iframe is only cross-origin isolated when its top-level document is. Only the landing (/) stays free of COEP. Isolating the docs is safe because they load only same-origin assets — adding a cross-origin resource (e.g. Algolia DocSearch, external images) would need credentialless or CORP headers.

Unified build​

A single command builds all three and assembles them into dist/:

npm run build:site

Under the hood it builds the Rust→Wasm crates, then the studio (with Vite base: "/studio/"), the /embed/ playground (base: "/embed/"), the landing, and the docs, then assembles the output. The preview Service Worker and its runtime asset tree stay at the origin root (/sw.js, /preview/*, /vv-devtools/*, /devtools/*) because the SW claims root scope; the studio and embed UIs are namespaced under /studio/ and /embed/.

Cloudflare Pages settings​

SettingValue
Build commandbash scripts/cloudflare-build.sh
Build output directorydist
Node version22 (via .nvmrc)

The build script provisions the Rust toolchain + wasm-pack in the build container, so no extra configuration is needed.

Headers (_headers)​

The assembler emits a dist/_headers file that scopes cross-origin isolation to every surface that runs (or hosts) the runtime, plus the Service Worker:

/studio/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

/embed/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

/docs/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

/blog/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

/devtools-host.html
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

/devtools-host
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

/devtools/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

/sw.js
Service-Worker-Allowed: /
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Preview responses (/preview/<port>/…) are synthesized by the Service Worker, which stamps their isolation headers itself — so they don't need an entry here.

The DevTools entries look redundant — the frontend is same-origin, and same-origin subresources are exempt from CORP under require-corp. A nested document is not: an iframe must send require-corp (or credentialless) on its own response or the browser blocks the frame and shows "<host> refused to connect". Because /devtools-host.html is hoisted out of /studio/ to the origin root, the /studio/* rule doesn't reach it, so it needs its own entry. Both the .html and the extensionless form are listed since Pages' clean URLs redirect /x.html to /x and the rule has to match whichever URL answers 200. This is invisible in npm run dev, where Vite stamps isolation on every response.

Preview isolation modes (optional)​

By default previews run same-origin with the IDE (mode A) — zero extra infra. Two opt-in modes move previews onto a separate origin so preview code (including your npm dependencies) can't touch the IDE's cookies / localStorage / OPFS. All three are the same client-side runtime; the mode is inferred from build-time env on the studio (main) project (the preview origin needs no env of its own).

A. same-originB. shared originC. wildcard per-port
Preview URL…/preview/5173/preview-origin/preview/5173/<token>--5173.<domain>/
Isolates IDE ↔ preview❌✅✅
Isolates preview ↔ preview❌❌✅ (own origin per port)
Extra infranone+1 Pages projectwildcard DNS + a Worker
Env (studio project)—VITE_PREVIEW_ORIGINVITE_PREVIEW_WILDCARD_DOMAIN

VITE_PREVIEW_WILDCARD_DOMAIN takes precedence over VITE_PREVIEW_ORIGIN when both are set.

Mode B — shared preview origin​

  1. Create a second Cloudflare Pages project (e.g. vivari-preview) with build command bash scripts/cloudflare-build-preview.sh and output dir dist-preview. It serves only the static SW runtime (sw.js, __vv-bridge.html, __vv-preview-boot.html, vv-devtools/) with COEP: credentialless, CORP: cross-origin and Service-Worker-Allowed: /.
  2. On the main project set VITE_PREVIEW_ORIGIN=https://vivari-preview.pages.dev and redeploy.
  3. Optional: VITE_PREVIEW_POPOUT=isolated opens "Open in new tab" on the preview origin instead of same-origin. On a cross-site preview origin (e.g. a *.pages.dev project, which the Public Suffix List makes a distinct site) the pop-out shows a one-time "connect this tab" Storage-Access gate. To skip the gate, put the IDE and preview on subdomains of one base domain (same-site) — see mode C, which does this by construction.

Mode C — wildcard per-port preview origins​

Each in-VM port gets its own origin <token>--<port>.<domain> (random per-boot <token>), which gives every preview real localhost:<port> semantics and isolates previews from each other. On a base domain dedicated to Vivari the route is the plain *.<domain>/*. Cloudflare Pages can't attach a wildcard custom domain, so a small Worker (worker/) serves the SW runtime for every matching subdomain; it gates on the hostname and passes anything that isn't a <token>--<port> host straight through, so other subdomains you add later keep working.

If the domain also serves other apps and you'd rather not point a broad route at it, set previewWildcardTag (e.g. "vv") in BootOptions: hosts become <token>--<port>-vv.<domain> and the route narrows to *-vv.<domain>/*. The tag has to be a suffix — Cloudflare routes only allow the * wildcard at the start of the hostname, so a prefix vv-*.<domain>/* is rejected as an infix wildcard.

  1. Point a base domain (e.g. vivari.run) at Cloudflare (nameservers on Cloudflare) and add one proxied (orange-cloud) wildcard DNS record: A * → a placeholder IP like 192.0.2.1 (the Worker responds directly, never forwards). Explicit records for your existing subdomains always win over the wildcard (RFC 4592), so they're untouched.
  2. Build + deploy the Worker (from your machine — or wire up Git deploys, see below):
    npm run build:worker # builds the studio + assembles worker/public/
    npm run deploy:worker # wrangler deploy (from worker/)
    worker/wrangler.toml ships with the route *.vivari.run/* bound — change zone_name/pattern to your own zone. Binding a route on deploy needs an API token with Workers Routes: Edit on the zone; otherwise comment the [[routes]] block out and add the route under Workers → your Worker → Domains & Routes. The Worker only acts on <token>--<port> hosts and passes every other host through untouched, so the broad route doesn't disturb other subdomains.
  3. On the main (IDE) project set VITE_PREVIEW_WILDCARD_DOMAIN=vivari.run and redeploy. Because the preview hosts are subdomains of the IDE's base domain they are same-site, so "Open in new tab" connects gate-free.
  4. Enable Universal SSL (see the next paragraph) — this is the step people miss.
Turn on Universal SSL — the easy one to miss

The wildcard preview hosts need a TLS certificate covering *.<domain>. Cloudflare's Universal SSL is sometimes OFF for a zone (and per-hostname certs that Pages creates for your named custom domains — e.g. vivari.<domain> — mask this, since they're separate certs). If Universal SSL is off, every preview host fails the TLS handshake with ERR_SSL_VERSION_OR_CIPHER_MISMATCH even though the Worker and route are correct.

Fix: SSL/TLS → Edge Certificates → make sure a Universal certificate is Active and lists *.<domain> in its Hosts. If it's off, turn it on (or disable + re-enable to re-provision); issuance is usually minutes. Also check SSL/TLS → Overview isn't set to Off.

Free Cloudflare Universal SSL covers the apex + a single-level wildcard (*.<domain>), which is exactly what <token>--<port>.<domain> needs — no paid certificate. A nested scheme like *.preview.<domain> would be two levels and require Advanced Certificate Manager.

All templates work in every mode. Keep-prefix templates (Docusaurus, VitePress, React Router 7, TanStack Router) hardcode a /preview/<port>/ base for modes A/B. Since mode C serves each port at its own origin root, the studio rewrites those templates' base to / at creation time — so you don't need to change anything per mode. Cross-service calls (a frontend hitting a backend on another port via /preview/<port>/…) keep working in mode C too.

Deploy the Worker via Git (Workers Builds)​

Instead of running build:worker / deploy:worker by hand, connect the repo to Cloudflare → Workers & Pages → Create → Workers → Connect to Git. Cloudflare then runs the build + deploy on every push. Use these settings:

FieldValue
Project namevivari-preview (must match name in worker/wrangler.toml)
Build commandnpm run build:worker
Deploy commandcd worker && npx wrangler deploy
Path (root directory)/
Non-production branch deploy commandcd worker && npx wrangler versions upload
API tokenneeds Workers Scripts: Edit (+ Workers Routes: Edit to bind the route)

Why these:

  • Path stays / (repo root). npm run build:worker runs scripts/cloudflare-build-worker.sh, which builds the studio + Wasm crates at the repo root and assembles worker/public/. Pointing Path at worker/ would break the build (there is no package.json there).
  • Deploy does cd worker so wrangler finds worker/wrangler.toml (whose main/[assets] directory are relative to that file).
  • The route in [[routes]] is bound automatically on deploy (given the token permission above); no separate dashboard step needed.

These are the Worker's build settings. The VITE_PREVIEW_WILDCARD_DOMAIN env var is a build-time variable of the IDE project (the studio/Pages project), not the Worker — set it there and redeploy the IDE.

Local preview​

npm run build:site
npx serve dist # or any static server that honors _headers

Because _headers is a Cloudflare Pages convention, a plain static server won't apply it — use wrangler pages dev dist to preview headers locally.