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¶
- HTTP/1.1 Feature Matrix
- Performance / architecture
- Compression & Negotiation
- Inbound Request Decompression (Config Details)
- Chunked Transfer Encoding (RFC 7230 §4.1)
- Connection Close Semantics - includes graceful drain lifecycle
- Reserved & Managed Response Headers
- Request Header Duplicate Handling (Detailed)
- Path Handling
- Middleware Pipeline
- Trailing Slash Policy
- Construction Model (RAII & Ephemeral Ports)
- HttpServer Lifecycle
- Built-in Kubernetes-style probes
- TLS Features
- CONNECT (HTTP tunneling)
- Streaming Responses
- Static File Handler (RFC 7233 / RFC 7232)
- Mixed Mode Dispatch Precedence
- Logging
- OpenTelemetry Integration
- Access-Control (CORS) Helpers
- WebSocket (RFC 6455)
- HTTP/2 (RFC 9113)
- JWT (RFC 7519 — JWS profile)
- Future Expansions
- Large-body optimization
- 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,GeTare 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
HEADERSblock, RFC 9113 §8.1), surfaced uniformly viaHttpRequestView::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: 0for 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=0and 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
Expecttoken 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/httpso it can be used for debugging loopback-style probes as per RFC 7231 §4.3.2. - The server exposes a
TraceMethodPolicyinHttpServerConfigwith 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]
Expectheader processing with opt-in application-level expectation handler
Behavior summary
- The server preserves the standard
100-continuesemantics: when a client sendsExpect: 100-continuethe server will emit100 Continueif the request proceeds to body reading. Detection recognizes100-continueeven when it appears in a comma-separatedExpectheader list and tolerates surrounding whitespace per the RFC. - For expectation tokens other than
100-continueaeronet exposes an opt-inExpectationHandlerAPI (seeSingleHttpServer::setExpectationHandlerinhttp-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-continueexpectation 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
Interimresult itsinterimStatusMUST 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.cppfor example usages and behavior expectations (including mixedExpectlists containing100-continueand 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.1request 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 replies200 Connection Establishedand 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> 65535is rejected with400 Bad Requestbefore any name resolution (service names such ashost:httpsare not accepted). Tests: http-connect_test.cpp (NonNumericConnectPortReturns400,OutOfRangeConnectPortReturns400,EmptyConnectPortReturns400). - The server uses a small
ConnectResulthelper to capture whether the upstream connection completed immediately or is still pending (EINPROGRESS) on a non-blocking socket. Pending connects are tracked using aconnectPendingflag on the upstreamConnectionState; when the event loop notifies writable readiness we checkSO_ERRORto determine whether the connect completed successfully or failed and, on failure, attempt to notify the client with502. - For tunneling we record
peerFdon each side (client and upstream). A connection is considered in tunneling mode whenpeerFd != -1(exposed viaConnectionState::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 thewithConnectAllowlist()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
_connStatesmap 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
tunnelOutBufferto 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
:authoritypseudo-header (must behost:portwith a numeric port; a non-numeric or out-of-range port is rejected with400), checks the connect allowlist, and delegates TCP connection setup to the server's event loop via aTunnelCallbacksinterface. - On success, a
200response is sent on the stream withoutEND_STREAM. SubsequentDATAframes on the stream carry tunneled bytes bidirectionally. - Client → upstream:
DATAframes 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 asDATAframes on the tunnel stream. - Cleanup: when either side sends
END_STREAMorRST_STREAM, the tunnel is torn down. If an async connect fails,RST_STREAMwithCONNECT_ERRORis 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 (
HttpServerakaMultiHttpServer) (forcesreusePort=truefor >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_NODELAYis active. ClearingTCP_CORKflushes accumulated data immediately. No-op on macOS (TCP_NOPUSH does not flush on clear, andwritevalready coalesces) and Windows. Enabled per-connection whenTCP_NODELAYis set. SeeSetTcpCork()insocket-ops.hppandTcpCorkGuardRAII helper. Tests insocket-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 to0for unlimited. Builder:withMaxAcceptBatchSize(uint32_t). - [x] Adaptive event-loop poll timeout (
HttpServerConfig::pollIntervalMinFactor/pollIntervalMaxFactor, default1.0F/1.0F). Bounds are derived frompollInterval; 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.cppandaeronet/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
HttpRequestViewobject is populated withstd::string_viewinstances 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_viewmembers ofHttpRequestView(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:
- The single-threaded event loop per server instance eliminates concurrency concerns
- Synchronous handler execution ensures the buffer cannot be modified during handler execution
- The per-connection buffer design provides clear ownership boundaries
- 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 viapostConfigUpdate(). Seedocs/CONFIG_MUTABILITY.mdfor 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 asconst char*orstd::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
HttpResponseAPI is affected (streaming responses always write from user buffers). The streamingHttpResponseWriteronly partially supports this optimization internally. - When a handler returns an
HttpResponsewith 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-onlyunique_ptrsemantics).
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-Encodingwith 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-Encodingprevents auto compression. - Adds
Vary: Accept-Encodingautomatically (configurable) when compression applied. - If identity is explicitly disallowed and no supported encoding is acceptable, a
406 Not Acceptableresponse is returned. encodeChunk()writes up to the provided output capacity and returns:-1on error0on success with no output produced>0number of bytes writtenmaxCompressedBytes()provides an upper bound for sizing output buffers during chunk encoding.end()may require multiple invocations; call repeatedly until it returns0(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:
- Response was created via
HttpRequestView::makeResponse()(providesAccept-Encodingnegotiation context) DirectCompressionModeis notOff- No user-supplied
Content-Encodingheader is present - Body is set as inline data (not captured by value, not a file)
- In
Automode: body size ≥CompressionConfig::minBytesand content-type matchescontentTypeAllowList - In
Onmode: 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-EncodingandVary: Accept-Encodingheaders 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-Encodingis 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 existingContent-Encodingheader and skips, preventing double compression. - The
bodyInlineAppend()template works with direct compression: it writes through the active encoder viaappendEncodedInlineOrThrow().
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-Encodingor (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 ofContent-Encodingmeans no automatic compression layer is injected. Vary: Accept-Encodingis ONLY auto-added when aeronet itself performs outbound compression. Supplying your ownContent-Encodingdoes not implicitly addVary(you can add it manually if appropriate for caches).- Supplying
Content-Encodingdoes 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
zlibcompression - the classiczliblibrary and the newerzlib-ngfork. The choice is controlled by theAERONET_ENABLE_ZLIBNGbuild flag, which isONby default, as in general casezlib-ngis faster thanzlib. - 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::preferredFormatsis:gzip, deflateif only zlib enabled;zstd, gzip, deflateif zstd also enabled;br, zstd, gzip, deflateif brotli enabled (brotli first due to typical superior ratio). - Negotiation: Parses
Accept-Encodingwith q-values; chooses format with highest q (server preference breaks ties). Falls back to identity if none acceptable. - Server preference nuance: Order in
preferredFormatsonly 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:
minBytesdelays compression until buffered bytes reach threshold (streaming buffers until decision). Fixed responses decide immediately. - Streaming integration: Headers withheld until compression activation decision so
Content-Encodingis always accurate. - Per-response opt-out: user-supplied
Content-Encoding(e.g.identity) disables automatic compression. Vary: Accept-Encodingautomatically 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.9chooses deflate). - Explicit identity rejection: If
identity;q=0and 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:
- Chunk size line: hex size (without 0x prefix), optional chunk extensions (after semicolon), CRLF
- Chunk data:
sizebytes of actual data - Chunk ending: CRLF
- Zero chunk:
0followed by optional trailer headers - 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:
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):
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\nterminator - 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:
- Parse chunk size: Read hex digits until CRLF or semicolon (chunk extension marker)
- Handle chunk extensions: If semicolon found, skip to CRLF (extensions ignored)
- Read chunk data: Copy
sizebytes into body buffer - Consume chunk CRLF: Validate and skip the trailing CRLF
- Repeat until zero-size chunk encountered
- Parse trailers: After
0\r\n, parse optional trailer headers - 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
maxBodyBytes→ 413 Payload Too Large - Malformed trailers (no colon, forbidden headers) → 400 Bad Request
- Trailer section exceeds
maxHeaderBytes→ 431 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 CONNECTtunneling 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
bodyAndTrailersBufferas chunks are decoded; trailer text is appended after the final chunk withtrailerLenmarking the length of the trailer data at the end of the buffer - Zero-copy trailers: Trailer name/value pairs are stored as
string_viewreferences intobodyAndTrailersBuffer, 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
maxHeaderBytesto 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:
- Buffered responses (
HttpResponse): Trailers added viatrailerAddLine()after body is set - 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-Lengthheader is replaced withTransfer-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()orbodyOwned()) - 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\nfor 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 toDrainThenClose(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: closeor exhaustingmaxRequestsPerConnectionnaturally transitions toDrainThenClose. - 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 injectsConnection: closeso the client does not reuse the socket. WhenmaxWaitis non-zero, a deadline is armed; any connections still open when it expires are closed immediately. CallingbeginDrain()again with a shorter timeout shrinks the deadline.isDraining()reflects whether the server is currently in the draining state.isRunning()still reportstrueuntil the drain completes or a stop occurs.- Wrappers -
HttpServer::beginDrain()/isDraining()forward to the underlyingSingleHttpServerinstances, 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
Idleand the server can be started again with anotherrun(). stop()remains the immediate shutdown primitive; it transitions toStopping, 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()andstartDetachedWithStopToken().
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()installsSIGINTandSIGTERMhandlers that set a globalsig_atomic_tflag, visible to allSingleHttpServerinstances. - Automatic drain: Each server's event loop checks
SignalHandler::IsStopRequested()at the end of every iteration (after lifecycle state checks) and callsbeginDrain(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 callbeginDrain()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
Stoppingwhere 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: closeso clients do not reuse the socket. - When
maxWait > 0a deadline is armed; any remaining idle connections are forcibly closed when the deadline expires.
-
Observability & lifecycle:
isDraining()becomes true afterbeginDrain()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 afterstop()). -
Blocking vs non‑blocking:
-
Both
stop()andbeginDrain()are non‑blocking control requests in the current API. If consumers want synchronous semantics they must explicitly wait (e.g. monitorisDraining()/isRunning()or join the thread that runs the server). -
Typical usage patterns:
- Graceful shutdown (recommended when you can wait or use a supervisor):
- Remove instance from load balancer.
- Call
beginDrain(maxWait)to allow in‑flight requests to finish and bound the wait. - 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.
- Call
-
Wrapper behavior:
HttpServer::beginDrain()forward to their underlyingSingleHttpServerinstances 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:
- The first occurrence of each header stores
nameandvalueasstd::string_viewslices into the connection read buffer (no copy). - 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). - Override simply rebinds the existing
valueview to point at the newest occurrence (no buffer mutation). - Disallowed duplicates short‑circuit parsing and return
400 Bad Requestimmediately.
Security / robustness notes:
- Disallowing duplicate
content-lengthandhostprevents 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:
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::addRequestMiddlewareandRouter::addResponseMiddleware(or the convenienceSingleHttpServer::add*wrappers) to install request/response middleware that runs for every request. - Per-route hooks –
PathHandlerEntry::before(RequestMiddleware)and::after(ResponseMiddleware)scope middleware to a specific path registration. - Execution order –
global 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¶
HttpResponseWriterdriven 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 ofHttpResponse. - Registration: Use
Router::setPathjust 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
deferWorkmatches the return type of the work function. - Sequential composition: You can chain multiple
deferWorkcalls:
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
deferWorkcall spawns a newstd::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::movefor 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,Tmust beHttpResponse. - 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 aMiddlewareMetricsrecord for every middleware invocation. The record captures whether the middleware belongs to the global or per-route chain, the execution phase (PreorPost), 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
streamingflag is set when the active route usesHttpResponseWriter. 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:
RedisSlidingWindowRateLimitStoredoes not own a Redis client.- You provide an
EvalCallbackconsumingRedisEvalRequestand producingRedisEvalResponse. - Script contract shape:
KEYS[1]: rate limit keyARGV[1]: now_msARGV[2]: window_msARGV[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.
Related Tests¶
- See
tests/http-routing_test.cppfor 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:
- 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
/fooand/foo/were registered, they remain distinct only under theStrictpolicy. UnderNormalizeandRedirectthe 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. - If no exact match:
- 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
/fooand/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 Permanentlyto 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.
- 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) - Otherwise: 404
- 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 numericid/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::regexfor 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}}/datamatches the literal path/api/{version}/data/glob/{{{{}}/file.txtmatches 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/bwins over/a/*for/a/b). - Asterisks can be part of a parameter fragment (e.g.
/files/{name*}matches/files/report2024withname*=report2024).
Examples:
/assets/*matches/assets/images/logo.pngwith wildcard captureimages/logo.png/path/seg*/*matches/path/seg*/somethingbut not/path/segment/extra(second segment is literalseg*)/path/*/*matches/path/*/somethingbut 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
SingleHttpServerdispatches to a handler, it copies routing captures into theHttpRequestViewobject. Within your handler you can access them viareq.pathParams()which returns aflat_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)¶
SingleHttpServersupports copy construction and copy assignment while the source instance is fully stopped. Copy assignment automatically callsstop()on the destination before duplicating sockets and router state; attempting to copy from a running instance throwsstd::logic_errorto avoid duplicating active event loops. When copying bound sockets, ensure the original server either relinquishes the port (callstop()or destroy the instance) or hasreusePort=trueso the new copy can bind successfully.MultiHttpServermirrors the same rule: copies are only allowed from a stopped source. Copy construction and assignment rebuild freshSingleHttpServerinstances carrying the same port, router, and thread count while wiring them to a new lifecycle tracker. Running sources throwstd::logic_errorto prevent duplicating active thread pools.- Tests: see
tests/http-server-lifecycle_test.cpp(HttpServerCopy.*) andtests/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=truerequired forthreadCount > 1.- Movable even while running (vector storage stable).
- Graceful drain propagates:
beginDrain(maxWait)stops all accept loops, existing keep-alive connections receiveConnection: close, andisDraining()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 = falsecreates the listening socket without that reuse option.-
reusePort = truerequests the kernel-level reuse option (SO_REUSEPORTon Linux 3.9+ and macOS 12+,SO_REUSEADDRon 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 byMultiHttpServerwill 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 theMultiHttpServerwill 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)orenableBuiltinProbes(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 returnsConnection: closeto 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 byBuiltinProbesConfig::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.MultiHttpServeronly.BuiltinProbesConfig::withLivenessStaleThreshold(duration, default10s): 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):200while any worker is accepting traffic,503once all workers are draining. - Startup (
/startupz):200once 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 reported200unless every worker's heartbeat has been stale for longer thanlivenessStaleThreshold(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 topollInterval * pollIntervalMaxFactorstale — so keeplivenessStaleThresholdcomfortably above both your longest legitimate handler runtime and the worker poll interval (the default10ssits well above the default500mspollInterval). 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.cppwhich validates startup/readiness transitions and drain-time behavior. Tests also cover collision detection for probe paths. - The dedicated probe listener is covered by the
MultiHttpServerDedicatedProbessuite 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 reporting503during 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_OPENSSLto 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
Hostheader; any port in it is replaced by the configuredtargetPort. The standard HTTPS port443is 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
Locationvalue, 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
Hostheader (no absolute URL can be built) receives400 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:
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¶
- Ensure the project was compiled with both
AERONET_ENABLE_OPENSSL=ONand uses a modern OpenSSL version (>= 3.0). - Provide TLS credentials - either files or in-memory PEM strings - exactly as for any HTTPS deployment.
- 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);
- Optionally consult
server.stats()to monitorktlsSendEnabledConnections,ktlsSendEnableFallbacks, andktlsSendBytesduring 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_shutdownprior to socket close (best-effort, non-blocking).
Testing guidance:
- Use
withTlsCertKeyMemorywith 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: chunkedunlesscontentLength()was called before any body writes. write()queues data; returnsfalseonly 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 terminating0\r\n\r\nin 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(...)andHttpResponseWriter::file(...)accept anaeronet::Filedescriptor and stream its contents withsendfile(2)(Linux-only) on plaintext sockets. When TLS is active, aeronet reuses the connection's tunnel buffer and feeds encrypted writes viapread+SSL_write, so no additional heap allocations are introduced beyond that shared buffer. fileautomatically wiresContent-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
maxOutboundBufferBytesmarks connection to close after pending data flush; additional queued chunks are rejected immediately so subsequentwrite()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]
StaticFileHandlerserves directory trees with zero-copyfile - [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 kernelsendfile(2)path. TLS endpoints automatically fall back to the buffered write path that aeronet already uses for file responses. - Directory listings: when
StaticFileConfig::enableDirectoryIndexis 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 obeymaxEntriesToListand advertise truncation viax-directory-listing-truncated: 1. - Single-range support:
Range: bytes=N-M(RFC 7233 §2.1) is parsed with strict validation. Valid ranges return206 Partial ContentwithContent-Range. Invalid syntax returns416withContent-Range: bytes */<size>per the spec. - Multi-range support (
multipart/byteranges, RFC 7233 §4.1): comma-separated byte ranges such asRange: bytes=0-99,200-299,500-are fully supported. The response uses status206 Partial ContentwithContent-Type: multipart/byteranges; boundary=<token>and each MIME part carries its ownContent-TypeandContent-Rangeheader. 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-Rangeresponse (no multipart overhead). - Unsatisfiable sub-ranges are silently dropped; a
416is returned only when all sub-ranges are unsatisfiable (RFC 7233 §4.4). If-Rangeinteraction: 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, andIf-Rangeare honoured using strong validators. Requests that do not modify the resource return304 Not Modifiedfor GET/HEAD or412 Precondition Failedfor unsafe methods.If-Rangetransparently falls back to the full body when the validator mismatches. - Headers: the handler always emits
Accept-Ranges: bytesso clients learn range capability.ETagandLast-Modifiedare 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-Modifiedand the resolvedContent-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 singlefstat()(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 toStaticFileConfig::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. SetheaderCacheCapacityto 0 (or usewithHeaderCacheCapacity(0)) to disable the cache. AcontentTypeResolvercallback, 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-Typeheader 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 inHttpServerConfig(if non-empty), and (3) the hard fallbackapplication/octet-stream. The library exposesFile::detectedContentType()which performs filename-extension based detection using the bundled extension → mime table (the table was extended to include common C/C++ extensions such asc,h,cpp,hpp,cc). Applications with different heuristics (case-insensitive lookup, longest-suffix matching liketar.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.
Mixed Mode & Dispatch Precedence¶
Registration supports simultaneous fixed and streaming handlers at global and per-path scope. Precedence order:
- Path-specific streaming handler
- Path-specific fixed handler
- Global streaming handler
- 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::accessLogand are independent from optional spdlog. - Supported formats:
clf(common log format style) andjson. - Supported sinks:
none,stdout,file. - Optional client IP behavior:
useForwardedFor=trueusesX-Forwarded-Forwhen 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):
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.requestspans for each HTTP requesthttp.middlewarespans 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 (seetests/http-routing_test.cppfor 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 processedaeronet.connections.accepted– new connectionsaeronet.bytes.read– bytes read from clientsaeronet.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:
Alpine Linux:
Fedora/RHEL:
Arch Linux:
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 viaRouterConfig::withDefaultCorsPolicy(policy) - Automatic preflight: OPTIONS requests with
Access-Control-Request-Methodheader 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
HttpResponseand streamingHttpResponseWriter)
Key Features¶
- Origin validation:
- Wildcard (
*) for public APIs - Exact-match allow-list (case-insensitive, zero-alloc lookup)
-
Automatic origin mirroring when credentials are enabled or specific origins configured
-
Credentials support:
-
allowCredentials(true)enablesAccess-Control-Allow-Credentials: trueand forces specific origin mirroring (never*) -
Method & header control:
allowMethods(Method bitmask)- configures which HTTP methods are allowed for the routeallowRequestHeader(name)/allowRequestHeaders({...})- controls which custom headers clients can send-
exposeHeader(...)- controls which response headers are exposed to client JavaScript -
Preflight caching:
-
maxAge(duration)setsAccess-Control-Max-Ageto reduce preflight requests -
Private network access:
-
allowPrivateNetwork(true)enablesAccess-Control-Allow-Private-Network: truefor local network requests -
Vary header handling:
- When origin is mirrored (credentials or specific origins), aeronet automatically adds
Vary: Originor appends, Originto existingVaryheader - Prevents cache confusion when different origins receive different responses
- 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:
OPTIONSmethod +Access-Control-Request-Methodheader present - Response:
204 No Contentwith: 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
HttpResponseandHttpResponseWriter) - Headers injected:
Access-Control-Allow-OriginAccess-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¶
- Per-route policy (via
.cors(...)) - highest priority - Router default policy (via
RouterConfig::withDefaultCorsPolicy(...)) - 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:
- HTTP method is GET
Upgrade: websocketheader presentConnection: Upgradeheader presentSec-WebSocket-Version: 13header present- 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
HEADERSblock after the request body, RFC 9113 §8.1) are decoded and exposed throughHttpRequestView::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 withRST_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 byStaticFileHandler) 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_UPDATEframes (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:
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(includingco_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.StreamingHandleris 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:
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:
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-memoryITransportimplementation wrapping aTestPipewithFaultPolicyapplied. 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 realITransport(e.g.,PlainTransporton a live socket) and appliesFaultPolicyto 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 coveringTestPipeandTestTransportwith 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::allowedAlgorithmspins the accepted set, and a key is refused outright when its family does not match the tokenalg(an HMAC key cannot verify anRS256token →JwtError::KeyMismatch), so the classic RS256↔HS256 confusion is structurally impossible. - The signature is verified before any claim is parsed or trusted.
- A
critheader 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-sidebodyAs/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 sidebodyAs/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).