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>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
import Localization
|
||||
|
||||
public extension Negotiate {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The language to serve a request in.
|
||||
///
|
||||
/// The `lang` query parameter wins as a deliberate override; failing that, a leading path segment naming a supported language pins it, so an
|
||||
/// unrouted path under a language's prefix answers in that language. Anything naming no supported language is ignored, leaving the
|
||||
/// `Accept-Language` header and its fallback to the default.
|
||||
///
|
||||
/// Called where the answer is used rather than stamped onto every request on the way past: page routes that pin their language by URL never ask.
|
||||
/// - Parameter request: the request to negotiate for.
|
||||
/// - Returns: the identifier of the supported language to serve.
|
||||
func callAsFunction(
|
||||
for request: Request
|
||||
) -> String {
|
||||
let requested = request.uri.queryParameters[.Parameter.language].map(String.init)
|
||||
?? request.uri.path.split(separator: "/").first.map(String.init)
|
||||
|
||||
return callAsFunction(
|
||||
requested: requested,
|
||||
acceptLanguage: request.headers[.acceptLanguage]
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private extension Substring {
|
||||
/// A namespace for the query parameters the negotiation reads.
|
||||
enum Parameter {
|
||||
/// The query parameter carrying an explicit language choice; the site's language switcher appends it to the current path.
|
||||
static let language: Substring = "lang"
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,11 @@ extension String {
|
||||
/// The default `Content-Security-Policy`.
|
||||
///
|
||||
/// Restricts every resource to the site's own origin (`default-src 'self'`), blocks plugins (`object-src 'none'`), pins the document
|
||||
/// base URL (`base-uri 'self'`), and forbids framing (`frame-ancestors 'none'`). No inline-style exception is included, so pages must
|
||||
/// link external stylesheets.
|
||||
public static let contentSecurityPolicy = "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
|
||||
/// base URL (`base-uri 'self'`), holds form submissions to the origin (`form-action 'self'`), and forbids framing
|
||||
/// (`frame-ancestors 'none'`). No inline-style exception is included, so pages must link external stylesheets.
|
||||
///
|
||||
/// `form-action` is named outright because it inherits from nothing: `default-src` does not cover it, however tight.
|
||||
public static let contentSecurityPolicy = "default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
|
||||
/// The default `X-Content-Type-Options` (disables MIME sniffing).
|
||||
public static let contentTypeOptions = "nosniff"
|
||||
/// The default `X-Frame-Options` (forbids framing the page).
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
import HummingbirdCompression
|
||||
import Logging
|
||||
|
||||
/// Compresses the responses that are not already encoded, and passes the ones that are through untouched.
|
||||
///
|
||||
/// Hummingbird's `ResponseCompressionMiddleware` appends to `Content-Encoding` without checking for one, so a pre-compressed page (see
|
||||
/// ``CachedHTMLResponse``) would go out as `gzip, gzip` — a client decodes once and renders the inner gzip stream. This stands in for that
|
||||
/// middleware and delegates to it, so the threshold, the negotiation and the compressor stay its behaviour.
|
||||
///
|
||||
/// - Note: `Context` is the request context the middleware is resolved against.
|
||||
public struct CompressionMiddleware<Context: RequestContext>: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The middleware the unencoded responses are handed to.
|
||||
private let compression: ResponseCompressionMiddleware<Context>
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a compression middleware.
|
||||
/// - Parameter minimumResponseSizeToCompress: the smallest response body, in bytes, that is compressed at all.
|
||||
public init(
|
||||
minimumResponseSizeToCompress: Int
|
||||
) {
|
||||
self.compression = .init(
|
||||
minimumResponseSizeToCompress: minimumResponseSizeToCompress
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterMiddleware
|
||||
|
||||
extension CompressionMiddleware: RouterMiddleware {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Passes the request down the chain and compresses the response on the way back up, unless it already names an encoding.
|
||||
/// - 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, compressed when it was not already.
|
||||
/// - Throws: any error thrown downstream.
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
let response = try await next(
|
||||
request,
|
||||
context
|
||||
)
|
||||
|
||||
guard response.headers[.contentEncoding] == nil else {
|
||||
return response
|
||||
}
|
||||
|
||||
// The response is already in hand, so the delegate gets it rather than the chain — `next` runs once.
|
||||
return try await compression.handle(
|
||||
request,
|
||||
context: context
|
||||
) { _, _ in
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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 `lang` query parameter, its path, and its
|
||||
/// `Accept-Language` header, negotiates the best supported match (falling back to the default language), and stores it on the context's
|
||||
/// ``LocalizedRequestContext/language``.
|
||||
///
|
||||
/// The query parameter is a deliberate override; failing that, a leading path segment naming a supported language pins it, so an unrouted path under
|
||||
/// a language's prefix — its not-found page — answers in that language. Values naming no supported language are ignored, leaving the header. The
|
||||
/// request is passed through untouched — the path and routing are unaffected.
|
||||
///
|
||||
/// A site whose page routes pin their language by URL consults this only for responses that belong to no URL — in practice, its not-found page.
|
||||
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
|
||||
|
||||
// The deliberate query override first; failing that, the leading path segment, so a language's whole URL
|
||||
// prefix — routed or not — answers in its language.
|
||||
let requested = request.uri.queryParameters[.Parameter.language].map(String.init)
|
||||
?? request.uri.path.split(separator: "/").first.map(String.init)
|
||||
|
||||
context.language = negotiate(
|
||||
requested: requested,
|
||||
acceptLanguage: request.headers[.acceptLanguage]
|
||||
)
|
||||
|
||||
return try await next(request, context)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private extension Substring {
|
||||
/// A namespace for the query parameters the middleware reads.
|
||||
enum Parameter {
|
||||
/// The query parameter carrying an explicit language choice; the site's language switcher appends it to the current path.
|
||||
static let language: Substring = "lang"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import Localization
|
||||
|
||||
/// 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.
|
||||
/// path and responds with the rendered error page and a `404 Not Found` status.
|
||||
///
|
||||
/// Its responses declare `Vary: Accept-Language`: where the page routes pin their language by URL, this is the one responder that negotiates.
|
||||
/// An unrouted path names no edition, so no canonical URL contradicts the header.
|
||||
public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
|
||||
/// It negotiates the language itself, on the way out, so the cost falls on the 404s rather than on every request through the chain — where page
|
||||
/// routes pin their language by URL, this is the one responder that has to ask. Its responses declare `Vary: Accept-Language` accordingly: an
|
||||
/// unrouted path names no edition, so no canonical URL contradicts the header.
|
||||
public struct NotFoundMiddleware<Context: RequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// Negotiates the language a not-found response is served in.
|
||||
private let negotiate: Negotiate
|
||||
|
||||
/// The error page, rendered once per supported language and reused for every not-found response.
|
||||
private let responses: LocalizedHTMLCollectionResponse
|
||||
|
||||
@@ -27,6 +31,7 @@ public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
|
||||
bundle: Bundle,
|
||||
document: (Locale) -> Document
|
||||
) {
|
||||
self.negotiate = .init(bundle: bundle)
|
||||
self.responses = .init(
|
||||
bundle: bundle,
|
||||
status: .notFound,
|
||||
@@ -69,7 +74,7 @@ extension NotFoundMiddleware: RouterMiddleware {
|
||||
}
|
||||
|
||||
return responses.response(
|
||||
for: context.language,
|
||||
for: negotiate(for: request),
|
||||
request: request
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import Hummingbird
|
||||
|
||||
/// A request context that carries the language negotiated for the request.
|
||||
///
|
||||
/// ``LocalizationMiddleware`` resolves the visitor's preferred language from the `Accept-Language` header and stores it here, so downstream
|
||||
/// controllers and middleware can serve the matching localization without re-reading the header.
|
||||
public protocol LocalizedRequestContext: RequestContext {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The language identifier negotiated for the request.
|
||||
var language: String { get set }
|
||||
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import CompressNIO
|
||||
import Elementary
|
||||
import Foundation
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
import NIOCore
|
||||
@@ -9,14 +11,14 @@ import NIOCore
|
||||
/// 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.
|
||||
/// 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.
|
||||
///
|
||||
/// The body is written as an unsized stream (no `Content-Length`), mirroring `HTMLResponse`, so the response-compression middleware downstream
|
||||
/// treats it exactly as it would a freshly rendered page.
|
||||
public struct CachedHTMLResponse: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
@@ -27,6 +29,9 @@ public struct CachedHTMLResponse: Sendable {
|
||||
/// 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
|
||||
|
||||
@@ -35,7 +40,7 @@ public struct CachedHTMLResponse: Sendable {
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Renders the given document to bytes once.
|
||||
/// 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.
|
||||
@@ -74,16 +79,19 @@ public struct CachedHTMLResponse: Sendable {
|
||||
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 full page is served, mirroring the
|
||||
/// `text/html; charset=utf-8` content type `HTMLResponse` produces and leaving the
|
||||
/// `Content-Length` unset so small pages remain eligible for compression.
|
||||
/// 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(
|
||||
@@ -101,10 +109,24 @@ public struct CachedHTMLResponse: Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
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: headers,
|
||||
body: .init { [buffer] writer in
|
||||
headers: gzip.headers,
|
||||
body: .init { [buffer = gzip.buffer] writer in
|
||||
try await writer.write(buffer)
|
||||
try await writer.finish(nil)
|
||||
}
|
||||
@@ -112,3 +134,122 @@ public struct CachedHTMLResponse: Sendable {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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 = "*"
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public struct Analytics: Sendable {
|
||||
/// - Parameters:
|
||||
/// - scriptURL: the URL the tracker script is loaded from.
|
||||
/// - websiteID: the analytics website identifier the tracker reports as.
|
||||
/// - domains: the comma-delimited domains the tracker reports from; visits from any other host are ignored. Empty to report from every host.
|
||||
/// - domains: the comma-delimited domains the tracker reports from; empty to report from every host.
|
||||
/// - excludeHash: whether the tracker drops the URL fragment from reported pageviews; defaults to `true`.
|
||||
/// - doNotTrack: whether the tracker honors the visitor's browser Do Not Track preference; defaults to `true`.
|
||||
/// - performance: whether the tracker collects Core Web Vitals (requires Umami v3.1 or newer); defaults to `true`.
|
||||
@@ -61,6 +61,8 @@ public struct Analytics: Sendable {
|
||||
// MARK: Computed
|
||||
|
||||
/// The tracker script's attributes: the website id, the reporting domains when set, then each enabled behavior flag; disabled flags are omitted.
|
||||
///
|
||||
/// An empty ``domains`` is omitted rather than rendered: `data-domains=""` is a filter matching no host.
|
||||
public var attributes: [Attribute] {
|
||||
var attributes: [Attribute] = [
|
||||
.init("data-website-id", value: websiteID)
|
||||
|
||||
Reference in New Issue
Block a user