Files
javierandClaude Fable 5.1 916df7e2f0 Project updates from Template
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>
2026-09-20 12:45:58 +02:00

256 lines
8.9 KiB
Swift

import CompressNIO
import Elementary
import Foundation
import HTTPTypes
import Hummingbird
import NIOCore
/// A pre-rendered HTTP response for a fully static HTML page.
///
/// The document is rendered to bytes once, at initialization, and every ``response(for:)`` reuses those bytes — along with a fixed status and
/// precomputed headers — instead of re-rendering. This suits pages whose markup never changes between requests, such as the landing page and the
/// not-found page, avoiding a per-request Elementary render on hot paths.
///
/// The bytes are gzipped once as well, so a page is compressed at startup rather than per request, and served sized rather than chunked. A client that
/// accepts no gzip gets the rendered bytes, leaving ``CompressionMiddleware`` to apply whatever encoding it did negotiate.
///
/// A successful page also revalidates cheaply: its headers carry a weak entity tag derived from the rendered bytes and a `Cache-Control` that asks
/// clients to revalidate (`no-cache`), so a repeat visit costs a `304 Not Modified` instead of a full transfer — and a deploy that changes the page
/// changes the tag, propagating immediately. The tag is weak and spans both encodings, so a revalidation succeeds whichever copy the client holds.
///
/// ``LocalizedHTMLCollectionResponse`` builds on this type, caching one instance per supported language.
public struct CachedHTMLResponse: Sendable {
// MARK: Properties
/// The page rendered to bytes once.
private let buffer: ByteBuffer
/// The weak entity tag of the rendered bytes, present on successful pages only.
private let eTag: String?
/// The page gzipped once, and the headers announcing it, or `nil` when the bytes did not compress.
private let gzip: (buffer: ByteBuffer, headers: HTTPFields)?
/// The headers applied to every response, precomputed once.
private let headers: HTTPFields
/// The status applied to every response.
private let status: HTTPResponse.Status
// MARK: Initializers
/// Renders the given document to bytes once, and gzips them once.
///
/// A `200 OK` page gets the revalidation headers (`ETag` and `Cache-Control`); an error page does not, since a `304 Not Modified` only
/// ever stands in for a success.
/// - Parameters:
/// - status: the status applied to every response. Defaults to `.ok`.
/// - additionalHeaders: extra headers merged onto every response, alongside the content type.
/// Used to carry per-language signals such as `Content-Language` and `Vary`.
/// - document: the static HTML document to render and cache.
public init(
status: HTTPResponse.Status = .ok,
additionalHeaders: HTTPFields = [:],
document: some HTMLDocument
) {
let buffer = ByteBuffer(string: document.render())
var headers: HTTPFields = [
.contentType: "text/html; charset=utf-8"
]
var eTag: String?
if status == .ok {
var hash = FNV1aHash()
hash.combine(buffer.readableBytesView)
eTag = "W/\"\(hash.digest)\""
headers[.eTag] = eTag
headers[.cacheControl] = "public, no-cache"
}
for field in additionalHeaders {
headers[field.name] = field.value
}
self.buffer = buffer
self.eTag = eTag
self.headers = headers
self.status = status
// A page that will not compress is served as rendered.
self.gzip = Self.gzipped(
buffer,
headers: headers
)
}
// MARK: Methods
/// Builds a response from the cached, pre-rendered bytes.
///
/// A conditional request whose `If-None-Match` names the page's entity tag is answered with a bodyless `304 Not Modified`. Otherwise the
/// page is served: the gzipped copy when the request accepts gzip, and the rendered bytes when it does not.
/// - Parameter request: the request the response answers.
/// - Returns: the response carrying the cached HTML body, or its `304` revalidation.
public func response(
for request: Request
) -> Response {
if
let eTag,
request.method == .get || request.method == .head,
let match = request.headers[.ifNoneMatch],
match == "*" || match.contains(eTag)
{
return Response(
status: .notModified,
headers: headers
)
}
guard
let gzip,
Self.acceptsGzip(request)
else {
return Response(
status: status,
headers: headers,
body: .init { [buffer] writer in
try await writer.write(buffer)
try await writer.finish(nil)
}
)
}
return Response(
status: status,
headers: gzip.headers,
body: .init { [buffer = gzip.buffer] writer in
try await writer.write(buffer)
try await writer.finish(nil)
}
)
}
}
// MARK: - Helpers
private extension CachedHTMLResponse {
// MARK: Methods
/// Whether the request accepts a gzipped body.
///
/// Only gzip is precomputed: a request asking for another encoding alone falls through to the rendered bytes for ``CompressionMiddleware`` to
/// encode. A `q=0` is a refusal; the wildcard accepts on the client's behalf.
/// - Parameter request: the incoming request.
/// - Returns: `true` when the gzipped copy may be served.
static func acceptsGzip(
_ request: Request
) -> Bool {
var wildcard = false
for value in request.headers[values: .acceptEncoding] {
for entry in value.split(separator: .Separator.comma) {
let parts = entry.split(separator: .Separator.semicolon)
let name = parts.first?
.trimmingCharacters(in: .whitespaces)
.lowercased()
guard
let name,
name == .gzip || name == .xGzip || name == .wildcard
else {
continue
}
let isAccepted = quality(of: parts.dropFirst()) > 0
if name == .wildcard {
wildcard = isAccepted
} else if isAccepted {
return true
} else {
// An explicit `gzip;q=0` refuses it outright, whatever the wildcard says.
return false
}
}
}
return wildcard
}
/// The `q` weight carried by an `Accept-Encoding` entry's parameters; parameters without one are the highest preference.
/// - Parameter parameters: the entry's parameters, the coding name already dropped.
/// - Returns: the entry's weight.
static func quality(
of parameters: some Sequence<Substring>
) -> Double {
for parameter in parameters {
let parameter = parameter
.trimmingCharacters(in: .whitespaces)
.lowercased()
guard parameter.hasPrefix(.qualityPrefix) else {
continue
}
return Double(parameter.dropFirst(String.qualityPrefix.count)) ?? 0
}
return 1
}
/// Gzips the rendered bytes and builds the headers announcing them.
///
/// The copy is complete before the first byte is written, so it carries a `Content-Length` rather than being chunked. Compression that fails or does
/// not pay for itself yields `nil`.
/// - Parameters:
/// - buffer: the rendered bytes.
/// - headers: the headers the response carries before the encoding is announced.
/// - Returns: the compressed bytes and their headers, or `nil` when the page is better served uncompressed.
static func gzipped(
_ buffer: ByteBuffer,
headers: HTTPFields
) -> (buffer: ByteBuffer, headers: HTTPFields)? {
var source = buffer
guard
let compressed = try? source.compress(with: .gzip()),
compressed.readableBytes < buffer.readableBytes
else {
return nil
}
var headers = headers
headers[.contentEncoding] = .gzip
headers[.contentLength] = String(compressed.readableBytes)
return (compressed, headers)
}
}
// MARK: - Constants
private extension Character {
enum Separator {
static let comma: Character = ","
static let semicolon: Character = ";"
}
}
private extension String {
/// The content coding the pages are precompressed with.
static let gzip = "gzip"
/// The prefix of an `Accept-Encoding` entry's weight parameter.
static let qualityPrefix = "q="
/// The content coding some older clients spell `gzip` as.
static let xGzip = "x-gzip"
/// The `Accept-Encoding` entry accepting any coding on the client's behalf.
static let wildcard = "*"
}