Merge branch 'setup' into template

This commit is contained in:
2026-08-13 02:37:43 +02:00
37 changed files with 1249 additions and 474 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ struct App {
return
}
let app = await application(
let app = try await application(
reader: reader
)
@@ -12,13 +12,14 @@ import WebsiteLibrary
/// Reads the log level, server name, static files location, minimum response size to compress, and security headers from the configuration, then assembles
/// the router, server configuration, and logger. It warns when the localization catalog cannot be read, since pages would serve raw localization keys.
/// It also builds the persistence driver, registers its migrations, and attaches the `Fluent` service so it starts
/// and stops alongside the HTTP server; the ephemeral in-memory backend is migrated on startup, while a MySQL/MariaDB backend is migrated out of
/// and stops alongside the HTTP server; the ephemeral in-memory backend is migrated on startup, while a PostgreSQL backend is migrated out of
/// band (so a shared database is never migrated on boot).
/// - Parameter reader: the configuration reader the values are read from.
/// - Returns: the configured application, ready to run as a service.
/// - Throws: an error when the persistence service cannot be built (e.g. its TLS context fails to build).
func application(
reader: ConfigReader
) async -> some ApplicationProtocol {
) async throws -> some ApplicationProtocol {
let languages = LanguageList()
let logger = logger(
serverName: reader.serverName,
@@ -33,7 +34,7 @@ func application(
logger.warning("String Catalog is \(isCatalogMissing ? "missing" : "undecodable"); pages will serve raw localization keys")
}
let persistence = Service(
let persistence = try Service(
driver: reader.driver,
logger: logger
)
@@ -63,7 +64,7 @@ func application(
app.addServices(fluent)
// The in-memory backend is recreated on every launch, so it is migrated on startup. The MySQL/MariaDB backend is
// The in-memory backend is recreated on every launch, so it is migrated on startup. The PostgreSQL backend is
// left untouched here: a shared database is migrated out of band to avoid multi-instance races.
if case .inMemory = reader.driver {
app.beforeServerStarts {
@@ -77,7 +78,7 @@ func application(
/// Runs every registered migration against the configured backend, then exits.
///
/// This is the out-of-band migration path selected by the `database.migrate` flag: it builds the same driver the service would run against, applies the
/// migrations, and shuts the database down so a shared MySQL/MariaDB database is migrated by a single deliberate invocation rather than by every
/// migrations, and shuts the database down so a shared PostgreSQL database is migrated by a single deliberate invocation rather than by every
/// booting instance.
/// - Parameter reader: the configuration reader the values are read from.
func migration(
@@ -87,7 +88,7 @@ func migration(
serverName: reader.serverName,
logLevel: reader.logLevel
)
let service = Service(
let service = try Service(
driver: reader.driver,
logger: logger
)
@@ -14,6 +14,43 @@ package extension ConfigReader {
// MARK: Computed
/// The analytics tracker the landing page embeds, built from the `analytics.*` keys, or `nil` when `analytics.websiteID` resolves
/// empty a deployment disables analytics entirely by clearing the identifier.
///
/// The script URL is not configurable: its origin is single-sourced in `String.Analytics`, so the tracker tag and the
/// `Content-Security-Policy` that must allow it derive from one constant and cannot drift apart.
///
/// The `analytics.domains` filter must name the host the pages are served from i.e. the host of ``siteOrigin``. The two keys are
/// independent, so a deployment that overrides `site.origin` without matching `analytics.domains` reports from a host it no longer
/// serves and records nothing; change them together.
///
/// Recorder mode is on by default the pages embed the session recorder script alongside the tracker and the `analytics.recorder`
/// flag turns it off for a deployment. The recorder loads from the same origin as the tracker, so the `Content-Security-Policy` needs
/// no extra allowance.
var analytics: Analytics? {
let websiteID = string(
forKey: .Analytics.websiteID,
default: .Analytics.websiteID
)
guard !websiteID.isEmpty else {
return nil
}
return .init(
scriptURL: .Analytics.scriptURL,
websiteID: websiteID,
domains: string(
forKey: .Analytics.domains,
default: .Analytics.domains
),
recorder: bool(
forKey: .Analytics.recorder,
default: true
)
)
}
/// The `Cache-Control` policy applied to static files, grouped by media type.
///
/// The max-ages are read from the `cache.maxAge.asset`, `cache.maxAge.text`, `cache.maxAge.image`, and
@@ -59,16 +96,16 @@ package extension ConfigReader {
/// The persistence backend the service runs against, derived from the `database.*` keys.
///
/// When `database.driver` selects MySQL, the connection parameters are assembled from the `database.host`, `database.port`,
/// `database.name`, `database.username`, `database.password` (empty when unset), `database.tls`, and
/// `database.pool.maxPerEventLoop` keys. Any other driver value falls back to the in-memory database.
/// When `database.driver` selects PostgreSQL, the connection parameters are assembled from the `database.host`, `database.port`,
/// `database.name`, `database.username`, `database.password` (empty when unset), `database.tls`,
/// `database.pool.maxPerEventLoop`, and `database.pool.timeout` keys. Any other driver value falls back to the in-memory database.
var driver: Driver {
switch string(
forKey: .Database.driver,
default: .Database.driver
) {
case .Database.driverMySQL:
return .mysql(
case .Database.driverPostgres:
return .postgres(
.init(
host: string(
forKey: .Database.host,
@@ -94,7 +131,11 @@ package extension ConfigReader {
maxConnectionsPerEventLoop: int(
forKey: .Database.poolMaxPerEventLoop,
default: .Database.poolMaxPerEventLoop
)
),
poolTimeout: .seconds(int(
forKey: .Database.poolTimeout,
default: .Database.poolTimeout
))
)
)
default:
@@ -199,7 +240,7 @@ private extension ConfigReader {
// MARK: Properties
/// The TLS posture for the MySQL connection, mapped from the `database.tls` key: `off` and `require` map to their postures, and any
/// The TLS posture for the PostgreSQL connection, mapped from the `database.tls` key: `off` and `require` map to their postures, and any
/// other value falls back to `prefer`.
var tls: TLS {
switch string(
@@ -1,6 +1,15 @@
import Configuration
extension AbsoluteConfigKey {
/// A namespace for the analytics configuration keys, as absolute keys.
public enum Analytics {
/// The absolute configuration key for the analytics website identifier.
public static let websiteID: AbsoluteConfigKey = .init(.Analytics.websiteID)
/// The absolute configuration key for the comma-delimited domains the tracker reports from.
public static let domains: AbsoluteConfigKey = .init(.Analytics.domains)
/// The absolute configuration key for recorder mode, loading the session recorder script alongside the tracker.
public static let recorder: AbsoluteConfigKey = .init(.Analytics.recorder)
}
/// A namespace for the static files cache configuration keys, as absolute keys.
public enum Cache {
/// The absolute configuration key for the max-age, in seconds, applied to fingerprinted assets and fonts.
@@ -23,9 +32,9 @@ extension AbsoluteConfigKey {
public static let migrate: AbsoluteConfigKey = .init(.Database.migrate)
/// The absolute configuration key for the persistence driver.
public static let driver: AbsoluteConfigKey = .init(.Database.driver)
/// The absolute configuration key for the MySQL/MariaDB host.
/// The absolute configuration key for the PostgreSQL host.
public static let host: AbsoluteConfigKey = .init(.Database.host)
/// The absolute configuration key for the MySQL/MariaDB port.
/// The absolute configuration key for the PostgreSQL port.
public static let port: AbsoluteConfigKey = .init(.Database.port)
/// The absolute configuration key for the database name.
public static let name: AbsoluteConfigKey = .init(.Database.name)
@@ -37,6 +46,8 @@ extension AbsoluteConfigKey {
public static let tls: AbsoluteConfigKey = .init(.Database.tls)
/// The absolute configuration key for the maximum pooled connections per event loop.
public static let poolMaxPerEventLoop: AbsoluteConfigKey = .init(.Database.poolMaxPerEventLoop)
/// The absolute configuration key for the longest wait, in seconds, for a pooled connection to become available.
public static let poolTimeout: AbsoluteConfigKey = .init(.Database.poolTimeout)
}
/// A namespace for the HTTP server configuration keys, as absolute keys.
public enum HTTP {
@@ -1,6 +1,15 @@
import Configuration
extension ConfigKey {
/// A namespace for the analytics configuration keys.
public enum Analytics {
/// The configuration key for the analytics website identifier (cleared to disable analytics).
public static let websiteID: ConfigKey = "analytics.websiteID"
/// The configuration key for the comma-delimited domains the tracker reports from.
public static let domains: ConfigKey = "analytics.domains"
/// The configuration key for recorder mode, loading the session recorder script alongside the tracker (set to `false` to disable).
public static let recorder: ConfigKey = "analytics.recorder"
}
/// A namespace for the static files cache configuration keys.
public enum Cache {
/// The configuration key for the max-age, in seconds, applied to fingerprinted assets (CSS, JavaScript) and fonts.
@@ -21,11 +30,11 @@ extension ConfigKey {
public enum Database {
/// The configuration key selecting migrate-and-exit mode (run migrations, then exit) instead of serving.
public static let migrate: ConfigKey = "database.migrate"
/// The configuration key for the persistence driver (`inMemory` or `mysql`).
/// The configuration key for the persistence driver (`inMemory` or `postgres`).
public static let driver: ConfigKey = "database.driver"
/// The configuration key for the MySQL/MariaDB host.
/// The configuration key for the PostgreSQL host.
public static let host: ConfigKey = "database.host"
/// The configuration key for the MySQL/MariaDB port.
/// The configuration key for the PostgreSQL port.
public static let port: ConfigKey = "database.port"
/// The configuration key for the database name.
public static let name: ConfigKey = "database.name"
@@ -37,6 +46,8 @@ extension ConfigKey {
public static let tls: ConfigKey = "database.tls"
/// The configuration key for the maximum pooled connections per event loop.
public static let poolMaxPerEventLoop: ConfigKey = "database.pool.maxPerEventLoop"
/// The configuration key for the longest wait, in seconds, for a pooled connection to become available.
public static let poolTimeout: ConfigKey = "database.pool.timeout"
}
/// A namespace for the HTTP server configuration keys.
public enum HTTP {
@@ -17,9 +17,11 @@ extension Int {
}
/// A namespace for the persistence's default configuration values.
public enum Database {
/// The default MySQL/MariaDB port.
public static let port = 3_306
/// The default PostgreSQL port.
public static let port = 5_432
/// The default maximum pooled connections per event loop.
public static let poolMaxPerEventLoop = 4
/// The default longest wait, in seconds, for a pooled connection to become available (the driver's own default).
public static let poolTimeout = 10
}
}
@@ -1,11 +1,28 @@
extension String {
/// A namespace for the analytics default configuration values.
public enum Analytics {
/// The origin the analytics scripts are loaded from and their beacons are sent to (scheme and host, no trailing slash).
///
/// Single-sourced here: both ``scriptURL`` and the session recorder script the pages embed in recorder mode derive from this
/// constant, and the site's `Content-Security-Policy` must allow it.
public static let origin = "https://analytics.rock-n-code.com"
/// The URL the analytics tracker script is loaded from.
public static let scriptURL = "\(origin)/script"
/// The default analytics website identifier the tracker reports as.
public static let websiteID = "f28681d6-20e8-43f3-9c3b-5d6a0f8e0591"
/// The default comma-delimited domains the tracker reports from; visits from any other host are ignored.
///
/// Keep it paired with the host the pages are served at: a deployment that serves from another host without overriding
/// `analytics.domains` to match reports from a host it no longer serves, so analytics silently records nothing.
public static let domains = "loud.amsterdam"
}
/// A namespace for the persistence's default configuration values and recognized tokens.
public enum Database {
/// The default persistence driver: in-memory SQLite, which needs no external infrastructure.
public static let driver = "inMemory"
/// The driver token selecting the MySQL/MariaDB backend.
public static let driverMySQL = "mysql"
/// The default MySQL/MariaDB host.
/// The driver token selecting the PostgreSQL backend.
public static let driverPostgres = "postgres"
/// The default PostgreSQL host.
public static let host = "localhost"
/// The default database name.
public static let name = "site"