Desde

Viewer guide

Serving prototypes

How the Viewer puts a built prototype on the web. Covers path URLs, optional subdomain isolation, the content-security-policy, and the router bug that 404s a successful build.

Once a project has a deployment, the Viewer serves it. Where it serves it from is the one decision on this page that has real consequences, because it changes both the isolation model and whether some apps work at all.

This page is about the URL mechanics of two of the Viewer's four origin modes: path serving below is what fallback mode uses, and "Subdomain isolation" further down is subdomain mode. This page does not cover the other two. One is loopback mode, used automatically for local development on localhost, 127.0.0.1, or [::1]; it involves no URL rewriting at all. The other is prototype-origin mode (set with VIEWER_PROTOTYPE_ORIGIN), a single alternate origin shared by every prototype. See Deploy the Viewer for the full picture across all four modes, including which one your deployment actually picks.

Path serving (fallback mode)#

Every prototype is served under /p/{slug}/ on the Viewer's own origin. /p/acme-checkout without the trailing slash redirects to /p/acme-checkout/, because relative asset URLs resolve one level too high otherwise.

Serving an app that was built for / at a sub-path takes two rewrites, applied to HTML at request time:

  • A <base href="/p/{slug}/"> tag is inserted into <head>, so relative URLs resolve against the prototype's prefix.
  • Root-relative attribute values are rewritten. src, href and srcset values starting with / are prefixed. <base> cannot do this on its own. The HTML spec resolves only relative URLs against the base, so a URL beginning with / always resolves against the origin root. A stock Vite build emits root-relative asset URLs, so without this rewrite every asset 404s.

The HTML rewrite above is attribute-level only. An inline <script> or <style> block's body is never touched. Rewriting URLs inside JavaScript is not safe to do with pattern matching.

A standalone .css file is different. The Viewer does rewrite a root-absolute url(...) reference there. CSS's url() syntax is simple and stable, so pattern matching is safe there in a way it isn't for JavaScript. One gap remains: a CSS custom property assembled into a url() at compute time, like background: url(var(--u)), is not rewritten. The real path does not exist as one piece of text until the browser resolves the custom property, and that happens after this rewrite already ran.

That leaves root-absolute URLs baked inside JS bundles, which arrive at the Viewer root with no prefix. A fallback catches most of them: a request for a path with a file extension is matched first against the prototype named in the request's Referer, then (for nested paths only) against active deployments that contain that exact path. Content-hashed filenames resolve reliably this way. Non-hashed root-level paths depend on the Referer header and may not.

Because that fallback is a best-effort compensation, not a guarantee, every build or upload is also scanned for this pattern up front: an HTML <script src>/<link href> that starts with /, a CSS url(/...), or the Vite/webpack runtime signature that builds one at runtime. The scan's result travels with the deployment either way. The review screen shows a warning banner above its rail for it only when the failure would actually happen right now (path serving, and a project that isn't genuinely public-link), since a project's access can change after the deployment was built.

Finally, a miss on an extensionless path falls back to index.html, which is what makes client-side routes work at all.

Root-absolute asset URLs#

This is the page the review screen's "may not load fully" banner links to. The banner means the deployed build references assets from the site root (URLs like /assets/index-abc123.js) and this deployment serves prototypes under a path (/p/{slug}/), so those URLs escape the prototype's prefix. For a prototype anyone can open, the fallback lane described above usually recovers them; for a prototype behind sign-in it cannot be relied on, which is exactly when the banner shows.

Two real fixes, either of which makes the banner go away on the next deployment:

Build with a relative base, so the bundle's own URLs stay inside whatever path it is served from:

  • Vite: base: './' in vite.config.ts.
  • Create React App: "homepage": "." in package.json.
  • Most other bundlers have an equivalent "public path" or "base" setting; ./ is the value you want.

Or give prototypes their own origins, so there is no path prefix to escape: set VIEWER_SERVE_DOMAIN and each prototype serves from {slug}.<domain> at its root. See subdomain isolation above. This is the better answer for a deployment with real DNS, and it removes the whole class rather than fixing one build.

The client-side router trap#

Warning An app whose client-side router was built for base / can render its own 404 page when served at /p/{slug}/, while the build reports success and nothing in the log hints at a problem.

This is the known limitation with the sharpest edge. The Viewer rewrites asset references in HTML; it cannot rewrite a router basename that is compiled into your JavaScript bundle. Your router sees the path /p/acme-checkout/ and matches none of its routes, so it renders whatever it renders for an unknown path.

