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 = "*"
}