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