This commit contains the latest updates from the generic Website template, which rework the compression and localization: - Reworked the compression and localization in the Infrastructure package. (3c568e4) - Adopted the reworked compression and localization in the Website service. (08cf3e3) The template commit that only touched the root README (53676ed) was left out, as this project no longer carries that file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
216 lines
8.3 KiB
Swift
216 lines
8.3 KiB
Swift
import Configuration
|
|
import Hummingbird
|
|
import Localization
|
|
import Logging
|
|
import Persistence
|
|
import Infrastructure
|
|
import WebsiteLibrary
|
|
|
|
/// Builds the website application.
|
|
///
|
|
/// 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 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: a ``ConfigError`` when a `database.*` key holds an unrecognized token, or an error when the persistence service cannot be built
|
|
/// (e.g. its TLS context fails to build).
|
|
func application(
|
|
reader: ConfigReader
|
|
) async throws -> some ApplicationProtocol {
|
|
let languages = LanguageList()
|
|
let logger = logger(
|
|
serverName: reader.serverName,
|
|
logLevel: reader.logLevel
|
|
)
|
|
|
|
// A broken catalog degrades to serving raw localization keys rather than failing, so it is only ever visible to
|
|
// visitors — surface it here instead.
|
|
if languages.catalogState != .loaded {
|
|
let isCatalogMissing = languages.catalogState == .missing
|
|
|
|
logger.warning("String Catalog is \(isCatalogMissing ? "missing" : "undecodable"); pages will serve raw localization keys")
|
|
}
|
|
|
|
let driver = try reader.driver
|
|
let persistence = try Service(
|
|
driver: driver,
|
|
logger: logger
|
|
)
|
|
let fluent = persistence()
|
|
|
|
let fingerprintAssets = FingerprintAssets(logger: logger)
|
|
let prepareDB = PrepareDB()
|
|
|
|
await prepareDB(for: fluent)
|
|
|
|
var app = Application(
|
|
router: router(
|
|
staticFilesPath: reader.staticFilesPath,
|
|
assetVersion: fingerprintAssets(reader.staticFilesPath),
|
|
analytics: reader.analytics,
|
|
cacheControl: reader.cacheControl,
|
|
compressionMinResponseSize: reader.compressionMinResponseSize,
|
|
httpsRedirect: reader.httpsRedirect,
|
|
rateLimit: reader.rateLimit,
|
|
securityHeaders: reader.securityHeaders,
|
|
// An unset origin leaves the pages without canonical URLs and language alternates, rather than
|
|
// building both against an empty host.
|
|
siteOrigin: reader.siteOrigin.isEmpty ? nil : reader.siteOrigin,
|
|
logLevel: reader.logLevel,
|
|
probe: Probe(fluent: fluent)
|
|
),
|
|
configuration: ApplicationConfiguration(
|
|
reader: reader.scoped(to: "http")
|
|
),
|
|
logger: logger
|
|
)
|
|
|
|
app.addServices(fluent)
|
|
|
|
// 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 = driver {
|
|
app.beforeServerStarts {
|
|
try await fluent.migrate()
|
|
}
|
|
}
|
|
|
|
return app
|
|
}
|
|
|
|
/// 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 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(
|
|
reader: ConfigReader
|
|
) async throws {
|
|
let logger = logger(
|
|
serverName: reader.serverName,
|
|
logLevel: reader.logLevel
|
|
)
|
|
let service = try Service(
|
|
driver: try reader.driver,
|
|
logger: logger
|
|
)
|
|
|
|
let fluent = service()
|
|
let prepareDB = PrepareDB()
|
|
|
|
await prepareDB(for: fluent)
|
|
|
|
do {
|
|
try await fluent.migrate()
|
|
}
|
|
catch {
|
|
try? await fluent.shutdown()
|
|
|
|
throw error
|
|
}
|
|
|
|
try await fluent.shutdown()
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// The request context type the application serves its routes with.
|
|
private typealias AppRequestContext = WebsiteRequestContext
|
|
|
|
/// Builds the application's logger.
|
|
/// - Parameters:
|
|
/// - serverName: the label applied to the logger.
|
|
/// - logLevel: the minimum level the logger emits.
|
|
/// - Returns: the configured logger.
|
|
private func logger(
|
|
serverName: String,
|
|
logLevel: Logger.Level
|
|
) -> Logger {
|
|
var logger = Logger(label: serverName)
|
|
|
|
logger.logLevel = logLevel
|
|
|
|
return logger
|
|
}
|
|
|
|
/// Builds the application's router.
|
|
///
|
|
/// The chain below reads as its own list; the order is what does not. Security headers sit just inside request logging, so they reach every response a
|
|
/// client sees — pages, compressed bodies, the not-found page, the served files. The HTTPS redirect sits directly beneath, keeping those headers while
|
|
/// skipping the compression and file lookup it would otherwise pay for. The trailing-slash redirect follows, ahead of the routes and `FileMiddleware`
|
|
/// that would otherwise answer both spellings of every path. The language is negotiated by the responders that read it — the landing route and the
|
|
/// not-found middleware — rather than on every request past.
|
|
/// - Parameters:
|
|
/// - staticFilesPath: the folder, relative to the working directory, the static files are served from.
|
|
/// - assetVersion: the version token the pages append to their asset URLs, or `nil` to leave them unversioned.
|
|
/// - analytics: the analytics tracker both pages embed, or `nil` to omit it.
|
|
/// - 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 configuration, currently applied to no route.
|
|
/// - securityHeaders: the security headers applied to every response.
|
|
/// - siteOrigin: the public origin the pages derive their canonical URLs and language alternates from, or `nil` to omit them.
|
|
/// - logLevel: the level the request-logging middleware logs at.
|
|
/// - probe: the probe consulted by the `HealthController` readiness route.
|
|
/// - Returns: the configured router.
|
|
private func router(
|
|
staticFilesPath: String,
|
|
assetVersion: String?,
|
|
analytics: Analytics?,
|
|
cacheControl: CacheControl,
|
|
compressionMinResponseSize: Int,
|
|
httpsRedirect: HTTPSRedirectMiddleware<AppRequestContext>.Configuration,
|
|
rateLimit: RateLimitMiddleware<AppRequestContext>.Configuration,
|
|
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
|
|
siteOrigin: String?,
|
|
logLevel: Logger.Level,
|
|
probe: Probe
|
|
) -> Router<AppRequestContext> {
|
|
// HEAD siblings are generated for every GET route, so uptime monitors and crawlers probing with HEAD requests get
|
|
// the page's status and headers instead of a 404.
|
|
let router = Router(
|
|
context: AppRequestContext.self,
|
|
options: .autoGenerateHeadEndpoints
|
|
)
|
|
|
|
router.addMiddleware {
|
|
LogRequestsMiddleware(logLevel)
|
|
SecurityHeadersMiddleware(
|
|
configuration: securityHeaders
|
|
)
|
|
HTTPSRedirectMiddleware(
|
|
configuration: httpsRedirect
|
|
)
|
|
TrailingSlashRedirectMiddleware()
|
|
VaryMiddleware()
|
|
CompressionMiddleware(
|
|
minimumResponseSizeToCompress: compressionMinResponseSize
|
|
)
|
|
NotFoundMiddleware(
|
|
assetVersion: assetVersion,
|
|
analytics: analytics
|
|
)
|
|
FileMiddleware(
|
|
staticFilesPath,
|
|
cacheControl: cacheControl
|
|
)
|
|
}
|
|
|
|
router.addController {
|
|
RootController<AppRequestContext>(
|
|
assetVersion: assetVersion,
|
|
siteOrigin: siteOrigin,
|
|
analytics: analytics
|
|
)
|
|
HealthController<AppRequestContext>(
|
|
probe: probe
|
|
)
|
|
}
|
|
|
|
return router
|
|
}
|