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 directory GET serves this file from that directory instead of a listing. Set it alone for classic default-document hosting (GET /site//site/index.html). Defaults to index.html when spaFallback is on.
  • spaFallback: true — extension-less misses (client-side routes like /users/42/profile) serve the root app shell with 200, so your SPA router takes over. Misses with an extension (/missing.js) stay 404 — a real asset that isn't there shouldn't masquerade as the app.
  • listings: false — suppresses the dir+json listing (returns 404), so a public site isn't browsable. Defaults to true.

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 Accept must name application/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, the rs2 CLI, the discovery surface) flips to the listing.
  • listings: false still wins. It's a hard suppression: an explicit Accept: application/vnd.rs2.dir+json against a listings: false mount is still a 404, never a leaked listing. Negotiation can surface a listing the default doc hides; it can't override a listing you've forbidden.
  • Directory GET responses carry Vary: 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/readme resolves docs/readme.md (or .html, .json, … — any servable type), returns 200, and advertises the real path in a Content-Location header. Defaults to false.

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.md and page.html exist — the variant is chosen by the request's Accept header (Accept: text/html → the .html), falling back to a fixed preference order (documents before data before assets) when Accept doesn'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/page stores page.html (extensionPriority[0]), regardless of the request Content-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) and Content-Location: /site/page.html (where it actually landed).
  • /site/page and /site/page.html are the same resource. DELETE /site/page removes the pinned file; GET /site/page.html serves it directly.
  • The declared Content-Type is discarded on an extension-less PUT — the file is served as extensionPriority[0]'s type. If you need a specific type, PUT with that extension (PUT /site/page.json).
  • An extension-less PUT with friendlyUrls on but no extensionPriority configured is a 400 — there's no slot to pin to. (With friendlyUrls off, 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 →