Full-surface integration suite (tests/full_surface.rs, mcp feature): - one HttpAdapter over real TCP (ProtocolHandler::handle path) serving gateway endpoints, /openapi.json, /mcp, and the WS channels session - gateway: search/schema/call/subscribe/batch/publish presence, envelope shapes, error fidelity end-to-end - from_openapi import -> Internal-by-default invisible from the wire -> External facade composes it via env.invoke -> upstream HTTP API called end-to-end (ADR-015 composition model exercised) - to_openapi 6-path doc validated against openapiv3 over the wire - to_mcp: MCP client connects to /mcp on the served adapter, lists the 4 gateway tools, search returns ACL-filtered ops (Sub excluded) Production fix: the WS upgrade route was reserved but never wired into HttpAdapter's router (the ws-upgrade-session tests built their own router). Now wired with ws_bearer_auth (401 without a resolvable token) around ws_upgrade_handler. Docs sync: all 28 'Port notes' sections/blockquotes stripped from ported ADRs/specs; OQ-01/OQ-02 statuses corrected to resolved in overview.md, websocket.md, and the README table (open-questions.md was already current). Publish prep: cargo publish --dry-run --allow-dirty succeeds; cargo doc --no-deps warning-free (ADR link targets fixed); feature combinations (default / test-support / mcp / wss / all) compile warning-free under clippy -D warnings. Verified: cargo test (182 lib default), --all-features (227 lib + 29 integration), clippy -D warnings x3 feature sets, fmt, doc, publish --dry-run.
4.3 KiB
ADR-001: ALPN-Based Protocol Dispatch
Ported from alknet ADR-001 (ALPN-Based Protocol Dispatch); re-targeted to alkhttp.
Status
Accepted
Context
The previous architecture used a three-layer model: transports produced byte streams, interfaces defined how to interpret those streams (StreamInterface, MessageInterface), and OperationEnv dispatched operations through local, irpc, or remote paths. This required a ListenerConfig enum with three variants (Stream, Http, Dns), a server accept loop handling three different listener types, and a complex dispatch model that mixed concerns across layers.
Protocol detection was done by byte-peeking — the server read the first bytes of an incoming connection and guessed which protocol the client was speaking. This is fragile, limits protocol extensibility, and cannot work with encrypted transports where the payload is opaque.
ALPN (Application-Layer Protocol Negotiation) is a TLS extension where the client advertises supported protocols during the handshake and the server selects one. QUIC builds on this natively — every QUIC connection has an ALPN. This is the same pattern iroh uses: Router dispatches incoming QUIC connections to ProtocolHandler implementations based on the ALPN string. Hickory DNS registers ALPN protocols (dot, doq, h2, h3). The reverse-proxy project at @alkdev/reverse-proxy uses the same pattern for TLS.
The core insight: a service IS an ALPN. Every protocol handler registers an ALPN string on a shared endpoint. The ALPN negotiation during the handshake routes the connection to the correct handler before any application bytes are read.
Decision
All protocol dispatch in alkhttp is ALPN-based: the HttpAdapter registers on the standard HTTP ALPNs (h2, http/1.1) on the shared endpoint (the endpoint and TLS/transport layer are alkcall/alknet-side concerns — see the alkcall crate docs). A single endpoint accepts connections, and the ALPN string selected during the handshake determines which ProtocolHandler receives the connection. There is no byte-peeking, no ListenerConfig enum, and no three-layer dispatch model.
The endpoint advertises the union of all registered handlers' ALPN strings. When a client connects, the TLS/QUIC handshake negotiates the ALPN. If the client's offered ALPNs and the server's advertised ALPNs have no intersection, the handshake fails — this is the correct behavior, not an error to work around.
Consequences
Positive:
- Single dispatch mechanism replaces three separate listener types
- Protocol detection happens at the TLS layer, not application layer — no byte-peeking
- Adding a new protocol is registering a new ALPN string — no server code changes
- Each handler owns its entire wire format — no shared framing layer
- QUIC connections are cheap — a client that needs multiple protocols opens one connection per ALPN, all multiplexed over the same UDP flow
- Stealth mode (byte-peek protocol detection on port 443) is unnecessary — ALPN negotiation handles this cleanly
- WASM story is clean: handlers receive byte streams, protocol parsers that operate on bytes compile to WASM
Negative:
- ALPN is negotiated per-connection, not per-stream — a client that wants to use multiple ALPNs (e.g., SSH and call protocol) opens separate QUIC connections for each. QUIC connections are cheap (multiplexed over the same UDP flow), so this is acceptable, but it means
alkcallcannot serve as a multiplexer for other ALPNs within a single connection unless explicitly designed to do so (see ADR-006, ALPN convention and connection model, in the alkcall crate docs). - All protocols must be registered at endpoint creation time (or use hot-reload via ArcSwap for dynamic addition)
- Custom protocols require reserving ALPN strings — we own the
alknet/namespace - Debugging requires knowing which ALPN was negotiated (mitigated by logging at the endpoint level)
References
- Pivot proposal (alknet mono-repo):
docs/research/pivot/alpn-service-architecture.md - ADR-002: ProtocolHandler trait
- ADR-003: Crate decomposition
- iroh reference (alknet mono-repo):
docs/research/references/iroh/(ALPN dispatch, ProtocolHandler pattern) - Replaces the old three-layer model (StreamInterface/MessageInterface/OperationEnv)