Viewer guide

Where prototypes are served

Each prototype should get its own address. On your own machine this happens with no setup. On a real deployment, one wildcard DNS record and certificate does it; this page is the detail, and the fallbacks when you cannot.

Short version: on your own machine, you already have this, with nothing to set up. On a real deployment, set VIEWER_SERVE_DOMAIN, and add one wildcard DNS record and one wildcard certificate for it. Either way, every prototype gets an address of its own, and most of this page is detail you will not need.

A prototype is untrusted code someone uploaded or built from a repo. The Viewer keeps it from reading your session cookie and from reaching into your browser tab. It does this with one of five origin modes, picked automatically, never by a setting you choose directly. The order they are tried in: subdomain first, then the single alternate origin, then the local default described next, then loopback, then fallback last.

On your own machine, by default#

Open the Viewer at http://desde.localhost:3100. That is the address a fresh install prints, and it needs no setup: Chrome and Firefox resolve any .localhost name to your own machine on their own, with no DNS record and nothing to add to your hosts file.

Every prototype then gets its own address in the same family: http://{slug}.apps.desde.localhost:3100. A prototype's own sign-in cookies work inside the review frame, the same as they would on a real site, because the prototype's address and the Viewer's address both sit under desde.localhost, and the browser treats that as one site. The Viewer's own session cookie still never reaches a prototype: it is set on desde.localhost alone, not on the wider family the prototypes sit under.

This is the same subdomain mode described below for a real deployment. The only difference is that the address is worked out for you, instead of typed into VIEWER_SERVE_DOMAIN.

Safari does not resolve .localhost names. See Safari, and turning the default off for what to do instead.

Modes for a real deployment#

For a deployment other people reach over a network, the mode comes from VIEWER_PUBLIC_URL, VIEWER_SERVE_DOMAIN, and VIEWER_PROTOTYPE_ORIGIN:

Mode Picked when Where the prototype is served Storage inside the prototype
Subdomain VIEWER_SERVE_DOMAIN is set {slug}.{VIEWER_SERVE_DOMAIN}, at the origin root Works for every access level. Real origin. A public-link project needs no credential; an all-members or invited project gets one carried on the document load, see the note below.
Single alternate origin VIEWER_PROTOTYPE_ORIGIN is set (and no serve domain) That one origin, at /p/{slug}/, shared by every prototype Works. Real origin, but shared. Every prototype uses the same one, so they share storage and a cookie jar and can script each other.
Fallback None of the above, for example a bare IP or a hostname with no wildcard DNS The same host as the Viewer itself, at /p/{slug}/, sandboxed Does not work. The prototype has no origin of its own.

The other two modes, the local default above and loopback below, are not something you set: they follow from the address you or a teammate types.

Subdomain mode is what you set up for a real, shared deployment: it needs one wildcard DNS record and a matching wildcard TLS certificate for *.{VIEWER_SERVE_DOMAIN}, set up once alongside the rest of your DNS, never asked for again per project. See serving for how VIEWER_SERVE_DOMAIN is configured.

If the review screen is blank, check the certificate#

A certificate covers an exact list of names. One that covers the Viewer's own address but not *.{VIEWER_SERVE_DOMAIN} leaves the review screen blank, with nothing on it to say why.

The silence is the browser's doing, not the Viewer's. The review screen loads the prototype in a frame. A browser only offers its "continue anyway" warning page for an address someone typed into the address bar, never for a frame inside a page. So the frame fails and nothing explains it.

DNS will not warn you either. One wildcard DNS record answers for every name under it, including names the certificate does not cover. The address resolves normally, and only then does the browser reject the certificate.

To confirm it in a few seconds, open a prototype's own address in a tab of its own. That is a typed address, so the browser shows the warning page and names the certificate.

The fix is to reissue the certificate so it covers *.{VIEWER_SERVE_DOMAIN}. A wildcard certificate is issued by proving control of the domain through a DNS record rather than through a file on the server, so it needs a certificate tool that can write that record. Certbot does this with a plugin for your DNS provider, listed under Certbot's DNS plugins. A managed host that issues wildcard certificates for you does it without any of this.

When you cannot add a wildcard: VIEWER_PROTOTYPE_ORIGIN#

Read the tradeoff first. Subdomain mode is stronger, and it is the mode to use whenever you can. VIEWER_PROTOTYPE_ORIGIN exists for the one case subdomain mode cannot cover: a deployment where you can add one more DNS name and one more certificate SAN, but not a wildcard. Set it to a single alternate origin, for example https://prototypes.example.net, and every prototype is served from that one origin, cross-origin from the Viewer's own shell.

The cost is that all prototypes share that single origin. They share localStorage, IndexedDB, and a cookie jar with each other, and one prototype's JavaScript can script another's. Subdomain mode does not have this, because each prototype gets its own registrable host. So use VIEWER_PROTOTYPE_ORIGIN only when a wildcard is genuinely unavailable, and prefer VIEWER_SERVE_DOMAIN.

