import Configuration import Hummingbird import HummingbirdCompression 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: 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 persistence = Service( driver: reader.driver, logger: logger ) let fluent = try 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), cacheControl: reader.cacheControl, compressionMinResponseSize: reader.compressionMinResponseSize, rateLimit: reader.rateLimit, securityHeaders: reader.securityHeaders, 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 = reader.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 = Service( driver: reader.driver, logger: logger ) let fluent = try 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. /// /// Registers the request-logging middleware, the security-headers middleware that stamps the given `securityHeaders` onto every response, 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 /// 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. /// /// The security-headers middleware sits just inside request logging so it covers every response that reaches a client — the landing page, the compressed /// responses, the rendered error page, and the served static files. /// - 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. /// - cacheControl: the cache-control directives applied to the served static files. /// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied. /// - rateLimit: the rate limit applied to the subscription endpoint. /// - 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. /// - Returns: the configured router. private func router( staticFilesPath: String, assetVersion: String?, cacheControl: CacheControl, compressionMinResponseSize: Int, rateLimit: RateLimitMiddleware.Configuration, securityHeaders: SecurityHeadersMiddleware.Configuration, logLevel: Logger.Level, probe: Probe ) -> Router { // 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 ) VaryMiddleware() ResponseCompressionMiddleware( minimumResponseSizeToCompress: compressionMinResponseSize ) LocalizationMiddleware() NotFoundMiddleware( assetVersion: assetVersion ) FileMiddleware( staticFilesPath, cacheControl: cacheControl ) } router.addController { RootController( assetVersion: assetVersion ) HealthController( probe: probe ) } return router }