Experimental HTTP/3 server framework written in Rust on top of the QUIC implementation provided by quiche.
The goal of this crate is to provide a lightweight abstraction layer over QUIC and HTTP/3 in order to simplify the implementation of networking applications while keeping control over the underlying transport behavior.
FacesHttp3Server was developed in the context of a broader experimental application project exploring distributed multimedia systems and modern networking architectures.
The server framework was progressively designed while building the backend of this application. This allowed the framework to evolve through real usage scenarios and to be continuously tested by the application itself.
This approach allowed architectural decisions to emerge from concrete needs encountered during development.
- HTTP/3 server built on top of QUIC (quiche)
- event-driven network architecture
- modular routing system
- middleware processing pipeline
- configurable request body management
- channel-based communication between subsystems
- application state injection via a generic
AppState
The framework focuses on separating networking infrastructure from application logic.
Use try_run_blocking(router) to receive a Result<(), ServerRunError>. It builds
configuration fallibly, starts the actual network worker with thread::Builder,
and joins it. Startup failures, fatal network errors, an unexpected normal return
and an unwinding panic are reported to the caller. Applications must propagate
that error to their own exit decision. The library does not terminate the process.
TLS/QUIC/HTTP3, entropy, the socket and its poller/waker are prepared before the
transport processing workers start. network_ready is logged afterwards; an
actual HTTP3 response remains necessary to verify service availability. PEM loading
is not a complete certificate/deployment qualification.
The existing build() and run_blocking() signatures remain available as panic
facades for compatibility. Their panic occurs on the caller and can be caught by
an external consumer. try_build() and try_run_blocking() are the fallible paths.
The detached run() API is not supervised by this change. Secondary workers,
graceful shutdown, restart policy and detection of a live but stuck worker remain
outside this guarantee. panic=abort terminates the process before join can return
a typed panic error.
The startup subprocess tests inject errors only under cfg(test), inside the real
network worker after preparation and a parent-controlled barrier. No fault route,
production feature or production environment switch is provided. Test processes
use loopback, disposable certificates, bounded waits and kill/wait cleanup.
flowchart TD
A["QUIC / HTTP3 (quiche)"] --> B["Network Event Loop"]
B --> C["HTTP/3 Stream Management"]
C --> D["Middleware Pipeline"]
D --> E["Route Handlers"]
G["AppState"] --> D
G --> E
This layered structure allows the networking layer to remain independent from domain-specific application logic.
A request handled by the server follows the processing pipeline below.
flowchart TD
A["QUIC / HTTP3 Transport (quiche)"] --> B["Network Event Loop"]
B --> C["HTTP/3 Stream Creation"]
C --> D["Header Parsing"]
D --> E["Request Job Dispatch"]
F["Worker Thread Pool"]
F -. executes.-> G["Middleware Pipeline"]
E -. injects.-> F
G --> H["Route Handlers"]
H --> I["Response Generation"]
I --> J["Stream Dispatch Queue"]
J --> K["HTTP/3 Stream Send"]
L["AppState"] --> G
L --> H
Each request is first received through the QUIC transport layer and dispatched by the network event loop. It then goes through the middleware pipeline where headers can be inspected or modified.
Once the middleware stage is completed, the request is passed to the route handlers associated with the requested path. The response generated by the final handler is then sent back to the client through the corresponding HTTP/3 stream.
The framework does not impose any persistence model or domain structure.
Instead, application-specific logic is injected through a user-defined AppState type when creating the router. This allows developers to integrate their own storage systems or application logic without modifying the server infrastructure.
This application state is represented by the generic parameter S and must statisfied the following trait bounds:
Send + Sync + 'static
Example:
// Application state must be Send + Sync
#[derive(Clone)]
pub struct AppState {
pub database: DatabasePool,
}
let router = RouteManager::new_with_app_state(AppState { database });The state instance is then accessible inside middleware and route handlers:
router.handler(&|event, app_state, previous_response| {
// access application state or storage
});This design allows the framework to remain independent from the application domain while enabling flexible integration with different persistence strategies.
// Application state must be Send + Sync
#[derive(Clone)]
pub struct AppStateTest;
let mut router = RouteManager::new_with_app_state(AppStateTest);Attach the router and the configuration paths. As the server relies on QUIC protocol, valid TLS certificates are required.
let _server = Http3Server::new(ip_addr)
.add_key_path("./key.pem")
.add_cert_path("./cert.pem")
.set_file_storage_path("~/.temp_server/")
.run_blocking(router);Middlewares allow inspection or modification of request headers before the request is processed by handlers.
let middle_ware_0 = router.middleware(&|headers, app_state| {
// auth logic, etc.
MiddleWareFlow::Continue(headers)
//or: MiddleWareFlow::Abort
});
Handlers process completed requests.
Example:
let handler_0 = router.handler(&|event, app_state, current_status_response| {
Response::ok_200_with_data(event, vec![9; 23])
});Handlers receive:
- an Event describing the completed request
- the shared application state
- the response returned by the previous handler
Multiple handlers can be chained on a route. The response returned by the last handler is sent to the client.
Example of a POST route:
router.route_post(
"/large_data",
RouteConfig::new(DataManagement::Storage(BodyStorage::File)),
|route_builder| {
route_builder.middleware(&middle_ware_0);
route_builder.handler(&handler_0);
},
);RouteConfig allows configuration of how request bodies are handled:
-
BodyStorage::InMemory -
BodyStorage::File
This project explores several aspects of modern systems programming:
- QUIC and HTTP/3 protocols
- event-driven server architectures
- concurrent request processing
- modular software design
- Rust systems programming
The implementation served as a practical environment for experimenting with these topics while progressively refining the architecture.
MIT
The QUIC listener validates Retry tokens before allocating a connection. Tokens
use HMAC-SHA256 through the existing ring dependency, with a random key dedicated
to each server-loop lifetime. The authenticated input binds the source IP and
UDP port, the server's Retry connection ID, the original destination connection
ID, a format version, and a monotonic issue time. Tokens are 50–62 bytes; both
issuance and validation enforce an original DCID length of 8–20 bytes. The server
continues to emit 20-byte Retry connection IDs.
A token expires at an age of 30 seconds (millisecond resolution). Unknown formats, truncations, suffixes, future timestamps, wrong addresses or connection IDs, and invalid authentication tags are dropped without a response or connection allocation. Token contents are not logged. The 30-second window is this server's policy, not a requirement imposed by QUIC.
A NAT port change between the Retry and the next Initial invalidates the token. Restarting the server also invalidates outstanding tokens. In either case, or on expiration, the client must establish a new connection with a fresh Retry. A valid token can be reused for the same address and Retry CID during its validity window; this is not single-use token enforcement or HTTP 0-RTT replay protection. This change does not alter early data, scheduling, quotas, routing, or persistence. There is no shared key or cross-instance token validation.
The policy follows the integrity protection and address-binding guidance in RFC 9000 section 8.1.4.
Run the transport tests, including the loopback fixture but excluding its child entry point:
cargo test --locked --offline --lib -- --include-ignored --skip server_init::retry_loopback::retry_server_child --show-outputThe fixture requires loopback UDP and openssl. It owns a disposable child
server, key and certificate; it checks invalid Initial/Retry cases and then a
fresh real QUIC Retry, verified loopback TLS, and HTTP/3 200 response. It kills
and waits for its child on success or assertion failure, removing temporary files
on success and retaining diagnostics on failure. Wire silence checks availability
and absence of a reply; the unit tests plus validation-before-accept control flow
establish token rejection before connection allocation. IPv6 binding and exact
expiration boundaries are covered by unit tests without sleeping. These local
tests do not establish Android or production-network behavior.
When validating a backend, resolve this checkout as the transport in an isolated
integration manifest and inspect cargo metadata; testing the backend's existing
Git pin does not test this change. Preserve each lockfile: the standalone transport
currently resolves quiche 0.23.2, while the backend integration resolves 0.23.7.