Sites
Declaring a site
Add a [site] block to a configuration and the application becomes a site: every id the build emits is prefixed with its name and every route sits under its path.
[site]
name = "docs"
at = "/fsr/docs"
shell = "../../app/generated/shell.json"Nothing in the TypeScript changes. Routes are still routes/[slug]/page.tsx and a link is still a literal path. Only the plan and the bundle spell the prefix, as docs:routes/[slug]/page.tsx#default.
A site runs alone under fsr dev with its own layout as the page, so it can be developed without the application that will mount it.
Mounting a site in a shell
The shell is the application that owns the document: the root layout, the sign-in and the vendor tree. Its configuration names what it mounts.
[sites.docs]
artifact = "sites/docs"That row and the site's own [site] block are two halves of one link, so fsr writes both at once rather than leaving you to keep two files in step:
fsr sites link www www/sites/docs --at /fsr/docs
fsr sites unlink www docslink refuses 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. unlink takes both halves back out. With --keep-site it leaves the site's own block alone.
fsr sites list www reports the resolved table: each row with the prefix its artifact claims, the version, the hash and a note when it does not hold, followed by every version the cache holds. Given --host <url> it compares instead. It puts each instance's mounted rows beside the table, marks them ok, lags or absent and exits non-zero when any of them disagrees.
An artifact path resolves against the shell's config root, so a site kept inside the shell resolves identically in development and in a release. At boot the host reads each artifact and checks it is the site it claims to be. It refuses one carrying engine-owned rows or a leaked server module, then grafts its routes into the shell's root layout.
The nesting is exactly one level. Only the shell's root layout wraps a site; a deeper layout in the shell does not.
What the shell and site share
The shell and the site exchange typed information in both directions at build time. There are no runtime calls between them.
The shell's build writes generated/shell.json: every store key its loaders seed, with the type the browser reads, its import map and the exact version of each framework it vendors. A site names that file and its build writes generated/shell.d.ts in return, so the site reads the shell's store with the right type and imports React from the shell's URL rather than shipping a second copy.
The framework versions are there so a site does not have to track the shell's by hand. A mounted site vendors no framework at all: the shell's import map overrides its own on every shared specifier, so a second copy is bytes the browser never loads. Its build reads the version it renders under out of the contract instead. Vendoring one anyway at a different version is a build error naming both copies, as is pointing a shared specifier at the site's own URL or pinning a version the shell does not serve.
import { key } from "@snapfire/fsr-client/store";
import type { ShellStore } from "@generated/shell";
export const theme = key<ShellStore["site/theme"]>("site/theme");The other direction is the site's own contract. Its clients, cache tags and types are prefixed and merged into the shell's registry without collision.
What the shell keeps
The shell owns the document, the session and navigation. A click from the shell into the site is a payload navigation that keeps the header's island alive and imports the site's entry on the way.
That entry calls enableNavigation too, as every entry does. A second call on a document the navigator already wired leaves the first call's wiring in place. The next click after moving into the site also patches in place. Moving between two mounted sites keeps the header throughout.
The shell wins on every collision. Its import map overrides the site's on any shared specifier and the site's [session], [auth], [cache] and not-found.tsx are dropped. A static root outside the site's own prefix is dropped too, so a site cannot claim /static/js/vendor and shadow the shell's. The roots the host infers already sit under the prefix, so a package the site vendors for itself is served at /<site>/static/js/vendor, its stylesheets and icons the same way. All of it is listed under ignored in the boot report.
Store keys are the exception: nothing namespaces them. A site must prefix its own by hand.
Artifacts and versions
An artifact is a deploy tree: the files the host reads, laid out by what each file is rather than by where it sat in the project. The configuration goes under config/, the plan and the contracts under app/ and every static root under serve/ at the route it answers. Routes, sources, types and markdown are build inputs and stay out.
Destinations are derived instead of copied. A site whose static root points at a shared build outside its own directory still packs, because the tree does not record where that directory sat. Laying a tree out again also yields the same tree, so a hash taken in a working tree is the hash of what a release copied out of it.
fsr sites hash www/sites/docs
fsr sites pack www/sites/docs --version 1.4.0hash prints the hash, the parts it covers and, with --files, every file and its digest. pack writes the whole thing as a gzipped tar with a manifest at its root, listing each 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 the bytes, which means a manifest alone yields it: a pin can be checked before anything is downloaded.
Deploying a version
[sites] root is a cache: <root>/<name>/<version> per installed version, which is where artifact = "docs@1.4.0" already resolves.
fsr sites install www docs-1.4.0.tar.gz --keep 3An install unpacks into a dot-prefixed staging directory beside its destination. It verifies every file against the manifest and the whole listing against the hash. Only then does it rename the directory into place. If a fetch dies, no mount sees any of it. If the archive arrives wrong, the running version keeps serving and the install says which file disagreed. --keep sweeps older versions, never the one in use, so a rollback needs no network.
It then pins what it placed, writing the hash into the mount naming that version, because install is when the hash is computed and when you intend to ship that version. --no-pin leaves the table alone. A mount still pointing at the previous version is untouched, since moving the pointer is a separate decision. fsr sites pin www docs makes that decision on its own. Only a name@version mount can be pinned: a path is a linked working tree that changes on every build, so a pin there would be stale after the next build.
The pin lives in the shell instead of beside the artifact. A pin inside the artifact would be replaced by whoever replaced the artifact, so it is only useful as a statement the shell makes about the site.
The source of the archive is pluggable. A directory of archives and a single archive ship with the framework. An object store, a registry or a company artifact service needs one method, fetch(package, version, into). The install path around it does not change.
Then move the row and reload. The host rebuilds its tables whole and swaps them; a request in flight finishes on the old ones. A pinned hash refuses bytes that do not match it. GET /__fsr/sites lists what is actually mounted, with version and hash, so a monitor can compare the fleet against the table.
Reloading the sites without the shell
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 happen to be in.
A sites-only reload reads the artifacts again and rebuilds against the shell exactly as the process booted it. The shell's configuration, plan and contracts are held values, so an edited configuration or a half-written plan on disk cannot reach the tables through it. A shell change needs a restart.
For an operator who cannot signal the process, such as in a container with no exec or on a platform with no restart hook, there is a route:
curl -X POST /__fsr/sites/reloadIt answers with the mounted rows. When the candidate is refused it answers 409 with the reason, which a signal cannot tell you.
The route exists only when the dependency has the feature and the application has installed the mounter. A host missing either has no route and no reload. The dependency carries the feature:
snapfire_fsr_sites = { version = "0", features = ["reload_route"] }The application installs the mounter, which tells the host how to mount the sites a second time:
let builder = snapfire_fsr_sites::mountable(builder);
snapfire_fsr_sites::mount_all(builder)?mount_all on its own mounts the sites at boot and leaves the process with no way to do it again.
fsr sites reload www --host <url> posts it. It does the same thing from a laptop instead of a shell on the box. --host repeats for a fleet and the instances are asked one at a time, stopping at the first refusal, since a 409 says what was published is bad and carrying on would ship it to the rest. --all continues anyway, for when one instance is already known to be broken. With no --host it reads the shell's own server.listen.
Protecting the administrative routes
/__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. The reload route above changes what the process serves. Neither authenticates. Restricting them is the deployment's job, done at whatever sits in front of the host. A host published straight to the internet with no proxy exposes both. fsr sites list --host and fsr sites reload forward credentials to it 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 there is nothing here to authenticate against: the header is for whatever you put in front.
Add a rule wherever your traffic already passes that denies the prefix except from the network your operators are on.
nginx. ^~ matters: a plain location /__fsr/ loses to any regex location. This one has to win against the catch-all that proxies everything else.
location ^~ /__fsr/ {
allow 10.0.0.0/8;
deny all;
proxy_pass http://127.0.0.1:8080;
}Apache. Place it before the ProxyPass for /, since the first match wins.
<LocationMatch "^/__fsr/">
Require ip 10.0.0.0/8
</LocationMatch>Caddy.
@fsr path /__fsr/*
handle @fsr {
@denied not remote_ip 10.0.0.0/8
respond @denied 404
reverse_proxy 127.0.0.1:8080
}HAProxy.
acl fsr_path path_beg /__fsr/
acl operators src 10.0.0.0/8
http-request deny deny_status 404 if fsr_path !operatorsEnvoy. Match the prefix /__fsr/ on its own route and give it a direct_response of 404. You can also send it to a cluster only the operators' listener reaches. An RBAC filter with a url_path prefix permission is the equivalent where the mesh already runs one.
Traefik. Put an ipAllowList middleware on a router whose rule is PathPrefix(`/__fsr/`), declared before the catch-all router so it matches first.
Kubernetes ingress-nginx. Use a server-snippet annotation carrying the nginx block above. A separate ingress for the prefix with whitelist-source-range also works.
Answering 404 rather than 403 is the better default, since it does not confirm the surface is there.
A proxy rule does not cover everything. 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 not the public internet, but it is not an operator either. That is why the rule allows only the operators' network instead of only denying the public one.
Try it
Run curl /__fsr/sites on this site. The docs row is these pages, mounted into the main application at /fsr/docs from sites/docs inside it.