Renamed the Web package as Infrastructure (#25)

This PR contains the work done to rename the _Web_ package as _Infrastructure_, to provide a clear naming and purpose to this particular package within the project.

To provide further details about the work:

* Infrastructure
  * Asset fingerprinting: an FNV-1a token derived from the static files directory, appended as ?v= to asset URLs so deploys bust caches; pre-rendered pages also revalidate via weak ETags.
  * New middlewares: fixed-window RateLimitMiddleware (per-client budgets keyed by trusted X-Forwarded-For or remote address) and VaryMiddleware (Accept-Encoding on every response); SecurityHeadersMiddleware now also stamps error responses.
  * Auto-generated HEAD endpoints, cache max-age configuration, and Docker build/Compose refinements.
  * Protocols and scaffolding: Asset/AssetExtension, the Page protocol (viewport, stylesheets, scripts, versioned URLs), and LocalizedRequestContext.
  * Rate limiter's counter store swapped from an actor to a Mutex (no executor hop per request) with amortized batch eviction instead of O(n²) scans under client floods.
  * FingerprintAssets reports unreadable files to a logger instead of silently producing a token that never busts their cache.

Reviewed-on: rock-n-code/loud-amsterdam#25
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
2026-07-23 01:04:37 +00:00
committed by javier
parent a868275347
commit cdded06ba3
58 changed files with 2844 additions and 519 deletions
@@ -1,9 +1,11 @@
import Infrastructure
/// A static file shipped with the website service.
///
/// Each case identifies a file name stored under the static files root (the `Resources/Static`
/// directory) and served by Hummingbird's `FileMiddleware` middleware. A name can be available
/// with more than one extension (see ``fileExtensions``), each resolving to its own file.
enum StaticFile: CaseIterable, Sendable {
enum StaticFile: Asset, CaseIterable {
/// The `apple-touch-icon.png` icon.
case appleTouchIcon
/// The `css/error.css` stylesheet and `js/error.js` script for the not-found page.
@@ -28,30 +30,6 @@ enum StaticFile: CaseIterable, Sendable {
case sitemap
}
// MARK: - Enumerations
extension StaticFile {
/// A file extension used by a ``StaticFile``.
enum Extension: String, Sendable {
/// A Cascading Style Sheets file.
case css
/// A JavaScript file.
case js
/// A Portable Network Graphics image.
case png
/// A Windows icon image.
case ico
/// A Scalable Vector Graphics image.
case svg
/// A plain text file.
case txt
/// A web application manifest file.
case webmanifest
/// An Extensible Markup Language file.
case xml
}
}
// MARK: - Extensions
extension StaticFile {
@@ -59,7 +37,7 @@ extension StaticFile {
// MARK: Computed
/// The file extensions the file is available with.
var fileExtensions: [Extension] {
var fileExtensions: [AssetExtension] {
switch self {
case .appleTouchIcon,
.icon192,
@@ -92,79 +70,4 @@ extension StaticFile {
}
}
// MARK: Methods
/// Resolves the file's path against the given base directory.
///
/// - Parameters:
/// - basePath: the directory the static files are served from.
/// - fileExtension: the extension of the file to resolve.
/// - Returns: the path to the file, relative to the `basePath` path.
func path(
relativeTo basePath: String,
for fileExtension: Extension
) -> String {
let relativePath = relativePath(for: fileExtension)
guard !basePath.isEmpty else {
return relativePath
}
return "\(basePath)/\(relativePath)"
}
/// Resolves the file's path relative to the static files root (e.g. `"css/shared.css"`).
///
/// This also matches the URL path the file is served at by `FileMiddleware`.
///
/// - Parameter fileExtension: the extension of the file to resolve.
/// - Returns: the path to the file, relative to the static files root.
func relativePath(
for fileExtension: Extension
) -> String {
let file = "\(fileName).\(fileExtension.rawValue)"
return fileExtension.subdirectory
.map { "\($0)/\(file)" } ?? file
}
/// Resolves the absolute URL path the file is served at (e.g. `"/css/shared.css"`).
///
/// - Parameter fileExtension: the extension of the file to resolve.
/// - Returns: the path to use in `href` and `src` attributes.
func urlPath(
for fileExtension: Extension
) -> String {
"/\(relativePath(for: fileExtension))"
}
}
extension StaticFile.Extension {
// MARK: Computed
/// The file's content type.
var contentType: String {
switch self {
case .css: "text/css"
case .js: "text/javascript"
case .png: "image/png"
case .ico: "image/vnd.microsoft.icon"
case .svg: "image/svg+xml"
case .txt: "text/plain"
case .webmanifest: "application/manifest+json"
case .xml: "application/xml"
}
}
/// The sub-directory within the static root that holds files with this extension, if any.
var subdirectory: String? {
switch self {
case .css: "css"
case .js: "js"
default: nil
}
}
}
@@ -0,0 +1,70 @@
import Elementary
import Foundation
import Infrastructure
import Localization
/// The site-wide defaults shared by every page of the website.
extension Page {
// MARK: Computed
/// The document language, derived from the page's locale and falling back to the default language.
var lang: String {
locale.language.languageCode?.identifier
?? LanguageList(bundle: .module).default
}
/// The icon, manifest, and theme colour metadata shared by every page of the website.
@HTMLBuilder
var metadata: some HTML {
link(
.rel(.icon),
.href(StaticFile.favicon.urlPath(
for: .ico,
version: assetVersion
)),
.custom(
name: "sizes",
value: "any"
)
)
link(
.rel(.icon),
.href(StaticFile.icon.urlPath(
for: .svg,
version: assetVersion
)),
.custom(
name: "type",
value: "image/svg+xml"
)
)
link(
.rel("apple-touch-icon"),
.href(StaticFile.appleTouchIcon.urlPath(
for: .png,
version: assetVersion
))
)
link(
.rel("manifest"),
.href(StaticFile.site.urlPath(
for: .webmanifest,
version: assetVersion
))
)
meta(
.name("theme-color"),
.content("#fafafa")
)
meta(
.name("theme-color"),
.content("#0c0710"),
.custom(
name: "media",
value: "(prefers-color-scheme: dark)"
)
)
}
}
@@ -1,5 +1,6 @@
import Elementary
import Foundation
import Infrastructure
import Localization
/// The HTML page rendered for a not-found response, with its text localized to a given locale.
@@ -7,6 +8,9 @@ struct ErrorPage {
// MARK: Properties
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
let assetVersion: String?
/// The locale the page content is localized to.
let locale: Locale
@@ -16,10 +20,15 @@ struct ErrorPage {
// MARK: Initializers
/// Creates a not-found page localized to the given locale.
/// - Parameter locale: the locale the page content is localized to.
/// - 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.
init(
locale: Locale
locale: Locale,
assetVersion: String? = nil
) {
self.assetVersion = assetVersion
self.locale = locale
self.localize = .init(bundle: .module)
}
@@ -40,12 +49,12 @@ extension ErrorPage: Page {
}
}
var scripts: [StaticFile] {
[.error, .shared]
var scripts: [any Asset] {
[StaticFile.error, StaticFile.shared]
}
var stylesheets: [StaticFile] {
[.shared, .error]
var stylesheets: [any Asset] {
[StaticFile.shared, StaticFile.error]
}
var title: String {
@@ -1,5 +1,6 @@
import Elementary
import Foundation
import Infrastructure
import Localization
/// The website's landing page, with its text localized to a given locale.
@@ -7,6 +8,9 @@ struct IndexPage {
// MARK: Properties
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
let assetVersion: String?
/// The locale the page content is localized to.
let locale: Locale
@@ -16,10 +20,15 @@ struct IndexPage {
// MARK: Initializers
/// Creates a landing page localized to the given locale.
/// - Parameter locale: the locale the page content is localized to.
/// - 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.
init(
locale: Locale
locale: Locale,
assetVersion: String? = nil
) {
self.assetVersion = assetVersion
self.locale = locale
self.localize = .init(bundle: .module)
}
@@ -38,12 +47,12 @@ extension IndexPage: Page {
}
}
var scripts: [StaticFile] {
[.index, .shared]
var scripts: [any Asset] {
[StaticFile.index, StaticFile.shared]
}
var stylesheets: [StaticFile] {
[.shared, .index]
var stylesheets: [any Asset] {
[StaticFile.shared, StaticFile.index]
}
var title: String {
@@ -1,109 +0,0 @@
import Elementary
import Foundation
import Localization
/// A page of the website: an HTML document with the shared scaffolding assembled around the page's content.
///
/// A conforming page supplies its locale, its localized title, the stylesheets and scripts it needs, and its content; the protocol assembles the rest of the
/// document around them: the metadata, stylesheet, icon, and manifest links in the head, the content followed by the script tags in the body, and the
/// document language derived from the locale.
protocol Page: HTMLDocument, Sendable {
// MARK: Associated types
/// The type of the page's markup.
associatedtype Content: HTML
// MARK: Properties
/// The page's markup, rendered before the ``scripts``.
@HTMLBuilder
var content: Content { get }
/// The locale the page content is localized to.
var locale: Locale { get }
/// The scripts loaded at the end of the document body, in order.
var scripts: [StaticFile] { get }
/// The stylesheets linked in the document head, in order.
var stylesheets: [StaticFile] { get }
}
// MARK: - Implementations
extension Page {
// MARK: Computed
/// The page ``content`` followed by its ``scripts``.
@HTMLBuilder
var body: some HTML {
content
for file in scripts {
script(.src(file.urlPath(for: .js))) {}
}
}
/// The metadata, ``stylesheets``, icon, and manifest links placed in the document head.
///
/// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already
/// emits `<meta charset="UTF-8">` before this markup, and HTML5 allows only one.
@HTMLBuilder
var head: some HTML {
meta(
.name(.viewport),
.content("width=device-width, initial-scale=1")
)
for file in stylesheets {
link(
.rel(.stylesheet),
.href(file.urlPath(for: .css))
)
}
link(
.rel(.icon),
.href(StaticFile.favicon.urlPath(for: .ico)),
.custom(
name: "sizes",
value: "any"
)
)
link(
.rel(.icon),
.href(StaticFile.icon.urlPath(for: .svg)),
.custom(
name: "type",
value: "image/svg+xml"
)
)
link(
.rel("apple-touch-icon"),
.href(StaticFile.appleTouchIcon.urlPath(for: .png))
)
link(
.rel("manifest"),
.href(StaticFile.site.urlPath(for: .webmanifest))
)
meta(
.name("theme-color"),
.content("#fafafa")
)
meta(
.name("theme-color"),
.content("#0c0710"),
.custom(
name: "media",
value: "(prefers-color-scheme: dark)"
)
)
}
/// The document language, derived from the page's locale and falling back to the default language.
var lang: String {
locale.language.languageCode?.identifier
?? LanguageList(bundle: .module).default
}
}
@@ -1,72 +0,0 @@
import Elementary
import HTTPTypes
import Hummingbird
import NIOCore
/// A pre-rendered HTTP response for a fully static HTML page.
///
/// The document is rendered to bytes once, at initialization, and every ``response()`` reuses those
/// bytes along with a fixed status and precomputed headers instead of re-rendering. This suits
/// pages whose markup never changes between requests, such as the landing page and the not-found
/// page, avoiding a per-request Elementary render on hot paths.
///
/// ``LocalizedHTMLCollectionResponse`` builds on this type, caching one instance per supported language.
///
/// The body is written as an unsized stream (no `Content-Length`), mirroring `HTMLResponse`, so the
/// response-compression middleware downstream treats it exactly as it would a freshly rendered page.
struct CachedHTMLResponse: Sendable {
// MARK: Properties
/// The page rendered to bytes once.
private let buffer: ByteBuffer
/// The headers applied to every response, precomputed once.
private let headers: HTTPFields
/// The status applied to every response.
private let status: HTTPResponse.Status
// MARK: Initializers
/// Renders the given document to bytes once.
/// - Parameters:
/// - status: the status applied to every response. Defaults to `.ok`.
/// - additionalHeaders: extra headers merged onto every response, alongside the content type.
/// Used to carry per-language signals such as `Content-Language` and `Vary`.
/// - document: the static HTML document to render and cache.
init(
status: HTTPResponse.Status = .ok,
additionalHeaders: HTTPFields = [:],
document: some HTMLDocument
) {
var headers: HTTPFields = [
.contentType: "text/html; charset=utf-8"
]
for field in additionalHeaders {
headers[field.name] = field.value
}
self.status = status
self.headers = headers
self.buffer = .init(string: document.render())
}
// MARK: Methods
/// Builds a response from the cached, pre-rendered bytes.
///
/// Mirrors the `text/html; charset=utf-8` content type `HTMLResponse` produces, and leaves the
/// `Content-Length` unset so small pages remain eligible for compression.
/// - Returns: the response carrying the cached HTML body.
func response() -> Response {
Response(
status: status,
headers: headers,
body: .init { [buffer] writer in
try await writer.write(buffer)
try await writer.finish(nil)
}
)
}
}
@@ -1,65 +0,0 @@
import Elementary
import Foundation
import HTTPTypes
import Hummingbird
import Localization
/// A per-language collection of pre-rendered HTML responses.
///
/// At initialization it renders the document once for each language the bundle's ``LanguageList``
/// reports and caches the bytes, mirroring ``CachedHTMLResponse``'s render-once model but keyed by
/// language. Each cached response carries a `Content-Language` header and `Vary: Accept-Language`, so
/// shared caches key on the negotiated language instead of serving one language to everyone.
struct LocalizedHTMLCollectionResponse: Sendable {
// MARK: Properties
/// The supported languages and default language, derived from the module's String Catalog.
private let list: LanguageList
/// The pre-rendered responses, keyed by language identifier.
private let responses: [String: CachedHTMLResponse]
// MARK: Initializers
/// Renders the document once per supported language.
/// - Parameters:
/// - status: the status applied to every response. Defaults to `.ok`.
/// - document: builds the document to render for a given locale.
init<Document: HTMLDocument>(
status: HTTPResponse.Status = .ok,
document: (Locale) -> Document
) {
self.list = .init(bundle: .module)
self.responses = list.all
.reduce(into: [:]) { responses, language in
responses[language] = CachedHTMLResponse(
status: status,
additionalHeaders: [
.contentLanguage: language,
.vary: "Accept-Language",
],
document: document(.init(identifier: language))
)
}
}
// MARK: Methods
/// Builds the response for the given language, falling back to the default language.
/// - Parameter language: the negotiated language identifier.
/// - Returns: the cached response for the language, the default language's response when the
/// language is unavailable, or a `500 Internal Server Error` if neither is cached.
func response(
for language: String
) -> Response {
guard
let response = responses[language] ?? responses[list.default]
else {
return .init(status: .internalServerError)
}
return response.response()
}
}