HTTP server

Crate hopf-http: server face of an HTTP Stream. Implement ServerHandler (and optionally wrap with auth factories); wire a ProtocolHandler adapter (CleartextHttpEndpoint, AlpnHttpEndpoint, H1Endpoint::server, …) on a TCP listener. H3 uses the same handler SPI via listen_h3.

ServerHandler SPI

Trait Role
ServerHandlerFactory create_handler() -> Box<dyn ServerHandler> per inbound Stream
ServerHandler Incremental push events for one request
ServerWriter Outbound response for the current Stream
ServerResponseHandle Cloneable offload handle (execute, pause/resume body)
ProtocolUpgradeHandler Post-upgrade byte protocol

Coming from Gumdrop: HTTPRequestHandlerFactory.createHandler(state, headers) sees the request and can route by path or reject early (401/404) before a handler exists. ServerHandlerFactory::create_handler() deliberately does not — it takes no arguments. Routing and auth-gating are composed by decoration instead: wrap an inner factory/handler in one that inspects headers inside ServerHandler::headers and either forwards or answers directly. BasicAuthFactory/DigestAuthFactory/BearerAuthFactory below are the reference examples; a path router is the same shape, dispatching to a different inner handler per route instead of challenging.

Callback sequence

With a request body:

  1. headers(&mut dyn ServerWriter, &Headers)
  2. start_request_body
  3. request_body_content (zero-copy chunks)
  4. end_request_body
  5. request_complete

Without a body: headers → request_complete. Body callbacks have empty default implementations.

use hopf_http::{Headers, ServerHandler, ServerWriter};

struct Hello;

impl ServerHandler for Hello {
    fn headers(&mut self, response: &mut dyn ServerWriter, req: &Headers) {
        let path = req.get(":path").unwrap_or("/");
        let mut h = Headers::new();
        h.status(200);
        h.set("content-type", "text/plain");
        response.headers(h);
        response.start_response_body();
        response.response_body_content(format!("hello {path}\n").as_bytes());
        response.end_response_body();
        response.complete();
    }

    fn request_complete(&mut self, _response: &mut dyn ServerWriter) {}
}

ServerWriter methods

Method Purpose
send_informational(code, headers) Send a 1xx response
headers(Headers) Buffer response headers (flush on body start or complete)
start_response_body() Flush headers and begin the body
response_body_content(&[u8]) Write body bytes (auto-chunked when applicable)
end_response_body() Finish body (final chunk if chunked)
trailers(Headers) Trailer headers after the body (H2/H3; H1 ignores)
complete() Complete the response (flushes headers if no body was started)
upgrade(headers, handler) 101 (H1) or 200 Extended CONNECT (H2/H3); see overview
traceparent() Optional W3C traceparent for outbound continuation
conn_handle() ConnHandle for storage / reactor hops
connection_info() ConnectionInfo — remote/local address, TLS metadata
response_handle() Cloneable ServerResponseHandle for deferred writes
pause_request_body() / resume_request_body() Stop / resume inbound body events

Pseudo-headers on outbound responses use Headers::status(u16) (sets :status). Request inspection uses :method, :path, :scheme, :authority, plus ordinary fields.

