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:
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
import Localization
|
||||
|
||||
/// Resolves the visitor's preferred language and records it on the request context.
|
||||
///
|
||||
/// Placed ahead of the localized responders in the middleware chain, it reads the request's
|
||||
/// `Accept-Language` header, negotiates the best supported match (falling back to the default
|
||||
/// language), and stores it on the context's ``LocalizedRequestContext/language``.
|
||||
///
|
||||
/// The request is otherwise passed through untouched — the URL and routing are not affected — so
|
||||
/// each page is served at its existing path and varies its content by header.
|
||||
public struct LocalizationMiddleware<Context: LocalizedRequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// Negotiates the request's language from its `Accept-Language` header.
|
||||
private let negotiate: Negotiate
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a localization middleware that negotiates against the given bundle's String Catalog languages.
|
||||
/// - Parameter bundle: the bundle whose String Catalog names the supported languages.
|
||||
public init(
|
||||
bundle: Bundle
|
||||
) {
|
||||
self.negotiate = .init(bundle: bundle)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterMiddleware
|
||||
|
||||
extension LocalizationMiddleware: RouterMiddleware {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Negotiates the request's language and records it on the context before passing it down.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
/// - next: the next responder in the middleware chain.
|
||||
/// - Returns: the downstream response.
|
||||
/// - Throws: any error thrown downstream.
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
var context = context
|
||||
|
||||
context.language = negotiate(
|
||||
acceptLanguage: request.headers[.acceptLanguage]
|
||||
)
|
||||
|
||||
return try await next(request, context)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
|
||||
/// Serves a custom error page for requests that match neither a route nor a static file.
|
||||
///
|
||||
/// Placed ahead of `FileMiddleware` in the middleware chain, it catches the `.notFound` error that bubbles up when no file exists for the requested
|
||||
/// path and responds with the rendered error page and a `404 Not Found` status. The page is served in the language stored on the context by
|
||||
/// ``LocalizationMiddleware``, falling back to the default language.
|
||||
public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The error page, rendered once per supported language and reused for every not-found response.
|
||||
private let responses: LocalizedHTMLCollectionResponse
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a not-found middleware.
|
||||
/// - Parameters:
|
||||
/// - bundle: the bundle whose String Catalog names the languages the page is rendered for.
|
||||
/// - document: builds the error page to render for a given locale.
|
||||
public init<Document: HTMLDocument>(
|
||||
bundle: Bundle,
|
||||
document: (Locale) -> Document
|
||||
) {
|
||||
self.responses = .init(
|
||||
bundle: bundle,
|
||||
status: .notFound,
|
||||
document: document
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterMiddleware
|
||||
|
||||
extension NotFoundMiddleware: RouterMiddleware {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Passes the request down the chain, rendering the error page if it results in a not-found response.
|
||||
///
|
||||
/// Any error other than `.notFound` is rethrown unchanged.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
/// - next: the next responder in the middleware chain.
|
||||
/// - Returns: the downstream response, or the rendered error page with a `404 Not Found` status.
|
||||
/// - Throws: any non-not-found error thrown downstream.
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
do {
|
||||
return try await next(request, context)
|
||||
}
|
||||
catch let error {
|
||||
guard
|
||||
let responseError = error as? any HTTPResponseError,
|
||||
responseError.status == .notFound
|
||||
else {
|
||||
throw error
|
||||
}
|
||||
|
||||
return responses.response(
|
||||
for: context.language,
|
||||
request: request
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import Foundation
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
import NIOCore
|
||||
import Synchronization
|
||||
|
||||
/// Rejects a client's requests with `429 Too Many Requests` once they exceed a fixed-window rate limit.
|
||||
///
|
||||
/// Added to the routes that must not be hammered — the subscription endpoint, an unauthenticated database write — it admits up to the configured limit of
|
||||
/// requests per client per window, and answers the excess with `429 Too Many Requests` and a `Retry-After` header naming the seconds until the
|
||||
/// window resets.
|
||||
///
|
||||
/// A client is keyed by the first `X-Forwarded-For` entry when the ``Configuration`` trusts it, by the connection's remote address otherwise, and by
|
||||
/// one shared bucket when neither names the client. The counters live in memory with a bounded capacity, so a flood of distinct clients cannot grow the
|
||||
/// store without bound — and each instance of a multi-instance deployment enforces its own budget.
|
||||
public struct RateLimitMiddleware<Context: RequestContext>: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The fixed-window request counters, keyed by client.
|
||||
private let buckets: Buckets
|
||||
|
||||
/// The limits the middleware enforces.
|
||||
private let configuration: Configuration
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a rate-limit middleware.
|
||||
/// - Parameter configuration: the limits the middleware enforces. Defaults to a budget suited to a form endpoint: a handful of requests per
|
||||
/// client per minute.
|
||||
public init(
|
||||
configuration: Configuration = .init()
|
||||
) {
|
||||
self.buckets = .init(
|
||||
limit: configuration.limit,
|
||||
window: configuration.window
|
||||
)
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterMiddleware
|
||||
|
||||
extension RateLimitMiddleware: RouterMiddleware {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Passes the request down the chain while the client stays within its budget, and answers it with `429 Too Many Requests` and a `Retry-After`
|
||||
/// header once it does not.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
/// - next: the next responder in the middleware chain.
|
||||
/// - Returns: the downstream response, or the `429` rejection.
|
||||
/// - Throws: any error thrown downstream.
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
let admission = buckets.admit(
|
||||
client(
|
||||
for: request,
|
||||
context: context
|
||||
)
|
||||
)
|
||||
|
||||
switch admission {
|
||||
case .admitted:
|
||||
return try await next(
|
||||
request,
|
||||
context
|
||||
)
|
||||
case .limited(let retryAfter):
|
||||
var response = Response(status: .tooManyRequests)
|
||||
|
||||
response.headers[.retryAfter] = String(max(1, retryAfter.components.seconds))
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension RateLimitMiddleware {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The key identifying the requesting client: the first `X-Forwarded-For` entry when trusted, the connection's remote address otherwise, and one
|
||||
/// bucket shared by every unidentifiable client when neither is known.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
/// - Returns: the client key the request is counted under.
|
||||
func client(
|
||||
for request: Request,
|
||||
context: Context
|
||||
) -> String {
|
||||
if
|
||||
configuration.trustForwardedFor,
|
||||
let forwarded = request.headers[.xForwardedFor]?
|
||||
.split(separator: ",")
|
||||
.first?
|
||||
.trimmingCharacters(in: .whitespaces),
|
||||
!forwarded.isEmpty
|
||||
{
|
||||
return forwarded
|
||||
}
|
||||
|
||||
if let address = (context as? any RemoteAddressRequestContext)?.remoteAddress {
|
||||
return address.ipAddress
|
||||
?? address.description
|
||||
}
|
||||
|
||||
return .unidentified
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Buckets
|
||||
|
||||
private extension RateLimitMiddleware {
|
||||
|
||||
/// The outcome of asking the ``Buckets`` store to admit a request.
|
||||
enum Admission {
|
||||
/// The request is within the client's budget.
|
||||
case admitted
|
||||
/// The client exhausted its budget; the payload is the time until its window resets.
|
||||
case limited(retryAfter: Duration)
|
||||
}
|
||||
|
||||
/// The fixed-window request counters, keyed by client.
|
||||
///
|
||||
/// The counters sit behind a mutex rather than an actor: an admission is a handful of dictionary operations, so the lock is held only briefly and the
|
||||
/// calling task never suspends — requests skip the executor hop an actor would add on every pass through the middleware.
|
||||
final class Buckets: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The maximum number of clients tracked at once, bounding the store's memory.
|
||||
private let capacity: Int
|
||||
|
||||
/// The per-client counters: the start of the client's current window and its request count.
|
||||
private let counters: Mutex<[String: (start: ContinuousClock.Instant, count: Int)]>
|
||||
|
||||
/// The number of requests admitted per client per ``window``.
|
||||
private let limit: Int
|
||||
|
||||
/// The length of the fixed window the ``limit`` applies to.
|
||||
private let window: Duration
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a counter store.
|
||||
/// - Parameters:
|
||||
/// - limit: the number of requests admitted per client per window.
|
||||
/// - window: the length of the fixed window the limit applies to.
|
||||
/// - capacity: the maximum number of clients tracked at once.
|
||||
init(
|
||||
limit: Int,
|
||||
window: Duration,
|
||||
capacity: Int = 10_000
|
||||
) {
|
||||
self.capacity = capacity
|
||||
self.counters = .init([:])
|
||||
self.limit = limit
|
||||
self.window = window
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Counts a request against the client's current window and admits it while the count stays within the limit.
|
||||
/// - Parameter client: the key the request is counted under.
|
||||
/// - Returns: the admission outcome.
|
||||
func admit(
|
||||
_ client: String
|
||||
) -> Admission {
|
||||
let now = ContinuousClock.now
|
||||
|
||||
return counters.withLock { counters in
|
||||
if let counter = counters[client], now < counter.start.advanced(by: window) {
|
||||
guard counter.count < limit else {
|
||||
return .limited(retryAfter: now.duration(to: counter.start.advanced(by: window)))
|
||||
}
|
||||
|
||||
counters[client] = (counter.start, counter.count + 1)
|
||||
|
||||
return .admitted
|
||||
}
|
||||
|
||||
makeRoom(
|
||||
in: &counters,
|
||||
at: now
|
||||
)
|
||||
|
||||
counters[client] = (now, 1)
|
||||
|
||||
return .admitted
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Keeps the store within its capacity before a new client is tracked: expired windows are dropped first, and when the store remains full, the
|
||||
/// oldest live windows are evicted in one batch — a tenth of the capacity — so the sort that finds them runs once per batch of admissions
|
||||
/// instead of once per request while a flood of distinct clients keeps the store full.
|
||||
/// - Parameters:
|
||||
/// - counters: the counters the room is made in.
|
||||
/// - now: the instant the expiry is evaluated against.
|
||||
private func makeRoom(
|
||||
in counters: inout [String: (start: ContinuousClock.Instant, count: Int)],
|
||||
at now: ContinuousClock.Instant
|
||||
) {
|
||||
guard counters.count >= capacity else {
|
||||
return
|
||||
}
|
||||
|
||||
counters = counters.filter {
|
||||
now < $0.value.start.advanced(by: window)
|
||||
}
|
||||
|
||||
let headroom = max(1, capacity / 10)
|
||||
let excess = counters.count - (capacity - headroom)
|
||||
|
||||
guard excess > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
let oldest = counters
|
||||
.sorted { $0.value.start < $1.value.start }
|
||||
.prefix(excess)
|
||||
|
||||
for counter in oldest {
|
||||
counters.removeValue(forKey: counter.key)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
extension RateLimitMiddleware {
|
||||
/// The limits a ``RateLimitMiddleware`` enforces.
|
||||
public struct Configuration: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The number of requests admitted per client per ``window``.
|
||||
public let limit: Int
|
||||
|
||||
/// Whether a client is keyed by the first `X-Forwarded-For` entry.
|
||||
///
|
||||
/// Enable it only behind a reverse proxy that sets the header — there, the connection's own address would name the proxy for every visitor,
|
||||
/// sharing one budget across all of them. On a directly reachable server the header is client-supplied, so trusting it lets a client forge fresh keys
|
||||
/// at will.
|
||||
public let trustForwardedFor: Bool
|
||||
|
||||
/// The length of the fixed window the ``limit`` applies to.
|
||||
public let window: Duration
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a rate-limit configuration.
|
||||
/// - Parameters:
|
||||
/// - limit: the number of requests admitted per client per window.
|
||||
/// - window: the length of the fixed window the limit applies to.
|
||||
/// - trustForwardedFor: whether a client is keyed by the first `X-Forwarded-For` entry.
|
||||
public init(
|
||||
limit: Int = .RateLimit.limit,
|
||||
window: Duration = .seconds(Int.RateLimit.window),
|
||||
trustForwardedFor: Bool = false
|
||||
) {
|
||||
self.limit = limit
|
||||
self.trustForwardedFor = trustForwardedFor
|
||||
self.window = window
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String+Constants
|
||||
|
||||
private extension String {
|
||||
/// The bucket shared by every client the middleware cannot identify.
|
||||
static let unidentified = "unidentified"
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
|
||||
/// Stamps a set of security-related HTTP headers onto every response.
|
||||
///
|
||||
/// Placed at (or near) the top of the middleware chain, it adds the configured headers to whatever
|
||||
/// response bubbles back up — the rendered pages, the error page produced by
|
||||
/// ``NotFoundMiddleware``, and every static file served by `FileMiddleware` — so the browser applies
|
||||
/// the strict, hardened interpretation of the content instead of its lenient legacy defaults.
|
||||
///
|
||||
/// The headers are precomputed once from the ``Configuration`` at initialization and reused for
|
||||
/// every request, so the per-request cost is a handful of header copies.
|
||||
public struct SecurityHeadersMiddleware<Context: RequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The precomputed headers applied to every response.
|
||||
private let fields: HTTPFields
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a security-headers middleware.
|
||||
/// - Parameter configuration: the headers applied to every response. Defaults to a hardened
|
||||
/// baseline suitable for a static site, with `Strict-Transport-Security` left off (see
|
||||
/// ``Configuration``).
|
||||
public init(
|
||||
configuration: Configuration = .init()
|
||||
) {
|
||||
self.fields = configuration.fields
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterMiddleware
|
||||
|
||||
extension SecurityHeadersMiddleware: RouterMiddleware {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Passes the request down the chain and stamps the configured security headers onto the
|
||||
/// response on the way back up.
|
||||
///
|
||||
/// Errors that can render themselves (`HTTPResponseError`, like the `HTTPError`s thrown by the
|
||||
/// controllers) are converted to their response here rather than left to the router: the router
|
||||
/// converts them above the middleware chain, where the response would escape these headers.
|
||||
/// Existing values for the same header names are replaced so downstream middleware cannot leave
|
||||
/// a weaker policy in place.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
/// - next: the next responder in the middleware chain.
|
||||
/// - Returns: the downstream response with the security headers applied.
|
||||
/// - Throws: any downstream error that does not render as an HTTP response.
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
var response: Response
|
||||
|
||||
do {
|
||||
response = try await next(
|
||||
request,
|
||||
context
|
||||
)
|
||||
} catch let error as any HTTPResponseError {
|
||||
response = try error.response(
|
||||
from: request,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
for field in fields {
|
||||
response.headers[field.name] = field.value
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension SecurityHeadersMiddleware.Configuration {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The configuration expressed as the headers to apply, omitting any whose value is `nil`.
|
||||
var fields: HTTPFields {
|
||||
var fields = HTTPFields()
|
||||
|
||||
fields[.contentSecurityPolicy] = contentSecurityPolicy
|
||||
fields[.xContentTypeOptions] = contentTypeOptions
|
||||
fields[.frameOptions] = frameOptions
|
||||
fields[.referrerPolicy] = referrerPolicy
|
||||
fields[.permissionsPolicy] = permissionsPolicy
|
||||
fields[.strictTransportSecurity] = strictTransportSecurity
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
extension SecurityHeadersMiddleware {
|
||||
/// The set of security headers a ``SecurityHeadersMiddleware`` applies.
|
||||
///
|
||||
/// Each property maps to a single response header. A `nil` value omits that header entirely,
|
||||
/// which is how `Strict-Transport-Security` stays disabled by default: it is only safe to send
|
||||
/// over HTTPS and is "sticky" in browsers, so it must stay off in plain-HTTP development and be
|
||||
/// switched on (via configuration) only in TLS-terminated production.
|
||||
public struct Configuration: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The `Content-Security-Policy` value (controls which sources the browser will load).
|
||||
public let contentSecurityPolicy: String?
|
||||
/// The `X-Content-Type-Options` value (disables MIME sniffing when set to `nosniff`).
|
||||
public let contentTypeOptions: String?
|
||||
/// The `X-Frame-Options` value (controls whether the page may be framed).
|
||||
public let frameOptions: String?
|
||||
/// The `Referrer-Policy` value (controls how much referrer information is shared).
|
||||
public let referrerPolicy: String?
|
||||
/// The `Permissions-Policy` value (gates access to powerful browser features).
|
||||
public let permissionsPolicy: String?
|
||||
/// The `Strict-Transport-Security` value, or `nil` to omit the header (the default).
|
||||
public let strictTransportSecurity: String?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a security-headers configuration.
|
||||
///
|
||||
/// Every parameter defaults to the hardened baseline defined in `String.Security`, except
|
||||
/// `strictTransportSecurity`, which defaults to `nil` (omitted). Pass `nil` for any header
|
||||
/// to drop it from the response.
|
||||
/// - Parameters:
|
||||
/// - contentSecurityPolicy: the `Content-Security-Policy` value.
|
||||
/// - contentTypeOptions: the `X-Content-Type-Options` value.
|
||||
/// - frameOptions: the `X-Frame-Options` value.
|
||||
/// - referrerPolicy: the `Referrer-Policy` value.
|
||||
/// - permissionsPolicy: the `Permissions-Policy` value.
|
||||
/// - strictTransportSecurity: the `Strict-Transport-Security` value, or `nil` to omit it.
|
||||
public init(
|
||||
contentSecurityPolicy: String? = String.Security.contentSecurityPolicy,
|
||||
contentTypeOptions: String? = String.Security.contentTypeOptions,
|
||||
frameOptions: String? = String.Security.frameOptions,
|
||||
referrerPolicy: String? = String.Security.referrerPolicy,
|
||||
permissionsPolicy: String? = String.Security.permissionsPolicy,
|
||||
strictTransportSecurity: String? = nil
|
||||
) {
|
||||
self.contentSecurityPolicy = contentSecurityPolicy
|
||||
self.contentTypeOptions = contentTypeOptions
|
||||
self.frameOptions = frameOptions
|
||||
self.referrerPolicy = referrerPolicy
|
||||
self.permissionsPolicy = permissionsPolicy
|
||||
self.strictTransportSecurity = strictTransportSecurity
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
|
||||
/// Appends header names to the `Vary` header of every response passing through.
|
||||
///
|
||||
/// Placed just above the response-compression middleware, it marks each response as varying on `Accept-Encoding`: the static files and pre-rendered
|
||||
/// pages are served with `Cache-Control: public`, so without the signal a shared cache could store a compressed body and hand it to a client that
|
||||
/// never advertised support for the encoding.
|
||||
///
|
||||
/// Names already present on a response's `Vary` header — such as the `Accept-Language` the localized pages carry — are kept, and duplicates are not
|
||||
/// added.
|
||||
public struct VaryMiddleware<Context: RequestContext>: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The header names appended to every response's `Vary` header.
|
||||
private let names: [String]
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a vary middleware.
|
||||
/// - Parameter fields: the header names appended to every response's `Vary` header. Defaults to `Accept-Encoding`, the request header
|
||||
/// the response-compression middleware acts on.
|
||||
public init(
|
||||
fields: [HTTPField.Name] = [.acceptEncoding]
|
||||
) {
|
||||
self.names = fields.map(\.rawName)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterMiddleware
|
||||
|
||||
extension VaryMiddleware: RouterMiddleware {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Passes the request down the chain and appends the configured names to the response's `Vary` header on the way back up.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
/// - next: the next responder in the middleware chain.
|
||||
/// - Returns: the downstream response with the `Vary` names applied.
|
||||
/// - Throws: any error thrown downstream.
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
var response = try await next(
|
||||
request,
|
||||
context
|
||||
)
|
||||
|
||||
var vary = response.headers[.vary]?
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) } ?? []
|
||||
|
||||
for name in names where !vary.contains(where: {
|
||||
$0.caseInsensitiveCompare(name) == .orderedSame
|
||||
}) {
|
||||
vary.append(name)
|
||||
}
|
||||
|
||||
response.headers[.vary] = vary.joined(separator: ", ")
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user