Skip to content

aeronet Feature Reference

Single consolidated reference for aeronet features.

Platform support: aeronet runs on Linux (primary, epoll), macOS (kqueue), and Windows (WSAPoll). Features marked (Linux-only) are automatically disabled on other platforms with graceful fallbacks.

Scripted Benchmark Framework Coverage

  • Scripted benchmark orchestration includes a Boost.Beast backend (beast-bench-server) for HTTP/1.1 and WebSocket comparisons.
  • Beast is intentionally excluded from scripted HTTP/2 runs because Boost.Beast does not provide HTTP/2 server transport.

Index

  1. HTTP/1.1 Feature Matrix
  2. Performance / architecture
  3. Compression & Negotiation
  4. Inbound Request Decompression (Config Details)
  5. Chunked Transfer Encoding (RFC 7230 §4.1)
  6. Connection Close Semantics - includes graceful drain lifecycle
  7. Reserved & Managed Response Headers
  8. Request Header Duplicate Handling (Detailed)
  9. Path Handling
  10. Middleware Pipeline
  11. Trailing Slash Policy
  12. Construction Model (RAII & Ephemeral Ports)
  13. HttpServer Lifecycle
  14. Built-in Kubernetes-style probes
  15. TLS Features
  16. CONNECT (HTTP tunneling)
  17. Streaming Responses
  18. Static File Handler (RFC 7233 / RFC 7232)
  19. Mixed Mode Dispatch Precedence
  20. Logging
  21. OpenTelemetry Integration
  22. Access-Control (CORS) Helpers
  23. WebSocket (RFC 6455)
  24. HTTP/2 (RFC 9113)
  25. JWT (RFC 7519 — JWS profile)
  26. Future Expansions
  27. Large-body optimization
  28. Network Fault Injection Testing

HTTP/1.1 Feature Matrix

Legend: [x] implemented, [ ] planned / not yet.

Core HTTP parsing & routing

  • [x] Request line parsing (method, target, version)
  • [x] Header field parsing (no folding / continuations)
  • [x] Case-insensitive header lookup helper
  • [x] Router path matching and allowed-method computation
  • [x] Method token parsing / matching is case-insensitive (incoming method tokens like GET, get, GeT are accepted and normalized)
  • [x] Pipelined sequential requests (no parallel handler execution)

Where to look: see the "Core parsing & connection handling" and router sections below for details.

Transport & connection

  • [x] Persistent connections (HTTP/1.1 default, HTTP/1.0 opt-in)
  • [x] HTTP/1.0 response version preserved (no silent upgrade)
  • [x] Connection: close handling
  • [x] CONNECT tunneling (proxy-style TCP CONNECT handling, HTTP/1.1 and HTTP/2)
  • [x] Backpressure / partial write buffering
  • [x] Header read timeout (Slowloris mitigation) (configurable, disabled by default)
  • [x] Keep-alive limits (maxRequestsPerConnection)

Where to look: see the "CONNECT (HTTP tunneling)" subsection and the Connection Manager notes for implementation details.

Request bodies & decoding

  • [x] Content-Length bodies with size limit
  • [x] Chunked Transfer-Encoding decoding (request) with trailer header support (RFC 7230 §4.1.2)
  • [x] Trailer header exposure for incoming request trailers over both HTTP/1.1 (chunked) and HTTP/2 (a trailing HEADERS block, RFC 9113 §8.1), surfaced uniformly via HttpRequestView::trailers(). Tests: aeronet/http2/test/http2-protocol-handler_test.cpp (RequestTrailers*)
  • [x] Outbound trailer headers (response trailers for both buffered and streaming responses)
  • [x] Automatic chunked encoding for buffered responses with trailers (RFC 7230 §4.1.2)
  • [x] Content-Encoding request body decompression (gzip, deflate, zstd, multi-layer, identity skip, safety limits)
  • [x] Multipart/form-data convenience utilities
  • [x] Forbidden trailer headers rejected for incoming chunked trailers (security)

Where to look: see "Inbound Request Decompression (Config Details)" for decompression behavior and the parser docs for chunked/CL handling.

Response generation & streaming

  • [x] Basic fixed body responses
  • [x] Automatic Content-Length: 0 for empty-body responses (RFC 7230 §3.3.3) so keep-alive clients frame the zero-length body immediately instead of waiting for connection close — excluded for body-less statuses (1xx, 204, 304), HEAD, file, streaming and direct-compression responses. Tests: aeronet/http/test/http-response_test.cpp (EmptyBody*), tests/http-core_test.cpp (HttpKeepAlive.EmptyBodyResponseCarriesContentLengthZeroAndReusesConnection).
  • [x] HEAD method (suppressed body, correct Content-Length)
  • [x] Outgoing chunked / streaming responses (basic API: status/headers + incremental write + end, keep-alive capable)
  • [x] Outbound trailer headers (buffered via HttpResponse::trailerAddLine, streaming via HttpResponseWriter::trailerAddLine)
  • [x] Mixed-mode dispatch (simultaneous registration of streaming and fixed handlers with precedence)
  • [x] Compression (gzip, deflate, br, zstd) (zlib / zlib-ng, native mode, brotli, zstd) – streaming + buffered with threshold & q-values
  • [x] Streaming compression: per-writer reusable output buffer (zero reallocation between chunks)
  • [x] Large-body optimization (zero-copy capture for large fixed responses)
  • [x] Identity rejection -> 406 Not Acceptable when identity;q=0 and no acceptable encoding

Where to look: see the "Compression & Negotiation" section for full details and configuration.

Methods & special semantics

  • [x] OPTIONS * handling (returns an Allow header per RFC 7231 §4.3)
  • [x] TRACE method support (echo) - optional and configurable via HttpServerConfig::TraceMethodPolicy
  • [x] CONNECT method support - proxy-style TCP tunneling to an upstream host:port target.

Where to look: see the "OPTIONS & TRACE behavior" subsection below.

Status & error handling

  • [x] 400 Bad Request (parse errors, CL+TE conflict)
  • [x] 400 on HTTP/1.0 requests carrying Transfer-Encoding
  • [x] 405 Method Not Allowed (enforced when path exists but method not in allow set)
  • [x] 406 Not Acceptable (identity rejected when no acceptable Accept-Encoding)
  • [x] 413 Payload Too Large (body limit)
  • [x] 415 Unsupported Media Type (content-encoding based)
  • [ ] 415 Unsupported Media Type (content-type based)
  • [x] 417 Expectation Failed (unknown Expect token when no handler installed)
  • [x] 431 Request Header Fields Too Large (header limit)
  • [x] 500 Internal Server Error (invalid interim status returned by ExpectationHandler for instance)
  • [x] 501 Not Implemented (unsupported Transfer-Encoding)
  • [x] 505 HTTP Version Not Supported

Note: aeronet already maps unknown request Content-Encoding values to 415 when the inbound decompression feature is enabled (see "Inbound Request Decompression"). However, automatic Content-Type (media-type) validation is intentionally left to application code. If you need global Content-Type enforcement, implement a small validator middleware or configure your handlers to check the Content-Type header and return 415 when appropriate.

Where to look: see the "Status & error handling" notes and parser error descriptions below.

Headers & protocol niceties

  • [x] Request header duplicate handling (merge/override/disallow policies)
  • [ ] Optional stricter duplicate-header policy (fail on unknown duplicates) TRACE semantics and safety:

  • TRACE, when allowed, echoes the received request (start-line, headers and body) back with Content-Type: message/http so it can be used for debugging loopback-style probes as per RFC 7231 §4.3.2.

  • The server exposes a TraceMethodPolicy in HttpServerConfig with the following values:
  • Disabled - TRACE disallowed (default).
  • EnabledPlainAndTLS - TRACE allowed on both plaintext and TLS connections.
  • EnabledPlainOnly - TRACE allowed on plaintext connections only; rejected on TLS.

Server enforcement uses the per-request TLS indicator (e.g. HttpRequestView::tlsVersion() being non-empty for TLS) to make the decision when TraceMethodPolicy is one of the TLS-bound options. Use withTracePolicy(TraceMethodPolicy) to configure the policy programmatically.

Use cases:

  • If you deploy behind TLS-terminating proxies and want to avoid exposing TRACE responses over TLS endpoints, set TraceMethodPolicy::EnabledPlainOnly.

Note: EnabledOnTls (TRACE allowed only on TLS) was removed - the policy set is now intentionally smaller and focuses on disabling TRACE entirely, allowing it everywhere, or allowing it only on plaintext.

Expect header handling (RFC 7231 §5.1.1)

  • [x] Expect header processing with opt-in application-level expectation handler

Behavior summary

  • The server preserves the standard 100-continue semantics: when a client sends Expect: 100-continue the server will emit 100 Continue if the request proceeds to body reading. Detection recognizes 100-continue even when it appears in a comma-separated Expect header list and tolerates surrounding whitespace per the RFC.
  • For expectation tokens other than 100-continue aeronet exposes an opt-in ExpectationHandler API (see SingleHttpServer::setExpectationHandler in http-server.hpp). When present, the handler is invoked with the parsed expectation token and may respond with one of:
  • Continue - allow normal request processing
  • Interim - emit an informational 1xx interim response (handler supplies the specific 1xx status)
  • FinalResponse - send the supplied final response immediately and abort normal request processing
  • Reject - equivalent to 417 Expectation Failed (server will send 417)
  • Default behavior when no handler is installed: any non-100-continue expectation token is treated as unknown and the server responds with 417 Expectation Failed per the RFC.

Implementation notes & constraints

  • The handler is invoked on the server's event-loop thread and must be fast; heavy work should be deferred to worker threads by the application.
  • If the handler returns an Interim result its interimStatus MUST be an informational status in the 1xx range; an invalid interim status is treated as a server bug and the server will log an error and return 500 Internal Server Error for that request (the request body will not be processed). This validation prevents sending non-1xx interim responses.
  • The Expect parsing and handler dispatch is implemented in SingleHttpServer::handleExpectHeader(...) (internal helper).
  • See unit tests in tests/http_additional_test.cpp for example usages and behavior expectations (including mixed Expect lists containing 100-continue and custom tokens).

CONNECT (HTTP tunneling)

  • [x] CONNECT method support - proxy-style TCP tunneling to an upstream host:port target.

Behavior summary

  • On receiving a CONNECT host:port HTTP/1.1 request the server attempts to resolve the target and establish a non-blocking TCP connection to the upstream address. If the connect attempt succeeds (or is in progress), the server replies 200 Connection Established and links the client and upstream sockets into a tunneling pair. From that point the connections bypass HTTP parsing and are proxied bidirectionally until either side closes.
  • The authority-form target must carry a numeric port (RFC 9110 §9.3.6 / RFC 3986 port = *DIGIT); a missing separator, an empty/non-numeric port, or a value > 65535 is rejected with 400 Bad Request before any name resolution (service names such as host:https are not accepted). Tests: http-connect_test.cpp (NonNumericConnectPortReturns400, OutOfRangeConnectPortReturns400, EmptyConnectPortReturns400).
  • The server uses a small ConnectResult helper to capture whether the upstream connection completed immediately or is still pending (EINPROGRESS) on a non-blocking socket. Pending connects are tracked using a connectPending flag on the upstream ConnectionState; when the event loop notifies writable readiness we check SO_ERROR to determine whether the connect completed successfully or failed and, on failure, attempt to notify the client with 502.
  • For tunneling we record peerFd on each side (client and upstream). A connection is considered in tunneling mode when peerFd != -1 (exposed via ConnectionState::isTunneling() accessor). When the tunnel is active bytes read on one side are written to the peer's transport directly. Each side keeps a dedicated tunnel buffer for the peer flow so frontend HTTP outbound buffering (outBuffer) and tunnel forwarding remain separate.

Configuration

  • HttpServerConfig::connectAllowlist - list of allowed target hosts (exact, case-insensitive string match). CONNECT is disabled when the list is empty, which is the default. Explicitly opt in trusted targets with the withConnectAllowlist() builder helper. The special entry "*" allows every host and port, restoring the previous unrestricted behavior. This includes loopback, private-network, link-local, and cloud metadata targets, so only use "*" when unrestricted proxy behavior is intentional and independently access-controlled. Ports are not part of a normal allowlist entry, so an explicitly allowed host can be reached on any valid numeric port.

Notes and implementation details

  • The CONNECT implementation carefully handles container rehashing: inserting the upstream connection into the server's internal _connStates map may rehash and invalidate iterators. To avoid UB the insertion re-resolves the client iterator (and updates the caller's iterator when appropriate).
  • The tunneling path prioritizes a dedicated tunnelOutBuffer to avoid mixing HTTP response buffering semantics with raw tunneled bytes. This keeps the HTTP response life-cycle and the TCP proxying semantics independent and easier to reason about.
  • Tests: Basic coverage includes successful echo tunneling, DNS resolution failures, exact and wildcard allowlisting, and fail-closed default behavior. See http-connect_test.cpp and http2-connect_test.cpp.

HTTP/2 CONNECT tunneling (RFC 7540 §8.3)

HTTP/2 CONNECT operates on a per-stream basis rather than hijacking the entire connection. Multiple tunnels can coexist alongside normal request/response streams on a single HTTP/2 connection.

Behavior summary

  • On receiving a CONNECT request, the handler validates the :authority pseudo-header (must be host:port with a numeric port; a non-numeric or out-of-range port is rejected with 400), checks the connect allowlist, and delegates TCP connection setup to the server's event loop via a TunnelCallbacks interface.
  • On success, a 200 response is sent on the stream without END_STREAM. Subsequent DATA frames on the stream carry tunneled bytes bidirectionally.
  • Client → upstream: DATA frames received on the tunnel stream are forwarded to the upstream fd via the write callback.
  • Upstream → client: when the upstream fd becomes readable, the server reads data and calls injectTunnelData() on the handler, which encodes it as DATA frames on the tunnel stream.
  • Cleanup: when either side sends END_STREAM or RST_STREAM, the tunnel is torn down. If an async connect fails, RST_STREAM with CONNECT_ERROR is sent per RFC 7540 §8.3.
  • When the HTTP/2 connection itself is closed, all tunnel upstream fds are drained and released.

Performance / architecture

Execution model & scaling

  • [x] Single-thread event loop (one server instance)
  • [x] Horizontal scaling via SO_REUSEPORT (multi-reactor)
  • [x] Multi-instance orchestration wrapper (HttpServer aka MultiHttpServer) (forces reusePort=true for >1 threads; aggregated stats; resolved port immediately after construction)
  • [x] writev scatter-gather for response header + body
  • [x] TCP_CORK response coalescing (Linux-only) - automatically corks the socket before writing response data and uncorks after, preventing partial TCP segments when TCP_NODELAY is active. Clearing TCP_CORK flushes accumulated data immediately. No-op on macOS (TCP_NOPUSH does not flush on clear, and writev already coalesces) and Windows. Enabled per-connection when TCP_NODELAY is set. See SetTcpCork() in socket-ops.hpp and TcpCorkGuard RAII helper. Tests in socket-ops_test.cpp.
  • [x] Outbound write buffering with event-driven backpressure (EPOLLOUT on Linux, kevent on macOS, WSAPoll on Windows)
  • [x] Header read timeout (Slowloris mitigation) (configurable, disabled by default)
  • [x] Benchmarks & profiling docs
  • [x] Zero-copy sendfile() support for static files
  • [x] Configurable accept batch size (HttpServerConfig::maxAcceptBatchSize, default 64). Limits how many new connections are accepted per event-loop iteration to prevent connection-burst starvation of existing connections. Set to 0 for unlimited. Builder: withMaxAcceptBatchSize(uint32_t).
  • [x] Adaptive event-loop poll timeout (HttpServerConfig::pollIntervalMinFactor / pollIntervalMaxFactor, default 1.0F / 1.0F). Bounds are derived from pollInterval; saturated polls switch to the minimum factor while repeated idle polls exponentially back off up to the maximum factor. Production cadence: backoff after 4 consecutive idle polls with a 2x growth factor. Builder: withPollIntervalFactors(minFactor, maxFactor). Tests: aeronet/sys/test/event-loop_test.cpp and aeronet/objects/test/http-server-config_test.cpp.
  • [x] Keep-alive idle reaping uses an intrusive min-heap of connection expiry deadlines instead of scanning all active connections on every maintenance tick. Idle HTTP/1.1 keep-alive cleanup checks only expired deadlines in the common case; full connection sweeps are reserved for active timeout/backpressure/drain maintenance. Tests: aeronet/main/test/keep-alive-deadline-queue_test.cpp; benchmark: aeronet-bench-internal-keep-alive-deadline-queue.
  • [x] MSG_ZEROCOPY for large payload sends (Linux-only), with automatic fallback for small payloads). Enables kernel DMA of user-space buffers directly to NIC, avoiding memcpy overhead for payloads ≥16KB. Configurable via HttpServerConfig::withZerocopyMode() with options: Disabled, Opportunistic (default), Enabled (logs warning if unavailable). Works with plain TCP and kTLS connections. For kTLS, bypasses OpenSSL's SSL_write and uses sendmsg() directly on the kTLS socket.

Configuration notes: The feature is controlled per-server via withZerocopyMode() and evaluated per accepted connection. Modes are: - Disabled: never attempt MSG_ZEROCOPY. - Opportunistic (default): attempt zerocopy on real network connections but automatically disable it for loopback-to-loopback connections (to keep localhost benchmarks stable). - Enabled: force attempts to enable zerocopy; failures are logged and the transport falls back to the regular send path.

Implementation details: the decision is made after accept() (per connection) so a single listener can accept both loopback and remote peers. The zerocopy path uses sendmsg(..., MSG_ZEROCOPY) for large payloads (threshold: 128KiB) and falls back to normal write/SSL_write when unsupported or on retryable errors. - [x] Scripted benchmarks include a gzip round-trip body codec scenario (/body-codec) to measure automatic request decompression + response compression (no public API changes). See benchmarks/scripted-servers/lua/body_codec.lua and benchmarks/scripted-servers/run_benchmarks.py. - [x] HTTP/2 benchmarks using h2load (nghttp2). Supports both h2c (cleartext) and h2-tls modes via run_benchmarks.py --protocol h2c|h2-tls. All existing benchmark scenarios are supported. Competitor servers (drogon, axum, undertow, go, python/hypercorn) are updated for HTTP/2. Internal micro-benchmarks for HPACK, frame parsing, and flow control are also included. See benchmarks/scripted-servers/README.md and .github/workflows/benchmarks-h2.yml. - [x] WebSocket benchmarks using k6 and optionally websocket-bench. Six scenarios (echo-small, echo-medium, mix text+binary, ping-pong, connection churn, compression) compare aeronet, uWebSockets, and Drogon via a /ws echo endpoint. Orchestrated by run_ws_benchmarks.py. See benchmarks/scripted-servers/README.md. - [x] Async handler state pooling for lower per-connection footprint: ConnectionState now stores AsyncHandlerState* and ConnectionStorage allocates/releases async state instances from a dedicated ObjectPool<ConnectionState::AsyncHandlerState>.

Memory Management & std::string_view Safety

aeronet extensively uses std::string_view throughout its API for zero-copy performance. This approach is safe because of careful buffer lifetime management:

Per-Connection Buffer Lifetime

  • Each connection maintains its own read buffer (inBuffer) for incoming data read from the socket
  • All request data (headers, path, query parameters, body) is stored in this per-connection buffer
  • The HttpRequestView object is populated with std::string_view instances that point directly into this buffer
  • URL decoding (for query parameters) is performed in-place on the buffer, which is safe because URL decoding can only shrink the data

Lifetime Guarantees

