Updated the documentations in the Website service target.

This commit is contained in:
2026-08-30 08:37:19 +02:00
parent 8a9402e8f1
commit 5a833be33d
7 changed files with 41 additions and 43 deletions
+29 -30
View File
@@ -38,14 +38,16 @@ The persistence backend runs as a `Fluent` service inside the application's Serv
Requests pass through the middleware chain in this order (outermost first), then reach the routes:
```
LogRequestsMiddleware
→ SecurityHeadersMiddleware (security headers on every response)
VaryMiddleware (marks every response as varying on Accept-Encoding)
ResponseCompressionMiddleware (gzip/deflate above the size threshold)
LocalizationMiddleware (negotiates the request's language)
NotFoundMiddleware (renders the localized not-found page on .notFound)
FileMiddleware (serves Resources/Static)
RootController (GET / → landing page)
HealthController (GET /health → liveness, GET /health/ready → readiness)
→ SecurityHeadersMiddleware (security headers on every response)
HTTPSRedirectMiddleware (301 to site.origin when forwarded over plain HTTP)
TrailingSlashRedirectMiddleware (301 to the path without a trailing slash)
VaryMiddleware (marks every response as varying on Accept-Encoding)
ResponseCompressionMiddleware (gzip/deflate above the size threshold)
LocalizationMiddleware (negotiates the request's language)
→ NotFoundMiddleware (renders the localized not-found page on .notFound)
→ FileMiddleware (serves Resources/Static)
RootController (GET / → landing page)
HealthController (GET /health → liveness, GET /health/ready → readiness)
```
The router is created with `.autoGenerateHeadEndpoints`, so each of those `GET` routes gets a `HEAD` sibling for free.
@@ -63,7 +65,9 @@ Four of those are page-authored, optional, and **omitted by default** — the re
The fifth, `analytics`, is *configuration*-authored rather than page-authored: the executable builds an `Analytics` from the `analytics.*` keys and hands it to `RootController` and `NotFoundMiddleware`, which pass it to both pages. It renders as a `<link rel="preconnect">` plus a deferred `<script>` carrying the [Umami](https://umami.is) `data-` attributes, and — unlike the structured data — it *is* executable, so the `Content-Security-Policy` must allow its origin. It is empty by default; see [Analytics](#analytics) for how to turn it on.
What the pages *do* ship is in `Page+Defaults` (`Sources/Library/Internal/Extensions`), shared by every page: the document `lang`, the favicon / SVG icon / apple-touch-icon / web-manifest links, and the two `theme-color` metas.
What the pages *do* ship is in `Page+Defaults` (`Sources/Library/Internal/Extensions`), shared by every page: the document `lang`, the `preloadedFonts` links, the favicon / SVG icon / apple-touch-icon / web-manifest links, and the two `theme-color` metas. Those live in `siteMetadata`, which `metadata` returns unchanged — a page that adds head tags of its own composes `siteMetadata` rather than replacing it.
`preloadedFonts` is empty until the site ships fonts. Listing one emits `<link rel="preload" as="font" crossorigin>` at `/font/<name>.woff2`, deliberately unversioned: a preload URL must match the stylesheet's `@font-face` source exactly, or the browser fetches the font twice. List only the faces the stylesheets actually render — a subset gated by a `unicode-range` no page reaches would add a download that never otherwise happens.
## Configuration
Configuration is read through [swift-configuration](https://github.com/apple/swift-configuration), **highest precedence first**:
@@ -88,12 +92,14 @@ A dotted config key maps to an environment variable by upper-casing, splitting c
### Static file caching
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `cache.maxAge.asset` | `CACHE_MAX_AGE_ASSET` | `31536000` (1 year) | `max-age` for fingerprinted assets (CSS, JS) and fonts; also marked `immutable`. The pages reference CSS/JS through content-versioned URLs (`?v=<token>`), so a deploy busts them by changing the URL. |
| `cache.maxAge.asset` | `CACHE_MAX_AGE_ASSET` | `31536000` (1 year) | `max-age` for the fingerprinted assets (CSS, JS, MP4, JPEG, WebP) and fonts; also marked `immutable`. The pages reference them through content-versioned URLs (`?v=<token>`), so a deploy busts them by changing the URL. Fonts are immutable subset files, preloaded unversioned. |
| `cache.maxAge.text` | `CACHE_MAX_AGE_TEXT` | `3600` (1 hour) | `max-age` for the remaining `text/*` assets (e.g. `robots.txt`), which keep unversioned URLs; also marked `must-revalidate`. |
| `cache.maxAge.image` | `CACHE_MAX_AGE_IMAGE` | `604800` (1 week) | `max-age` for images (ICO, PNG, SVG). |
| `cache.maxAge.image` | `CACHE_MAX_AGE_IMAGE` | `604800` (1 week) | `max-age` for the remaining images — the icons (ICO, PNG, SVG), which a browser fetches unversioned whatever the markup says, so they cannot be `immutable`. |
| `cache.maxAge.default` | `CACHE_MAX_AGE_DEFAULT` | `86400` (1 day) | `max-age` for everything else — including `site.webmanifest` (`application/manifest+json`) and `sitemap.xml` (`application/xml`), neither of which is `text/*`. |
The groups are matched in order, so the specific media types (`text/css`, `text/javascript`, `font/*`) are resolved before the general `text/*` category.
The groups are matched in order, so the specific media types (`text/css`, `text/javascript`, `font/*`, `video/mp4`, `image/jpeg`, `image/webp`) resolve before the general `text/*` and `image/*` categories.
> **An image the markup references without a `?v=` token must be neither JPEG nor WebP**, or it is served immutable for a year and no deploy can dislodge it.
### Response compression
| Config key | Environment variable | Default | Description |
@@ -120,28 +126,17 @@ Redirecting collapses the `http://` and `https://` copies of every page onto one
`docker-compose.yml` enables it for production; `docker-compose.override.yml` pins it off for local development.
Trailing slashes are canonicalized separately and unconditionally, with no configuration key: the router matches `/mr-rock` and `/mr-rock/` alike, so
Trailing slashes are canonicalized separately and unconditionally, with no configuration key: the router matches `/about` and `/about/` alike, so
every `GET`/`HEAD` whose path ends in a slash is answered with a `301` to the form without one (`//` collapses to `/`; `/` is left alone). The
`Location` is relative, so it keeps the request's own scheme and host. Other methods pass through, since a client may repeat a redirected `POST` as a
`GET` and drop the body.
### HTTPS redirect
### Site
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `https.trustForwardedProto` | `HTTPS_TRUST_FORWARDED_PROTO` | `false` | Read the visitor's scheme from the `X-Forwarded-Proto` header and answer the plain-HTTP ones with `301 Moved Permanently` to the same path on `site.origin`. Enable **only** behind a reverse proxy that sets the header — it is the sole trigger. |
| `site.origin` | `SITE_ORIGIN` | _(empty)_ | The public origin the site is served at (scheme and host, no trailing slash). The HTTPS redirect points at it, and absolute links derive from it. |
Redirecting collapses the `http://` and `https://` copies of every page onto one address, which is what a search engine consolidates a site's signals against. Three details:
- **`301`, not `302`** — a temporary redirect keeps the HTTP URLs indexed. Browsers cache it for a long time, so settle the target first.
- **Target built from `site.origin`, not the `Host` header** — a client cannot steer it. An origin that is not itself HTTPS disables the middleware instead of looping.
- **`/.well-known/` is exempt** — redirecting the ACME challenge path breaks certificate renewal.
`docker-compose.yml` enables it for production; `docker-compose.override.yml` pins it off for local development.
Trailing slashes are canonicalized separately and unconditionally, with no configuration key: the router matches `/mr-rock` and `/mr-rock/` alike, so
every `GET`/`HEAD` whose path ends in a slash is answered with a `301` to the form without one (`//` collapses to `/`; `/` is left alone). The
`Location` is relative, so it keeps the request's own scheme and host. Other methods pass through, since a client may repeat a redirected `POST` as a
`GET` and drop the body.
Empty by default, which leaves the [HTTPS redirect](#https-redirect) off: a deployment that trusts the forwarded-protocol header without setting this would otherwise redirect at a host the template guessed, and a `301` is cached for a long time. Bootstrap does **not** fill it in — it rewrites the crawler files only.
### Logging
| Config key | Environment variable | Default | Description |
@@ -174,10 +169,12 @@ See [Persistence](#persistence-1) below for the workflow.
### Rate limiting
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `rateLimit.limit` | `RATELIMIT_LIMIT` | `5` | Requests admitted per client per window on the subscribe endpoint; the excess is answered with `429 Too Many Requests` and a `Retry-After` header. |
| `rateLimit.limit` | `RATELIMIT_LIMIT` | `5` | Requests admitted per client per window; the excess is answered with `429 Too Many Requests` and a `Retry-After` header. |
| `rateLimit.window` | `RATELIMIT_WINDOW` | `60` | Window length, in seconds, the limit applies to. |
| `rateLimit.trustForwardedFor` | `RATELIMIT_TRUST_FORWARDED_FOR` | `false` | Key clients by the first `X-Forwarded-For` entry instead of the connection's address. Enable **only** behind a reverse proxy that sets the header — when the server is directly reachable, clients can forge it. |
> **Configured but unapplied.** The template ships no endpoint worth limiting, so `RateLimitMiddleware` is built from these keys and never added to the chain. Wire it onto the route group that needs it — a form submission, say — when the site grows one.
### Analytics
The template ships analytics **off**: `analytics.websiteID` is empty, so both pages embed no tracker at all and no third-party script is requested. Enabling it takes three steps, in this order:
@@ -316,7 +313,9 @@ docker compose -f docker-compose.yml up -d
```
### Static assets
`Resources/Static` holds the site's stylesheets and scripts under `css/` and `js/`, paired by name: `shared.*` is loaded by every page, alongside a per-page `index.*` and `not-found.*`. The remaining files — the icons, `site.webmanifest`, `robots.txt`, and `sitemap.xml` — sit at the root. Every one of them is a case of the `StaticFile` enumeration, which is what the pages derive their URLs from.
`Resources/Static` holds the site's stylesheets and scripts under `css/` and `js/`, paired by name: `shared.*` is loaded by every page, alongside a per-page `index.*` and `not-found.*`. The remaining files — the icons, `site.webmanifest`, `robots.txt`, and `sitemap.xml` — sit at the root.
Each extension resolves to its own folder (`css/`, `js/`, `img/`, `font/`, `video/`), which a `StaticFile` overrides with `in:` when it needs one of its own — imagery conventionally sits in a folder per page (`img/index`). `img/` ships empty. Every one of them is a constant of the `StaticFile` structure, which is what the pages derive their URLs from.
The image build optimizes the files under `Resources/Static` in its `assets` stage, in place, with pinned optimizer versions — and on a base image pinned by digest, not just by tag — so asset output is reproducible for a given Dockerfile commit:
- CSS and JS are minified with [esbuild](https://esbuild.github.io) (every file in `css/` and `js/`).
@@ -325,7 +324,7 @@ The image build optimizes the files under `Resources/Static` in its `assets` sta
The PNG and SVG passes walk the tree (`--recursive`), so images added in a subdirectory are optimized without touching the Dockerfile.
Files keep their names and paths, so the URLs derived from the `StaticFile` enumeration are unaffected. The repository sources stay readable and unminified: a direct `swift run` serves them as-is, while any image build — including the local `make site-mount` one — serves the optimized copies. Assets are copied from the `assets` stage *after* the binary is built, so editing a CSS/JS/image file does not invalidate the release build cache.
Files keep their names and paths, so the URLs derived from the `StaticFile` constants are unaffected. The repository sources stay readable and unminified: a direct `swift run` serves them as-is, while any image build — including the local `make site-mount` one — serves the optimized copies. Assets are copied from the `assets` stage *after* the binary is built, so editing a CSS/JS/image file does not invalidate the release build cache.
Preview the optimized output locally (Docker only, writes to the git-ignored `.build/minified`):
```sh
@@ -139,7 +139,7 @@ private func logger(
/// HTTPS-redirect middleware that bounces requests forwarded over plain HTTP to the canonical origin, the trailing-slash redirect middleware that
/// collapses each path onto its canonical form, the vary middleware that marks every response as varying on `Accept-Encoding`, the response-compression middleware that compresses responses
/// larger than `minimumResponseSizeToCompress` when the client advertises support, the localization middleware that negotiates the request's
/// language from its `Accept-Language` header, the not-found middleware that serves the error page, and the static file middleware that serves the
/// language from its `Accept-Language` header, the not-found middleware that serves the not-found page, and the static file middleware that serves the
/// contents of `staticFilesPath` (tagging responses with the given `cacheControl` directives), then adds the `RootController` routes that
/// render the landing page, and the `HealthController` routes that serve the health check.
///
@@ -154,7 +154,7 @@ private func logger(
/// - cacheControl: the cache-control directives applied to the served static files.
/// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied.
/// - httpsRedirect: the origin plain-HTTP requests are redirected to, and whether the forwarded-protocol header is trusted.
/// - rateLimit: the rate limit applied to the subscription endpoint.
/// - rateLimit: the rate limit configuration, currently applied to no route.
/// - securityHeaders: the security headers applied to every response.
/// - logLevel: the level the request-logging middleware logs at.
/// - probe: the probe consulted by the `HealthController` readiness route.
@@ -186,7 +186,7 @@ package extension ConfigReader {
)
}
/// The rate limit applied to the subscription endpoint, built from the `rateLimit.*` keys.
/// The rate limit built from the `rateLimit.*` keys; the template applies it to no route yet.
///
/// `rateLimit.limit` requests are admitted per client per `rateLimit.window` seconds. When `rateLimit.trustForwardedFor` is set,
/// clients are keyed by the first `X-Forwarded-For` entry enable it only behind a reverse proxy that sets the header, since clients can forge it
@@ -25,8 +25,7 @@ struct NotFoundPage {
/// Creates a not-found page localized to the given locale.
/// - Parameters:
/// - locale: the locale the page content is localized to.
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the
/// default) to leave them unversioned.
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
init(
locale: Locale,
@@ -38,9 +37,10 @@ struct NotFoundPage {
self.locale = locale
self.localize = .init(bundle: .module)
}
}
// MARK: Page
// MARK: - Page
extension NotFoundPage: Page {
@@ -3,7 +3,8 @@ import Infrastructure
/// A static file shipped with the website service.
///
/// Each constant declares one file stored under the static files root (the `Resources/Static` directory) and served by Hummingbird's
/// `FileMiddleware` middleware. A file can be available with more than one extension (see ``fileExtensions``), each resolving to its own file.
/// `FileMiddleware` middleware. A file can be available with more than one extension (see ``fileExtensions``), each resolving to its own file, and
/// sits in its extension's own folder unless it names a ``folder`` of its own.
struct StaticFile: Asset {
// MARK: Properties
@@ -117,10 +117,8 @@ private extension HealthController {
/// Builds a JSON response carrying the given status and payload.
///
/// Every response is marked `noindex`. The checks answer `200 OK` to anyone who asks and nothing on the site links to them, so they are
/// unlikely to be found but `robots.txt` allows the whole site, and a path that leaks into a log, a monitor, or an inbound link is crawlable
/// on discovery. The header keeps them out of the index even once they are fetched, which a `Disallow` rule would not: that stops the crawl, not
/// the indexing, and publishes the paths to everyone reading the file.
/// Every response is marked `noindex`: the checks answer `200 OK` to anyone, and `robots.txt` allows the whole site. A `Disallow` rule would
/// stop the crawl but not the indexing, and would publish the paths to everyone reading the file.
/// - Parameters:
/// - status: the HTTP status of the response.
/// - payload: the JSON body of the response.
@@ -5,10 +5,10 @@ public extension NotFoundMiddleware {
// MARK: Initializers
/// Creates a not-found middleware that renders the website's error page, localized to the module's String Catalog languages.
/// Creates a not-found middleware that renders the website's not-found page, localized to the module's String Catalog languages.
/// - Parameters:
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker the error page embeds, or `nil` (the default) to omit it.
/// - analytics: the analytics tracker the page embeds, or `nil` (the default) to omit the tracker script.
init(
assetVersion: String? = nil,
analytics: Analytics? = nil