205. Sites
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?
Declaring a site
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.
Running a site on its own
A site runs alone with fsr dev or cargo run, under its own prefix with its own layout as the page, so a team develops and tests it without the shell. Alone it keeps what a mount drops: its own [session], [auth], [cache] and not-found.tsx. It has no sign-in but its own [auth], so a guard that redirects to the shell's login lands on a page that is not there.
Its frameworks still come from the shell. The site's import map names React at the shell's URL, so when the generated/shell.json that [site] shell names sits in the shell's app/ beside a vendor/ directory, the host serves that directory at /static/js/vendor and the report lists it under inferred. That root is only for the site running alone. A mount never reads it and a deploy tree never carries it, so the site's artifact and its hash are the same either way.
Running alone is for development and tests. A site is deployed by mounting it: a deploy tree has no shell beside it, so a site deployed by itself has no framework to import.
Running under the real shell is one command too. fsr dev app on a site finds the shell through [site] shell when that contract sits in a shell tree with a configuration. It serves the shell as it stands with the site mounted from its working tree in place of whatever the shell's table says; --under <shell> names a shell elsewhere, its project directory or the deploy tree its fsr bundle wrote. The site team sees their routes inside the real header with the real sign-in, rebuilt on every save. The shell's files are never written to. --alone is the standalone shape above. A shell handed over as a deploy tree carries its development overlay, its mock files and its users file, so its sign-in and its mocked clients work from the copy; a shell whose development clients are real services needs its own local.toml, which the site team does not have, so that client fails on its first request rather than at boot.
Mounting sites in the shell
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 describe the same 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. The shell and its sites share one document, session and 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 site's entry calls enableNavigation too, as every entry does. A second call on a document the navigator is already wired to leaves the navigation that loaded it in place, so the click after it patches as well.
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 the shell and a site share
Two typed things pass between them and neither is a runtime call. The shell's build writes generated/shell.json: every store key its loaders seed, typed as the browser reads it, the import map it serves and the exact version of each framework it vendors. 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.
A site owns its own backends
A site declares [clients] in its own configuration and puts the contract beside it in app/clients/, exactly as a standalone application does. Nothing about the mount changes that: at mount the host runs the same client build over the site's configuration as over the shell's, so a grpc client gets its own GrpcTransport against its own base_url, an OpenAPI client its own HTTP transport and a transport = "mock" client the JSON file beside it.
[clients.ledger]
transport = "grpc"
base_url = "http://ledger.internal:50051"
document = "clients/ledger.proto"In the loader it is services.ledger, because the site's build lowered its plan against its own contract. In the host's table it is billing:ledger, since a site's client names carry the same <name>: prefix as its route ids. So the shell and a site can each have a ledger, pointing at different backends with different contracts and neither can see the other's. The merge is checked: a genuine collision fails the mount and names the site.
Credentials are scoped the same way. A bearer key on a site's client installs an interceptor for that client alone, so a site's token never rides on the shell's calls or another site's.
The exception is a host built with its services supplied directly rather than from configuration. That override replaces every client the process would have built, the sites' included, so it is meant for a test harness and nowhere else.
Where the shell overrides a site
The shell owns the document, so where the two configurations disagree the shell's answer stands. Its import map overrides the site's on any shared specifier, which is what keeps one React in the page. The site's [session], [auth] and [cache] are dropped, along with its not-found.tsx, because the shell already answers those for the whole document. A static root outside the site's own prefix is dropped rather than served, so a site cannot claim /static/js/vendor and shadow the shell's. The roots the host infers are already under the prefix for a site, its vendor/ tree along with its stylesheets and icons, so a site that vendors a package of its own is served it at /billing/static/js/vendor. Every one of those is listed under ignored in the boot report rather than happening quietly.
A mounted site therefore vendors no framework of its own. Its build reads the React version it renders under out of the contract, so there is no second copy to keep in step with the shell's. A site that does vendor one must vendor the version the shell serves. The site's map still names react at the shell's URL. The build refuses a framework specifier pointed anywhere else, so the entry cannot drift from what the shell answers.
Store keys are the exception and the only place a site and the shell can genuinely collide: nothing namespaces them, the map is flat and shared. A site prefixes its own by hand, billing/draft rather than draft, the way the shell's own keys already are.
Cache tags go the other way: a site's tags take its prefix at mount, so invoices in billing's contract is billing:invoices in the shell's one data cache and a write in another site never drops it. Two sites that mean to share a tag write it with an @, tags = ["@catalog"], which every prefix leaves alone, so a write in one site drops what the other cached under it. The boot report lists each @ tag under shared with the methods that cache under it and the ones that drop it, since a shared tag is a coupling two teams agreed to and should be visible to both.
What a version is
An artifact is a deploy tree: the files the host reads and nothing more, laid out the way chapter 303 describes. The configuration goes under config/. The plan, the contracts, the service documents, the message catalogs and every .tera template go under app/. Every static root goes under serve/ at the route it answers. The TypeScript in routes and sources, types and a site's own markdown are build inputs and stay out.
The destinations are derived from the configuration rather than copied from the project. So a site whose static root points at a shared build outside its own directory still packs, because the artifact does not record where that directory sat. A hash taken in a working tree is the hash of what a release copied out of it, because laying a tree out again yields the same tree, so a pin made in development holds against the deployed directory.
fsr sites hash <site> prints that hash with the parts it covers; --files adds every file and digest. A part is a path in the artifact rather than in the project. fsr sites pin <shell> [<name>] writes it into the shell's table instead of leaving it to be copied between two files, replacing whatever the mount pinned before and reporting the move. --layer <name> writes it into config/<name>.toml instead, created when absent, for a table that is committed unpinned and pinned by the deployment that installs its artifacts; the ladder reads the pin from there. Only a name@version artifact is pinned: a mount naming a path is a linked working tree that changes on every build, so a pin there would be stale by the next one. 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.
How a site reaches the shell
A [sites.<name>] row names its artifact one of two ways and a version gets into the cache one of two ways, which gives three shapes. The portal example mounts a site of each.
| Row | Where the artifact comes from | For |
|---|---|---|
artifact = "sites/billing" | The site's working tree, read in place. Reported as version path and never pinned, since it changes on every build. | Developing the shell and its sites together in one repository. |
artifact = "billing@1.4.2", installed | <root>/billing/1.4.2, placed by fsr sites install from an archive before the row moves. | An operator who ships each release by hand or from a job. |
artifact = "billing@1.4.2", fetched | The same directory, filled by the shell from [sites] store when the cache lacks the version. | A release pipeline that publishes archives to a registry: moving the row is the deploy. |
In every shape the site runs inside the shell's process. The shell reads the site's plan, runs its loaders and actions and renders its pages under its own root layout. That is how they share one document, session and navigation. A site that needs a backend of its own gets one through [clients], described above. Its pages still render in the shell.
One sitemap across the sites
robots.txt and the sitemap belong to the origin, so the shell serves them. Its [crawl] names the paths and its document.origin the URLs are absolute against:
[document]
origin = "https://acme.example"
[crawl]
robots = "/robots.txt"
sitemap = "/sitemap.xml"A site needs nothing of its own. Its routes are the shell's routes once mounted, so the shell's sitemap lists them, each fixed route and each value of a route's paths export. The shell finds them by asking itself for every candidate page as a signed-out visitor, through its own middleware and then the site's. So a site page whose middleware sends that visitor to sign in is left out, as billing's /billing/overdue is in the portal. A site route with a parameter and no paths is listed on the shell's boot report as one the sitemap cannot list.
A page's lastmod is the date it names in its head with modified(...), the same in a site as in the shell. cargo test -p portal_react_ts checks the portal's sitemap and what it leaves out, the reports pages among it listed from the site's metrics client.
Deploying a site version
[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. It then writes the hash of what it placed into the mount that names that version, because installing a version is when you mean to ship it; --no-pin leaves the table alone and --layer <name> writes it into config/<name>.toml. A mount still pointing at the previous version is left as it is, since moving the pointer is a separate decision.
The shell can also fetch a version itself. [sites] store names where a version the cache lacks comes from: a directory of <name>-<version>.tar.gz archives against the project root or an http:// or https:// URL answering GET <url>/<name>-<version>.tar.gz. On boot and on every reload the shell fetches each name@version row the cache does not hold, stages it, verifies it the way install does and renames it into place before mounting anything. A fetch that fails refuses the mount and names the row, so a reload leaves the running version serving.
[sites]
root = "/srv/sites"
store = "https://artifacts.example.com/sites"
[sites.billing]
artifact = "billing@1.4.2"
hash = "3a098783bbb3ebc5"FSR_SITES_STORE_HEADER="Authorization: Bearer <token>" puts a header on every fetch, which keeps the token out of the table. Any server answering those names is a store: nginx over a directory, a bucket, an Artifactory generic repository. A store that needs more than a GET, a registry with its own protocol or a company artifact service, is a type implementing snapfire_fsr_sites::Store, one method, fetch(package, version, into). A shell with Rust of its own hands it to mount_all_with and mountable_with. fsr serve has the directory and HTTP stores only.
Then move the row. The shell rereads the table on SIGHUP and on the poll, rebuilds all its tables and swaps them; a request in flight finishes on the old ones. A pinned hash refuses bytes the table did not mean. The pin lives in the shell rather than beside the artifact on purpose: a pin stored inside the artifact would be replaced along with the artifact, so only a pin the shell holds can catch a wrong one. 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 needs a restart. 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. A signal gives no such answer.
fsr sites reload [<shell dir>] --host <url> posts it, which is the same thing from a laptop on the VPN rather than a shell on the box. --host repeats for a fleet and the instances are asked one at a time, stopping at the first refusal: a 409 says what was published is bad, so carrying on would ship it to the rest. --all continues anyway, for the case where one instance is already known to be broken. With no --host it reads the shell's own server.listen, which is the same-machine case.
fsr sites list <shell dir> --host <url> compares the table against each instance: every row of the table beside what each instance is actually serving, marked ok, lags or absent, exiting non-zero when any of them disagrees. It does the monitor's job described above as a command. It exists only when the host carries the feature and the application installed a sites mounter.
Checking a site before it swaps in
Every boot check runs on a reload, but none of them runs a loader, so a release whose loader fails on every request passed the swap and failed per request. A reload a deploy drives now renders the candidate first. The shell does part of it unasked: every source it can warm is loaded and every route that reads nothing of the request is rendered, a pattern with a parameter once per set its paths export returns. A backend that is down, a timeout or a crash refuses the candidate; a page that answers not_found or unauthorized to an anonymous visitor is answering and passes.
A route that reads the request cannot be rendered without a request, so the site names the ones that matter in its own configuration, since the team that owns the routes knows which, and the artifact carries the list to every shell that mounts it:
[site]
name = "billing"
at = "/billing"
probe = ["/billing", "/billing/invoice/1", { path = "/billing/overdue", as = "probe" }]A string is a path rendered anonymously. A table adds as, a subject the shell signs a throwaway session in as, with no claims and no password, so a guarded page is probed as a signed-in visitor. A probe someone named must answer the page it asked for: any failure refuses the reload and the refusal names the site and the path. Middleware does not run under a probe, so the guard that would redirect an anonymous visitor to the login page is not what is being tested; the page is.
A shell with Rust of its own adds checks of its own with HostBuilder::reload_check: a ReloadCheck gets the candidate, renders paths through it anonymously or as a subject, reads its report and answers Ok or the reason. The portal registers one that renders / and refuses a candidate whose directory lists no teams, which is the shell checking itself, since its own routes are probed the same way as a site's.
POST /__fsr/sites/reload answers a refused candidate with 409 and the probe's reason, so fsr sites reload prints which site's which route broke and stops at that instance while the version it was serving keeps serving. SIGHUP and the poll, which are what a moved row goes through, log the same reason and fsr sites list --host shows the instance lagging behind the table. The dev loop's reload is not probed: a developer wants the broken page on screen with its error, not a refusal.
Protecting the administrative routes
/__fsr/ is the host's administrative surface and the framework does not guard it. fsr sites list --host and fsr sites reload forward credentials with --header "Name: Value", which repeats. $FSR_SITES_HEADER carries one that should stay out of shell history. They create no credentials of their own, because the host has nothing to authenticate against; the header is for whatever sits in front of the host. 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.
A proxy rule does not cover a host reached around the proxy or a neighbour on the same network. 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. A sidecar or another pod on the same network is neither the public internet nor an operator, so the allow list is what keeps it out, not 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. Click Blog, Status and then Billing again: each hop is a payload navigation and the header's team count survives all of them. Now edit the blog's index page, run fsr build sites/blog/app and kill -HUP the portal. The blog is a linked tree, so GET /__fsr/sites shows a new hash and the page shows your edit, with your session intact. For a release rather than an edit, pack status as 1.1.0 into the store and move [sites.status] artifact to status@1.1.0: within the poll the portal fetches it and /__fsr/sites reports the new version. Ship a broken billing: copy sites/billing aside, make its ledger.mock.json fail listInvoices, pack it as 1.1.0 into the store and move the row. The poll fetches it, the warm pass of billing's loader fails with the ledger's message, the log says so and 1.0.0 keeps serving; fsr sites list --host shows the instance lagging on purpose. Fix the mock, pack 1.1.1 and the next poll swaps it in. Then click Reports and open a report: its rows stream in under the portal's header, read from a metrics client the site declares and the portal does not.