Files
ccn/Packages/Infrastructure/Sources/Public/Responses/CachedHTMLResponse.swift
T

256 lines
8.9 KiB
Swift
Raw Normal View History

2026-09-20 12:45:58 +02:00
import CompressNIO
2026-08-19 22:55:00 +02:00
import Elementary
2026-09-20 12:45:58 +02:00
import Foundation
2026-08-19 22:55:00 +02:00
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.
///
2026-09-20 12:45:58 +02:00
/// 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.
///
2026-08-19 22:55:00 +02:00
/// 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
2026-09-20 12:45:58 +02:00
/// changes the tag, propagating immediately. The tag is weak and spans both encodings, so a revalidation succeeds whichever copy the client holds.
2026-08-19 22:55:00 +02:00
///
/// ``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?
2026-09-20 12:45:58 +02:00
/// The page gzipped once, and the headers announcing it, or `nil` when the bytes did not compress.
private let gzip: (buffer: ByteBuffer, headers: HTTPFields)?
2026-08-19 22:55:00 +02:00
/// 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
2026-09-20 12:45:58 +02:00
/// Renders the given document to bytes once, and gzips them once.
2026-08-19 22:55:00 +02:00
///
/// 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
2026-09-20 12:45:58 +02:00
// A page that will not compress is served as rendered.
self.gzip = Self.gzipped(
buffer,
headers: headers
)
2026-08-19 22:55:00 +02:00
}
// MARK: Methods
/// Builds a response from the cached, pre-rendered bytes.
///
2026-09-20 12:45:58 +02:00
/// 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.
2026-08-19 22:55:00 +02:00
/// - 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
)
}
2026-09-20 12:45:58 +02:00
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)
}
)
}
2026-08-19 22:55:00 +02:00
return Response(
status: status,
2026-09-20 12:45:58 +02:00
headers: gzip.headers,
body: .init { [buffer = gzip.buffer] writer in
2026-08-19 22:55:00 +02:00
try await writer.write(buffer)
try await writer.finish(nil)
}
)
}
}
2026-09-20 12:45:58 +02:00
// 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 = "*"
}