connection_info() (Gumdrop getRemoteAddress/getLocalAddress/isSecure/getSecurityInfo) returns a ConnectionInfo snapshot — remote_addr(), local_addr(), is_secure(), and security_info() (ALPN, TLS protocol/cipher, SNI, and — for mTLS — the peer's certificate fingerprint and full DER certificate chain). Captured once per connection, so it's cheap to read from every callback; useful for access logging, per-client rate limiting, or gating on the mTLS fingerprint. Wired for H1 and H2 today; H3 (QUIC) returns the default (unknown/plaintext) until hopf_quic::QuicConnApi grows the equivalent accessors.

Deferred responses

When request handling needs blocking work (filesystem, mailbox, remote call), do not block the reactor. Take a ServerResponseHandle, pause the body if needed, submit to StorageExecutor, then hop back with execute:

use hopf_core::StorageExecutor;
use hopf_http::{Headers, ServerHandler, ServerWriter};
use std::sync::Arc;

struct Offload {
    storage: Arc<StorageExecutor>,
}

impl ServerHandler for Offload {
    fn headers(&mut self, response: &mut dyn ServerWriter, _req: &Headers) {
        let handle = response.response_handle();
        handle.pause_request_body();
        let conn = handle.conn_handle().clone();
        self.storage.submit_on(conn, || {
            // blocking work off the reactor
            Ok::<_, Box<dyn std::error::Error + Send + Sync>>("ok".to_string())
        }, move |result| {
            handle.execute(move |w| {
                let body = match result {
                    Ok(s) => s,
                    Err(_) => "error".into(),
                };
                let mut h = Headers::new();
                h.status(200);
                h.set("content-type", "text/plain");
                w.headers(h);
                w.start_response_body();
                w.response_body_content(body.as_bytes());
                w.end_response_body();
                w.complete();
                w.resume_request_body();
            });
        });
    }

    fn request_complete(&mut self, _response: &mut dyn ServerWriter) {}
}

Framing stays inside the transport’s ResponseControl; application code only sees ServerWriter / ServerResponseHandle.

Strict-Transport-Security (HSTS)

RFC 6797 tells a browser to use only HTTPS for a host for a stated time, so a downgrade or SSL-stripping attempt on a repeat visit fails before any request is sent. Enable it with HttpServer::hsts(HstsPolicy):

HttpServer::new()
    .tls(acceptor)
    .hsts(HstsPolicy::new(Duration::from_secs(31_536_000)).include_subdomains())

Redirects. A browser only honours HSTS received over HTTPS, so HSTS does not replace redirecting plaintext visitors: the first visit to a site still arrives over HTTP unless the host is preloaded. hopf-http has no built-in HTTP-to-HTTPS redirect; answer plaintext requests with a 301/308 to the https:// URL from your plaintext listener's handler. Once a browser has followed that redirect and received HSTS, it goes straight to HTTPS on later visits without contacting the plaintext listener at all.

Caching and conditional requests

Scope: origin-server correctness (RFC 9110 §8.8 and §13, RFC 9111 §5.2). hopf-http is not itself a cache, so it stores nothing.

Conditional GET/HEAD is automatic. HttpServer::bind wraps every handler factory in a ConditionalServerFactory by default. When a GET or HEAD carries If-None-Match, If-Modified-Since, If-Match or If-Unmodified-Since and your handler answers 200 with an ETag and/or Last-Modified, the request becomes a 304 Not Modified (repeating ETag, Last-Modified, Cache-Control, Expires, Vary, Content-Location and Date, no body) or a 412 Precondition Failed. Your handler's body writes are discarded, including ones made later through ServerResponseHandle::execute. Other statuses, other methods and requests without a precondition are untouched at no cost. Turn it off with HttpServer::disable_conditional_requests(); listen_h3 takes a factory directly, so wrap it yourself with ConditionalServerFactory::new.

So the handler's only job is to emit validators:

let mut h = Headers::new();
h.status(200);
h.set("ETag", EntityTag::strong("v42").to_string());
h.set("Last-Modified", format_http_date(mtime_secs));
CacheControl::new().public().max_age(Duration::from_secs(300)).apply(&mut h);
response.headers(h);

Use a strong entity-tag only when the bytes are identical whenever it matches; otherwise EntityTag::weak. Dates have one-second resolution, and comparisons truncate to it. When content coding compresses a response its strong ETag becomes weak, and revalidation still works because If-None-Match compares weakly.

State-changing methods are yours to evaluate. A decorator cannot stop an action that already happened, so a handler for PUT, DELETE and similar calls evaluate_preconditions(method, &request_headers, current) before acting, where current is the resource's Validators (or None if it does not exist), and answers 412 on Precondition::PreconditionFailed. This is how If-Match lost-update guards and If-None-Match: * create-only writes work; hopf-webdav does exactly this for PUT and DELETE. The evaluation follows RFC 9110 §13.2.2 precisely (including that If-Match needs a strong match and If-None-Match a weak one) and ignores malformed or repeated fields as the RFC requires.

Freshness is opt-in. Nothing adds Cache-Control or Expires for you: only the handler knows how long a representation stays valid. CacheControl builds the value; hopf-webdav exposes it as WebDavConfig::cache_control for static file responses. Without it, caches fall back on their own heuristics, which work from Last-Modified.

Content-Encoding (br, gzip, deflate)

HttpServer::bind wraps every handler factory in a ContentEncodingServerFactory by default, on HTTP/1.1 and HTTP/2. Replace the policy with HttpServer::content_encoding(ServerContentEncodingPolicy) or turn it off with disable_content_encoding(). HTTP/3 listeners (listen_h3) take a factory directly, so wrap it yourself with ContentEncodingServerFactory::new there.

Responses. The application’s headers are held until its first body byte. The client’s Accept-Encoding is read (q=0 and * honoured) and the server picks br, else gzip, else deflate. A response is eligible when it is not HEAD/CONNECT, its status is not 1xx/204/205/304, it has no Content-Range, a compressible Content-Type (text, JSON, XML, JavaScript, SVG, +json/+xml by default; replaceable) and a declared Content-Length of at least 256 bytes (or none). Eligible responses get Vary: Accept-Encoding, and if the client accepts a coding the body is compressed chunk by chunk as the handler writes it (constant memory), Content-Encoding is set, Content-Length is removed and a strong ETag becomes weak. Bodies written later through ServerResponseHandle::execute go through the same encoder. A response with no body is forwarded untouched.

Handler opt-out. The handler’s explicit instructions win. A Content-Encoding the handler sets itself (identity is enough) or Cache-Control: no-transform leaves the response exactly as written, with no Vary and no compression. Use this for responses that mix secrets with attacker-influenced content over TLS, where compression can leak information (BREACH-style attacks); the server cannot detect that on its own.

Advertising. Unless request decoding is off, every response also carries Accept-Encoding: br, gzip, deflate (RFC 9110 §12.5.3), which is how hopf clients learn they may compress the bodies they send.

Requests. A request carrying Content-Encoding is decoded before the wrapped handler sees it, and Content-Encoding/Content-Length are removed from the headers it is shown (turn off with ServerContentEncodingPolicy::decode_requests(false)). Failures answer directly and the wrapped handler hears nothing further: 415 (naming the accepted codings) for an unknown coding, 400 for a corrupt or truncated body, 413 for output past HttpLimits::max_decoded_body (default 64 MiB).

Authentication factories

All three wrap an inner ServerHandlerFactory and challenge before forwarding. They depend on hopf-auth (TrustPolicy / CredentialStore).

Factory Credential surface Config
BasicAuthFactory TrustPolicy via IdentityMaterial::UsernamePassword BasicAuthConfig { realm }
DigestAuthFactory CredentialStore (HA1) DigestAuthConfig { realm }
BearerAuthFactory TrustPolicy via bearer token optional with_realm(...)
use hopf_auth::{PasswordStore, PasswordTrustPolicy};
use hopf_http::{
    BasicAuthConfig, BasicAuthFactory, BearerAuthFactory, DigestAuthConfig, DigestAuthFactory,
};
use std::sync::Arc;

let inner: Arc<dyn hopf_http::ServerHandlerFactory> = /* app factory */;

let basic = Arc::new(BasicAuthFactory::new(
    Arc::clone(&inner),
    PasswordTrustPolicy::new().with_user("alice", "s3cret").shared(),
    BasicAuthConfig::new("realm"),
));

let digest = Arc::new(DigestAuthFactory::new(
    Arc::clone(&inner),
    Arc::new(PasswordStore::new().with_user("alice", "s3cret")),
    DigestAuthConfig::new("realm"),
));

let bearer = Arc::new(
    BearerAuthFactory::new(
        inner,
        PasswordTrustPolicy::new() /* with_token in PasswordStore / custom policy */
            .shared(),
    )
    .with_realm("api"),
);

Helpers: parse_basic_authorization, build_digest_authorization. Failed auth sends 401 with WWW-Authenticate and does not forward body events to the inner handler.

Protocol upgrades

use hopf_http::{Headers, ProtocolUpgradeHandler, ServerHandler, ServerWriter};

struct EchoUpgrade;

impl ProtocolUpgradeHandler for EchoUpgrade {
    fn receive(&mut self, data: &[u8]) { /* inbound app bytes */ let _ = data; }
    fn take_outbound(&mut self) -> Vec<u8> { Vec::new() }
}

impl ServerHandler for /* ... */ {
    fn headers(&mut self, response: &mut dyn ServerWriter, req: &Headers) {
        let mut h = Headers::new();
        // fill Upgrade / Sec-WebSocket-* as required by the protocol
        let _ = response.upgrade(h, Box::new(EchoUpgrade));
    }
    fn request_complete(&mut self, _: &mut dyn ServerWriter) {}
}

Prefer stock factories from hopf-websocket / hopf-grpc rather than hand-rolling handshake headers.

HttpServer facade

HttpServer is the app-facing bind facade, symmetric to HttpClient on the dial side: pick cleartext or TLS with one builder call, then bind once per listen address — no manual CleartextHttpEndpoint / AlpnHttpEndpoint selection.

use hopf_core::{acceptor_from_pem, Runtime, RuntimeConfig};
use hopf_http::{HttpServer, ServerHandlerFactory};

let rt = Runtime::start(RuntimeConfig::default())?;
let factory: Arc<dyn ServerHandlerFactory> = Arc::new(HelloFactory);

// Cleartext (h2c prior-knowledge + Upgrade + HTTP/1.1):
let (addr, _binding) = HttpServer::new().bind(&rt, "127.0.0.1:8080".parse()?, factory.clone())?;

// TLS (ALPN h2 / http/1.1) — acceptor already advertises those protocols:
let acceptor = acceptor_from_pem(&cert_path, &key_path, &[b"h2", b"http/1.1"])?;
let (addr, _binding) = HttpServer::new().tls(acceptor).bind(&rt, "127.0.0.1:8443".parse()?, factory)?;

Reach for the manual wiring below when a listener needs an adapter HttpServer doesn't cover yet (H3/QUIC, or a non-TCP transport) — same ServerHandler either way.

Wiring example

use std::sync::Arc;
use hopf_core::{ProtocolHandler, Runtime, RuntimeConfig, TcpListenerConfig};
use hopf_http::{
    CleartextHttpEndpoint, Headers, HttpLimits, ServerHandler, ServerHandlerFactory, ServerWriter,
};

struct HelloFactory;
impl ServerHandlerFactory for HelloFactory {
    fn create_handler(&self) -> Box<dyn ServerHandler> {
        Box::new(Hello)
    }
}

struct Hello;
impl ServerHandler for Hello {
    fn headers(&mut self, response: &mut dyn ServerWriter, _req: &Headers) {
        let mut h = Headers::new();
        h.status(200);
        h.set("content-type", "text/plain");
        h.set("content-length", "6");
        response.headers(h);
        response.start_response_body();
        response.response_body_content(b"hello\n");
        response.end_response_body();
        response.complete();
    }
    fn request_complete(&mut self, _: &mut dyn ServerWriter) {}
}

fn main() -> std::io::Result<()> {
    let rt = Runtime::start(RuntimeConfig::default())?;
    let factory: Arc<dyn ServerHandlerFactory> = Arc::new(HelloFactory);
    let limits = HttpLimits::default();
    rt.add_tcp_listener(TcpListenerConfig::new("127.0.0.1:8080".parse()?, move || {
        Box::new(CleartextHttpEndpoint::new(Arc::clone(&factory), limits))
            as Box<dyn ProtocolHandler>
    }))?;
    // hold rt / park main
    Ok(())
}

For TLS ALPN or H3, swap the adapter — same ServerHandlerFactory. See overview.

Examples

Example Notes
examples/http-hello Cleartext + optional --tls
examples/http3-hello Self-signed QUIC + listen_h3
examples/webdav ServerHandlerFactory from hopf-webdav
examples/websocket Upgrade via WebSocketFactory
examples/grpc Unary gRPC factory

Limitations

See also