From 6c1a0f713b2c74e06a0391c18e1a3cdb153562aa Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 28 Jun 2026 05:49:32 +0000 Subject: [PATCH] Static file caching for the Website service (#6) This PR contains the work done to add cache-control headers to static file responses by configuring the `FileMiddleware` middleware to tag served static files with *Cache-Control* directives, tuned per media type. To provide further details about the work done: * Added a cache control that sets per-type policies: text assets (CSS/JS) get public, max-age, must-revalidate; images get public, max-age; everything else gets a default public, max-age. * Files stay validated via `ETag/Last-Modified` header. * Made the max-age values configurable, with defaults, via new cache constants. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/6 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .swift-format | 2 +- Services/Website/Sources/App/App+build.swift | 50 +++++++++++++++++-- .../AbsoluteConfigKey+Constants.swift | 9 ++++ .../Extensions/ConfigKey+Constants.swift | 9 ++++ .../Public/Extensions/Int+Constants.swift | 11 ++++ Services/Website/Tests/App/AppTests.swift | 19 ++++++- 6 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift diff --git a/.swift-format b/.swift-format index f015b36..fbd0fa3 100644 --- a/.swift-format +++ b/.swift-format @@ -39,7 +39,7 @@ "NeverForceUnwrap": true, "NeverUseForceTry": true, "NeverUseImplicitlyUnwrappedOptionals": true, - "NoAccessLevelOnExtensionDeclaration": true, + "NoAccessLevelOnExtensionDeclaration": false, "NoAssignmentInExpressions": true, "NoBlockComments": true, "NoCasesWithOnlyFallthrough": true, diff --git a/Services/Website/Sources/App/App+build.swift b/Services/Website/Sources/App/App+build.swift index 2358bb5..d79bf0f 100644 --- a/Services/Website/Sources/App/App+build.swift +++ b/Services/Website/Sources/App/App+build.swift @@ -12,6 +12,20 @@ import WebsiteCore func application( reader: ConfigReader ) async -> some ApplicationProtocol { + let cacheControl = cacheControl( + textMaxAge: reader.int( + forKey: .Cache.maxAgeText, + default: .Cache.maxAgeText + ), + imageMaxAge: reader.int( + forKey: .Cache.maxAgeImage, + default: .Cache.maxAgeImage + ), + defaultMaxAge: reader.int( + forKey: .Cache.maxAgeDefault, + default: .Cache.maxAgeDefault + ) + ) let logLevel = reader.string( forKey: .Log.level, as: Logger.Level.self, @@ -29,6 +43,7 @@ func application( return Application( router: router( staticFilesPath: staticFilesPath, + cacheControl: cacheControl, logLevel: logLevel ), configuration: ApplicationConfiguration( @@ -46,6 +61,29 @@ func application( // Request context used by application private typealias AppRequestContext = BasicRequestContext +/// Builds the cache-control policy applied to the served static files. +/// +/// Static files are public and validated by `FileMiddleware` through their `ETag` and +/// `Last-Modified` headers, so each media type is given a `max-age` after which the browser +/// revalidates. Text-based assets (CSS, JavaScript) are additionally marked `must-revalidate` +/// since they change between deployments while keeping their filenames. +/// - Parameters: +/// - textMaxAge: the max-age, in seconds, applied to text-based static files (CSS, JavaScript, plain text). +/// - imageMaxAge: the max-age, in seconds, applied to image static files (ICO, PNG, SVG). +/// - defaultMaxAge: the max-age, in seconds, applied to all other static files (e.g. the web manifest). +/// - Returns: the configured cache-control policy. +private func cacheControl( + textMaxAge: Int, + imageMaxAge: Int, + defaultMaxAge: Int +) -> CacheControl { + .init([ + (.text, [.public, .maxAge(textMaxAge), .mustRevalidate]), + (.image, [.public, .maxAge(imageMaxAge)]), + (.init(type: .any), [.public, .maxAge(defaultMaxAge)]), + ]) +} + /// Builds the application's logger. /// - Parameters: /// - serverName: the label applied to the logger. @@ -65,14 +103,17 @@ private func logger( /// Builds the application's router. /// /// Registers the request-logging middleware, the not-found middleware that serves the error -/// page, and the static file middleware that serves the contents of `staticFilesPath`, then -/// adds the `RootController` routes that render the landing page. +/// 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. /// - Parameters: /// - staticFilesPath: the folder, relative to the working directory, the static files are served from. +/// - cacheControl: the cache-control directives applied to the served static files. /// - logLevel: the level the request-logging middleware logs at. /// - Returns: the configured router. private func router( staticFilesPath: String, + cacheControl: CacheControl, logLevel: Logger.Level ) -> Router { let router = Router(context: AppRequestContext.self) @@ -80,7 +121,10 @@ private func router( router.addMiddleware { LogRequestsMiddleware(logLevel) NotFoundMiddleware() - FileMiddleware(staticFilesPath) + FileMiddleware( + staticFilesPath, + cacheControl: cacheControl + ) } router.addRoutes(RootController().routes) diff --git a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift index 94a8cbf..93dec9c 100644 --- a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift @@ -1,6 +1,15 @@ import Configuration extension AbsoluteConfigKey { + /// 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 text-based static files. + public static let maxAgeText: AbsoluteConfigKey = .init(.Cache.maxAgeText) + /// The absolute configuration key for the max-age, in seconds, applied to image static files. + public static let maxAgeImage: AbsoluteConfigKey = .init(.Cache.maxAgeImage) + /// The absolute configuration key for the max-age, in seconds, applied to all other static files. + public static let maxAgeDefault: AbsoluteConfigKey = .init(.Cache.maxAgeDefault) + } /// A namespace for the HTTP server configuration keys, as absolute keys. public enum HTTP { /// The absolute configuration key for the host the server binds to. diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift index 55c6f7d..225db80 100644 --- a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift @@ -1,6 +1,15 @@ import Configuration extension ConfigKey { + /// A namespace for the static files cache configuration keys. + public enum Cache { + /// The configuration key for the max-age, in seconds, applied to text-based static files (CSS, JavaScript, plain text). + public static let maxAgeText: ConfigKey = "cache.maxAge.text" + /// The configuration key for the max-age, in seconds, applied to image static files (ICO, PNG, SVG). + public static let maxAgeImage: ConfigKey = "cache.maxAge.image" + /// The configuration key for the max-age, in seconds, applied to all other static files (e.g. the web manifest). + public static let maxAgeDefault: ConfigKey = "cache.maxAge.default" + } /// A namespace for the HTTP server configuration keys. public enum HTTP { /// The configuration key for the host the server binds to. diff --git a/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift new file mode 100644 index 0000000..489fa2a --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift @@ -0,0 +1,11 @@ +extension Int { + /// A namespace for the cache's default configuration values. + public enum Cache { + /// The default max-age, in seconds, applied to text-based static files (1 hour). + public static let maxAgeText = 3_600 + /// The default max-age, in seconds, applied to image static files (1 week). + public static let maxAgeImage = 604_800 + /// The default max-age, in seconds, applied to all other static files (1 day). + public static let maxAgeDefault = 86_400 + } +} diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index fd64115..15d0765 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -13,6 +13,12 @@ import Testing struct AppTests { // MARK: Constants + + private let textExtensions: [StaticFile.Extension] = [ + .css, + .js, + .txt + ] // Absolute path to the package's "Resources/Static" folder, derived from this // file's location so the static files resolve regardless of the working directory. @@ -35,7 +41,7 @@ struct AppTests { let body = String(buffer: response.body) #expect(response.status == .ok) - #expect(response.headers[.contentType]?.hasPrefix("text/html") == true) + #expect(response.headers[.contentType] == "text/html; charset=utf-8") #expect(body.contains("Hello world!")) } } @@ -52,6 +58,15 @@ struct AppTests { ) { response in #expect(response.status == .ok) #expect(response.headers[.contentType] == file.contentType) + + let cacheControl = try #require(response.headers[.cacheControl]) + + #expect(cacheControl.contains("public") == true) + #expect(cacheControl.contains("max-age=") == true) + + if textExtensions.contains(file.fileExtension) { + #expect(cacheControl.contains("must-revalidate") == true) + } } } } @@ -66,7 +81,7 @@ struct AppTests { let body = String(buffer: response.body) #expect(response.status == .notFound) - #expect(response.headers[.contentType]?.hasPrefix("text/html") == true) + #expect(response.headers[.contentType] == "text/html; charset=utf-8") #expect(body.contains("Page Not Found")) } }