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:
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
-65
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user