205. Sites: one product, many teams
The question this chapter answers: how does a team own its part of the product, deploy it on its own schedule and still share the header, the sign-in and the navigation with everyone else?
A site is an application with a name
Add a [site] section to the configuration beside an application and it becomes a site. The build prefixes every id it emits with the name and puts every route under the prefix, so two sites can hold the same files.
[site]
name = "billing"
at = "/billing"
shell = "../portal/app/generated/shell.json"Nothing in the site's TypeScript changes. Routes are still routes/invoice/[id]/page.tsx, a loader still calls services.ledger, a page still calls actions.invoice.pay. The paths are literal, as they are everywhere in fsr: a link is /billing/invoice/1 and the middleware compares against /billing/overdue. Only the plan file and the browser bundle spell the prefix, billing:routes/invoice/[id]/page.tsx#default and billing:invoice.pay, and the host reads them that way.
A site runs alone with fsr dev or cargo run, its own layout as the page, so a team develops and tests it without the shell.
The shell mounts it
The shell is an application the platform team owns: the root layout, the sign-in, the locales, the vendor tree. Its configuration names the sites it mounts.
[sites]
root = "/srv/sites"
poll = "30s"
[sites.billing]
artifact = "billing@1.4.2"
hash = "3a098783bbb3ebc5"That row and the site's own [site] block are two halves of one link, so fsr writes both rather than leaving two files to keep in step. fsr sites link <shell> <site> --at /billing writes them, refusing a shell that is itself a site, a site that mounts sites, a name the table already holds and a site whose [site] names somewhere else; fsr sites unlink <shell> billing takes them back out, or leaves the site's own block with --keep-site. fsr sites list <shell> reports the table resolved, each row with the prefix its artifact claims, its version, its hash and a note when it does not hold, then every version the cache holds.
At boot the host reads each artifact, the directory a site's build leaves behind, checks that it is the site it claims to be, refuses one carrying engine-owned rows or a leaked server module, nests its routes under the shell's root layout and adds its rows to the shell's tables. One document, one session, one navigation: a click from the portal's directory into /billing is a payload navigation that keeps the header's island and imports the site's islands on the way, from an E row the payload carries.
The shell's middleware runs first on every path, with request.site naming the site a path belongs to. The site's runs second on the same path and may only narrow what the shell allowed. The shell's sign-in reaches the site's loaders and middleware as identity, so a site guards a route without ever seeing a password.
What crosses the seam
Two things, both typed, neither a runtime call. The shell's build writes generated/shell.json: every store key its loaders seed, typed as the browser reads it, plus the import map it serves. A site names that file with [site] shell and its build writes generated/shell.d.ts, so a site reads the shell's store with the right type and imports React from the shell's URL rather than its own copy.
import { key } from "@snapfire/fsr-client/store";
import type { ShellStore } from "@generated/shell";
export const who = key<ShellStore["portal/who"]>("portal/who");The other direction is the site's own contract: its clients, its cache tags, its types, all prefixed, merged into the shell's registry without a collision.
What a version is
An artifact is the files the host reads and nothing more: the configuration, the plan, the contracts, every static root and the import map. Routes, sources, types and a site's own markdown are build inputs and stay out. The set is derived from the site's configuration rather than written down twice, so a hash taken in a working tree is the hash of what a release copied out of it, and a pin made in development still holds against the deployed directory.
fsr sites hash <site> prints that hash with the parts it covers, and every file and digest under --files. fsr sites pack <site> --version 1.4.2 writes the artifact as a gzipped tar carrying a manifest of every file with its size and sha256. Packing the same tree twice writes the same bytes, so two builders can be compared. The hash is over that listing rather than over the bytes, so a manifest alone yields it and a pin is checked before anything is downloaded.
A deploy is a pointer moved
[sites] root is a cache: <root>/<name>/<version>, which is where artifact = "billing@1.4.2" already resolves. fsr sites install <shell> billing-1.4.2.tar.gz --keep 3 unpacks into a dot-prefixed staging directory beside the destination, verifies every file against the manifest and the listing against the hash, and only then renames it into place. A fetch that dies leaves nothing a mount can see; one that arrives wrong leaves the running version serving and names the file that disagreed. --keep sweeps older versions and never the one in use, so a rollback is offline.
Where the bytes come from is a seam, not a fixed answer: a directory of archives and a single archive ship, and an object store, a registry or a company artifact service is one method, fetch(package, version, into), with the install path around it unchanged.
Then the row moves. The shell rereads the table on SIGHUP and on the poll, rebuilds its tables whole and swaps them; a request in flight finishes on the old ones. A pinned hash refuses bytes the table did not mean. GET /__fsr/sites lists every mounted site with its version and hash, so a monitor compares the fleet against the table and resends a signal when an instance lags.
SIGHUP reloads everything, the shell's own configuration and plan included, which is the wrong operation for a site deploy: one team's signal would ship whatever state the shell's files are in. A sites-only reload reads the artifacts again and rebuilds against the shell as the process booted it, its configuration, plan and contracts held rather than reread, so a half-written shell plan on disk cannot reach the tables. A shell change is a restart, which is the cost of the guarantee. For an operator who cannot signal a process at all, POST /__fsr/sites/reload does the same and answers with the mounted rows, or a 409 and the reason when the candidate is refused, which is what a signal cannot tell you. It exists only when the host carries the feature and the application installed a sites mounter.
Keep the administrative routes off the internet
/__fsr/ is the host's administrative surface and the framework does not guard it. GET /__fsr/sites reports every mounted site with its version and hash, and the reload route changes what the process serves. Neither authenticates, so restricting them is the deployment's job at whatever sits in front of the host, and a host published straight to the internet with no proxy exposes both. Deny the prefix and allow it only from the network the operators are on; 404 is the better answer than 403, since it does not confirm the surface is there.
location ^~ /__fsr/ {
allow 10.0.0.0/8;
deny all;
proxy_pass http://127.0.0.1:8080;
}^~ matters in nginx: a plain location /__fsr/ loses to any regex location and has to win against the catch-all that proxies everything else. Apache wants a <LocationMatch "^/__fsr/"> with Require ip, placed before the ProxyPass for / since the first match wins. Caddy wants a handle on path /__fsr/* that responds 404 to anything outside the range. HAProxy wants acl fsr_path path_beg /__fsr/ with an http-request deny deny_status 404. Envoy wants the prefix on its own route with a direct_response, or an RBAC filter with a url_path permission. Traefik wants an ipAllowList middleware on a PathPrefix(`/__fsr/`) router declared before the catch-all. Kubernetes ingress-nginx takes the nginx block through a server-snippet annotation, or a separate ingress for the prefix with whitelist-source-range.
Two things a proxy rule does not cover. A host bound to 0.0.0.0 is reachable around the proxy, so bind it to loopback or to the interface the proxy is on. And a sidecar or another pod on the same network is not the public internet but is not an operator either, which is what the allow list is for rather than the deny.
The lab
Build and run portal_react_ts as its README says, then open /, sign in as alice and click Billing. Watch the header stay while the invoices arrive, open the browser's network view and find the payload with its E row, then the site's main.js loaded from /billing/static/js/app/. Click Overdue: the site's guard let you through on the portal's sign-in. Now stop the portal, edit the billing site's overdue page, rebuild it and kill -HUP the portal: GET /__fsr/sites shows a new hash and the page shows your edit, with your session intact.