The symptom is specific and easy to misread: the page loads, styling is intact, assets resolve, and the app itself says "not found". That is your app's 404, not the Viewer's. (The Viewer's own misses are plain text: "Prototype not found", "Prototype has no deployment yet", or "Not found".)

Three ways out, in order of how much you control:

  1. Build with a relative base. In Vite, base: './' produces fully self-contained output that works at any prefix.
  2. Tell your router its base. Vue Router's createWebHistory('/p/acme-checkout/'), React Router's basename. This ties the build to one slug.
  3. Use subdomain serving. The prototype then sits at the origin root, where a router built for / is simply correct.

Subdomain isolation#

Set VIEWER_SERVE_DOMAIN and each prototype gets its own hostname: {slug}.{VIEWER_SERVE_DOMAIN}, serving the prototype at /.

VIEWER_SERVE_DOMAIN=proto.example.com

Requests to a prototype hostname are rewritten internally into the same /p/{slug}/… handler, so both modes share one code path and one set of access rules. What changes:

  • No HTML rewriting. The prototype is at the origin root, so its own root-relative URLs are already correct. That removes the entire class of problems the rewrites exist to work around, including the router trap above.
  • A stronger policy. connect-src becomes 'self', which on a dedicated origin is the prototype and nothing else.
  • The API is not routed there at all. On a prototype hostname, /api/v1/projects is rewritten into a prototype asset path and almost always 404s. The API is not merely policy-blocked on that origin; it is absent.
  • The session cookie is never sent. It has no Domain attribute, so it does not travel to a prototype subdomain. This does not stop a private (all-members or invited) project from being served here: it gets a separate, short-lived capability instead, described in Deploy the Viewer.

Why this is not the default: it needs wildcard DNS and a wildcard TLS certificate for *.{VIEWER_SERVE_DOMAIN}. Many hosting setups cannot do that, especially with a PaaS-provided domain.

Hostname matching is strict: exactly one label may precede the serve domain (so evil.acme.proto.example.com is not a prototype host), the label must satisfy the slug rule, and the bare serve domain is not a prototype host at all.

For local testing you need hostnames that resolve. A wildcard resolver works without touching /etc/hosts. Set VIEWER_SERVE_DOMAIN=127.0.0.1.nip.io and reach a prototype at http://acme-checkout.127.0.0.1.nip.io:3100/.

The content-security-policy#

Under path serving, the prototype and the Viewer's own API share an origin. A policy header is what keeps them apart, and it is sent on every /p/** response: HTML, JavaScript, CSS, images, the bridge bundle, everything. Scoping it to HTML was a real bug: an .svg is a scriptable, same-origin document type, and one served with no policy escaped the isolation entirely.

Five directives carry the actual security property:

Directive Value under path serving What it stops
connect-src the prototype's own prefix, e.g. http://localhost:3100/p/acme-checkout/ Prototype JavaScript calling /api/v1/** with the reviewer's session cookie. A bare 'self' would allow exactly that.
frame-src 'none' Framing a same-origin API response and reading its contentDocument.
object-src 'none' The same read via <object> or <embed>.
form-action 'none' Auto-submitting a form to an attacker-controlled URL.
worker-src 'none' Registering a service worker from this response. With no explicit worker-src, a worker load falls through to child-src and then script-src, so it would otherwise be permitted; a registered worker's scope would then be bounded only by how long a URL happens to stay valid, not by policy.

The resource directives are deliberately permissive: inline scripts and styles are allowed, and fonts, images and stylesheets may load over https:. That is not laziness. A live run against a real Vue + Vite prototype under a strict policy blocked the prototype's own inline scripts, its Google Fonts stylesheet, and an inline style set by app code. None of that is optional for a prototype to be usable, and loosening it does not weaken the four directives above. 'unsafe-eval' is not granted; no real prototype has needed it.

VIEWER_PROTOTYPE_CSP overrides all of this, with three states:

  • Unset (or whitespace-only): the computed default above.
  • The literal off: no policy header at all.
  • Any other string: sent verbatim as the header value.

Warning VIEWER_PROTOTYPE_CSP=off under path serving removes the only thing preventing a hosted prototype from reading the Viewer's API with the reviewer's own credentials, including the endpoint that mints personal access tokens. If you need the policy gone, use subdomain serving instead, where the browser's own same-origin rule does the work.

How the bridge gets in#

The bridge (the script that draws comment pins and talks to the review shell) is injected into every HTML response at request time, just before </body>. Two tags go in:

<script data-prototype-flow="config">window.__DESDE_SHELL_ORIGIN__="http://localhost:3100";</script>
<script data-prototype-flow="bridge" data-shell-origin="http://localhost:3100" src="/p/acme-checkout/__desde/bridge-2026-09-01a-callsite-edit-target.js"></script>

The data-shell-origin attribute on the bridge tag is the one that actually matters. It is the authoritative channel the bridge reads its shell origin from. The inline window.__DESDE_SHELL_ORIGIN__ script above is only a fallback. A prototype that sends a strict script-src 'self' (no 'unsafe-inline') drops that inline tag entirely, but the attribute survives, because it is markup, not a script a policy can block.

The bridge is an external script, not an inline one, and that is not a style choice. The built bundle contains the literal sequence <!-- inside a string. Per the HTML spec, that sequence inside a classic inline <script> switches the tokenizer into escaped state and corrupts everything after it. Verified live in Chrome, where the bridge simply never initialized and commenting was dead on every hosted prototype. Served as its own file, there is no such hazard.

The __desde/ path is reserved and the filename carries the exact bridge version, which doubles as the cache-buster: a version bump makes old cached URLs stop resolving rather than silently serving a stale bundle.

Nothing is written into your repository or your build output. Your dist/ is served byte-for-byte except for the HTML rewrites described above.

Caching and headers#

Response Cache-Control
HTML no-store (the injected tags depend on the current bridge version and the current slug).
The bridge bundle private, max-age=31536000, immutable (the version is in the filename).
Every other asset private, max-age=300

private rather than public on the two cacheable ones is intentional: a shared cache holding a member's 200 and later serving it to an anonymous caller would turn the access check into a working existence oracle.

Every response also carries X-Content-Type-Options: nosniff.

Next#