Critical safety guarantee: The connection buffer remains valid and unchanged for the entire duration of the request handler execution. This means:

  • All std::string_view members of HttpRequestView (path, query params, headers, body) are safe to use throughout your handler
  • The buffer is only deallocated after the handler completes and the connection processing finishes
  • For keep-alive connections, the buffer is reused for subsequent requests, but only after the previous handler has fully completed

Connection Object Caching

To avoid frequent memory allocations and deallocations:

  • Connection objects (including their buffers) are cached and reused via a configurable caching system
  • When a connection closes, the connection object may be cached for reuse with future connections
  • This optimization is transparent to handlers - lifetime guarantees remain unchanged

Why This Pattern Is Safe

Using std::string_view extensively would typically be an anti-pattern due to dangling reference risks. However, in aeronet's architecture:

  1. The single-threaded event loop per server instance eliminates concurrency concerns
  2. Synchronous handler execution ensures the buffer cannot be modified during handler execution
  3. The per-connection buffer design provides clear ownership boundaries
  4. For asynchronous handlers awaiting body data, the server automatically copies head data (path, query params, headers) into a pinned buffer via pinHeadStorage(), so these views remain valid across suspensions

Best Practices for Handlers

Safe: Use std::string_view from HttpRequestView directly in synchronous handlers:

Router router;
router.setPath(http::Method::GET, "/api/user/{id}", [](const HttpRequestView& req) {
  auto idIt = req.pathParams().find("id");
  std::string_view userId = idIt->second; // Safe - points into connection buffer
  // Use userId throughout handler
  // processUser(userId)...
  return HttpResponse("User ID: " + std::string(userId));
});

Safe: Use head data (path, query params, headers) in async handlers - the server pins this data automatically when the handler needs to await the body:

Router router;
router.setPath(http::Method::GET, "/api/async", [](HttpRequestView& req) -> RequestTask<HttpResponse> {
  std::string_view body = co_await req.bodyAwaitable(); // safe to use request data after await
  co_return HttpResponse(200).body(std::string(body));
});

⚠️ Requires care: If you have the strange need to store std::string_view for use outside the handler scope (e.g., in a cache or callback), copy the data:

// Example: storing data for later use outside the handler
std::string storedUserId;  // external storage
Router router;
router.setPath(http::Method::GET, "/api/store", [&](const HttpRequestView& req) {
  for (const auto& [k, v] : req.queryParams()) {
    // process query params...
    if (k == "id") {
      // Copy the value to external storage to ensure safety
      storedUserId = std::string(v); // Copy required for external storage
    }
  }
  return HttpResponse(200);
});

Safety / robustness

  • [x] Configurable header/body limits
  • [x] Graceful shutdown loop (runUntil)
  • [x] Slowloris style header timeout mitigation (implemented as header read timeout)
  • [x] TLS termination (OpenSSL) with ALPN, mTLS, version bounds, handshake timeout & per-server metrics
  • [x] Graceful drain lifecycle (beginDrain / stop semantics)

Developer experience

  • [x] Server objects moveable
  • [x] Builder style HttpServerConfig
  • Note: Some configuration fields are immutable after construction (for example: port, reusePort, and OpenTelemetry setup). Most mutable fields (limits, timeouts, compression, headers, TLS configuration) are runtime-updatable via postConfigUpdate(). See docs/CONFIG_MUTABILITY.md for the full field-by-field guide.
  • [x] Simple lambda handler signature
  • [x] Simple exact-match per-path routing (setPath)
  • [x] Configurable trailing slash handling (Strict / Normalize / Redirect)
  • [x] Lightweight built-in logging (spdlog optional integration) – pluggable interface TBD
  • [x] Built-in Kubernetes-style probes (liveness/readiness/startup)
  • [x] OpenTelemetry integration (optional build flag)
  • [x] Middleware helpers (global + per-route pre/post chains, streaming support)
  • [ ] Pluggable logging interface (abstract sink / formatting hooks)
  • [x] Ephemeral port support (server.port() available after construction)
  • [x] JSON stats export / per-server metrics
  • [ ] Pluggable structured sinks / user-defined writer API

Large body optimization

To improve performance when serving large fixed responses (for example, generated payloads or read-in files), aeronet implements a large-body optimization that may save one copy (and growing allocation) by capturing the body by value. This section explains the behavior and the supported capture types.

Key points

  • Some HttpResponse::body(...) overloads accept non-owning views such as const char* or std::string_view. These overloads copy the referenced bytes into the response's inline/buffered storage and therefore force an allocation + copy even for large inputs. To avoid that allocation for large buffers, prefer the move-friendly overloads shown above (std::string, std::vector<char>, std::unique_ptr<char[]>) which hand ownership to the server without copying.
  • Currently only the non-streaming HttpResponse API is affected (streaming responses always write from user buffers). The streaming HttpResponseWriter only partially supports this optimization internally.
  • When a handler returns an HttpResponse with a body whose size is lower or equal to a configurable threshold (HttpServerConfig::minCapturedBodySize), the captured body will be appended inline with the response head.

Ergonomic body capture types

The HttpResponse body API accepts several convenient ownership types so handlers may hand off buffers without copying:

  • std::string - move a string into the response body for zero-copy handoff;
  • std::vector<char> - move a character vector when your data is in a non-null-terminated buffer;
  • std::unique_ptr<char[]> - for blob ownership without a resizing container (move-only unique_ptr semantics).

Usage examples

It is possible to avoid a full allocation + copy for large buffers by moving ownership of an existing buffer into the response. The HttpResponse API accepts move-only types and will take ownership, so the server does not need to allocate a new buffer and copy bytes.

Short examples:

// Move a std::string into the response
std::string big /* = generate_large_string() */;
HttpResponse(200).body(std::move(big), "application/octet-stream");

// Move a vector<char>
std::vector<char> v /* = read_file_bytes(path) */;
HttpResponse(200).body(std::move(v), "application/octet-stream");

// Move a unique_ptr<char[]> for raw blob ownership
std::unique_ptr<char[]> blob /* = load_blob() */;
std::size_t blobSize /* = known size */;
HttpResponse(200).body(std::move(blob), blobSize, "application/octet-stream");

These patterns hand ownership to the server without duplicating the payload, enabling efficient zero-copy handoff for large responses.

Appending body data

The HttpResponse::bodyAppend(...) overloads allows appending additional data to an existing body.

For maximum efficiency, use the overloads taking a writer lambda to write directly into the response's internal buffer without intermediate copies.

Example:

HttpResponse resp(200);

// Append a simple string line
resp.bodyAppend("Header line\n");

// Append generated data via writer lambda for maximum efficiency
std::size_t maxLen = 256;
resp.bodyInlineAppend(maxLen, [](char* buf) -> std::size_t {
  // write directly into buf up to bufSize bytes
  std::string_view data = "Body data generated on the fly...\n";
  std::memcpy(buf, data.data(), data.size());
  return data.size(); // return number of bytes actually written (should be less than maxLen)
});

Static body capture (zero-copy for static buffers)

For static buffers known at compile time, HttpResponse::bodyStatic(...) enables zero-copy capture without allocation. This is especially useful for serving constant payloads such as small JSON responses or HTML snippets.

Example:

HttpResponse resp(200);

// Works with string literals
resp.bodyStatic(R"({"status":"ok", "message":"Hello, World!"})");

// or with bytes span
static constexpr std::byte kLargeStaticBytes[]{ std::byte{'A'} /* ... large static data ... */ };
resp.bodyStatic(kLargeStaticBytes);

Compression & Negotiation

Supported (build‑flag gated): gzip, deflate (zlib-ng), zstd, brotli.

Outbound Response Compression

  • Parses Accept-Encoding with q-values; the highest client q-value wins; server preference is used only to break ties.
  • Threshold (CompressionConfig::minBytes) defers activation; streaming path buffers until threshold.
  • Default server preference (tie‑break list) when not overridden:
  • zlib / zlib-ng only: gzip, deflate
  • zlib + zstd: zstd, gzip, deflate
  • brotli + zstd + zlib: br, zstd, gzip, deflate
  • Per-response opt‑out: user Content-Encoding prevents auto compression.
  • Adds Vary: Accept-Encoding automatically (configurable) when compression applied.
  • If identity is explicitly disallowed and no supported encoding is acceptable, a 406 Not Acceptable response is returned.
  • encodeChunk() writes up to the provided output capacity and returns:
  • -1 on error
  • 0 on success with no output produced
  • >0 number of bytes written
  • maxCompressedBytes() provides an upper bound for sizing output buffers during chunk encoding.
  • end() may require multiple invocations; call repeatedly until it returns 0 (finished) or <0 (error).

Direct Compression (Inline Body Streaming Compression)

Responses created via HttpRequestView::makeResponse() gain an earlier compression layer called direct compression: the body is compressed inline as body() / bodyAppend() calls are made, before finalization. This eliminates the need for a compression pass at finalization time (TryCompressBody) for eligible inline bodies, reducing latency and memory copies.

Two-layer compression model:

Layer When Scope
Direct compression At body() / bodyAppend() call time Inline (non-captured) bodies only
Finalization compression At response finalization (TryCompressBody) Captured and inline bodies not already compressed

Direct compression only activates when all conditions are met:

  1. Response was created via HttpRequestView::makeResponse() (provides Accept-Encoding negotiation context)
  2. DirectCompressionMode is not Off
  3. No user-supplied Content-Encoding header is present
  4. Body is set as inline data (not captured by value, not a file)
  5. In Auto mode: body size ≥ CompressionConfig::minBytes and content-type matches contentTypeAllowList
  6. In On mode: conditions 4–5 are bypassed

DirectCompressionMode enum:

Mode Description
Auto Compress if Accept-Encoding present, size ≥ minBytes, content-type matches allow‑list
Off Never initiate direct compression; finalization layer handles compression
On Like Auto but bypasses minBytes and content-type checks (still requires Accept-Encoding)

Configuration:

// Set the default direct compression mode for all responses
CompressionConfig cfg;
cfg.defaultDirectCompressionMode = DirectCompressionMode::Auto; // default

Router router;
router.setPath(http::Method::GET, "/direct-compression", [](const HttpRequestView& req) {
  auto resp = req.makeResponse();
  // Override per-response
  resp.directCompressionMode(DirectCompressionMode::On);  // force direct compression
  resp.body("my response body that may be compressed inline...");
  return resp;
});

Key behaviors:

  • When direct compression is active, Content-Encoding and Vary: Accept-Encoding headers are automatically managed (added, updated, or removed as body changes).
  • Resetting the body (calling body() again) re-initiates direct compression with a new encoder context.
  • Removing the body removes the associated compression headers.
  • HEAD responses do not activate direct compression (to avoid extra CPU work). As a result, HEAD headers reflect the uncompressed body size and no Content-Encoding is added. A future configuration option may allow matching GET headers if needed.
  • Appending via bodyAppend() feeds additional chunks to the active encoder.
  • If direct compression is active, the finalization layer (TryCompressBody) sees the existing Content-Encoding header and skips, preventing double compression.
  • The bodyInlineAppend() template works with direct compression: it writes through the active encoder via appendEncodedInlineOrThrow().

Important API note: body(std::string, ...) captures the body by value and does not trigger direct compression (captured bodies are compressed at finalization). Use body(std::string_view, ...) for inline storage with direct compression eligibility.

Tests: aeronet/http/test/http-response_test.cpp (unit), tests/http-compression_test.cpp (e2e - DirectCompression_* tests).

Per-Response Manual Content-Encoding (Automatic Compression Suppression)

When you stream or build a response using HttpResponseWriter, aeronet will decide whether to apply automatic compression (based on Accept-Encoding, size threshold, configured preferences, and build flags). However, if you explicitly set a Content-Encoding header yourself (via header() / contentEncoding() or on a fixed HttpResponse), aeronet treats this as a hard override and will NEVER engage its own encoder for that response. This applies even if the header value is identity.

Practical implications:

Scenario Result
You set Content-Encoding: gzip and write pre-compressed bytes aeronet forwards bytes verbatim; no size threshold buffering; no double compression risk
You set Content-Encoding: identity Automatic compression fully disabled; body sent as-is
You set multiple encodings (e.g. gzip, br) Currently respected verbatim (aeronet does not multi-encode outbound); use only a single encoding value for clarity
You set Content-Length + Content-Encoding You MUST ensure the length matches the encoded payload size; aeronet does not recompute
You set neither header aeronet may choose an encoding and add Content-Encoding + Vary when activating

Detection logic (streaming path): first time a Content-Encoding header name is observed before headers flush, a _userProvidedContentEncoding flag is latched; subsequent internal compression activation checks this flag and abort.