To set it up: add one DNS name pointing at the same Viewer (an A/AAAA or CNAME record), add that name to the certificate as a SAN, and set VIEWER_PROTOTYPE_ORIGIN to its full origin. The origin must use the same scheme as VIEWER_PUBLIC_URL and must be on a different registrable domain than the shell. A shell at app.example.com with a prototype origin at prototypes.example.net is fine; prototypes.example.com is not, because a prototype there could set a cookie the shell would receive. The Viewer refuses an unsafe value at boot: an origin equal to the shell's, a different scheme (mixed content), or a same-site sibling.

A public-link project serves correctly on its subdomain with no credential at all. An all-members or invited project gets a short-lived capability appended to its document load (?~c={token}). The Viewer verifies it and sets it as a host-only dsv_cap cookie on the prototype's own subdomain, so every later same-site request from that document, its assets included, is authorized without the Viewer's session cookie ever being involved. On an https deployment that cookie is actually named __Host-dsv_cap: the __Host- prefix is what locks it to this exact host over a secure connection. Plain dsv_cap is used only on http, since a browser rejects a __Host- cookie that isn't marked Secure. See deploying a prototype for the project-access side of this.

This needs VIEWER_SERVE_DOMAIN to be same-site with VIEWER_PUBLIC_URL. "Same-site" means the two share a registrable domain: app.example.com for the Viewer and proto.example.com for VIEWER_SERVE_DOMAIN both share example.com, so this works. A shell at example.com and a serve domain at other.net do not share one, and the browser then silently withholds the dsv_cap cookie: a private prototype's HTML loads, then every one of its assets 404s inside the review iframe. There is no boot-time check for this; a public suffix list would be needed to check it correctly, and none is built in.

Fallback mode's review iframe carries sandbox="allow-scripts allow-forms". Loopback and subdomain mode add allow-same-origin, because the prototype is already on a different origin there, so restoring its own origin gives it nothing that reaches your session.

Note The Viewer only answers a Host header naming one of a closed set built from its own config: VIEWER_PUBLIC_URL's host, its own loopback address on PORT, and {slug}.VIEWER_SERVE_DOMAIN when a serve domain is set. Reaching it through a LAN IP, or through a proxy that rewrites Host, now gets 400 Unexpected host instead of being served. If that happens to you, set VIEWER_PUBLIC_URL to the name people actually type.

Boot prints which mode is active, so you can confirm it picked what you expected. In fallback mode it also warns: a prototype built with a root-absolute asset base will not fully load for a signed-in member there, and the warning names both fixes (set VIEWER_SERVE_DOMAIN, or build the prototype with a relative base).

Safari, and turning the default off#

Safari does not resolve .localhost names the way Chrome and Firefox do. If you are on Safari, open http://localhost:3100 instead of the desde.localhost address. That gives you loopback mode. It is the mode every prototype used before the local default existed, and the rest of this section is about it.

You can also turn the new default off for everyone, not just yourself, by setting VIEWER_PUBLIC_URL=http://localhost:<port> explicitly. That gives every reviewer loopback mode, the same behavior the Viewer had before this default existed.

Loopback mode only works when the browser is on the same machine as the Viewer. A loopback listener binds an address on the Viewer's own host. A browser reaching the Viewer through a published container port, or from a separate remote machine, cannot reach that listener, even though the Viewer itself answered fine. In Docker, publish the prototype port range too, or loopback mode cannot work at all:

-p 127.0.0.1:3101-3120:3101-3120

The Viewer detects a container automatically and falls back when loopback cannot work. At boot it checks VIEWER_LOOPBACK_LISTENERS, which defaults to auto:

  • auto (the default). The Viewer looks for /.dockerenv or /run/.containerenv. If either file exists, it assumes it is in a container and does not open loopback listeners. Prototypes fall back to same-host path mode instead (fallback mode, from the table above).
  • off. Never open loopback listeners, regardless of what the container check finds.
  • on. Always open loopback listeners. Use this only when the browser genuinely shares the Viewer's network namespace, for example a container run with --network host.

This check is a heuristic, not a guarantee. A container runtime that writes neither marker file still looks like a normal host, and the Viewer will try loopback mode and fail. If that happens, set VIEWER_LOOPBACK_LISTENERS=off by hand.

For a real deployment, in a container or on a remote server, do not rely on loopback mode at all: set VIEWER_SERVE_DOMAIN (subdomain mode), or a non-loopback VIEWER_PUBLIC_URL. That gives every prototype a real, isolated origin instead of the degraded same-host fallback.

For loopback mode's boundary (ports are reachable by any process on the machine, and prototypes on one loopback host share cookies with each other across ports), and the full mechanics behind fallback mode's iframe sandbox and content-security-policy, see viewer/README.md's "How prototypes are isolated" section.