4.3 Static-site & SPA hosting from a file mount
Serving a website or single-page app is not a separate service — it's a
file mount with a few config keys. This is the "variants are config" idea
(1.2) in practice: the same store that holds your files can present them as a
site. A mount configured this way declares the static-site facet on discovery.
The config
{ "path": "/site", "service": "file",
"config": {
"defaultResource": "index.html",
"spaFallback": true,
"listings": false,
"access": { "read": "all" },
"caching": { "mode": "cache", "maxAgeSeconds": 300, "public": true }
} }
Three keys turn a store into a site:
defaultResource— a directoryGETserves this file from that directory instead of a listing. Set it alone for classic default-document hosting (GET /site/→/site/index.html). Defaults toindex.htmlwhenspaFallbackis on.spaFallback: true— extension-less misses (client-side routes like/users/42/profile) serve the root app shell with200, so your SPA router takes over. Misses with an extension (/missing.js) stay404— a real asset that isn't there shouldn't masquerade as the app.listings: false— suppresses thedir+jsonlisting (returns404), so a public site isn't browsable. Defaults totrue.
Listing files behind a default document
Setting defaultResource means GET /site/ serves index.html — which
shadows the dir+json listing. That's right for a browser, but it leaves the
store unenumerable for tooling and the discovery surface. The escape hatch is
content negotiation: a directory GET that explicitly asks for the listing
type gets the listing instead of the default doc, at the same URL.
GET /site/ → index.html (default doc)
GET /site/ Accept: application/vnd.rs2.dir+json → dir+json listing
Precisely:
- The
Acceptmust nameapplication/vnd.rs2.dir+json. A wildcard (*/*,application/*) does not count — browsers send…,*/*, so normal navigation keeps getting the default doc. Only a deliberate request (an agent, thers2CLI, the discovery surface) flips to the listing. listings: falsestill wins. It's a hard suppression: an explicitAccept: application/vnd.rs2.dir+jsonagainst alistings: falsemount is still a404, never a leaked listing. Negotiation can surface a listing the default doc hides; it can't override a listing you've forbidden.- Directory
GETresponses carryVary: Accept, so a shared cache won't hand the HTML doc to a listing request (or vice-versa).
SPA fallback, precisely
The rule is deliberately asymmetric:
| Request | With spaFallback: true |
|---|---|
GET /site/users/42 (no extension, doesn't exist) |
200 with the root app shell |
GET /site/app.css (has extension, doesn't exist) |
404 |
GET /site/ (directory) |
defaultResource (or the dir+json listing — see above — when Accept names it) |
So client-side routes resolve to your app, while genuine missing assets fail loudly — which is what you want for cache-busting and debugging.
Friendly (extension-less) URLs
friendlyUrls: true— lets any stored file be served at its path without the type suffix.GET /site/docs/readmeresolvesdocs/readme.md(or.html,.json, … — any servable type), returns200, and advertises the real path in aContent-Locationheader. Defaults tofalse.
Resolution is conservative:
- An exact match always wins — a real file literally named
readme(no extension) is served as-is, and a request that already carries an extension is never rewritten. Friendly resolution only runs when the lookup misses and the final path segment has no.. - On a collision — say both
page.mdandpage.htmlexist — the variant is chosen by the request'sAcceptheader (Accept: text/html→ the.html), falling back to a fixed preference order (documents before data before assets) whenAcceptdoesn't decide.
friendlyUrls is independent of static-site mode — it's just a presentation
option on any file mount, and doesn't declare the static-site facet on its
own. It does compose cleanly with spaFallback: a real friendly file is tried
first, so it beats the SPA shell, and only a genuine miss falls through to the
app shell. Because resolution probes by name (not by listing), it works even
with listings: false.
Extension-less PUTs and the round-trip invariant
With friendly URLs on, what happens when you write to an extension-less slug? You set the priority explicitly and the slug is pinned to it:
{ "path": "/site", "service": "file",
"config": { "access": { "read": "all", "write": "A" },
"friendlyUrls": true,
"extensionPriority": ["html", "md", "json"] } }
extensionPriority— extensions (no dots) in preference order. Its first entry is the canonical slot:PUT /site/pagestorespage.html(extensionPriority[0]), regardless of the requestContent-Type. The list also fronts the GET probe order.
This buys a clean invariant: a GET with no Accept (or Accept: */*) to an
extension-less slug always returns the most recent extension-less PUT to that
slug. Because the write lands in the top-priority slot and a no-Accept GET
probes that slot first, nothing ranks above it — a later PUT /site/page.md
can't dislodge it. (A GET that sends a specific Accept is explicitly asking for
a different representation and may get the sibling — that's on the caller, and
outside the invariant.)
Consequences worth knowing:
- The PUT response advertises
Location: /site/page(the request URL, still the canonical address) andContent-Location: /site/page.html(where it actually landed). /site/pageand/site/page.htmlare the same resource.DELETE /site/pageremoves the pinned file;GET /site/page.htmlserves it directly.- The declared
Content-Typeis discarded on an extension-less PUT — the file is served asextensionPriority[0]'s type. If you need a specific type, PUT with that extension (PUT /site/page.json). - An extension-less PUT with
friendlyUrlson but noextensionPriorityconfigured is a400— there's no slot to pin to. (WithfriendlyUrlsoff, the slug is taken literally and stored as-is, no pinning.)
Pair it with caching
For a real site you almost always add the universal caching config (9.4). The
CDN-style setup:
"caching": { "mode": "cache", "maxAgeSeconds": 86400, "public": true, "immutable": true }
public is honored only on openly readable mounts (the host clamps it to
private otherwise), which is why static sites set access.read: "all".
Deploying content
There's no special publish step — content deploys are just store writes:
PUT /site/index.html
PUT /site/assets/app.js
PUT /site/assets/app.css
…or any sync tool that drives the store contract (3.3). Build locally, PUT the
output, done.
Next: the other everyday store, data, which holds validated JSON.
← Previous: 4.2 Ranges, ETags, conditional GETs · Manual home · Next: 4.4 The data service →