Edge cases & notes:

  • Threshold buffering still occurs until either (a) you set your own Content-Encoding or (b) aeronet activates its own.
  • If you mistakenly set an unsupported or misspelled value (e.g. Content-Encoding: gziip), aeronet will still skip auto compression and send it literally (client may misinterpret). Validation may be added later, so prefer correct tokens.
  • For fixed (non-streaming) responses created via HttpResponse, the same rule applies: presence of Content-Encoding means no automatic compression layer is injected.
  • Vary: Accept-Encoding is ONLY auto-added when aeronet itself performs outbound compression. Supplying your own Content-Encoding does not implicitly add Vary (you can add it manually if appropriate for caches).
  • Supplying Content-Encoding does not affect inbound request body decompression logic (that is driven by the request's headers, not the response).

Minimal example (manual gzip):

std::string preCompressedHelloGzipBytes /* = gzip-compressed "Hello, World!" */;
Router router;
router.setDefault([&](const HttpRequestView&, HttpResponseWriter& w){
  w.status(http::StatusCodeOK);
  w.contentType(http::ContentTypeTextPlain);
  w.contentEncoding("gzip");            // suppress auto compression
  w.writeBody(preCompressedHelloGzipBytes);  // already gzip-compressed data
  w.end();
});

To “force identity” even if thresholds would normally trigger compression:

std::string largePlainBuffer(10 * 1024 * 1024, 'A'); // 10 MiB of 'A's
Router router;
router.setDefault([&](const HttpRequestView&, HttpResponseWriter& w){
  w.contentEncoding("identity"); // blocks auto compression
  w.writeBody(largePlainBuffer);
  w.end();
});

Introspecting the suppression in custom logic (advanced): HttpResponseWriter::userProvidedContentEncoding() exposes the latched flag (primarily for future middleware instrumentation / metrics).

Inbound Request Body Decompression (Symmetric Flags)

Codec flags enable BOTH outbound compression & inbound decoding. Multi-layer Content-Encoding chains decoded last→first with per-layer expansion & absolute size guards; successful decode removes the header before handler.

Condition Response
Unknown coding 415
Empty / malformed token 400
Expansion / size limit exceeded 413
Corrupt compressed data 400

Configuration sketch:

CompressionConfig c; c.minBytes = 128; c.preferredFormats = {Encoding::zstd};
HttpServerConfig cfg; cfg.withCompression(c);

Detailed Behavior (Compression)

Implemented capabilities:

  • Formats: gzip & deflate (zlib), zstd, br (brotli1) – each behind its own feature flag: AERONET_ENABLE_ZLIB, AERONET_ENABLE_ZSTD, AERONET_ENABLE_BROTLI.
  • aeronet supports 2 implementations of zlib compression - the classic zlib library and the newer zlib-ng fork. The choice is controlled by the AERONET_ENABLE_ZLIBNG build flag, which is ON by default, as in general case zlib-ng is faster than zlib.
  • Enabling a format flag activates BOTH outbound response compression and inbound request body decompression for that format (symmetry keeps configuration minimal).
  • Default server preference order (tie-break among equal effective q-values) when nothing specified in CompressionConfig::preferredFormats is: gzip, deflate if only zlib enabled; zstd, gzip, deflate if zstd also enabled; br, zstd, gzip, deflate if brotli enabled (brotli first due to typical superior ratio).
  • Negotiation: Parses Accept-Encoding with q-values; chooses format with highest q (server preference breaks ties). Falls back to identity if none acceptable.
  • Server preference nuance: Order in preferredFormats only breaks ties among encodings with equal effective q-values; encodings with strictly higher client q still win even if not listed (if you specify a subset). Listing all enabled encodings guarantees deterministic ordering.
  • Threshold: minBytes delays compression until buffered bytes reach threshold (streaming buffers until decision). Fixed responses decide immediately.
  • Streaming integration: Headers withheld until compression activation decision so Content-Encoding is always accurate.
  • Per-response opt-out: user-supplied Content-Encoding (e.g. identity) disables automatic compression.
  • Vary: Accept-Encoding automatically added when compression applied (configurable toggle).
  • Identity safety: If threshold not met, buffered bytes flushed uncompressed with no misleading header.
  • Q-value precedence: honors client preference (e.g. gzip;q=0.1, deflate;q=0.9 chooses deflate).
  • Explicit identity rejection: If identity;q=0 and no supported positive-q encoding present -> 406 Not Acceptable with short plain text body.

Zstd Tuning Example

CompressionConfig cfg;
cfg.zstd.compressionLevel = 5;   // default ~3
cfg.zstd.windowLog = 0;          // 0 => library default; >0 to bound window explicitly

Version String Fragment

aeronet 0.1.0
  tls: OpenSSL 3.0.13 30 Jan 2024
  logging: spdlog 1.15.3
  compression: zlib 1.2.13, zstd 1.5.6, brotli 1.1.0

Minimal Usage

CompressionConfig c;
c.minBytes = 64;
c.preferredFormats = {Encoding::gzip, Encoding::deflate};
HttpServerConfig cfg; cfg.withCompression(c);

Router router;
router.setDefault([](const HttpRequestView&) {
  return HttpResponse(200).body(std::string(1024,'A'));
});

SingleHttpServer server(cfg, std::move(router));

Multipart/form-data utilities (RFC 7578)

MultipartFormData parses aggregated multipart/form-data payloads with zero-copy std::string_view slices referencing the original request buffer. Use it after calling req.body() / co_await req.bodyAwaitable() so the full payload is buffered.

Basic usage

#include <aeronet/log.hpp>

Router router;
router.setPath(http::Method::POST, "/upload", [](const HttpRequestView& req) {
  const auto body = req.body();
  const auto contentType = req.headerValueOrEmpty(http::ContentType);

  MultipartFormData form(contentType, body);
  if (!form.valid()) {
    std::string body("invalid multipart payload: ");
    body += form.invalidReason();
    return HttpResponse(400).body(std::move(body));
  }

  if (const auto* note = form.part("description")) {
    log::info("desc={} bytes", note->value.size());
  }

  if (const auto* file = form.part("file")) {
    if (file->filename) {
      // user helper
      // PersistFile(*file->filename, file->value);
    }
  }

  return HttpResponse(204);
});

Each Part exposes name, optional filename/contentType, the raw value, and a span of headers (use headers() or headerValueOrEmpty()). part("field") returns the first match, while parts("field") gathers duplicates.

Options & limits

MultipartFormDataOptions protects against abusive payloads:

Option Default Effect
maxParts 128 Rejects payloads containing more than this many parts (0 disables the check).
maxHeadersPerPart 32 Caps the number of header lines per part.
maxPartSizeBytes 32 MiB Rejects an individual part when its body would exceed this size (0 disables the check).

Malformed payloads (missing boundary markers, absent Content-Disposition, exceeded limits) leave MultipartFormData::valid() set to false and produce no parts. The parser never throws for content/limit issues, so handlers should check valid() and return an appropriate 4xx response (or log/recover) when it is false.

Current behavior:

  • Accepts quoted boundary attributes (boundary="Aa--123").
  • Understands the simple filename*= syntax (RFC 5987) by returning the substring after the second ' (percent decoding can be layered on top if needed).
  • Requires CRLF-delimited MIME boundaries (per RFC 7578).
  • Optimized for aggregated bodies; streaming multipart parsing is a future roadmap item.

Inbound Request Decompression (Config Details)

Supported: gzip, deflate, zstd, br, identity (skip). Order: decode reverse of header list. Safety controls:

Field Meaning
maxCompressedBytes Cap on original compressed size (0 = unlimited)
maxDecompressedBytes Cap on expanded size (0 = unlimited)
maxExpansionRatio Per-layer (expanded / originalTotalCompressed) bound (0 = disabled)
streamingDecompressionThresholdBytes Enable streaming inflaters when Content-Length >= threshold (0 = disabled)

Breaches ⇒ 413. Malformed ⇒ 400. Unknown coding ⇒ 415. Disabled feature passes body through.

Example:

DecompressionConfig dc;
dc.enable = true;
dc.maxDecompressedBytes = 8*1024*1024;
HttpServerConfig cfg;
cfg.withRequestDecompression(dc);

Detailed Behavior (Inbound Decompression)

Implemented capabilities (independent from outbound compression):

Aspect Details
Supported codings gzip, deflate when AERONET_ENABLE_ZLIB; zstd when AERONET_ENABLE_ZSTD; br when AERONET_ENABLE_BROTLI; identity always recognized
Multi-layer chains Fully supported (Content-Encoding: deflate, gzip, zstd) decoded in reverse order (last token decoded first)
Parsing Allocation-free reverse split; trims whitespace; rejects empty tokens -> 400
Unknown coding 415 (Unsupported Media Type) when feature enabled
Disabled feature If enable=false, encodings ignored (body left compressed; no automatic 415)
Safety limits maxCompressedBytes, maxDecompressedBytes, maxExpansionRatio guard against bombs (breach -> 413)
Error mapping Malformed data -> 400; unknown -> 415; ratio/size -> 413
Identity in chains Skipped (deflate, identity, gzip)
Buffering model Aggregates full body first; optional streaming inflaters kick in automatically when the Content-Length crosses the configured threshold
Header normalization Removes Content-Encoding header after successful full decode

Configuration:

DecompressionConfig cfg; cfg.enable = true;
cfg.maxCompressedBytes = 0;        // 0 => unlimited (still bounded by global body limit)
cfg.maxDecompressedBytes = 0;      // 0 => unlimited
cfg.maxExpansionRatio = 0.0;       // 0 => disabled ratio guard
cfg.streamingDecompressionThresholdBytes = 512 * 1024;  // switch to streaming inflaters when CL >= 512 KiB
HttpServerConfig scfg; scfg.withRequestDecompression(cfg);

When streamingDecompressionThresholdBytes is non-zero, aeronet automatically routes large encoded payloads through streaming decoder contexts (per codec) instead of materializing every intermediate stage at once. Each stage consumes the compressed data in decoderChunkSize slices and appends the decoded bytes to the alternating buffers already used for the aggregated path, so handlers still see a single contiguous req.body().

Security / robustness notes:

  • Per-layer ratio check after each stage vs original compressed size stops staged amplification.
  • Absolute size guard halts decode early even if ratio guard disabled.
  • Empty / whitespace-only tokens rejected early (400) to avoid ambiguous partial decode states.
  • Unknown codings not skipped; fail-fast prevents partial decode inconsistencies.
  • Feature off => transparent pass-through (handlers can explicitly decode if desired).

Examples:

Content-Encoding: gzip                -> decode gzip
Content-Encoding: gzip, zstd          -> decode zstd then gzip
Content-Encoding: deflate, identity, gzip -> decode gzip then deflate
Content-Encoding: gzip,,deflate       -> 400
Content-Encoding: br                  -> 415 (if brotli disabled)

Typical handler setup:

HttpServerConfig serverCfg; serverCfg.withRequestDecompression(DecompressionConfig{});
Router router;
router.setDefault([](const HttpRequestView& req){
  return HttpResponse(200).body(std::string(req.body()));
});
SingleHttpServer server(std::move(serverCfg), std::move(router));

Streaming example (switch to inflaters when compressed payloads reach 1 MiB):

DecompressionConfig big;
big.streamingDecompressionThresholdBytes = 1024 * 1024;
big.decoderChunkSize = 32 * 1024;  // keep each streaming slice manageable
HttpServerConfig cfg; cfg.withRequestDecompression(big);

Chunked Transfer Encoding (RFC 7230 §4.1)

aeronet implements full support for chunked transfer encoding on incoming HTTP/1.1 requests, including chunk extensions (§4.1.1), trailer headers (§4.1.2), and decoding chunked (§4.1.3) as specified in RFC 7230.

Chunked Encoding Format

Chunked format consists of:

  1. Chunk size line: hex size (without 0x prefix), optional chunk extensions (after semicolon), CRLF
  2. Chunk data: size bytes of actual data
  3. Chunk ending: CRLF
  4. Zero chunk: 0 followed by optional trailer headers
  5. Final CRLF: Terminating the message

Example:

POST /upload HTTP/1.1
Host: example.com
Transfer-Encoding: chunked

4
Wiki
5
pedia
0
X-Checksum: abc123
X-Timestamp: 2025-10-20T12:00:00Z

Chunk Extensions (RFC 7230 §4.1.1)

Chunk extensions allow metadata to be attached to individual chunks via semicolon-separated parameters after the chunk size:

<size-hex>;<extension-name>[=<extension-value>]

aeronet behavior: Chunk extensions are parsed and silently ignored. The parser validates their syntax (presence of semicolon) but does not expose or process the extension data. This follows the RFC's guidance that chunk extensions are primarily for protocol extensions and should not affect basic message processing.

Example (extension ignored but accepted):

7;metadata=test
payload

Trailer Headers (RFC 7230 §4.1.2)

Trailers are HTTP headers that appear after the final zero-size chunk. They allow metadata to be sent after the body (useful for checksums, signatures, or other computed values).

aeronet behavior: Trailer headers are fully supported. Trailers are:

  • Parsed from the chunk stream after the 0\r\n terminator
  • Exposed via HttpRequestView::trailers() (case-insensitive map)
  • Subject to the same size limit as regular headers (maxHeaderBytes)
  • Validated for forbidden headers (security-sensitive headers cannot appear as trailers)

Forbidden trailer headers (per RFC 7230 §4.1.2 and security best practices):

  • Authentication & authorization: Authorization, Proxy-Authorization, Proxy-Authenticate, WWW-Authenticate
  • Content framing: Transfer-Encoding, Content-Length, Content-Range, Content-Encoding, Content-Type
  • Request control: Host, Cache-Control, Expect, Max-Forwards, Pragma, Range, TE
  • Metadata: Trailer, Set-Cookie, Cookie

Attempting to send forbidden headers as trailers results in 400 Bad Request.

Trailer API

Router router;
router.setPath(http::Method::GET, "/upload", [](const HttpRequestView& req) {
  // Access request body
  std::string_view body = req.body();

  // Access trailer headers (if any)
  auto checksum = req.trailers().find("X-Checksum");
  if (checksum != req.trailers().end()) {
    std::string checksumValue = std::string(checksum->second);
    // Validate checksum against body...
  }

  return HttpResponse("OK");
});

Memory optimization: Trailers are stored in the same connection buffer as the body data (bodyAndTrailersBuffer), with a trailerLen marker indicating the length of trailer data at the end of the buffer. This avoids additional allocations and maintains zero-copy string_view semantics.

Decoding Chunked (RFC 7230 §4.1.3)

aeronet's chunked decoder implements the full decoding algorithm specified in §4.1.3:

  1. Parse chunk size: Read hex digits until CRLF or semicolon (chunk extension marker)
  2. Handle chunk extensions: If semicolon found, skip to CRLF (extensions ignored)
  3. Read chunk data: Copy size bytes into body buffer
  4. Consume chunk CRLF: Validate and skip the trailing CRLF
  5. Repeat until zero-size chunk encountered
  6. Parse trailers: After 0\r\n, parse optional trailer headers
  7. Consume final CRLF: Validate blank line terminating the message

Error handling:

  • Invalid hex digits → 400 Bad Request
  • Missing CRLF → need more data (or 400 if size limit reached)
  • Chunk data exceeds maxBodyBytes413 Payload Too Large
  • Malformed trailers (no colon, forbidden headers) → 400 Bad Request
  • Trailer section exceeds maxHeaderBytes431 Request Header Fields Too Large

Integration with other features:

  • Chunked decoding happens before Content-Encoding decompression
  • The complete, decoded body is available via HttpRequestView::body()
  • Trailers are available via HttpRequestView::trailers() after the request is fully parsed
  • CONNECT tunneling bypasses chunked decoding (raw TCP proxy mode)

Implementation Notes

  • State machine: Chunked decoding is implemented as part of the main HTTP parser state machine in http-parser.cpp
  • Buffer management: Chunk data is appended to bodyAndTrailersBuffer as chunks are decoded; trailer text is appended after the final chunk with trailerLen marking the length of the trailer data at the end of the buffer
  • Zero-copy trailers: Trailer name/value pairs are stored as string_view references into bodyAndTrailersBuffer, avoiding string copies
  • Whitespace trimming: Trailer values have leading/trailing whitespace (OWS per RFC 7230 §3.2) automatically trimmed
  • Case-insensitive trailer lookup: Trailer map uses the same case-insensitive hash/equality comparator as regular headers

Configuration

Chunked encoding behavior is controlled by existing size limits:

HttpServerConfig cfg;
cfg.maxBodyBytes = 16 * 1024 * 1024;     // Limit total decoded body size
cfg.maxHeaderBytes = 8 * 1024;           // Limit trailer header section size

Security considerations:

  • Trailer size is bounded by maxHeaderBytes to prevent trailer header bombs
  • Total body (all decoded chunks) is bounded by maxBodyBytes
  • Forbidden trailer headers are rejected to prevent request smuggling attacks
  • Chunk extensions are parsed but ignored to avoid complexity attacks

Outbound Trailers (Response Trailers)

aeronet supports sending HTTP trailers in responses, allowing metadata to be transmitted after the response body. This is useful for checksums, signatures, or other values computed while streaming the response.

Two APIs for different response patterns:

  1. Buffered responses (HttpResponse): Trailers added via trailerAddLine() after body is set
  2. Streaming responses (HttpResponseWriter): Trailers added during streaming, emitted in final chunk

Buffered Response Trailers (HttpResponse)

For fixed/buffered responses, use HttpResponse::trailerAddLine():

Router router;
router.setPath(http::Method::GET, "/data", [](const HttpRequestView& req) {
  HttpResponse resp("response data");

  // Add trailers after body (required)
  resp.trailerAddLine("X-Checksum", "abc123");
  resp.trailerAddLine("X-Timestamp", "2025-10-20T12:00:00Z");

  return resp;
});

Automatic Chunked Encoding (RFC 7230 §4.1.2 compliance):

When a buffered HttpResponse has trailers, aeronet automatically converts the response to chunked transfer encoding during finalization. This is required because:

  • Per RFC 7230 §4.1.2, trailers can only appear in chunked-encoded messages
  • The Content-Length header is replaced with Transfer-Encoding: chunked
  • The body is wrapped in chunked format: hex(len)\r\n body \r\n 0\r\n trailers \r\n

This conversion is transparent to application code - simply set the body and add trailers, and aeronet handles the encoding conversion automatically.

Wire format example:

HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked

d\r\n
response data\r\n
0\r\n
X-Checksum: abc123\r\n
X-Timestamp: 2025-10-20T12:00:00Z\r\n
\r\n

Constraints:

  • Trailers MUST be added AFTER the body is set (via body() or bodyOwned())
  • Attempting to add trailers before the body throws std::logic_error
  • This ensures correct ordering in the final serialized response

Zero-allocation design:

  • Trailers are appended directly to the existing body buffer (no separate allocation)
  • For inline bodies: appended to the single buffer
  • For captured bodies: appended to captured body buffer
  • Format: name: value\r\n for each trailer, terminated with \r\n

Method chaining:

HttpResponse(200)
    .header("X-Custom", "value")
    .body("data")
    .trailerAddLine("X-Checksum", "xyz")
    .trailerAddLine("X-Signature", "sig123");

Trailer header can be added in normal handler (not yet supported in HttpResponseWriter streaming responses):

HttpServerConfig cfg;
cfg.withTrailerHeader();  // Will emit header line 'trailer: <trailer names>' in response headers

Streaming Response Trailers (HttpResponseWriter)

For chunked/streaming responses, use HttpResponseWriter::trailerAddLine():

Router router;
router.setPath(http::Method::GET, "/stream",
    [](const HttpRequestView& req, HttpResponseWriter& w) {
  w.status(200);
  w.writeBody("chunk1");
  w.writeBody("chunk2");

  // Add trailers during streaming
  w.trailerAddLine("X-Checksum", "computed-hash");
  w.trailerAddLine("X-Row-Count", "12345");

  w.end();  // Trailers emitted in final chunk
});

Behavior:

  • Trailers are buffered internally and emitted when end() is called
  • Only supported for chunked responses (Transfer-Encoding: chunked)
  • If contentLength() was set (fixed-length response), trailers are silently ignored with a warning log
  • Trailers added after end() are also ignored with a warning

Wire format (RFC 7230 §4.1.2):

HTTP/1.1 200 OK
Transfer-Encoding: chunked

6\r\n
chunk1\r\n
6\r\n
chunk2\r\n
0\r\n
X-Checksum: computed-hash\r\n
X-Row-Count: 12345\r\n
\r\n

The final 0\r\n is the zero-length chunk indicating end of body, followed by trailer lines and a blank line.

Memory management:

  • Trailers are buffered in their own buffer
  • Buffer size is reserved upfront when emitting the final chunk
  • Final chunk string is moved into HttpPayload for efficient transmission

Trailer Validation

Application responsibility: aeronet does not validate trailer names against the forbidden list when sending responses (for performance). Applications should avoid sending:

  • Content-framing headers: Transfer-Encoding, Content-Length, Content-Encoding, Content-Type
  • Authentication headers: Authorization, WWW-Authenticate, Set-Cookie
  • Request control headers: Host, Cache-Control, Trailer

Sending forbidden headers as trailers is undefined behavior and may break clients or intermediaries.

Best practices:

  • Use custom header names with X- prefix or domain-specific names
  • Suitable trailer use cases: checksums, signatures, row counts, timestamps, processing metadata
  • Keep trailer count and size modest (no hard limit, but consider client parsing overhead)

Trailer Testing

Comprehensive test coverage includes:

  • Buffered response trailers: 7 tests validating constraints, multiple trailers, empty values, chaining
  • Automatic chunked encoding: 10 tests validating RFC 7230 §4.1.2 compliance, inline/captured bodies, edge cases
  • Streaming response trailers: 5 tests validating chunked emission, fixed-length rejection, late addition
  • Integration tests verifying wire format compliance with RFC 7230 §4.1.2

Connection Close Semantics

Mode Meaning Triggers
None Connection reusable Normal success
DrainThenClose Flush pending then close Client Connection: close, keep-alive limit, explicit handler intent
Immediate Abort promptly Parse/protocol error, size breach, transport failure

Handlers normally rely on automatic policy; unrecoverable errors escalate to Immediate.

Keep-alive can be disabled globally by cfg.withKeepAliveMode(false); per-request Connection: close or Connection: keep-alive headers are also honored (HTTP/1.1 default keep-alive, HTTP/1.0 requires explicit header).

CloseMode Details

ConnectionState::CloseMode models post-response connection intent.

Helper methods:

  • requestDrainAndClose() – escalate to DrainThenClose (idempotent)
  • requestImmediateClose() – force immediate termination (used for fatal protocol / IO errors)
  • isDrainCloseRequested(), isImmediateCloseRequested(), isAnyCloseRequested() – state queries

Behavior rationale:

  • Graceful reuse preferred when protocol integrity intact.
  • Immediate close chosen for malformed request lines/headers, conflicting Content-Length + Transfer-Encoding, unsupported TE, size/limit breaches (413/431), HTTP/1.0 with TE, transport failures, or internal fatal errors.
  • Returning a response with Connection: close or exhausting maxRequestsPerConnection naturally transitions to DrainThenClose.
  • Helper error paths (e.g. emitSimpleError(..., /*immediate=*/true)) enforce Immediate to avoid reusing a compromised parser state.

Lifecycle: parse request → build response → determine keep-alive eligibility → either mark close mode or leave connection open for next pipelined request.

Graceful drain lifecycle

SingleHttpServer exposes a lifecycle state machine to coordinate shutdown:

State Description Entered via
Idle Listener closed, loop inactive Default / after drain/stop
Running Event loop servicing connections run() / runUntil()
Draining Listener closed; existing connections finish with Connection: close beginDrain() or signal-driven auto-drain
Stopping Immediate teardown, pending connections closed stop() or fatal I/O error

Key API points:

  • beginDrain(std::chrono::milliseconds maxWait = 0) stops accepting new connections, keeps existing keep-alive sessions long enough to finish their current response, and injects Connection: close so the client does not reuse the socket. When maxWait is non-zero, a deadline is armed; any connections still open when it expires are closed immediately. Calling beginDrain() again with a shorter timeout shrinks the deadline.
  • isDraining() reflects whether the server is currently in the draining state. isRunning() still reports true until the drain completes or a stop occurs.
  • Wrappers - HttpServer::beginDrain() / isDraining() forward to the underlying SingleHttpServer instances, enabling the same graceful drain flow when the server runs on background threads or across multiple reactors.
  • Draining is restart-friendly: once all connections are gone (or the deadline forces closure) the lifecycle resets to Idle and the server can be started again with another run().
  • stop() remains the immediate shutdown primitive; it transitions to Stopping, force-closes all connections and wakes the event loop right away.
  • Predicate and stop-token exits close the listener and active connections on the event-loop thread before returning, including startDetachedAndStopWhen() and startDetachedWithStopToken().

This drain lifecycle allows supervisors to quiesce traffic (e.g., removing an instance from load balancers) while letting outstanding requests complete and optionally bounding the wait for stubborn clients.

Signal-driven automatic drain

aeronet provides a global signal handler mechanism that coordinates graceful shutdown across all SingleHttpServer instances in the process:

#include <aeronet/aeronet.hpp>

using namespace aeronet;

// Install process-wide signal handlers for SIGINT/SIGTERM
SignalHandler::Enable(std::chrono::milliseconds{5000});  // 5s max drain

SingleHttpServer server1(HttpServerConfig{});
SingleHttpServer server2(HttpServerConfig{});
// Both servers will automatically call beginDrain(5s) when SIGINT/SIGTERM is received

Key characteristics:

  • Process-wide coordination: SignalHandler::Enable() installs SIGINT and SIGTERM handlers that set a global sig_atomic_t flag, visible to all SingleHttpServer instances.
  • Automatic drain: Each server's event loop checks SignalHandler::IsStopRequested() at the end of every iteration (after lifecycle state checks) and calls beginDrain(maxDrainPeriod) if the flag is set.
  • Thread-safe: The signal handler only sets an atomic flag; the actual drain logic runs from each server's event loop thread.
  • Multi-server friendly: Unlike per-server signal handling (which has races where only the first reader consumes the signal), the global flag ensures all servers in the process see the stop request simultaneously.
  • Optional: Applications that manage signals centrally can skip SignalHandler::Enable() and call beginDrain() directly as needed.

This mechanism is recommended for most deployments where a clean shutdown on SIGINT/SIGTERM is desired without writing custom signal-handling code. For containerized environments (Kubernetes, Docker), it ensures that SIGTERM (sent by orchestrators during pod shutdown) triggers a graceful drain with a bounded timeout, improving availability during rolling updates.

Where to look: signal-handler.hpp, http-server-lifecycle_test.cpp for signal-driven drain tests.

stop() vs beginDrain() - intent, semantics and guidance

The library exposes two related shutdown controls and they serve different intent: stop() is the immediate termination primitive while beginDrain() explicitly requests a graceful quiesce. The differences are summarized below to avoid confusion.

  • Semantics:
  • stop():
    • Non‑blocking request to terminate the event loop as soon as practical.
    • Closes the listening socket and transitions the server into Stopping where connections are closed and the loop wakes to exit quickly.
    • Intended for cases where you want the server to stop servicing immediately (e.g. fatal error, process shutdown).
  • beginDrain(maxWait):

    • Non‑blocking request to begin a graceful drain.
    • Closes the listening socket so no new connections are accepted, marks existing keep‑alive connections to be closed after their current response, and injects Connection: close so clients do not reuse the socket.
    • When maxWait > 0 a deadline is armed; any remaining idle connections are forcibly closed when the deadline expires.
  • Observability & lifecycle:

  • isDraining() becomes true after beginDrain() and remains true until the drain completes (or the deadline forces closure).
  • isRunning() remains true while the server's event loop is still executing; it becomes false after the loop returns (either naturally after drain completes or after stop()).

  • Blocking vs non‑blocking:

  • Both stop() and beginDrain() are non‑blocking control requests in the current API. If consumers want synchronous semantics they must explicitly wait (e.g. monitor isDraining()/isRunning() or join the thread that runs the server).

  • Typical usage patterns:

  • Graceful shutdown (recommended when you can wait or use a supervisor):
    1. Remove instance from load balancer.
    2. Call beginDrain(maxWait) to allow in‑flight requests to finish and bound the wait.
    3. Optionally wait for isDraining() -> isRunning() transition (or stop the wrapper thread) before exiting process.
  • Immediate teardown (fast exit / fatal conditions):

    • Call stop() to request immediate termination; the server will close connections promptly.
  • Wrapper behavior:

  • HttpServer::beginDrain() forward to their underlying SingleHttpServer instances so the same graceful behavior is available for background or multi‑reactor setups. stop() continues to request immediate termination on wrappers as before.

Recommendation: prefer beginDrain() when you intend to quiesce traffic and let outstanding requests complete; use stop() when you require immediate termination. If you need a blocking API (wait until drain completes), add a small wait in the supervisor code that observes isDraining()/isRunning() or joins the server thread - the public API intentionally separates "request" (non‑blocking) from "wait" to keep shutdown control explicit.

Reserved & Managed Response Headers

Managed: Date, Content-Length, Connection, Transfer-Encoding, Trailer, TE, Upgrade.

User attempts to override are ignored (release) / asserted (debug) except via sanctioned APIs (e.g. contentLength).

Request Header Duplicate Handling (Detailed)

Incoming request headers are parsed into a flat buffer and exposed through case‑insensitive lookups on HttpRequestView. aeronet applies a deterministic, allocation‑free in‑place policy when a duplicate request header field name is encountered while parsing. The policy is driven by a constexpr classification table that maps well‑known header names (case‑insensitive) to one of the following behaviors:

Policy Code Meaning Examples
, List merge: append a comma and the new non‑empty value Accept, Accept-Encoding, Via, Warning, TE
; Cookie merge: append a semicolon (no extra space) Cookie
(space) Space join: append a single space and the new non‑empty value User-Agent
O Override: keep ONLY the last occurrence (replace existing value, no concatenation) Authorization, Range, From, conditional time headers
\0 Disallowed duplicate: second occurrence triggers 400 Bad Request Content-Length, Host

Fallback for unknown (unclassified) headers currently assumes list semantics (,). This is configurable internally (a server config flag exists for future tightening) and is chosen to preserve extension / experimental headers that follow conventional 1#token or 1#element ABNF patterns.

Merging rules are value‑aware:

  • If the existing stored value is empty and a later non‑empty value arrives, the new value replaces it (no leading separator is inserted).
  • If the new value is empty and the existing value is non‑empty, no change is made (avoid trailing separators manufacturing an empty list member).
  • Only when both values are non‑empty is the separator inserted (, / ; / space) followed by the new bytes.
  • Override (O) headers always adopt the last (even if empty → empty replaces previous non‑empty).

Implementation details:

  1. The first occurrence of each header stores name and value as std::string_view slices into the connection read buffer (no copy).
  2. On a mergeable duplicate, the new value bytes are temporarily copied into a scratch buffer, the tail of the original buffer is shifted right with a single memmove, and the separator plus new value are written into the gap. All subsequent header string_views are pointer‑adjusted (stable hashing / equality are preserved because key characters do not change, only their addresses move uniformly).
  3. Override simply rebinds the existing value view to point at the newest occurrence (no buffer mutation).
  4. Disallowed duplicates short‑circuit parsing and return 400 Bad Request immediately.

Security / robustness notes:

  • Disallowing duplicate content-length and host prevents common request smuggling vectors relying on conflicting or ambiguous canonicalization across intermediaries.
  • A future stricter mode may treat unknown header duplicates as disallowed instead of comma‑merging; the hook for that decision exists in the classification fallback.
  • The implementation never allocates proportional to header count on a merge path; each merge performs at most one temporary copy (size of the new value) plus one tail shift.

Example:

Accept: text/plain
Accept: text/html
→ Accept: text/plain,text/html

Summary table (quick reference):

Policy Action Examples
, Comma merge non-empty Accept, Accept-Encoding, Via
; Semicolon merge Cookie
space Space join User-Agent
O Override keep last Authorization, Range
disallow 400 duplicate Content-Length, Host

Unknown headers default to comma merge. Empty values skipped when merging. Disallowed duplicates short‑circuit to prevent smuggling.

Global headers

You can define global headers applied to every response of a SingleHttpServer via HttpServerConfig.globalHeaders. These are appended after any user-set headers in a handler, so you can override them per-response if needed. Useful for consistent security headers (CSP, HSTS, etc). They will not override any header of the same name already set in a response.

Global headers are applied to every response including error responses generated internally by aeronet (400, 413, etc).

By default, it contains a server: aeronet header unless you explicitly clear it out.

Path Handling

Query String & Parameters

  • Path percent-decoded once; invalid escape ⇒ 400.
  • Query left raw; per-key/value decode on iteration (queryParams()).
  • + converted to space only in query pairs.
  • Missing = ⇒ empty value; duplicates preserved.
  • Malformed escapes in query components surfaced literally (non-fatal).

Example:

Router router;
router.setPath(http::Method::GET, "/users/{id}", [](const HttpRequestView& req) {
  for (auto [k, v] : req.queryParams()) { /* use k,v */ }
  return HttpResponse(200);
});

Middleware Pipeline

  • Global hooks – use Router::addRequestMiddleware and Router::addResponseMiddleware (or the convenience SingleHttpServer::add* wrappers) to install request/response middleware that runs for every request.
  • Per-route hooksPathHandlerEntry::before(RequestMiddleware) and ::after(ResponseMiddleware) scope middleware to a specific path registration.
  • Execution orderglobal pre → route pre → handler → route post → global post. When a route does not match (404/405/redirect), only the global hooks run; per-route chains are skipped.
  • Short-circuiting – returning MiddlewareResult::ShortCircuit(HttpResponse) from any pre middleware skips the remaining pre chain and the handler. The produced response is still passed through the post chain so that shared concerns (headers, logging, metrics) execute uniformly.
  • Threading – middleware executes on the server's event loop thread; avoid blocking work inside hooks.

Streaming Integration

  • HttpResponseWriter driven handlers share the same middleware semantics. Post middleware runs right before headers are flushed, allowing status and header mutation even when body chunks were emitted.
  • Automatic CORS headers are applied after middleware adjustments, mirroring buffered responses.
  • Synthetic responses generated before the handler (CORS denials, 406 content-coding fallback, pre-chain short-circuits) still traverse the post middleware chain.

Coroutine Handlers (Async)

aeronet supports C++20 coroutines for request handling, allowing you to write asynchronous code that looks synchronous. This is particularly useful when your handler needs to perform asynchronous operations (like database queries, upstream HTTP requests, or timers) without blocking the event loop thread.

Key Concepts

  • Signature: Handlers return RequestTask<HttpResponse> instead of HttpResponse.
  • Registration: Use Router::setPath just like normal handlers. The router automatically detects the return type.
  • Execution: The coroutine is started immediately on the event loop. When it co_awaits, it suspends, returning control to the event loop. When the awaited operation completes, the coroutine resumes.
  • Middleware: Fully supported. Request middleware runs before the coroutine starts. Response middleware runs after the coroutine co_returns the response.
  • CORS: Fully supported. CORS checks happen before the coroutine starts.
  • Early Dispatch: Async handlers are invoked as soon as the request head is parsed, even if the body is still uploading. Call co_await req.bodyAwaitable() (or the chunk helpers) before touching the body. Because of this, request middleware on async routes should not rely on the body or trailers being populated-they will become available only after the coroutine awaits them.

Async Handler Example

using namespace aeronet;

struct User { int id; /* ... */ };

// A hypothetical async database client
// Minimal awaitable used for the demo: provides the three awaiter
// methods so it can be consumed with `co_await` inside an async handler.
struct GetUserAwaitable {
  int id;
  bool await_ready() const noexcept { return false; }
  void await_suspend(std::coroutine_handle<> handle) noexcept { handle.resume(); }
  User await_resume() const noexcept { return User{id}; }
};

GetUserAwaitable getUserAsync(int id) {
  return GetUserAwaitable{id};
}

int main() {
  Router router;

  // Register an async handler
  router.setPath(http::Method::GET, "/users/{id}", [](HttpRequestView& req) -> RequestTask<HttpResponse> {
    // 1. Parse parameters (synchronous)
    int userId = std::stoi(std::string(req.pathParams().at("id")));

    // 2. Suspend while fetching data (non-blocking)
    // The event loop is free to handle other requests while we wait.
    User user = co_await getUserAsync(userId);

    // 3. Resume and build response
    co_return HttpResponse(200).body(std::to_string(userId));
  });

  // Async body reading
  router.setPath(http::Method::POST, "/upload", [](HttpRequestView& req) -> RequestTask<HttpResponse> {
    // Wait for the full body to be received
    std::string_view body = co_await req.bodyAwaitable();

    co_return HttpResponse(200).body("Received " + std::to_string(body.size()) + " bytes");
  });

  SingleHttpServer server(HttpServerConfig{}, std::move(router));
  server.run();
}

When a route uses an async handler, request middleware may observe an empty body/trailer map because aggregation now happens in parallel with handler execution; apply validation inside the coroutine if the middleware needs the payload.

Awaitables

You can co_await any type that satisfies the C++ coroutine awaitable concept. aeronet provides built-in awaitables:

  • req.bodyAwaitable(): Suspends until the full request body is available (buffered).
  • req.readBodyAsync(maxBytes): (Future) Suspends until a chunk of body data is available.
  • req.deferWork(work): Runs blocking work on a background thread, suspends the coroutine, and resumes when the work completes.

Deferring Blocking Work to Background Threads

When your async handler needs to perform blocking operations (database queries, file I/O, CPU-intensive computations), you can use req.deferWork(work) to run the work on a background thread without blocking the event loop. This allows the server to continue handling other requests while waiting for the blocking operation to complete.

struct User {
  int id;
  std::string toJson() const { return "{\"id\": " + std::to_string(id) + "}"; }
};

Router router;
router.setPath(http::Method::GET, "/users/{id}", [](HttpRequestView& req) -> RequestTask<HttpResponse> {
  int userId = std::stoi(std::string(req.pathParams().at("id")));

  // Run blocking database query on background thread
  // Event loop continues handling other requests while we wait
  std::optional<User> user = co_await req.deferWork([userId]() {
    // databaseLookup(userId);
    return User{userId};  // Simulated DB result
  });

  if (!user) {
    co_return HttpResponse(http::StatusCodeNotFound).body("User not found");
  }
  co_return HttpResponse(http::StatusCodeOK).body(user->toJson());
});

Key Features:

  • Non-blocking: The event loop remains responsive while the work executes on a background thread.
  • Type-safe: The return type of deferWork matches the return type of the work function.
  • Sequential composition: You can chain multiple deferWork calls:

Runnable demo: examples/async-handlers.cpp (binary aeronet-async-handlers) exposes /async and /users/{id} endpoints showing deferWork with and without request bodies.

Router router;
router.setPath(http::Method::POST, "/process", [](HttpRequestView& req) -> RequestTask<HttpResponse> {
  // First, wait for body
  std::string_view body = co_await req.bodyAwaitable();
  std::string bodyCopy(body);

  // Then, process on background thread
  auto result = co_await req.deferWork([data = std::move(bodyCopy)]() {
    // expensiveProcessing(data);
    return data;
  });

  // Finally, save result on background thread
  bool saved = co_await req.deferWork([result]() {
    // saveToDatabase(result);
    return true;
  });

  co_return HttpResponse(saved ? 200 : 500);
});

Implementation Notes:

  • Each deferWork call spawns a new std::thread (consider using a thread pool for high-throughput scenarios).
  • The coroutine resumes on the event loop thread, maintaining thread-safety for server state access.
  • The work function is moved into the background thread, so capture by value or use std::move for non-copyable types.
  • Exception Handling: If the work function throws an exception, it is captured and rethrown when the coroutine resumes, propagating normally through the coroutine.
  • deferWork() is fully non-blocking for both HTTP/1.1 and HTTP/2. On HTTP/2, each stream owns its own async task; when a coroutine suspends (e.g., waiting for background work), other streams on the same connection continue to be processed without blocking.

Implementation Details

  • Return Type: RequestTask<T> is a lightweight task object. For handlers, T must be HttpResponse.
  • Exception Handling: Exceptions thrown within the coroutine (before the first suspension or after resumption) are caught by the server infrastructure and result in a 500 Internal Server Error, just like synchronous handlers.
  • Thread Safety: The coroutine resumes on the same thread (the event loop). You don't need locks to access server state, but you must ensure your async operations (like the DB client in the example) are thread-safe or properly synchronized if they use other threads.

Middleware Example

auto isAuthenticated = [](const HttpRequestView &req) { return true; };  // user-defined

Router router;
router.addRequestMiddleware([isAuthenticated](HttpRequestView& req) {
  if (!isAuthenticated(req)) {  // user-defined helper
    HttpResponse resp(http::StatusCodeUnauthorized);
    resp.body("auth required");
    return MiddlewareResult::ShortCircuit(std::move(resp));
  }
  return MiddlewareResult::Continue();
});

router.addResponseMiddleware([](const HttpRequestView&, HttpResponse& resp) {
  resp.header("X-Powered-By", "aeronet");
});

auto renderMetrics = []() { return std::string{}; };  // user-defined

auto& entry = router.setPath(http::Method::GET, "/metrics", [renderMetrics](const HttpRequestView&) {
  HttpResponse resp;
  resp.body(renderMetrics());  // user-defined helper
  return resp;
});

entry.before([](HttpRequestView& req) {
  // tagRequest(req, "metrics");  // user-defined helper
  return MiddlewareResult::Continue();
});

entry.after([](const HttpRequestView&, HttpResponse& resp) {
  resp.header("Cache-Control", "no-store");
});

SingleHttpServer server(HttpServerConfig{}, std::move(router));

Middleware Metrics Callback

  • SingleHttpServer::setMiddlewareMetricsCallback(MiddlewareMetricsCallback) installs an opt-in hook that receives a MiddlewareMetrics record for every middleware invocation. The record captures whether the middleware belongs to the global or per-route chain, the execution phase (Pre or Post), the zero-based index within that chain, whether the middleware short-circuited request processing, threw an exception, and how long the call lasted in nanoseconds.
  • Metrics are emitted for both buffered and streaming handlers; the streaming flag is set when the active route uses HttpResponseWriter. Request method and the canonical request path are included to simplify downstream tagging.
  • When no callback is registered, the server skips the timing code paths entirely to keep the hot path allocation-free and avoid the additional steady clock reads.
  • Tests: see tests/http-routing_test.cpp (HttpMiddlewareMetrics.RecordsPreAndPostMetrics, HttpMiddlewareMetrics.MarksShortCircuit, HttpMiddlewareMetrics.StreamingFlagPropagates).

Rate Limiting Middleware

build() builds a request middleware that enforces limits and short-circuits with 429 Too Many Requests + Retry-After when over the quota.

In-memory token bucket example:

Router router;
router.setPath(http::Method::GET, "/limited", [](const HttpRequestView&) {
  return HttpResponse(200).body("ok");
});

RateLimitRequestMiddlewareBuilder opts;
opts.config.requestsPerSecond = 10;
opts.config.burst = 20;
opts.keyStrategy = RateLimitClientKeyStrategy::PeerAddress;

router.addRequestMiddleware(std::move(opts).build());

Per-route / group scoping uses the standard middleware APIs:

Router router;

auto api = router.group("/api/v1");
api.addRequestMiddleware(RateLimitRequestMiddlewareBuilder{}.build());

Redis boundary:

  • RedisSlidingWindowRateLimitStore does not own a Redis client.
  • You provide an EvalCallback consuming RedisEvalRequest and producing RedisEvalResponse.
  • Script contract shape:
  • KEYS[1]: rate limit key
  • ARGV[1]: now_ms
  • ARGV[2]: window_ms
  • ARGV[3]: limit
  • return: {allowed(0|1), retry_after_seconds}
  • Default key schema helper: aeronet:rl:{clientKey} (hash-tag enabled).

This contract is designed for distributed quotas across multiple aeronet processes behind a load balancer.

  • See tests/http-routing_test.cpp for examples covering ordering, short-circuits, and streaming responses.

Trailing Slash Policy

HttpServerConfig::TrailingSlashPolicy controls how paths differing only by a single trailing / are treated.

Resolution algorithm:

  1. Attempt an exact match first. If the incoming target exactly equals a registered path, that handler is used and the policy does not intervene. Note: if both /foo and /foo/ were registered, they remain distinct only under the Strict policy. Under Normalize and Redirect the system canonicalizes paths (registrations for a trailing-slash variant are mapped to the canonical form), so duplicate registrations for the same canonical path will be merged and the first registration wins.
  2. If no exact match:
  3. If the request ends with a single trailing slash (excluding root /) and the canonical form without that slash exists:
    • Strict – 404 (variants are distinct; no mapping)
    • Normalize – treat as the canonical path (strip the slash internally, no redirect). Note: if both /foo and /foo/ were registered by the caller, only the first registration for the canonical form is kept to avoid different endpoints for normalized variants.
    • Redirect – emit 301 Moved Permanently to the canonical path. Redirect mode operates symmetrically: if the registered canonical form has a trailing slash and the request omits it, the server will redirect to the slashed form, and vice-versa.
  4. Else if the request does not end with a slash, policy is Normalize, and only the slashed variant exists (e.g. only /foo/ registered): dispatch to that variant (symmetry in the opposite direction)
  5. Otherwise: 404
  6. The root path / is never redirected or normalized.

Behavior summary:

Policy /foo only /foo/ only Both
Strict /foo/→404 /foo→404 each exact served
Normalize /foo/→serve /foo /foo→serve /foo/ only first one is registered
Redirect /foo/→301 /foo /foo→301 /foo/ only first one is registered

Tests: tests/http_routing_test.cpp.

Usage:

RouterConfig routerConfig;
routerConfig.withTrailingSlashPolicy(RouterConfig::TrailingSlashPolicy::Redirect);

Rationale: Normalize avoids duplicate handler registration while preserving SEO-friendly consistent canonical paths; Redirect enforces consistent public URLs; Strict maximizes explicitness (APIs where /v1/resource vs /v1/resource/ semantics differ).

Routing patterns & path parameters

Path pattern syntax

  • Paths are absolute and must begin with /.
  • A path is split into segments by the / character. Each segment may be:
  • A literal segment with no braces (e.g. hello, v1).
  • A pattern segment containing parameter fragments interleaved with literals. Example: foo{}bar.

Empty segments (double slashes //) are not allowed.

Parameter fragments

  • Named captures use {name} and become available under the provided key (name).
  • Unnamed captures use {}; the router assigns sequential numeric string keys ("0", "1", ...) in segment order.
  • Mixing named and unnamed captures in the same pattern is not allowed - registration (setPath) will throw if you mix them.
  • Consecutive parameter fragments with no literal separator (e.g. {}{} within a segment) are rejected.

Route parameter constraints

Path parameters can include an inline constraint pattern using {name:pattern}.

Examples:

  • /users/{id:[0-9]+} accepts only numeric id
  • /assets/{slug:[a-zA-Z0-9_-]{3,32}} validates a bounded slug
  • /files/{name:[^/]+} captures a single non-slash segment

Behavior and implementation notes:

  • Constraint compilation happens at registration time (setPath()), not at request time.
  • The matcher uses a two-tier engine:
  • a fast custom matcher for simple character-class patterns ([0-9]+, \\d+, .{3,8}, {n,m})
  • automatic fallback to std::regex for complex expressions (groups, alternation, anchors)
  • Invalid constraint patterns throw std::regex_error (both fast-path parser errors and regex fallback compilation errors).
  • Constraints are evaluated per captured parameter segment during route matching; non-matching constraints make the route candidate fail and matching continues with other candidates.
  • Constrained parameter alternatives are tried before unconstrained parameter fallbacks, independent of registration order.
  • When multiple constrained alternatives at the same position match the same segment, registration order decides which handler wins.

Escape sequences for literal special characters

{% raw %} To use literal braces in path segments, escape them by doubling:

  • {{ → literal {
  • }} → literal }

Examples:

  • /api/{{version}}/data matches the literal path /api/{version}/data
  • /glob/{{{{}}/file.txt matches the literal path /glob/{{}/file.txt

This allows registering handlers for paths that contain these characters literally.

Wildcard semantics

The asterisk * is allowed in a URL and is considered as a literal character unless it is equal to the entire final segment of the pattern, in which case it acts as a wildcard matching any remaining path suffix.

  • Exact registrations take precedence over wildcard matches (e.g. /a/b wins over /a/* for /a/b).
  • Asterisks can be part of a parameter fragment (e.g. /files/{name*} matches /files/report2024 with name* = report2024).

Examples:

  • /assets/* matches /assets/images/logo.png with wildcard capture images/logo.png
  • /path/seg*/* matches /path/seg*/something but not /path/segment/extra (second segment is literal seg*)
  • /path/*/* matches /path/*/something but not /path/foo/something (second segment is literal *)

Registration errors

  • setPath() will throw on:
  • pattern not starting with /
  • empty segment (double slash //)
  • unterminated { in a segment
  • consecutive parameters without a literal separator
  • wildcard * used in a non-terminal position
  • mixing named and unnamed parameters within the same pattern
  • conflicting parameter naming or wildcard usage for an identical registered pattern

Matching & capture lifetime

  • Patterns are compiled at registration; matching returns captures as string_views (no copies of the captured substrings). Captures returned by the router are transient and reference the original request path buffer and the router's internal transient storage.
  • Callers must copy captured values if they need them to survive beyond the original request buffer lifetime or beyond a subsequent match() call which may mutate the router's transient buffers.

How to retrieve path params from handlers

  • When SingleHttpServer dispatches to a handler, it copies routing captures into the HttpRequestView object. Within your handler you can access them via req.pathParams() which returns a flat_hash_map<std::string_view, std::string_view>.
  • Example:
Router router;
router.setPath(http::Method::GET, "/users/{id}/posts/{post}", [](const HttpRequestView& req) {
  auto params = req.pathParams();
  auto it = params.find("id");
  if (it != params.end()) {
    std::string_view userId = it->second; // points into request buffer
    // copy if you need to keep it beyond request lifetime: std::string(userId)
  }
  return HttpResponse(200);
});

Unnamed capture example (keys are "0", "1", ...)

Router router;
router.setPath(http::Method::GET, "/files/{}/chunk/{}", [](const HttpRequestView&) {
  return HttpResponse(200);
});
// In handler: req.pathParams().at("0"), req.pathParams().at("1")

Construction Model (RAII & Ephemeral Ports)

SingleHttpServer binds, configures the listening socket and registers it with the platform I/O backend (epoll on Linux, kqueue on macOS, WSAPoll on Windows) inside its constructor (RAII). If you request an ephemeral port (port = 0), the kernel-assigned port is immediately available via server.port() after construction (no separate setup step).

Why RAII:

  • Fully initialized, listening server object or an exception (no half states)
  • Simplifies lifecycle and tests (ephemeral port resolved synchronously)
  • Enables immediate inspection / registration before running

Ephemeral pattern:

HttpServerConfig cfg; // port left 0 => ephemeral
SingleHttpServer server(cfg);
uint16_t actual = server.port();

Restart semantics: Both SingleHttpServer and HttpServer support restart via run() after a prior stop() or completed beginDrain(). The listening socket and reactor state are rebuilt on each run() call, allowing reuse of the same server object across multiple start/stop cycles.

Removed experimental factory: a previous non-throwing tryCreate was dropped to keep API surface minimal.

Design trade-offs: Constructor may throw on errors (bind failure, TLS init failure if configured). This is intentional to surface unrecoverable configuration issues early.

Copy semantics (SingleHttpServer & MultiHttpServer)

  • SingleHttpServer supports copy construction and copy assignment while the source instance is fully stopped. Copy assignment automatically calls stop() on the destination before duplicating sockets and router state; attempting to copy from a running instance throws std::logic_error to avoid duplicating active event loops. When copying bound sockets, ensure the original server either relinquishes the port (call stop() or destroy the instance) or has reusePort=true so the new copy can bind successfully.
  • MultiHttpServer mirrors the same rule: copies are only allowed from a stopped source. Copy construction and assignment rebuild fresh SingleHttpServer instances carrying the same port, router, and thread count while wiring them to a new lifecycle tracker. Running sources throw std::logic_error to prevent duplicating active thread pools.
  • Tests: see tests/http-server-lifecycle_test.cpp (HttpServerCopy.*) and tests/multi-http-server_test.cpp (MultiHttpServerCopy.*).

HttpServer lifecycle

Manages N reactors via SO_REUSEPORT.

In a nutshell

  • Constructor binds & resolves port (ephemeral resolved once).
  • Restart rebuilds underlying single‑shot servers; same port reused.
  • Modify handlers only while stopped (between stop/start).
  • reusePort=true required for threadCount > 1.
  • Movable even while running (vector storage stable).
  • Graceful drain propagates: beginDrain(maxWait) stops all accept loops, existing keep-alive connections receive Connection: close, and isDraining() reports when any underlying instance is still draining.

HttpServer restart example

Router router;
router.setDefault([](const HttpRequestView&){ return HttpResponse(200,"OK").body("hi\n"); });
HttpServerConfig cfg;
cfg.nbThreads = 4;
HttpServer multi(cfg, std::move(router));
// Use `start()` as a void convenience which manages an internal handle. Use `startDetached()` if you need
// an `AsyncHandle` to control or inspect the background threads explicitly.
multi.start();
multi.stop();
multi.start();

Port reuse semantics

The library interprets this boolean slightly differently depending on whether you use a single SingleHttpServer or the MultiHttpServer wrapper to make the behaviour both safe and intuitive:

  • Single SingleHttpServer:
  • reusePort = false creates the listening socket without that reuse option.
  • reusePort = true requests the kernel-level reuse option (SO_REUSEPORT on Linux 3.9+ and macOS 12+, SO_REUSEADDR on Windows) when creating the listening socket for that server instance.

  • MultiHttpServer (multi-reactor wrapper):

  • reusePort = false (recommended for explicit ports): the first server binds the explicit port exclusively (no reuse option) temporarily to ensure the process obtains the port and avoid accidentally binding to an unrelated process. Once the exclusive bind succeeds, subsequent internal sibling servers created by MultiHttpServer will be started to reuse that resolved port internally for multi-reactor operation. This gives a safe default for explicit ports while still providing multi-reactor scaling inside the process.
  • reusePort = true: no check about possible existing listener on the system on the given port is made. The first server will set the reuse option and all servers created for the MultiHttpServer will use socket reuse. This enables binding by other co-located processes as well as in-process siblings.

Note: ephemeral ports (port == 0) preserve prior behaviour: the first server discovers the kernel-assigned ephemeral port and subsequent siblings bind to that resolved port using reuse semantics so MultiHttpServer keeps working with ephemeral port allocation.

To sum-up, for most cases you will prefer reusePort = false (which is the default) to avoid accidental port conflicts with other processes and keep your own server instances listening for trafic, while still getting multi-reactor scaling internally. Use reusePort = true only when you explicitly want to share the port with other processes or have specific reuse semantics in mind.

Built-in Kubernetes-style probes

aeronet can optionally provide a small set of built-in HTTP probe endpoints intended to be used by Kubernetes-style health checks and load-balancers. These probes are lightweight, handled entirely by the server, and do not require application handlers to be installed when enabled.

Probes in a nutshell

  • Enabled via HttpServerConfig::withBuiltinProbes(BuiltinProbesConfig) or enableBuiltinProbes(true).
  • Default probe paths (configurable in BuiltinProbesConfig):
  • Liveness: /livez - consistently returns HTTP 200 while the server is running
  • Readiness: /readyz - indicates the server is ready to receive new requests (HTTP 200). During draining, it returns HTTP 503 and returns Connection: close to signal clients not to reuse connections.
  • Startup: /startupz - returns HTTP 503 until the server has fully initialized, then returns HTTP 200 like liveness.
  • The probe handlers return minimal responses (status only, configurable Content-Type) and avoid heavy work.

Probes configuration options

  • BuiltinProbesConfig::enabled (bool): enable/disable builtin probes.
  • BuiltinProbesConfig::contentType (enum): response Content-Type used by the probe responses.
  • BuiltinProbesConfig::withLivenessPath / withReadinessPath / withStartupPath: customize probe paths. Paths must be non-empty and begin with / - invalid values are rejected by BuiltinProbesConfig::validate().
  • BuiltinProbesConfig::withDedicatedPort (uint16, 0 = disabled): serve the probes from a dedicated listener on their own port/thread instead of inline on the application port. See below. MultiHttpServer only.
  • BuiltinProbesConfig::withLivenessStaleThreshold (duration, default 10s): liveness heartbeat window used by the dedicated listener (see below). Must be strictly positive when a dedicated port is set.

When enabled, if an application handler is already registered on the same path(s) the server will override them with the probes handlers.

Dedicated probe listener (isolating probes from application load)

Inline probes share the worker event loops, so a request handler that blocks a worker for a long time (a heavy CPU task, a slow blocking dependency call, ...) can delay a probe answered by that same worker. Under Kubernetes this can turn a merely-busy pod into a probe timeout and an unwanted restart. The common workaround — over-provisioning worker threads to "reserve" one for probes — is not deterministic: with SO_REUSEPORT the kernel, not the application, picks which listener a probe connection lands on.

Setting BuiltinProbesConfig::withDedicatedPort(port) on a MultiHttpServer starts an extra single-threaded event loop bound to port whose sole job is answering the probe endpoints. Because that loop never runs application handlers, probe availability is fully isolated from application load. Point your Kubernetes probes at that port (a different containerPort). When dedicatedPort == 0 (the default) probes remain inline on the application port (unchanged, backward-compatible). A standalone SingleHttpServer ignores dedicatedPort (it has no worker pool to isolate probes from).

The dedicated listener reflects the state of the worker pool rather than its own:

  • Readiness (/readyz): 200 while any worker is accepting traffic, 503 once all workers are draining.
  • Startup (/startupz): 200 once any worker has entered its event loop.
  • Liveness (/livez): heartbeat-based. Each worker publishes a heartbeat at the top of every event-loop iteration; a loop wedged inside a request handler (or otherwise not polling) stops advancing it. The pod is reported 200 unless every worker's heartbeat has been stale for longer than livenessStaleThreshold (a full deadlock); a worker that is idle, progressing, or merely busy under the threshold keeps the pod live. Because an idle worker only refreshes its heartbeat once per poll cycle, a healthy loop can look up to pollInterval * pollIntervalMaxFactor stale — so keep livenessStaleThreshold comfortably above both your longest legitimate handler runtime and the worker poll interval (the default 10s sits well above the default 500ms pollInterval). This matters most with only one or two threads, where a single long handler can trip it.
HttpServerConfig cfg;
cfg.withNbThreads(4).withReusePort();
BuiltinProbesConfig probesCfg;
probesCfg.enabled = true;
probesCfg.withDedicatedPort(9091);                    // probes served on :9091, application traffic elsewhere
probesCfg.withLivenessStaleThreshold(std::chrono::seconds{15});
cfg.withBuiltinProbes(std::move(probesCfg));

MultiHttpServer server(std::move(cfg));
// server.router().setDefault(...);  application routes on the main port
// server.probePort() == 9091

Probes Notes & recommendations

  • Builtin probes are intentionally tiny and designed for readiness/liveness checks only. If you need richer health diagnostics (dependencies, DB, caches), implement a custom application handler and register it on a non-conflicting path.
  • Enabling builtin probes is useful for quick deployments and reduces application boilerplate. If you prefer full control or want to return structured JSON diagnostics, disable builtin probes and register your own handlers.

Probes configuration example

Enable builtin probes with default paths and a plain-text content type:

HttpServerConfig cfg;
BuiltinProbesConfig probesCfg;
probesCfg.enabled = true;
probesCfg.contentType = BuiltinProbesConfig::ContentType::TextPlainUtf8;
probesCfg.withLivenessPath("/livenessz");
probesCfg.withReadinessPath("/readinessz");
probesCfg.withStartupPath("/startupz");

cfg.withBuiltinProbes(std::move(probesCfg));
SingleHttpServer server(std::move(cfg));

Testing

  • The test suite includes http_probes_test.cpp which validates startup/readiness transitions and drain-time behavior. Tests also cover collision detection for probe paths.
  • The dedicated probe listener is covered by the MultiHttpServerDedicatedProbes suite in tests/multi-http-server_test.cpp: probes served off the application port, a probe staying responsive while a worker is blocked in a handler, the liveness heartbeat tripping on a wedged worker and recovering, readiness reporting 503 during drain, and dedicated-port validation.

TLS Features

Optional (AERONET_ENABLE_OPENSSL). Provides termination, optional / required mTLS, ALPN (strict mode), handshake timeout, per‑server metrics.

Capability Status Notes
TLS termination File or in‑memory PEM cert/key
mTLS (request) withTlsRequestClientCert() (non-fatal absence)
mTLS (require) withTlsRequireClientCert() (fatal if absent / invalid). Implies request: enabling require always asks for a client certificate, regardless of the request flag.
ALPN negotiation Ordered list via withTlsAlpnProtocols()
Strict ALPN enforcement withTlsAlpnMustMatch(true) -> fatal if no overlap
Negotiated ALPN in request HttpRequestView::alpnProtocol
Negotiated cipher & version HttpRequestView::{tlsCipher,tlsVersion}
Handshake logging withTlsHandshakeLogging() (cipher, version, ALPN, peer subject)
Min / Max protocol version withTlsMinVersion("TLS1.2"), withTlsMaxVersion("TLS1.3")
Kernel TLS (kTLS) sendfile (Linux-only) zero-copy sendfile for TLS sockets; enabled by default with graceful fallback.
Handshake timeout withTlsHandshakeTimeout(ms) closes stalled handshakes
Graceful TLS shutdown Best‑effort SSL_shutdown before close
ALPN strict mismatch counter Per‑server stats
Handshake success counter Per‑server stats
Client cert presence counter Per‑server stats
ALPN distribution Vector (protocol,count) in stats
TLS version distribution Stats field
Cipher distribution Stats field
Handshake duration metrics Count / total ns / max ns
Handshake failure reason buckets ServerStats::tlsHandshakeFailureReasons
Handshake event callback SingleHttpServer::setTlsHandshakeCallback() (and MultiHttpServer::setTlsHandshakeCallback())
JSON stats export serverStatsToJson() includes TLS metrics
No process‑global mutable TLS state All metrics per server instance
Session resumption (tickets) Server-side TLS session ticket support with automatic key rotation.
Handshake full/resumed counters ServerStats::{tlsHandshakesFull,tlsHandshakesResumed}
Handshake admission control Concurrency limit + basic rate limiting via TLSConfig::{maxConcurrentHandshakes,handshakeRateLimitPerSecond,handshakeRateLimitBurst}
MultiHttpServer shared ticket keys Session ticket key store is shared across instances for consistent resumption
SNI multi-cert routing TLSConfig::withTlsSniCertificate*() selects cert by SNI
Hot cert/key reload (atomic swap) postConfigUpdate() with TLS config change rebuilds and swaps TLS context for new connections
Dynamic trust store update postConfigUpdate() with trust store change
OCSP stapling / revocation checks OCSP staple responses & revocation checking with caching.

TLS Configuration Example

HttpServerConfig cfg;
std::string certPem /* = R"(-----BEGIN CERTIFICATE----- */;
std::string keyPem  /* = R"(-----BEGIN PRIVATE KEY-----*/;
cfg.withPort(8443)
   .withTlsCertKeyMemory(certPem, keyPem)
   .withTlsAlpnProtocols({"http/1.1"})
   .withTlsAlpnMustMatch(true)
   .withTlsMinVersion("TLS1.2")
   .withTlsMaxVersion("TLS1.3")
   .withTlsHandshakeTimeout(std::chrono::milliseconds(750));
SingleHttpServer server(cfg);

Strict ALPN: if enabled and no protocol overlap, handshake aborts (connection closed, metric incremented).

Handshake event callback

You can subscribe to handshake outcomes (succeeded / failed / rejected) with a lightweight callback.

#include <aeronet/log.hpp>

SingleHttpServer server;
server.setTlsHandshakeCallback([](const TlsHandshakeEvent& ev) {
  // ev.result is one of: Succeeded, Failed, Rejected
  // ev.reason is a short string for failures/rejections (best-effort)
  // ev.selectedAlpn / negotiatedCipher / negotiatedVersion are filled for successful handshakes
  if (ev.result == TlsHandshakeEvent::Result::Failed) {
    log::warn("TLS handshake failed fd={} reason={} ver={} cipher={}", ev.fd, ev.reason,
              ev.negotiatedVersion, ev.negotiatedCipher);
  }
});

Handshake failure reason buckets (stats)

ServerStats::tlsHandshakeFailureReasons provides best-effort bucketing of why handshakes failed or were rejected.

#include <aeronet/log.hpp>

SingleHttpServer server;
const ServerStats st = server.stats();
for (const auto& kv : st.tlsHandshakeFailureReasons) {
  const std::string& reason = kv.first;
  const uint64_t count = kv.second;
  log::info("tls_handshake_reason={} count={}", reason, count);
}

Hot reload (cert/key) and dynamic trust store update

New connections pick up the updated TLS configuration after the next poll cycle. Use postConfigUpdate() to modify TLS settings at runtime - changes are detected and the SSL context is atomically rebuilt for new connections.

SingleHttpServer server;

// Hot swap certificate and key
TLSConfig tls;
tls.enabled = true;
std::string_view newCertPem /* = R"(-----BEGIN CERTIFICATE-----"*/;
std::string_view newKeyPem  /* = R"(-----BEGIN PRIVATE KEY-----"*/; 
tls.withCertPem(newCertPem).withKeyPem(newKeyPem);
server.postConfigUpdate([tls = std::move(tls)](HttpServerConfig& cfg) mutable {
  cfg.tls = std::move(tls);
});

// Or just update the trust store (clears existing, adds new)
std::string_view newClientCaPem /* = R"(-----BEGIN CERTIFICATE-----"*/;
server.postConfigUpdate([newClientCaPem](HttpServerConfig& cfg) {
  cfg.tls.withoutTlsTrustedClientCert().withTlsTrustedClientCert(newClientCaPem);
});

Automatic HTTP → HTTPS redirect

A plaintext listener can be turned into a pure redirector that answers every request with a 3xx redirect to the equivalent https:// URL, instead of routing it to handlers. This is the typical companion to a TLS listener: run one server on port 80 that redirects to a second TLS server on port 443.

This is a plaintext-side feature and does not require AERONET_ENABLE_OPENSSL to be built; it only emits a redirect. The TLS endpoint it points at is a separate listener.

// Plaintext redirector on :80 -> https://<host>/<path> (standard port 443 omitted from the URL)
HttpServerConfig http;
http.withPort(80).withHttpsRedirect(/*targetHttpsPort=*/443);
SingleHttpServer redirector(http);

// TLS server on :443 doing the real work
std::string certPem /* = R"(-----BEGIN CERTIFICATE-----...)" */;
std::string keyPem /* = R"(-----BEGIN PRIVATE KEY-----...)" */;
Router router;
HttpServerConfig https;
https.withPort(443).withTlsCertKeyMemory(certPem, keyPem);
SingleHttpServer app(https, std::move(router));

Behaviour:

  • The redirect host is taken from the request Host header; any port in it is replaced by the configured targetPort. The standard HTTPS port 443 is omitted from the URL (https://host/path); any other value is appended (https://host:8443/path).
  • The original path and query are preserved. Because aeronet decodes the request target during parsing, the path and query are re-encoded when building the Location value, so it is always a valid, injection-safe URL.
  • The redirect bypasses routing, protocol upgrades (h2c / WebSocket) and request-body handling, and closes the connection afterwards (the client reconnects over TLS).
  • A request without a Host header (no absolute URL can be built) receives 400 Bad Request.
Setting Builder Default Notes
Target port withHttpsRedirect(port) / httpsRedirect.targetPort 0 (disabled) 0 disables the redirect; 443 is omitted from the URL, other ports appended. Plaintext listeners only.
Status code withHttpsRedirect(port, code) / httpsRedirect.statusCode 301 One of 301, 302, 307, 308

The redirect is enabled simply by setting a non-zero targetPort (the convenience httpsRedirect.enabled() accessor returns targetPort != 0). Use 308 Permanent Redirect (http::StatusCodePermanentRedirect) instead of 301 if you need clients to preserve the method and body on non-GET/HEAD requests. Enabling httpsRedirect together with tls on the same listener is rejected by HttpServerConfig::validate() (a TLS listener cannot redirect to itself).

JSON / YAML configuration:

{
  "server": {
    "port": 80,
    "httpsRedirect": { "targetPort": 443, "statusCode": 301 }
  }
}

Kernel TLS (kTLS) sendfile

aeronet supports kTLS when supported by the system. It will attempt to enable kernel TLS sendfile on each TLS connection. The default mode is Opportunistic: the server opportunistically enables kTLS and will fall back silently to the existing user-space TLS path if the kernel, OpenSSL, or negotiated cipher suite does not support it. It increments the ktlsSendEnableFallbacks counter when offload is unavailable so operators are informed about the reason for fallback. Use Required to treat offload failure as fatal. Configuration lives in TLSConfig::ktlsMode, exposed via HttpServerConfig::withTlsKtlsMode(...) with the following modes:

Mode Behaviour
Disabled Never attempt kTLS.
Opportunistic (default) Attempt once per connection; on failure fall back to user-space TLS.
Enabled Same as Opportunistic, but emits a warning log in case of failure to set kTLS during the handshake.
Required Treat failure/unsupported as fatal and close the connection immediately.

Runtime counters (ServerStats) report how many connections enabled kTLS, how many fell back, forced shutdowns, and the aggregate bytes transferred via kernel TLS sendfile. Logs capture the reason for any fallback.

How to enable kTLS in your server

  1. Ensure the project was compiled with both AERONET_ENABLE_OPENSSL=ON and uses a modern OpenSSL version (>= 3.0).
  2. Provide TLS credentials - either files or in-memory PEM strings - exactly as for any HTTPS deployment.
  3. Set the desired kTLS mode before constructing the server:
HttpServerConfig cfg;
cfg.withPort(8443)
   .withTlsCertKey("/path/to/fullchain.pem", "/path/to/privkey.pem")
   .withTlsKtlsMode(TLSConfig::KtlsMode::Opportunistic);  // or Disabled / Enabled / Required
SingleHttpServer server(cfg);
  1. Optionally consult server.stats() to monitor ktlsSendEnabledConnections, ktlsSendEnableFallbacks, and ktlsSendBytes during runtime. These counters help verify whether your kernel accepted offload or if the code fell back to the classic user-space TLS path.

See examples/tls-ktls.cpp for a runnable end-to-end snippet combining all of the above.

TLS (HTTPS) Support Details

TLS termination is enabled at build time with AERONET_ENABLE_OPENSSL=ON (default ON in main project builds). The TLS layer is isolated in a dedicated module so the core stays free of OpenSSL headers when disabled.

Key configuration helpers:

Method Purpose
withTlsCertKey(pathCert, pathKey) Load certificate & key from filesystem
withTlsCertKeyMemory(certPem, keyPem) Supply in-memory PEM strings (tests / dynamic)
withTlsCipherList(list) Override OpenSSL cipher list (empty => library default)
withTlsAlpnProtocols({..}) Ordered ALPN protocol preference list
withTlsAlpnMustMatch(true) Enforce overlap; abort handshake on mismatch
withTlsMinVersion("TLS1.2") / withTlsMaxVersion("TLS1.3") Protocol version bounds
withTlsHandshakeTimeout(ms) Abort slow handshakes
withTlsHandshakeLogging() Emit per-handshake diagnostic log (cipher/version/ALPN)
withTlsRequestClientCert() Request (but not require) client cert (mTLS optional)
withTlsRequireClientCert() Strict mTLS (fatal if absent/invalid)
withTlsTrustedClientCert(pem) Append trust anchor (repeatable)

Client certificate modes:

  • Request: server asks; absence is tolerated; presence increments stats.
  • Require: absence / invalid chain => handshake termination.

ALPN behavior:

  • First overlapping protocol (server order) selected; exposed via HttpRequestView::alpnProtocol.
  • Strict mode aborts if no overlap (increments mismatch counter).

Security & metrics integration:

  • No global mutable OpenSSL state; each server instance owns its context to allow per-instance policies.
  • Stats track: successful handshakes, strict ALPN mismatches, cert-present count, distributions (ciphers, versions, ALPN protocols), handshake duration aggregates.

Runtime notes:

  • Handshake performed inside event loop with non-blocking BIO; integrates with the platform I/O backend.
  • Graceful shutdown attempts SSL_shutdown prior to socket close (best-effort, non-blocking).

Testing guidance:

  • Use withTlsCertKeyMemory with ephemeral self-signed test certificates (see test helper) to avoid filesystem dependencies.
  • For ALPN strict tests, provide a protocol set that intentionally does not match to exercise mismatch counter.

Roadmap (see also table above): OCSP stapling / revocation checks.

TLS Session Tickets

Session tickets allow TLS session resumption without server-side session caches, enabling faster subsequent handshakes (0-RTT negotiation). aeronet provides automatic key management with configurable rotation.

Session Ticket Concepts

  • Session Tickets: Encrypted session state sent to the client, allowing resumption without a full handshake.
  • Ticket Encryption Keys: 48-byte keys (16B key name + 16B AES key + 16B HMAC key) used to encrypt/decrypt tickets.
  • Key Rotation: Automatic rotation prevents stale keys from being used indefinitely.

Configuration Options

Method Purpose
withTlsSessionTickets(true) Enable session tickets (default: disabled)
withTlsSessionTicketLifetime(duration) Key rotation interval (default: 1 hour)
withTlsSessionTicketMaxKeys(n) Maximum keys in rotation (default: 3)
withTlsSessionTicketKey(key) Load a static 48-byte key (disables rotation)

Automatic Key Rotation

When enabled without a static key, aeronet generates cryptographically random keys and rotates them automatically:

HttpServerConfig cfg;
cfg.withPort(8443)
   .withTlsCertKey("cert.pem", "key.pem");
cfg.tls.withTlsSessionTickets(true)
       .withTlsSessionTicketLifetime(std::chrono::hours{2})
       .withTlsSessionTicketMaxKeys(4);

SingleHttpServer server(std::move(cfg));

This configuration:

  • Generates new keys every 2 hours
  • Keeps up to 4 keys for decrypting older tickets during rotation
  • Automatically purges keys beyond the maximum

Static Key Loading

For deployments requiring key consistency across restarts or multiple server instances, load a static key:

// 48-byte key: 16B name + 16B AES + 16B HMAC
TLSConfig::SessionTicketKey keyData{};

// populate keyData securely (e.g., from a secrets manager)

HttpServerConfig cfg;
cfg.withPort(8443)
   .withTlsCertKey("cert.pem", "key.pem");
cfg.tls.withTlsSessionTicketKey(keyData);  // Enables tickets + loads key

SingleHttpServer server(std::move(cfg));

When a static key is provided:

  • Session tickets are automatically enabled
  • Key rotation is disabled (only the static key is used)
  • The same key can be shared across server instances for distributed session resumption

Security Considerations

  • Key Size: Each key is exactly 48 bytes (TLSConfig::kSessionTicketKeySize).
  • Key Generation: Uses RAND_bytes() for cryptographically secure random generation.
  • Key Storage: Static keys should be stored securely (e.g., secrets manager, encrypted storage).
  • Rotation: Regular rotation limits the impact of key compromise; shorter lifetimes = better security.
  • OpenSSL 3.0+: Uses modern EVP_MAC API for HMAC operations.

Testing Session Tickets

Verify session resumption with OpenSSL's s_client:

# First connection (full handshake)
openssl s_client -connect localhost:8443 -sess_out session.pem

# Second connection (resumed)
openssl s_client -connect localhost:8443 -sess_in session.pem
# Look for "Reused, TLSv1.3" in output

See examples/tls-session-tickets.cpp for a complete working example.


TRACE method policy

The server exposes a configurable TraceMethodPolicy to control handling of the HTTP TRACE method. Use HttpServerConfig::withTracePolicy(...) to choose one of:

  • Disabled (default) - reject TRACE (405).
  • EnabledPlainAndTLS - allow TRACE and echo the received request message (RFC 7231 §4.3) on both plaintext and TLS.
  • EnabledPlainOnly - allow TRACE on plaintext connections only; reject when the request arrived over TLS.

This provides a safety-minded default while allowing deployments to express site-specific policies (e.g. disallow TRACE on TLS).

Quick reference matrix:

Policy Plaintext TRACE TLS TRACE Description
Disabled Rejected (405) Rejected (405) Default safe option - TRACE not allowed
EnabledPlainOnly Allowed (echo) Rejected (405) Useful when TLS endpoints must not expose request echoes
EnabledPlainAndTLS Allowed (echo) Allowed (echo) TRACE allowed on both plaintext and TLS

Examples:

  • To disable TRACE entirely (default): cfg.withTracePolicy(HttpServerConfig::TraceMethodPolicy::Disabled);
  • To allow TRACE only on plaintext: cfg.withTracePolicy(HttpServerConfig::TraceMethodPolicy::EnabledPlainOnly);
  • To allow TRACE on both plaintext and TLS: cfg.withTracePolicy(HttpServerConfig::TraceMethodPolicy::EnabledPlainAndTLS);

Streaming Responses (Chunked / Incremental)

Handlers can produce bodies incrementally using a streaming handler registration instead of fixed responses. When streaming, headers are deferred until either a compression decision (if enabled) or first write.

Key semantics:

  • Default transfer uses Transfer-Encoding: chunked unless contentLength() was called before any body writes.
  • write() queues data; returns false only when the connection is marked to close (e.g. outbound buffer limit exceeded or fatal error). Future versions may introduce a "should-pause" state.
  • end() finalizes, emitting terminating 0\r\n\r\n in chunked mode and flushing any compression trailers.
  • HEAD requests suppress body bytes automatically (still compute/send Content-Length when known).
  • Keep-alive preserved if policy allows and no fatal condition occurred.
  • Zero-copy file responses: both HttpResponse::file(...) and HttpResponseWriter::file(...) accept an aeronet::File descriptor and stream its contents with sendfile(2) (Linux-only) on plaintext sockets. When TLS is active, aeronet reuses the connection's tunnel buffer and feeds encrypted writes via pread + SSL_write, so no additional heap allocations are introduced beyond that shared buffer.
  • file automatically wires Content-Length, rejects trailers/body mutations, and honors HEAD semantics (headers only, body suppressed).

Backpressure & buffering:

  • Unified outbound queue for both fixed & streaming; immediate write path used when queue empty, else bytes accumulate and event-driven write-readiness notification drives flushing.
  • Exceeding maxOutboundBufferBytes marks connection to close after pending data flush; additional queued chunks are rejected immediately so subsequent write() yields false without growing the buffer further.

Limitations (current phase): compression integration limited to buffered activation decision; request body streaming (chunked delivery to handler as it arrives) not yet implemented.

Example:

Router router;
router.setDefault([](const HttpRequestView&, HttpResponseWriter& w){
  w.status(200);
  w.header("Content-Type", "text/plain");
  for (int i=0;i<5;++i) {
    if (!w.writeBody("chunk-" + std::to_string(i) + "\n")) break;
  }
  w.end();
});

SingleHttpServer server(HttpServerConfig{}.withPort(8080), std::move(router));

Testing: see tests/http_streaming.cpp.

  • [x] StaticFileHandler serves directory trees with zero-copy file
  • [x] RFC 7233 single-range parsing and validation (Range, If-Range)
  • [x] RFC 7232 validators (If-None-Match, If-Match, If-Modified-Since, If-Unmodified-Since)
  • [x] Strong ETag generation (size-lastWriteTime), Last-Modified, Accept-Ranges: bytes
  • [x] 416 (Range Not Satisfiable) with Content-Range: bytes */N
  • [x] Integration hooks in HttpServerConfig::staticFiles

Static File Handler (RFC 7233 / RFC 7232)

StaticFileHandler provides a hardened helper for serving filesystem trees while respecting HTTP caching and range semantics. The handler is designed to plug into the existing routing API: it is an invocable object that accepts an HttpRequestView and returns an HttpResponse, so it works with SingleHttpServer and MultiHttpServer exactly like any other handler.

  • Zero-copy transfers: regular GET requests use HttpResponse::file() so plaintext sockets reuse the kernel sendfile(2) path. TLS endpoints automatically fall back to the buffered write path that aeronet already uses for file responses.
  • Directory listings: when StaticFileConfig::enableDirectoryIndex is true and no default index file is present, aeronet emits an HTML index with optional trailing-slash redirect, hidden-file filtering (showHiddenFiles), configurable CSS (withDirectoryListingCss) and a pluggable renderer (directoryIndexRenderer). Large directories obey maxEntriesToList and advertise truncation via x-directory-listing-truncated: 1.
  • Single-range support: Range: bytes=N-M (RFC 7233 §2.1) is parsed with strict validation. Valid ranges return 206 Partial Content with Content-Range. Invalid syntax returns 416 with Content-Range: bytes */<size> per the spec.
  • Multi-range support (multipart/byteranges, RFC 7233 §4.1): comma-separated byte ranges such as Range: bytes=0-99,200-299,500- are fully supported. The response uses status 206 Partial Content with Content-Type: multipart/byteranges; boundary=<token> and each MIME part carries its own Content-Type and Content-Range header. Implementation details:
  • Overlapping and adjacent ranges are sorted and coalesced per RFC recommendation, reducing redundant I/O.
  • If multi-range resolution produces a single range after coalescing, the handler emits a simple Content-Range response (no multipart overhead).
  • Unsatisfiable sub-ranges are silently dropped; a 416 is returned only when all sub-ranges are unsatisfiable (RFC 7233 §4.4).
  • If-Range interaction: when the validator mismatches, the full body is returned (200) regardless of the number of ranges requested.
  • Safety limits are configurable via StaticFileConfig:
    • maxMultipartRanges (default 16) - requests exceeding this are treated as invalid (416).
    • maxMultipartBodySize (default 32 MiB) - if the assembled multipart body would exceed this limit the handler falls back to a full 200 response instead of partial content.
  • Conditional requests: If-None-Match, If-Match, If-Modified-Since, If-Unmodified-Since, and If-Range are honoured using strong validators. Requests that do not modify the resource return 304 Not Modified for GET/HEAD or 412 Precondition Failed for unsafe methods. If-Range transparently falls back to the full body when the validator mismatches.
  • Headers: the handler always emits Accept-Ranges: bytes so clients learn range capability. ETag and Last-Modified are enabled by default (configurable) and share the same strong validator used by conditionals.
  • Pre-computed header cache: the formatted per-file header fragments (ETag, Last-Modified and the resolved Content-Type) are cached keyed by the resolved file path, so repeated requests to the same, unchanged file skip the ETag/date/MIME formatting entirely. Each request still performs a single fstat() (done when opening the file); the file size and modification time it returns double as the cache validation key, so a modified or replaced file transparently rebuilds its entry — cached headers are never stale. The cache is per handler instance (hence per server thread, since each thread copies the router) and therefore lock-free. It grows lazily up to StaticFileConfig::headerCacheCapacity (default 1024); once full, inserting a new file evicts the least-recently-used entry so the cache stays bounded while keeping the hot working set resident. Set headerCacheCapacity to 0 (or use withHeaderCacheCapacity(0)) to disable the cache. A contentTypeResolver callback, when installed, is invoked at most once per (file, stat) rather than on every request. See tests (HeaderCache*).

  • Content-Type resolution: when serving files the handler resolves the Content-Type header with the following precedence: (1) a user-provided content-type resolver callback (if installed) and returning a non-empty value, (2) the configured default content type in HttpServerConfig (if non-empty), and (3) the hard fallback application/octet-stream. The library exposes File::detectedContentType() which performs filename-extension based detection using the bundled extension → mime table (the table was extended to include common C/C++ extensions such as c, h, cpp, hpp, cc). Applications with different heuristics (case-insensitive lookup, longest-suffix matching like tar.gz, etc.) can supply their own resolver to override the default behavior.

  • Safety: all request paths are normalised under the configured root; .. segments are rejected. Default index fallback (e.g. index.html) is configurable or can be disabled.
  • Config entry point: the immutable configuration lives in StaticFileConfig. The handler constructor also accepts a config directly.

Example usage:

#include <aeronet/aeronet.hpp>

using namespace aeronet;

int main() {
  HttpServerConfig cfg;
  cfg.withPort(8080);

  StaticFileConfig staticFileConfig;
  staticFileConfig.enableRange = true;
  staticFileConfig.addEtag = true;
  staticFileConfig.enableDirectoryIndex = true;  // fallback to HTML listings when index.html is absent
  staticFileConfig.withDefaultIndex("index.html");

  Router router;
  StaticFileHandler assets("/var/www/html", std::move(staticFileConfig));
  router.setPath(http::Method::GET, "/", [assets](const HttpRequestView& req) mutable {
    return assets(req);
  });

  SingleHttpServer server(std::move(cfg), std::move(router));

  server.run();
}

Try it (build & run the example)

# from repository root
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --target aeronet-static-example

# Run, optionally passing a port and a root directory to serve
./build/examples/aeronet-static-example 8080 ./examples/static-assets

# Test with curl (full file)
curl -i http://localhost:8080/somefile.txt

# Test single-range request
curl -i -H "Range: bytes=0-3" http://localhost:8080/somefile.txt

Testing lives in tests/http-core_test.cpp which exercises full-body responses, single-range 206, multi-range 206 multipart/byteranges, range coalescing, unsatisfiable requests, If-None-Match, If-Range, and safety-limit behaviour. Unit tests in aeronet/http/test/static-file-handler_test.cpp provide fine-grained coverage of the parsing, coalescing, boundary generation, and multipart body assembly paths.

# Test multi-range request
curl -i -H "Range: bytes=0-3,10-13" http://localhost:8080/somefile.txt

Mixed Mode & Dispatch Precedence

Registration supports simultaneous fixed and streaming handlers at global and per-path scope. Precedence order:

  1. Path-specific streaming handler
  2. Path-specific fixed handler
  3. Global streaming handler
  4. Global fixed handler

HEAD requests fallback to GET semantics for handler selection; streaming handlers auto-suppress body for HEAD.

Conflict rules:

  • Registering both streaming & fixed for the identical (path, method) pair is rejected.
  • Distinct method sets on same path may split across streaming vs fixed registrations (e.g. GET streaming, POST fixed).

Example precedence illustration:

Router router;
router.setDefault([](const HttpRequestView&){ return HttpResponse(200,"OK").body("GLOBAL"); });
router.setDefault([](const HttpRequestView&, HttpResponseWriter& w){ 
  w.status(200);
  w.contentType("text/plain");
  w.writeBody("STREAMFALLBACK"); 
  w.end(); 
});
router.setPath(http::Method::GET, "/stream", [](const HttpRequestView&, HttpResponseWriter& w){ 
  w.status(200); 
  w.contentType("text/plain"); 
  w.writeBody("PS"); 
  w.end(); 
});
router.setPath(http::Method::POST, "/stream", [](const HttpRequestView&){ return HttpResponse{201, "Created"}.body("NORMAL"); });

Behavior:

  • GET /stream → path streaming
  • POST /stream → path fixed
  • GET /other → global streaming fallback
  • POST /other → global fixed (since only global fixed + streaming; precedence chooses streaming for GET only)

Testing: tests/http_streaming_test.cpp covers precedence, conflicts, HEAD suppression, keep-alive reuse.

Accessing TLS Metrics

#include <aeronet/log.hpp>

SingleHttpServer server;
auto st = server.stats();
log::info("handshakes={} clientCerts={} alpnStrictMismatches={}\n",
          st.tlsHandshakesSucceeded,
          st.tlsClientCertPresent,
          st.tlsAlpnStrictMismatches);
for (const auto& [proto,count] : st.tlsAlpnDistribution) {
  log::info("ALPN {} -> {}\n", proto, count);
}

Metric fields include: handshake success/fail counts, strict ALPN mismatches, distribution of ALPN protocols, TLS versions, ciphers, handshake duration aggregate (count / total / max).

Security note: No process‑global mutable TLS state; each server instance tracks metrics independently.

Test usage: In-memory PEM configuration is convenient for ephemeral test cert generation.

Failure modes: missing key/cert, invalid PEM, unsupported protocol versions (outside bounds), ALPN mismatch under strict mode.

Logging

If built with AERONET_ENABLE_SPDLOG, aeronet uses spdlog sinks/formatting; otherwise a lightweight fallback replicates the API (log::info("msg {}", v)). Fallback uses std::vformat when available; failures degrade gracefully by concatenating arguments.

Characteristics:

  • ISO 8601 UTC timestamps (ms precision)
  • Levels: trace, debug, info, warn, error, critical
  • Runtime level adjustable: aeronet::log::set_level(aeronet::log::level::debug);
  • Dependency-free by default (flags opt-in to spdlog)
  • Planned: pluggable structured sinks / user-defined writer API

Structured access logging

  • Request access logs are configured with HttpServerConfig::accessLog and are independent from optional spdlog.
  • Supported formats: clf (common log format style) and json.
  • Supported sinks: none, stdout, file.
  • Optional client IP behavior: useForwardedFor=true uses X-Forwarded-For when present, otherwise peer socket address is used.
  • Implementation flushes periodically on maintenance ticks and at shutdown to avoid stale buffered lines.
  • Validation/tests: aeronet/main/test/access-log-writer_test.cpp.

Design goals: keep logging off the hot path when disabled, avoid mandatory third-party dependency for minimal builds, allow future structured logging integration without breaking existing code.

Usage example (fallback or spdlog):

#include <aeronet/log.hpp>

log::info("server listening on {}", 8080);

OpenTelemetry Integration

Optional (AERONET_ENABLE_OPENTELEMETRY). Provides distributed tracing and metrics via OpenTelemetry SDK.

Architecture

Instance-based telemetry. Each SingleHttpServer owns its own TelemetryContext instance. No global singletons or static state.

Key design principles:

  • Per-instance isolation: Multiple servers with independent telemetry configurations
  • Explicit lifecycle: Telemetry instance tied to server lifetime
  • Error transparency: All telemetry failures logged via log::error() (no silent no-ops)

Configuration via HttpServerConfig::telemetry:

#include <aeronet/aeronet.hpp>

using namespace aeronet;

int main() {
  HttpServerConfig cfg;
  cfg.withPort(8080)
     .withTelemetryConfig(TelemetryConfig{}
                              .withEndpoint("http://localhost:4318")  // OTLP HTTP endpoint
                              .withServiceName("my-service")
                              .withSampleRate(1.0)  // 100% sampling for traces
                              .enableDogStatsDMetrics());  // Optional DogStatsD metrics via UDS

  SingleHttpServer server(cfg);
  // Telemetry is automatically initialized when server.init() is called
  // Each server has its own independent TelemetryContext

  // ... register handlers ...
  server.run();
}

dogStatsDEnabled convenience flag plus socket/tag helpers so lightweight DogStatsD metrics (Unix Domain Socket) can be emitted even when OpenTelemetry support is compiled out. Covered by objects/test/opentelemetry-integration_test.cpp.

Built-in Instrumentation (phase 1)

Automatic (no handler code changes):

Traces:

  • http.request spans for each HTTP request
  • http.middleware spans around request (pre) and response (post) middleware execution with attributes capturing scope (aeronet.middleware.scope), position (aeronet.middleware.index), streaming state, short-circuit decisions, and exception flags (see tests/http-routing_test.cpp for middleware pipeline coverage)
  • Attributes: http.method, http.target, http.status_code, http.request.body.size, http.response.body.size

Metrics (counters):

  • aeronet.events.processed – I/O events processed
  • aeronet.connections.accepted – new connections
  • aeronet.bytes.read – bytes read from clients
  • aeronet.bytes.written – bytes written to clients

Metrics (histograms):

aeronet exposes a lightweight histogram API via TelemetryContext::histogram(name, value).

Bucket boundaries are configured explicitly in TelemetryConfig (OpenTelemetry explicit-bucket histogram view). This allows you to define stable bucket boundaries for a given instrument name.

Configuration:

  • Register boundaries per instrument name via TelemetryConfig::addHistogramBuckets(name, boundaries).
  • Boundaries must be finite and strictly increasing.

Behavior:

  • DogStatsD emission does not use client-side bucket boundaries; histogram aggregation/bucketing is configured on the DogStatsD backend/agent.

All instrumentation is fully async (OTLP HTTP exporter) with configurable endpoints and sample rates. When dogStatsDEnabled is enabled, Aeronet also emits counter metrics over DogStatsD/UDS even if the build does not include OpenTelemetry.

Testing & Observability

Comprehensive integration tests validate:

  • Multi-instance contexts with independent configurations
  • Span creation, attribute setting, lifecycle
  • Counter operations under various conditions
  • Error handling and logging

Use with OpenTelemetry Collector for full observability pipeline:

# Example collector config for testing
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318

exporters:
  logging:
    loglevel: debug

service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [logging]
    metrics:
      receivers: [otlp]
      exporters: [logging]

Dependencies

When OpenTelemetry is enabled, aeronet requires the following system packages:

Debian/Ubuntu:

sudo apt-get install libcurl4-openssl-dev libprotobuf-dev protobuf-compiler

Alpine Linux:

apk add curl-dev protobuf-dev protobuf-c-compiler

Fedora/RHEL:

sudo dnf install libcurl-devel protobuf-devel protobuf-compiler

Arch Linux:

sudo pacman -S curl protobuf

Access-Control (CORS) Helpers

Opt-in per-route CORS configuration for web APIs served by aeronet. The CORS implementation is fully RFC-compliant and production-ready.

Overview

  • Header: aeronet/cors-policy.hpp
  • Core class: aeronet::CorsPolicy - immutable after setup, thread-safe for reuse
  • Integration: Attach policy to individual routes via Router::setPath(...).cors(policy) or set a default policy for all routes via RouterConfig::withDefaultCorsPolicy(policy)
  • Automatic preflight: OPTIONS requests with Access-Control-Request-Method header are recognized as preflight and receive automatic 204 No Content responses with appropriate CORS headers
  • Actual request handling: CORS headers are injected into all matching responses (both buffered HttpResponse and streaming HttpResponseWriter)

Key Features

  1. Origin validation:
  2. Wildcard (*) for public APIs
  3. Exact-match allow-list (case-insensitive, zero-alloc lookup)
  4. Automatic origin mirroring when credentials are enabled or specific origins configured

  5. Credentials support:

  6. allowCredentials(true) enables Access-Control-Allow-Credentials: true and forces specific origin mirroring (never *)

  7. Method & header control:

  8. allowMethods(Method bitmask) - configures which HTTP methods are allowed for the route
  9. allowRequestHeader(name) / allowRequestHeaders({...}) - controls which custom headers clients can send
  10. exposeHeader(...) - controls which response headers are exposed to client JavaScript

  11. Preflight caching:

  12. maxAge(duration) sets Access-Control-Max-Age to reduce preflight requests

  13. Private network access:

  14. allowPrivateNetwork(true) enables Access-Control-Allow-Private-Network: true for local network requests

  15. Vary header handling:

  16. When origin is mirrored (credentials or specific origins), aeronet automatically adds Vary: Origin or appends , Origin to existing Vary header
  17. Prevents cache confusion when different origins receive different responses
  18. Works correctly for both buffered and streaming responses

Configuration API

CorsPolicy policy;
policy.allowOrigin("https://app.example.com")
      .allowOrigin("https://staging.example.com")
      .allowMethods(http::Method::GET | http::Method::POST | http::Method::PUT)
      .allowRequestHeader("Authorization")
      .allowRequestHeader("X-Custom-Header")
      .exposeHeader("X-Total-Count")
      .exposeHeader("X-Page-Size")
      .allowCredentials(true)
      .maxAge(std::chrono::hours{1});

All configuration methods return CorsPolicy& for fluent chaining.

Router Integration

Per-route policy:

CorsPolicy policy;
Router router;
router.setPath(http::Method::GET | http::Method::POST, "/api/data", 
               [](const HttpRequestView& req) { return HttpResponse(200); })
      .cors(std::move(policy));

Default policy for all routes:

CorsPolicy policy;
RouterConfig routerConfig;
routerConfig.withDefaultCorsPolicy(std::move(policy));
SingleHttpServer server(HttpServerConfig{}, routerConfig);

Route-specific override: Routes with explicit .cors(...) always take precedence over the default policy.

Behavior Details

Preflight Requests

  • Recognized when: OPTIONS method + Access-Control-Request-Method header present
  • Response: 204 No Content with:
  • Access-Control-Allow-Origin (mirrored or *)
  • Access-Control-Allow-Methods (computed from route registration)
  • Access-Control-Allow-Headers (echoed from request or from allow-list)
  • Access-Control-Max-Age (if configured)
  • Access-Control-Allow-Credentials (if enabled)
  • Vary: Origin (if origin is mirrored)

Actual Requests

  • CORS headers added to all responses (both HttpResponse and HttpResponseWriter)
  • Headers injected:
  • Access-Control-Allow-Origin
  • Access-Control-Allow-Credentials (if enabled)
  • Access-Control-Expose-Headers (if configured)
  • Vary: Origin (if origin is mirrored)

Origin Validation

  • Case-insensitive comparison
  • Empty origin header → rejected (no CORS headers)
  • Origin not in allow-list → rejected (403 Forbidden for preflight, suppressed handler for actual requests)

Precedence Rules

  1. Per-route policy (via .cors(...)) - highest priority
  2. Router default policy (via RouterConfig::withDefaultCorsPolicy(...))
  3. No CORS - no headers emitted

Performance Notes

  • Zero-allocation origin lookup (case-insensitive interned comparison)
  • Precomputed comma-joined header lists
  • Single-pass Vary header reconciliation

CORS Test Coverage

Comprehensive test coverage in tests/http_options_trace_test.cpp:

  • Preflight handling (success, method/header/origin denial)
  • Actual request CORS header injection
  • Vary header handling (both buffered and streaming)
  • Credentials + specific origins
  • Wildcard origins
  • Multiple allowed origins
  • Private network access

See also: docs/cors-helpers.md for extended design notes and implementation details.

Example: Multi-Origin API

CorsPolicy apiCors;
apiCors.allowOrigin("https://app.example.com")
       .allowOrigin("https://mobile.example.com")
       .allowMethods(http::Method::GET | http::Method::POST | http::Method::PUT | http::Method::DELETE)
       .allowRequestHeader("Authorization")
       .allowRequestHeader("Content-Type")
       .exposeHeader("X-Total-Count")
       .exposeHeader("X-RateLimit-Remaining")
       .allowCredentials(true)
       .maxAge(std::chrono::hours{24});

Router router;
router.setPath(http::Method::GET | http::Method::POST, "/api/*", 
               [](const HttpRequestView& req) { return req.makeResponse(200); })
      .cors(std::move(apiCors));

WebSocket (RFC 6455)

aeronet supports WebSocket connections via the HTTP upgrade mechanism per RFC 6455. WebSocket enables full-duplex, bidirectional communication between client and server over a single TCP connection.

Features

  • [x] HTTP/1.1 WebSocket upgrade handshake validation
  • [x] Sec-WebSocket-Key / Sec-WebSocket-Accept computation
  • [x] Text and Binary frame types
  • [x] Continuation frames for message fragmentation
  • [x] Control frames: Ping, Pong, Close
  • [x] Close handshake with status codes and reasons
  • [x] Frame masking (required for client-to-server, rejected if missing)
  • [x] SIMD payload demasking in ApplyMask (AVX2/SSE2 on x86 and NEON on ARM, scalar fallback)
  • Intent: improve throughput for large masked WebSocket frames by XORing 16-32 bytes per iteration.
  • Public API changes: none.
  • Tests: aeronet/websocket/test/websocket-frame_test.cpp (ApplyMaskLargeData, ApplyMaskLargeDataNonMultipleVectorWidth, ApplyMaskReversible).
  • [x] Configurable maximum message size
  • [x] Protocol subprotocol negotiation (Sec-WebSocket-Protocol)
  • [x] permessage-deflate compression (RFC 7692)
  • [x] Close timeout with automatic force-close

Quick Example

Register a WebSocket endpoint using Router::setWebSocket():

#include <aeronet/aeronet.hpp>
#include <iostream>
#include <span>

using namespace aeronet;
using namespace aeronet::websocket;

int main() {
  Router router;

  // Register WebSocket endpoint with factory for echo functionality
  router.setWebSocket("/ws", WebSocketEndpoint::WithFactory([](const HttpRequestView& /*req*/) {
    auto handler = std::make_unique<WebSocketHandler>();

    // Capture raw pointer before moving handler
    WebSocketHandler* handlerPtr = handler.get();

    handler->setCallbacks(websocket::WebSocketCallbacks{
        .onMessage =
            [handlerPtr](std::span<const std::byte> payload, bool isBinary) {
              // Echo back the message
              if (isBinary) {
                handlerPtr->sendBinary(payload);
              } else {
                handlerPtr->sendText({reinterpret_cast<const char*>(payload.data()), payload.size()});
              }
            },
        .onClose =
            [](CloseCode code, std::string_view reason) {
              std::cout << "Connection closed: " << static_cast<uint16_t>(code)
                        << " - " << reason << "\n";
            },
    });

    return handler;
  }));

  SingleHttpServer server(HttpServerConfig{}.withPort(8080), std::move(router));
  server.run();
}

For simpler use cases where you don't need to send messages from callbacks:

Router router;
// Simple logging endpoint (no echo, just log incoming messages)
router.setWebSocket("/log", WebSocketEndpoint::WithCallbacks(websocket::WebSocketCallbacks{
    .onMessage = [](std::span<const std::byte> payload, bool isBinary) {
      // Handle message
    },
}));

Upgrade Handshake

When a client sends a WebSocket upgrade request:

GET /ws HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

aeronet validates:

  1. HTTP method is GET
  2. Upgrade: websocket header present
  3. Connection: Upgrade header present
  4. Sec-WebSocket-Version: 13 header present
  5. Valid Sec-WebSocket-Key (24-character base64 string)

On success, the server responds with 101 Switching Protocols and transitions the connection to WebSocket mode.

Frame Types

Opcode Type Description
0x0 Continuation Continuation of a fragmented message
0x1 Text UTF-8 text message
0x2 Binary Binary message
0x8 Close Connection close request
0x9 Ping Heartbeat request
0xA Pong Heartbeat response

Close Codes

Common WebSocket close status codes:

Code Name Description
1000 Normal Normal closure
1001 GoingAway Endpoint going away (e.g., server shutdown)
1002 ProtocolError Protocol error detected
1003 UnsupportedData Received unsupported data type
1005 NoStatusReceived No status code in close frame
1006 AbnormalClosure Connection closed abnormally
1007 InvalidPayload Invalid frame payload data
1008 PolicyViolation Policy violation
1009 MessageTooBig Message too large
1011 InternalError Server encountered an error

WebSocket Configuration

WebSocket behavior can be configured via WebSocketConfig:

websocket::WebSocketConfig config;
config.maxMessageSize = 16 * 1024 * 1024;  // 16 MB max message
config.maxFrameSize = 1024 * 1024;          // 1 MB max frame
config.closeTimeout = std::chrono::seconds(5);  // 5 second close timeout

// Use config with callbacks
Router router;
router.setWebSocket("/ws", WebSocketEndpoint::WithConfigAndCallbacks(config, websocket::WebSocketCallbacks{}));

Close Timeout

When a close frame is sent, the connection enters the CloseSent state and waits for the peer's close response. If the peer doesn't respond within closeTimeout, you can force-close the connection:

websocket::WebSocketHandler handler;
if (handler.hasCloseTimedOut()) {
  handler.forceCloseOnTimeout();  // Transitions to Closed state
}

permessage-deflate Compression (RFC 7692)

aeronet supports WebSocket compression via the permessage-deflate extension per RFC 7692. This significantly reduces bandwidth for text-heavy payloads.

Enabling Compression

Compression is automatically negotiated during the WebSocket handshake when the client offers Sec-WebSocket-Extensions: permessage-deflate. Configure compression behavior via DeflateConfig:

websocket::DeflateConfig deflateConfig;
deflateConfig.serverMaxWindowBits = 15;          // Server LZ77 window size (9-15)
deflateConfig.clientMaxWindowBits = 15;          // Client LZ77 window size (9-15)
deflateConfig.serverNoContextTakeover = false;   // Reuse compression context
deflateConfig.clientNoContextTakeover = false;   // Reuse decompression context
deflateConfig.minCompressSize = 64;              // Don't compress messages < 64 bytes
deflateConfig.compressionLevel = 6;              // zlib compression level (1-9)

websocket::WebSocketConfig config;
config.deflateConfig = deflateConfig;

Router router;
router.setWebSocket("/ws", 
                    WebSocketEndpoint::WithConfigAndCallbacks(config,
                                                              websocket::WebSocketCallbacks{
                                                                .onMessage =
                                                                    [](std::span<const std::byte> payload, bool isBinary) {
                                                                      // handle message
                                                                    },
                                                                .onPing = {},
                                                                .onPong = {},
                                                                .onClose = {},
                                                                .onError = {},
                                                              }));

Negotiation Parameters

Parameter Description
server_max_window_bits Maximum LZ77 window size (log2) the server will use
client_max_window_bits Maximum LZ77 window size (log2) the client will use
server_no_context_takeover Server resets compression context after each message
client_no_context_takeover Client resets compression context after each message

When negotiation succeeds, messages are automatically compressed/decompressed transparently—callbacks receive uncompressed payloads.

Thread Safety

WebSocket handlers run on the same reactor thread as HTTP handlers. The WebSocketHandler pointer captured in callbacks is valid only during callback execution. For async operations, capture handler data (not the handler pointer) and use thread-safe mechanisms to communicate back.

HTTP/2 (RFC 9113)

aeronet provides optional HTTP/2 support implementing RFC 9113 with HPACK header compression (RFC 7541).

Feature Matrix

Feature Status Notes
HPACK header compression Static/dynamic table, Huffman encoding
Stream multiplexing Multiple concurrent streams per connection
Flow control Per-stream and connection-level
ALPN "h2" negotiation Over TLS (requires OpenSSL)
h2c (cleartext prior knowledge) Client sends HTTP/2 preface directly
h2c upgrade (HTTP/1.1 → HTTP/2) Via Upgrade: h2c header
Server push Disabled (rarely used by modern clients)
PRIORITY frames Optional, configurable
Request trailers Trailing HEADERS block (RFC 9113 §8.1) surfaced via HttpRequestView::trailers()
Response trailers Buffered and streaming responses, sent as a trailing HEADERS block

Additional notes

  • Request trailers (a trailing HEADERS block after the request body, RFC 9113 §8.1) are decoded and exposed through HttpRequestView::trailers() / trailerValueOrEmpty(), identically to HTTP/1.1 chunked trailers. Pseudo-header fields in a trailer block, and a trailing block that does not end the stream, are rejected with RST_STREAM(PROTOCOL_ERROR); trailer bytes count toward the request header-size budget. Tests: aeronet/http2/test/http2-protocol-handler_test.cpp (RequestTrailers*).
  • Static file responses created via HttpResponse::file(...) (used by StaticFileHandler) are serialized as HTTP/2 DATA frames by reading the file in bounded chunks.
  • The implementation is flow-control aware: it sends up to the available connection/stream window and continues after receiving WINDOW_UPDATE frames (no full in-memory file load).
  • Tests: see tests/http-tls-io_test.cpp (HttpRangeStatic_H2Tls.LargeFileStreaming_H2Tls).

Enabling HTTP/2

HTTP/2 support is controlled at build time via CMake:

cmake -S . -B build -DAERONET_ENABLE_HTTP2=ON
cmake --build build

When AERONET_ENABLE_HTTP2 is OFF, the aeronet/http2 module is not compiled and the HTTP/2-specific API surface (such as HttpServerConfig::withHttp2() / enableHttp2()) is not available.

When enabled, you configure HTTP/2 through Http2Config on your server:

#include <aeronet/aeronet.hpp>

using namespace aeronet;

int main() {
  Router router;

  // Unified handler for both HTTP/1.1 and HTTP/2
  // Use req.isHttp2() and req.streamId() to detect HTTP/2 if needed
  router.setDefault([](const HttpRequestView& req) {
    if (req.isHttp2()) {
      return req.makeResponse("Hello from HTTP/2! Stream " + std::to_string(req.streamId()) + "\n");
    }
    return req.makeResponse("Hello from HTTP/1.1\n");
  });

  // HTTP/2 configuration
  Http2Config http2Config;
  http2Config.enable = true;
  http2Config.maxConcurrentStreams = 100;
  http2Config.initialWindowSize = 65535;

  // Configure server with TLS, ALPN, and HTTP/2
  HttpServerConfig config;
  config.withPort(8443)
      .withTlsCertKey("server.crt", "server.key")
      .withTlsAlpnProtocols({"h2", "http/1.1"})  // Prefer HTTP/2
      .withHttp2(http2Config);

  SingleHttpServer server(std::move(config), std::move(router));
  server.run();
}

Unified Handler API

HTTP/2 requests use the same HttpRequestView type and handlers as HTTP/1.1. The framework automatically routes requests to your handlers regardless of protocol version. To detect HTTP/2 in your handler:

Router router;

// Single handler works for both HTTP/1.1 and HTTP/2
router.setDefault([](const HttpRequestView& req) {
  if (req.isHttp2()) {
    // HTTP/2-specific logic using req.streamId(), req.scheme(), etc.
    return req.makeResponse(200, "HTTP/2 stream " + std::to_string(req.streamId()) + "\n");
  }
  return req.makeResponse(200, "HTTP/1.1 response\n");
});

// Per-path handlers work identically for both protocols
router.setPath(http::Method::GET, "/api/{resource}", [](const HttpRequestView& req) {
  return req.makeResponse(200, "Resource: " + std::string(req.pathParams().at("resource")) + "\n");
});

HttpServerConfig config;
config.withPort(8080)
    .withHttp2(Http2Config{.enable = true, .enableH2c = true});

SingleHttpServer server(std::move(config), std::move(router));
server.run();

Notes:

  • Pattern syntax, trailing-slash policy, and HEAD→GET fallback apply identically to both protocols.
  • AsyncRequestHandler (including co_await req.deferWork(...)) is fully supported for HTTP/2 with true per-stream async execution - suspended coroutines do not block other streams on the connection.
  • StreamingHandler is fully supported for HTTP/2, including flow-control-aware DATA framing and compression.
  • All handler types registered in the Router work transparently for both HTTP/1.1 and HTTP/2.

Http2Config

The Http2Config structure provides comprehensive HTTP/2 tuning.

ALPN Protocol Negotiation (h2)

For TLS connections, HTTP/2 is negotiated via ALPN (Application-Layer Protocol Negotiation):

HttpServerConfig config;
config.withTlsCertKey("server.crt", "server.key")
    .withTlsAlpnProtocols({"h2", "http/1.1"});  // Server advertises both

// After TLS handshake, if client selected "h2":
// - Connection automatically switches to HTTP/2 protocol handler
// - All subsequent frames use HTTP/2 binary framing

The server automatically detects the negotiated protocol and routes the connection to the appropriate handler.

Cleartext HTTP/2 (h2c)

HTTP/2 over cleartext (without TLS) is supported via two mechanisms.

Prior Knowledge

Client sends the HTTP/2 connection preface (PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n) directly:

Http2Config config;
config.enableH2c = true;  // Accept direct HTTP/2 preface on plaintext

HTTP/1.1 Upgrade

Client sends an HTTP/1.1 request with upgrade headers:

GET / HTTP/1.1
Host: localhost
Connection: Upgrade, HTTP2-Settings
Upgrade: h2c
HTTP2-Settings: AAMAAABkAAQBAAAAAAIAAAAA

The server responds with 101 Switching Protocols and transitions to HTTP/2:

Http2Config config;
config.enableH2cUpgrade = true;  // Enable Upgrade mechanism

Testing HTTP/2

Test with curl:

# HTTPS with ALPN negotiation
curl -k --http2 https://localhost:8443/hello

# h2c (cleartext) with prior knowledge
curl --http2-prior-knowledge http://localhost:8080/hello

# h2c via upgrade
curl --http2 http://localhost:8080/hello

Network Fault Injection Testing

Test infrastructure for verifying server behavior under realistic network conditions. Compile-time gated via AERONET_ENABLE_TEST_HOOKS (automatically set when AERONET_BUILD_TESTS=ON).

Components

  • FaultPolicy (aeronet/test_support/basic/include/aeronet/fault-policy.hpp): Configuration struct for deterministic fault injection. Controls partial reads/writes (maxBytesPerRead, maxBytesPerWrite), periodic EAGAIN simulation (eagainAfterEveryNReads/Writes), connection reset thresholds (resetAfterTotalBytesRead/Written), one-shot errors (resetOnNextRead/Write), and optional PRNG seed for randomized partial sizes.

  • TestPipe (aeronet/test_support/basic/include/aeronet/test-pipe.hpp): Bidirectional in-memory byte channel for event-loop-free unit testing. Each side has an independent byte buffer; reads consume from one direction, writes append to the other.

  • TestTransport (aeronet/test_support/basic/include/aeronet/test-transport.hpp): In-memory ITransport implementation wrapping a TestPipe with FaultPolicy applied. Used for unit-level protocol testing without sockets or event loops.

  • FaultInjectingTransport (aeronet/test_support/basic/include/aeronet/fault-injecting-transport.hpp): Decorator that wraps a real ITransport (e.g., PlainTransport on a live socket) and applies FaultPolicy to all I/O. Used for integration tests with real sockets and the full event loop.

  • Transport test hook (aeronet/server/include/aeronet/transport-test-hook.hpp): Global atomic function pointer (g_transportDecorator) and RAII guard (ScopedTransportDecorator). When set, the server decorates each newly accepted transport at accept time. Guarded by #ifdef AERONET_ENABLE_TEST_HOOKS — zero overhead in production.

Test coverage

  • Unit tests (aeronet/sys/test/test-transport_test.cpp): 26 tests covering TestPipe and TestTransport with various fault policies.
  • Integration tests (tests/network-fault-injection_test.cpp): 11 tests exercising partial reads, partial writes, EAGAIN simulation, connection resets, combined faults, and HTTP pipelining under faults — all with real sockets and the full server event loop.

Limitations

  • Read-EAGAIN with EPOLLET: Simulated EAGAIN on reads (eagainAfterEveryNReads) is incompatible with edge-triggered epoll in integration tests. When the transport returns {0, ReadReady}, the server waits for a new EPOLLIN edge, but the socket already has data so no edge fires. EAGAIN simulation works correctly in unit tests (no event loop) and for writes (EPOLLOUT re-triggers because the socket is always writable).

JWT (RFC 7519 — JWS profile)

Compile-time module aeronet_jwt providing JSON Web Token signing and verification. It implements the JWS (signature) profile of JWT — JWE (encryption) is intentionally out of scope. The module reuses the OpenSSL crypto already linked for TLS and the glaze JSON parser, so it adds no new dependency: rather than a standalone opt-in flag it is enabled by default whenever both prerequisites are present (AERONET_ENABLE_JWT, a cmake_dependent_option on AERONET_ENABLE_OPENSSL + AERONET_ENABLE_GLAZE, with a kill switch to force it off).

Public headers: <aeronet/jwt.hpp> (encode/decode + JwtVerifyOptions + DecodedJwt), <aeronet/jwt-key.hpp> (JwtKey), <aeronet/jwks.hpp> (Jwks), <aeronet/jwt-algorithm.hpp>, <aeronet/jwt-error.hpp>.

Algorithms (RFC 7518 / RFC 8037)

Family Algorithms Key
HMAC HS256 HS384 HS512 JwtKey::Hmac(secret)
RSA PKCS#1 v1.5 RS256 RS384 RS512 JwtKey::FromPem(pem) (RSA)
RSA-PSS PS256 PS384 PS512 JwtKey::FromPem(pem) (RSA)
ECDSA ES256 ES384 ES512 JwtKey::FromPem(pem) (EC P-256/384/521)
EdDSA EdDSA JwtKey::FromPem(pem) (Ed25519)

A private-key PEM both signs and verifies; a public-key PEM verifies only. Keys can also be loaded from a JWK (RFC 7517): oct, RSA, EC, and OKP (Ed25519) public keys via JwtKey::FromJwk.

Security posture

  • The unsecured alg: none (RFC 7518 §3.6) is always rejected at parse time — a stripped signature can never verify.
  • Algorithm-substitution defense: JwtVerifyOptions::allowedAlgorithms pins the accepted set, and a key is refused outright when its family does not match the token alg (an HMAC key cannot verify an RS256 token → JwtError::KeyMismatch), so the classic RS256↔HS256 confusion is structurally impossible.
  • The signature is verified before any claim is parsed or trusted.
  • A crit header naming an extension aeronet does not implement is rejected (RFC 7515 §4.1.11).
  • HMAC comparison is constant-time (CRYPTO_memcmp).

Error model

The module is exception-free: failures are reported through return values, never thrown. The JwtKey factories return an invalid key (valid() == false) on bad input, Jwt::encode returns an empty string on failure, and Jwt::tryDecode returns an empty DecodedJwt (valid() == false) with a JwtError reason. Every failure is also logged.

Claim validation

Jwt::tryDecode (returns a DecodedJwt — empty / valid() == false on failure — plus a JwtError) validates the registered claims per JwtVerifyOptions: exp / nbf with a configurable leeway and injectable clock, optional requireExpiration, and iss / aud / sub matching. aud is accepted both as a string and as an array. DecodedJwt exposes typed accessors (issuer(), subject(), expiresAt(), audiences(), …) and the verbatim claim JSON via payloadJson() for application-specific deserialization. To keep the type small, absence is encoded by the natural empty state — an empty string_view for string claims, a 0 NumericDate for exp / nbf / iat — rather than wrapping every field in std::optional.

#include <aeronet/jwt.hpp>
#include <string>
#include <string_view>

// Sign with an HMAC secret (HS256).
JwtKey key = JwtKey::Hmac("super-secret-signing-key");
std::string token = Jwt::encode(R"({"sub":"alice","exp":4102444800})", key, JwtAlgorithm::HS256);

// Verify: signature + standard claim checks (here only allow HS256, and check expiry).
JwtVerifyOptions options;
options.allowedAlgorithms = JwtAlgorithmSet{JwtAlgorithm::HS256};
JwtError err = JwtError::None;
if (DecodedJwt decoded = Jwt::tryDecode(token, key, options, err)) {
  std::string_view subject = decoded.subject();  // "alice" (empty view == claim absent)
  (void)subject;
}

JWKS

Jwks(json) (a constructor — an unparseable document just leaves the set empty()) reads a {"keys":[...]} set (RFC 7517 §5), skipping unsupported keys, and Jwks::tryDecode(token, options, err) selects the verifying key by the token kid header (or the sole key when the set has exactly one and the token carries no kid). This pairs naturally with the HTTP client to fetch and cache an issuer's keys, but parsing is transport-agnostic.

Tests: aeronet/jwt/test/ (jwt-roundtrip_test.cpp covers every algorithm; jwt-claims_test.cpp the validations; jwt-decode-errors_test.cpp the rejection paths including alg:none, crit, and algorithm/key mismatch; jwt-jwk_test.cpp the JWK/JWKS paths) and base64url in aeronet/tech/test/base64url_test.cpp.

Future Expansions

Planned / potential: richer logging & metrics, additional OpenTelemetry instrumentation (histograms, gauges).

  • [ ] Additional OpenTelemetry instrumentation (histograms, gauges)

Glaze Integration (Optional)

When built with AERONET_ENABLE_GLAZE=ON, aeronet integrates with Glaze for high-performance JSON/YAML serialization.

Configuration Loading

Construct a server directly from a JSON or YAML configuration file:

#include "aeronet/single-http-server.hpp"
#include "aeronet/multi-http-server.hpp"

auto handler = [](const HttpRequestView&) { return HttpResponse(200); };

// Single-threaded server from config file (format auto-detected from extension)
SingleHttpServer server("/etc/aeronet/config.yaml");
server.router().setPath(http::Method::GET, "/hello", handler);
server.run();

// Single-threaded server from config file while keeping pre-registered routes from a Router
Router preconfigured;
preconfigured.setPath(http::Method::GET, "/health", handler);
SingleHttpServer serverWithRouter("/etc/aeronet/config.yaml", std::move(preconfigured));
serverWithRouter.run();

// Multi-threaded server from config file
MultiHttpServer multi("/etc/aeronet/config.json");
multi.router().setDefault(handler);
multi.run();

// Multi-threaded server from config file with a pre-configured router
Router multiRouter;
multiRouter.setPath(http::Method::GET, "/health", handler);
MultiHttpServer multiWithRouter("/etc/aeronet/config.json", std::move(multiRouter));
multiWithRouter.run();

For advanced use (e.g. loading only HttpServerConfig):

#include "aeronet/config-loader.hpp"

auto serverCfg = LoadServerConfig("/etc/aeronet/server.json");
auto serverCfg2 = LoadServerConfig(R"({"server":{"port":8080}})", ConfigFormat::json);

Configuration Dumping

Serialize a running server's configuration (including router settings) back to JSON or YAML:

SingleHttpServer server("/etc/aeronet/config.yaml");

// Dump as JSON string
std::string json = server.dumpConfig(ConfigFormat::json);
std::string yaml = server.dumpConfig(ConfigFormat::yaml);

// Save directly to a file (format auto-detected from extension)
server.saveConfig("/etc/aeronet/config-backup.yaml");

Response Body Serialization

Serialize any Glaze-compatible object directly into the response body, avoiding intermediate copies.

Opt-in header. The bodyJson / bodyYaml (and request-side bodyAs / bodyAsYaml) helpers pull in the Glaze dependency, which is expensive to compile. To keep that cost out of the widely-included core headers, their definitions live in <aeronet/http-json.hpp>. Include it wherever you serialize/parse JSON or YAML (the <aeronet/aeronet.hpp> umbrella already does). Tests: config-loader_test.cpp (response side), http-request-view_test.cpp (request side bodyAs/bodyAsYaml).

#include <unordered_map>
#include <string>

#include "aeronet/http-request-view.hpp"
#include "aeronet/http-response.hpp"
#include "aeronet/http-json.hpp"

using MyPayload = std::unordered_map<std::string, std::string>;

int main() {
  MyPayload payload{ {"id", "1"}, {"name", "example"} };

  HttpResponse resp;
  resp.bodyJson(payload);   // Content-Type: application/json
  resp.bodyYaml(payload);   // Content-Type: text/yaml

  // Builder-style chaining (inside a coroutine handler):
  // co_return HttpResponse(200).bodyJson(payload);
}

Request Body Deserialization

Parse incoming request bodies into typed C++ objects (also via the opt-in <aeronet/http-json.hpp> header):

#include "aeronet/http-json.hpp"
#include <unordered_map>

using MyPayload = std::unordered_map<std::string, std::string>;

auto handler = [](const HttpRequestView& req) {
  auto jsonResult = req.bodyAs<MyPayload>();       // parse JSON body

  // YAML variant:
  auto yamlResult = req.bodyAsYaml<MyPayload>();

  if (!jsonResult) return std::move(jsonResult.error());  // 400 Bad Request with parse error details
  // use jsonResult.value()
  return HttpResponse(200).bodyJson(*jsonResult);
};

Human-Readable Duration Serialization

Duration fields in configuration are serialized as human-readable strings (e.g. "3s", "500ms", "1h30m") instead of raw integers, ensuring symmetric read/write round-trips. Tests: config-loader_test.cpp (DurationHumanReadableRoundTrip, DurationSecondsHumanReadableRoundTrip, DurationZeroRoundTrip, DurationSubSecondMillisecondsRoundTrip, DurationHumanReadableYamlRoundTrip).

Router Configuration Validation

RouterConfig::validate() is called automatically after parsing a config file, alongside HttpServerConfig::validate(). It currently validates the TrailingSlashPolicy enum range. Tests: config-loader_test.cpp (RouterConfigValidateDefaultIsOk, RouterConfigValidateAllPoliciesOk).