Phase 2: HTTP/2 and HTTP/3 (QUIC)
Goal
bext speaks modern HTTP protocols natively — multiplexed streams, header compression, 0-RTT connection resumption — without a proxy in front.
Current State
- actix-web 4 serves HTTP/1.1 only
- No ALPN negotiation
- No HTTP/2 server push
- No QUIC/HTTP/3
Why This Matters
| Protocol | Benefit |
|---|---|
| HTTP/2 | Multiplexing (no head-of-line blocking), HPACK header compression, server push, stream priorities |
| HTTP/3 | 0-RTT connection resumption, no TCP head-of-line blocking, better on lossy/mobile networks |
HTTP/2 is required for Early Hints (Phase 3) — browsers only process 103 responses over H2.
Design
HTTP/2 via actix-web + rustls
actix-web 4 already supports HTTP/2 when TLS is enabled with ALPN h2. This is mostly configuration:
let mut rustls_config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)?;
// Enable H2 ALPN negotiation
rustls_config.alpn_protocols = vec![
b"h2".to_vec(), // HTTP/2
b"http/1.1".to_vec(), // HTTP/1.1 fallback
];
actix-web automatically handles H2 framing once ALPN negotiates h2.
HTTP/2 Cleartext (h2c)
For non-TLS deployments (behind a load balancer), support h2c upgrade:
[server]
h2c = true # Allow HTTP/2 without TLS (default: false)
HTTP/3 via quinn + s2n-quic
HTTP/3 runs over QUIC (UDP). Two approaches:
Option A: quinn crate (pure Rust)
- Mature, well-tested QUIC implementation
- Used by Cloudflare's quiche and others
- Run as a separate UDP listener alongside TCP
Option B: s2n-quic (AWS)
- Production-grade, used in AWS services
- More opinionated, fewer knobs
Recommended: quinn — better Rust ecosystem integration, more community support.
Architecture
┌─────────────────────────┐
TCP :443 ───────▶ actix-web (H1 + H2) │
│ ALPN negotiation │
└─────────────────────────┘
┌─────────────────────────┐
UDP :443 ───────▶ quinn (H3/QUIC) │
│ Same TLS cert │
└──────────┬──────────────┘
│
▼
┌─────────────────────────┐
│ Shared request handler │
│ (Route → App → Render) │
└─────────────────────────┘
Both listeners share the same TLS certificate and route to the same request handler. The H3 listener converts QUIC streams into the same HttpRequest abstraction actix-web uses.
Alt-Svc Header
Advertise HTTP/3 availability to clients via the Alt-Svc header on HTTP/2 responses:
Alt-Svc: h3=":443"; ma=86400
Browsers will upgrade to H3 on subsequent requests.
Implementation
Step 1: HTTP/2 (Low Effort)
Mostly wiring — actix-web handles H2 once rustls is configured with ALPN:
// In bext-server/src/main.rs, after Phase 1 TLS
let rustls_config = build_rustls_config(&tls_config)?;
// ALPN already set in Phase 1's cert resolver
HttpServer::new(app)
.bind_rustls_0_23(addr, rustls_config)? // H2 automatic
.run()
Add Alt-Svc header middleware:
// Middleware that adds Alt-Svc on every response
fn alt_svc_header(cfg: &ServerConfig) -> impl Fn(ServiceResponse) -> ServiceResponse {
move |mut res| {
if cfg.h3_enabled {
res.headers_mut().insert(
HeaderName::from_static("alt-svc"),
HeaderValue::from_str(&format!("h3=\":{}\"", cfg.h3_port)).unwrap(),
);
}
res
}
}
Step 2: HTTP/3 (Medium Effort)
New module in bext-server:
bext-server/src/
h3/
mod.rs # H3 server setup
handler.rs # Convert QUIC stream → HttpRequest → response
connection.rs # Connection lifecycle management
// Simplified H3 listener
async fn run_h3_server(
addr: SocketAddr,
tls_config: Arc<rustls::ServerConfig>,
app_state: web::Data,
) -> Result<()> {
let endpoint = quinn::Endpoint::server(quinn_config, addr)?;
while let Some(conn) = endpoint.accept().await {
let state = app_state.clone();
tokio::spawn(async move {
let conn = conn.await?;
let h3_conn = h3_quinn::Connection::new(conn);
let mut h3 = h3::server::Connection::new(h3_conn).await?;
while let Some((req, stream)) = h3.accept().await? {
let state = state.clone();
tokio::spawn(handle_h3_request(req, stream, state));
}
});
}
}
Step 3: Shared Request Abstraction
Extract the core request handling from actix-web's types into a protocol-agnostic layer:
/// Protocol-agnostic request
struct BextRequest {
method: Method,
uri: Uri,
headers: HeaderMap,
body: Bytes,
peer_addr: SocketAddr,
protocol: Protocol, // H1, H2, H3
}
/// Protocol-agnostic response
struct BextResponse {
status: StatusCode,
headers: HeaderMap,
body: ResponseBody, // Bytes | Stream | Empty
}
The H3 handler converts QUIC frames into BextRequest, calls the same routing/rendering logic, then serializes BextResponse back to QUIC frames. The actix-web handler wraps the existing code.
This abstraction also enables Phase 3 (Early Hints) — the handler can emit multiple responses per request.
Config
[server]
listen = "0.0.0.0:443"
[server.http2]
enabled = true # Default: true when TLS enabled
max_concurrent_streams = 100 # Per-connection stream limit
initial_window_size = 65535 # Flow control window
h2c = false # HTTP/2 cleartext (no TLS)
[server.http3]
enabled = true # Default: true when TLS enabled
max_concurrent_streams = 100
idle_timeout_ms = 30000 # Close idle QUIC connections
# Uses same port as TCP listener by default (UDP :443)
Testing Plan
| Test | Type | What it validates |
|---|---|---|
| ALPN negotiation | Integration | Client connects H2 when offered |
| H2 multiplexing | Integration | Multiple concurrent streams on one connection |
| H1 fallback | Integration | Clients without H2 still work |
| h2c upgrade | Integration | H2 over plaintext TCP |
| H3 listener startup | Integration | QUIC endpoint binds and accepts |
| H3 request/response | Integration | Full request cycle over QUIC |
| Alt-Svc header | Unit | Header present on H2 responses, absent on H1 |
| Shared handler | Unit | Same BextRequest produces same response regardless of protocol |
| Stream limits | Unit | Excess streams rejected with REFUSED_STREAM |
| Connection coalescing | Integration | Multiple H2 requests reuse connection |
Dependencies
| Crate | Purpose | Status |
|---|---|---|
rustls 0.23 |
TLS 1.2/1.3 | Already used by actix-web |
quinn |
QUIC implementation | New dependency |
h3 |
HTTP/3 protocol | New dependency |
h3-quinn |
h3 + quinn glue | New dependency |
Risks
| Risk | Mitigation |
|---|---|
| H3/QUIC UDP might be blocked by firewalls | Alt-Svc gracefully falls back to H2 over TCP |
| quinn adds significant binary size | Feature-gate H3 behind h3 cargo feature |
| H3 ecosystem still maturing | H2 alone is sufficient; H3 is a bonus |
Done Criteria
- H2 works automatically when TLS is enabled
- ALPN negotiation selects h2 or http/1.1 correctly
- h2c option works for behind-proxy deployments
- H3/QUIC listener accepts connections on UDP
- Alt-Svc header advertises H3 availability
- Shared request abstraction routes H1/H2/H3 through same handler
- Config options for stream limits and timeouts
- Feature-gated:
--features h3for H3 support - All tests passing