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.

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, plus its import map. 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.

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.

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. Static roots outside the site's own prefix are dropped too. 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 the files the host reads, and nothing else counts toward its hash: the configuration, the plan, the contracts, every static root and the import map. Routes, sources, types and markdown are build inputs and stay out. The set is derived from the site's own configuration rather than listed a second time, 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.

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. It exists only when the host was built with 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 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.

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.