Advanced · for everyone

Sites: one product, many applications

A site is an application with a name

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.

toml
[site]
name = "docs"
at = "/fsr/docs"
shell = "../../app/generated/shell.json"

Nothing in the TypeScript changes. Routes are still routes/[slug]/page.tsx, 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.

The shell mounts it

The shell is the application that owns the document: the root layout, the sign-in, the vendor tree. Its configuration names what it mounts.

toml
[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:

text
fsr sites link www www/sites/docs --at /fsr/docs
fsr sites unlink www docs

link 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, or leaves the site's own block alone with --keep-site.

fsr sites list www reports the table resolved: 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 stops being a dump and becomes a comparison, putting each instance's mounted rows beside the table and marking them ok, lags or absent, exiting 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, checks it is the site it claims to be, refuses one carrying engine-owned rows or a leaked server module, and grafts its routes into the shell's root layout.

shell#documentthe one documentroutes/layout.tsxnavbar, theme, footerthe shell's own pageat /the site's whole subtreedocs:routes/layout.tsx and belowone session, one navigationslot: contentgrafted into a cloneof the same slot

The nesting is exactly one level. Only the shell's root layout wraps a site; a deeper layout in the shell does not.

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, 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.

ts
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, its cache tags, its types, all prefixed, merged into the shell's registry without collision.

What the shell keeps

One document, one session, one 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 one's spine in place, so the click after the hop patches as well and hopping between two mounted sites keeps the header through all of it.

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.

What a version is

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/, every static root under serve/ at the route it answers. Routes, sources, types and markdown are build inputs and stay out.

Destinations being derived rather than copied is what makes two things work. A site whose static root points at a shared build outside its own directory still packs, because where that directory sat is not what the tree records. And laying a tree out again yields the same tree, so a hash taken in a working tree is the hash of what a release copied out of it.

text
fsr sites hash www/sites/docs
fsr sites pack www/sites/docs --version 1.4.0

hash 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.

A deploy is a pointer moved

[sites] root is a cache: <root>/<name>/<version> per installed version, which is where artifact = "docs@1.4.0" already resolves.

text
fsr sites install www docs-1.4.0.tar.gz --keep 3

An install unpacks into a dot-prefixed staging directory beside its destination, verifies every file against the manifest and the whole 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 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 the one moment when computing a hash and meaning to ship that version are the same act. --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 is 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 is stale by the next one.

The pin lives in the shell rather than beside the artifact on purpose. A pin inside the thing it pins is replaced by whoever replaced the artifact, so it is worth something only as a statement the shell makes about the site.

Where the bytes come from is a seam rather than a fixed answer. A directory of archives and a single archive ship with the framework; an object store, a registry or a company artifact service is one method, fetch(package, version, into), and 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 the table did not mean. 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 is a restart, which is the honest cost.

For an operator who cannot signal the process, a container with no exec, a platform with no restart hook, there is a route:

text
curl -X POST /__fsr/sites/reload

It answers with the mounted rows, or a 409 and the reason when the candidate is refused, which is what a signal cannot tell you.

Two things switch it on. A host missing either has no route and no reload. The dependency carries the feature:

toml
snapfire_fsr_sites = { version = "0", features = ["reload_route"] }

and the application installs the mounter, which is what tells the host how to mount the sites a second time:

rust
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, which is the same thing from a laptop 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, 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.

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 above changes what the process serves. Neither authenticates. 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. 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.

The rule is one line wherever your traffic already passes. Deny the prefix, and allow it only from the network your operators are on.

nginx. ^~ matters: a plain location /__fsr/ loses to any regex location, and this has to win against the catch-all that proxies everything else.

nginx
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.

apache
<LocationMatch "^/__fsr/">
  Require ip 10.0.0.0/8
</LocationMatch>

Caddy.

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.

haproxy
acl fsr_path path_beg /__fsr/
acl operators src 10.0.0.0/8
http-request deny deny_status 404 if fsr_path !operators

Envoy. Match the prefix /__fsr/ on its own route and give it a direct_response of 404, or 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. An ipAllowList middleware on a router whose rule is PathPrefix(`/__fsr/`), declared before the catch-all router so it matches first.

Kubernetes ingress-nginx. A server-snippet annotation carrying the nginx block above, or a separate ingress for the prefix with whitelist-source-range.

Answering 404 rather than 403 is the better default, since it does not confirm the surface is there.

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

curl /__fsr/sites on this very site. The docs row is these pages, mounted into the main application at /fsr/docs from sites/docs inside it.

Built with SnapFire FSR. Pure Rust runtime, zero Node.js on the server.

Proudly Created by Excerion Sun LLC