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 h3 for H3 support
  • All tests passing