Files
alknet/docs/architecture/crates/http/webtransport.md
glm-5.2 b71db99753 docs(http): add ADR-048 and websocket.md — WS carries native session, not gateway
Promote the WebSocket browser path from a section in http-server.md to a
first-class spec (websocket.md) and commit the contract-pattern decision
(ADR-048): a WS connection carries the native EventEnvelope call-protocol
session, not the HTTP gateway shape. The gateway endpoints are HTTP-only;
discovery on WS is via services/list/services/schema as ordinary call-protocol
ops; subscriptions project as native call.responded events (no SSE).

ADR-044 already decided WS as the v1 browser bidirectional path; ADR-048
clarifies the shape of what ADR-044 committed (§1 implies native session;
the ADR makes it an explicit implementer-visible rule). The from_wss adapter
(importing a remote node's ops over WS) is recorded as out-of-scope with a
concrete reversal trigger so it is not re-derived later.

Spec cleanup: http-server.md WS section collapsed to a stub pointer;
websocket.md Why section references ADRs rather than re-arguing them;
length-prefix decision made canonical (no prefix on WS — message boundary
is the delimiter); default upgrade path pinned (/alknet/call) with HTTP/2
extended CONNECT noted; indexes (README, http/README, overview) updated.
2026-06-30 12:27:00 +00:00

26 KiB

status, last_updated
status last_updated
deferred 2026-06-30

WebTransport — the h3 ALPN handler

DEFERRED per ADR-044. This spec is kept intact for revival. h3/WebTransport is not implemented in the initial alknet-http release; the browser bidirectional path uses WebSocket (see websocket.md). ADR-038 is superseded; ADR-040 and ADR-043 are parked (their decisions revive unchanged when WebTransport revives). The reversal trigger is a concrete deployment needing the ALPN-stream-proxy (a browser running a WASM SSH/SFTP/git client to reach a non-call ALPN). Two transfers apply during the deferment: ADR-043 §2 (call-protocol bidirectionality) and §3 (the no-PeerId connection-local overlay) apply over WebSocket unchanged; ADR-040 (the ALPN-stream-proxy) and ADR-043 §4 (the non-call-ALPN substrate) do not — they require WebTransport's stream model and revive with it.

Research note (for revival): wtransport (v0.7.1, the reference implementation read during initial research) is probably not the right dependency choice at revival time, despite being a complete and readable implementation. The load-bearing integration concern is that the h3 handler must route HTTP/3 requests through the same axum Router as h2/http/1.1 (ADR-036), and wtransport owns its own HTTP serving path — bridging its request type into the http::Request axum consumes is cross-ecosystem adapter work. The hyperium stack (h3 + h3-quinn + h3-webtransport + h3-datagram) operates at the stream level and produces http::Request types natively, which is a better fit for the axum integration — but its server-side WebTransport API needs verification before commitment (the axum-bridge feasibility is the load-bearing claim and is not yet confirmed against actual crate APIs, only against READMEs and design philosophy). This research is not run now (WebTransport is deferred); it is recorded here so the revival does not re-derive the question from scratch. See ADR-044 §"Research note (for revival)" for the cross-reference.

The HttpAdapter registration for the h3 ALPN: HTTP/3 and WebTransport. WebTransport is a bidirectional ALPN transport substrate (ADR-043) — it carries ALPN protocols as bidirectional streams, with the call protocol as the first/canonical target (needs no WASM parser) and the ALPN-stream-proxy (ADR-040) as the mechanism for non-call ALPNs (SSH, git, SFTP) that need a client-side parser. This document covers the WebTransport session/stream handling, the substrate's three stream destinations, the no-PeerId connection-local overlay for non-peer clients, and the relationship to the h2/ http/1.1 server (the one-directional projection WebTransport restores bidirectionality for). The h3 support is a first-class transport (ADR-038).

What

The h3 ALPN handler is the same HttpAdapter instance that serves h2/http/1.1, registered for the h3 ALPN when the h3 feature is enabled. It serves two things on a single h3 connection:

  1. HTTP/3 requests — the standard HTTP/3 over QUIC framing. An HTTP/3 request is dispatched through the same axum Router as h2/ http/1.1 requests (ADR-042 + ADR-047 — the gateway endpoints are the sole invoke path; the direct-call POST /{service}/{op} surface was removed). From the axum router's perspective, an HTTP/3 request is just another HTTP request; the framing difference is handled below the router. The HTTP/3 request path is the one-directional projection (client→server calls only — HTTP is request/response; see http-server.md §"One-directional projection").
  2. WebTransport sessions — the bidirectional path. WebTransport is a transport substrate that carries ALPN protocols as bidirectional streams (ADR-043), not a browser→hub one-way path. A WebTransport session is a long-lived connection over which either side can open bidirectional and unidirectional streams. Streams within a session target one of three destinations (see ADR-040):
    • The call protocol (EventEnvelope → the call Dispatcher) — the canonical target; needs no WASM parser because the EventEnvelope framing is platform/language/runtime agnostic (JSON-RPC over QUIC streams). Both sides can initiate calls — the call protocol's bidirectionality applies unchanged (ADR-043 §2, ../call/call-protocol.md § "Bidirectional Calls").
    • An ALPN handler proxy (the stream is handed to another ALPN handler like SshAdapter — the client runs a WASM parser for the target protocol). This is the substrate's mechanism for non-call ALPNs (SSH, git, SFTP) that need a parser on the client side (ADR-043 §4).
    • Another sub-protocol (declared at CONNECT time).

The ALPN-stream-proxy is what makes the browser a universal alknet client: with a WASM parser for SSH (or SFTP, git), a browser can reach any ALPN handler via WebTransport, no install, no native client, no VPN. This is the "VPN-like without being a VPN" use case the project was originally built for, now on a clean foundation. See ADR-040 and the substrate framing in ADR-043.

Why h3 is a first-class transport

WebTransport is the bidirectional streaming transport for the call protocol and a transport substrate for any ALPN. QUIC streams are cheap (multiplexed over one connection, no head-of-line blocking), and WebTransport is supported in major browsers and beyond (Deno, Node, native Rust). The call protocol's subscription/streaming model maps onto WebTransport streams with no translation loss — a call.responded stream over a WebTransport bidirectional stream is the native representation, not an SSE translation (which is the projection for h2/http/1.1 clients per ADR-036).

More importantly, WebTransport restores the call protocol's bidirectionality that the HTTP/1.1 + HTTP/2 surface structurally cannot carry. HTTP is request/response — the client initiates, the server responds; the server→client call direction has no HTTP expression (see http-server.md §"One-directional projection"). WebTransport is a long-lived connection over which either side can open bidirectional streams and send call.requested in either direction — the call protocol's native bidirectionality applies unchanged (ADR-043 §2). WebTransport is also supported beyond browsers (Deno, Node, native Rust via wtransport), and the call protocol — JSON-RPC over QUIC streams — is platform/language/runtime agnostic, so call-protocol-over-WebTransport is a general bidirectional RPC substrate, not a browser-only path (ADR-043 §1).

WebTransport is in scope, in this crate, as a first-class transport (ADR-038). See ADR-043 for the substrate framing.

Architecture

The h3 handler entry

The HttpAdapter::handle() method for the h3 ALPN drives two distinct stream types, distinguished at the HTTP/3 framing layer (not by peeking application bytes):

  1. HTTP/3 request streams — standard HTTP/3 GET/POST carrying :method/:path. These are the same request model as h2/ http/1.1, just over HTTP/3 framing. Dispatched through the axum Router (same router as h2/http/1.1, ADR-036). An HTTP/3 request is never a WebTransport stream — the stream type is set by the HTTP/3 frame that opens it.
  2. WebTransport sessions — opened by a browser's new WebTransport(url) call, which triggers an HTTP/3 extended CONNECT request. The handler accepts the session (the wtransport crate's Endpoint::server(config)?.accept().await.await?.accept() .await? pattern, or the quinn HTTP/3 endpoint's WebTransport extension — the exact library is a two-way-door implementation detail, ADR-038). Within an established session, the browser opens bidirectional streams via transport.createBidirectionalStream(); the handler accepts each via session.accept_bi().

The two stream types are not disambiguated by "reading the first frame" — they are distinguished by the HTTP/3 frame type that opens them (regular request headers vs. extended CONNECT). The "first frame" routing below applies within a WebTransport session, not between an HTTP/3 request and a WebTransport stream.

WebTransport session and stream handling

Once a WebTransport session is established (via extended CONNECT), the client creates bidirectional streams within it. The handler dispatches each stream to one of three destinations, determined by the session's CONNECT path (the routing key, declared at CONNECT time — not by peeking the first application frame):

  • / or /alknet/call → call-protocol session. Each bidirectional stream carries call-protocol EventEnvelope frames. The handler hands the (SendStream, RecvStream) pair to the call protocol's Dispatcher (see ../call/call-protocol.md for EventEnvelope and ../call/client-and-adapters.md §"Shared Dispatcher" for the Dispatcher — the same dispatch loop the CallAdapter uses for alknet/call connections, ADR-012, stream-agnostic correlation). The client speaks the EventEnvelope wire format directly over the WebTransport stream.

    Bidirectionality (ADR-043 §2): the call-protocol session inherits the call protocol's native bidirectionality — both sides can initiate calls. The client calls operations on the hub; the hub can call operations registered on the client's side, over the same session, using the same PendingRequestMap and EventEnvelope framing as alknet/call (see ../call/call-protocol.md §"Bidirectional Calls"). The browser case (ADR-034 §4) is the common case where the client registers no operations of its own, so the server→client call direction is unused — that is a use-case scoping, not an architectural limitation. A non-browser WebTransport client (Deno, Node, a peer preferring WebTransport) that registers operations receives calls from the hub over the same session.

  • /alknet/<name> → ALPN-handler proxy session. Each bidirectional stream is handed to the target ALPN handler (e.g., SshAdapter for /alknet/ssh, GitAdapter for /alknet/git) as a Connection wrapping the WebTransport stream. The client runs a WASM parser for the target protocol and speaks it directly over the stream. This is the substrate's mechanism for non-call ALPNs (ADR-043 §4) — the ALPN-stream-proxy — see ADR-040. The h3 handler looks up the target ALPN handler in the HandlerRegistry (HttpAdapter holds Arc<HandlerRegistry> for this purpose), wraps the WebTransport stream as a Connection, and calls handler.handle(connection, &auth). The target handler runs its normal protocol over the stream — SSH key exchange, git smart protocol, SFTP — exactly as if the stream had arrived on that ALPN via a native QUIC connection.

  • Other paths → other sub-protocols. Sessions may carry other framing conventions; the session's purpose is declared at CONNECT time by path/origin. The first-frame tag is a belt-and-suspenders confirmation for sessions that multiplex sub-protocols, not the routing mechanism.

The browser's WebTransport JS API is one client side of this: new WebTransport('https://hub.example.com/alknet/ssh')transport.createBidirectionalStream() → the browser's WASM SSH client reads/writes the stream as a BiStream (ADR-007). No SSE translation, no HTTP framing — the target protocol speaks directly over the WebTransport stream. For the call-protocol session, the browser writes EventEnvelope frames; for an SSH session, the browser runs the WASM SSH parser. A non-browser client (Deno, Node, native Rust) speaks the same wire formats over the same substrate without a WASM parser — the call protocol needs no parser, and native ALPN clients (SSH, git) use native parsers rather than WASM.

Subscription projection (native, not SSE)

A Subscription operation served over WebTransport projects its call.responded stream directly onto the WebTransport bidirectional stream — each call.responded event is a frame on the stream, no SSE data: framing. call.completed closes the stream; call.aborted closes the stream with an error frame. This is the native streaming projection; SSE (ADR-036) is the projection for h2/http/1.1 clients that don't speak WebTransport.

ALPN-stream-proxy (ADR-040, repositioned by ADR-043 §4)

The ALPN-stream-proxy is the h3 handler's third stream destination and the substrate's mechanism for non-call ALPNs — the protocols (SSH, git, SFTP) that need a client-side parser, unlike the call protocol which speaks EventEnvelope directly. ADR-040 framed it as "the browser's gateway to every ALPN handler"; ADR-043 §4 repositions it as the substrate's non-call-ALPN mechanism, of which the browser use case is the primary (but not the only) instance. The decision in ADR-040 (the HandlerRegistry reference, path-based routing) stands unchanged; the framing is what ADR-043 refines.

The browser use case: a browser opens a WebTransport session to /alknet/ssh (or /alknet/git, /alknet/sftp), and the h3 handler hands each bidirectional stream within that session to the target ALPN handler as a Connection. The browser runs a WASM parser for the target protocol and speaks it directly over the stream.

Why this matters: SSH-over-WebTransport is HTTPS-shaped at the network layer (WebTransport is HTTP/3 over QUIC over UDP, the same as HTTP/3). Blocking it requires blocking HTTP/3, which breaks the web. This is the anti-censorship property — the protocol that governments most want to block (VPN-like connectivity) rides on the protocol they can't block without breaking the web. This is the "VPN-like without being a VPN" use case on a clean foundation.

The WASM client side: the browser's WASM parser for the target protocol (SSH, SFTP, git) reads/writes the WebTransport stream as a BiStream (ADR-007). The BiStream trait (AsyncRead + AsyncWrite + Send + Unpin) was designed for this — a browser implements it over a WebTransport stream, and the WASM parser speaks the protocol over it. The WASM parsers are downstream artifacts (the SSH WASM client, the SFTP WASM client), not part of alknet-http; russh-sftp's WASM targeting demonstrates feasibility, SSH is the next target.

Auth for proxied ALPN sessions: the browser authenticates by bearer token on the WebTransport session request (the HTTP Authorization header on the CONNECT request), resolved by the hub's IdentityProvider::resolve_from_token — same as any other browser connection (ADR-034 §4). The browser is not an alknet peer (no PeerId). The target ALPN handler receives the Connection and AuthContext from the h3 handler; the AuthContext carries the bearer-token-resolved Identity. The target protocol then runs its own auth (the browser's WASM SSH client does SSH key exchange over the WebTransport stream, same as a native SSH client over a QUIC stream). Two layers: the bearer token gates the WebTransport session (does the browser have access to this hub?); the protocol's own auth gates the protocol session (does this SSH identity have access to this shell?).

The HandlerRegistry reference: the HttpAdapter holds Arc<HandlerRegistry> so the h3 handler can look up the target ALPN handler. The assembly layer constructs the HttpAdapter with the HandlerRegistry it already builds for the endpoint — no new registry, no new construction path. The HandlerRegistry is static at startup (ADR-010), so the lookup is against an immutable registry. See ADR-040.

The TLS constraint (browsers require X.509)

Browsers do not support RFC 7250 raw public keys (ADR-027, OQ-12). A WebTransport session from a browser requires an X.509 cert — the h3 handler is a domain-hosted-service concern, not a P2P concern. A node serving WebTransport must have an X.509 identity (TlsIdentity::X509 or TlsIdentity::Acme).

This is a property of the browser, not a decision this spec makes. It's recorded so the spec doesn't pretend a raw-key node can serve browsers. A raw-key node serves h2/http/1.1 (for curl, axios, alknet-native clients) but not h3/WebTransport (for browsers). A browser-facing hub has a PeerEntry with mixed fingerprints (Ed25519 for P2P, X.509 for browsers — ADR-030, ADR-034 §3).

Browsers are not alknet peers

A browser connecting to a hub over WebTransport is served by the h3 handler. The browser authenticates by bearer token (HTTP Authorization header on the WebTransport session request), resolved by the hub's IdentityProvider::resolve_from_token against the hub's PeerEntry.auth_token_hash or ApiKeyEntry. The browser is not an alknet peer (ADR-034 §4): it gets no PeerId, does not enter PeerCompositeEnv, and its "ops" are WebTransport streams served by the h3 handler, not entries in the call-protocol peer-keyed overlay.

The no-PeerId connection-local overlay (ADR-043 §3)

A non-peer WebTransport client (a browser, or any WebTransport client that is not a PeerEntry-bearing alknet peer) has no PeerId on the hub's side. The connection is served by the h3 handler; the bearer-token-resolved Identity authorizes calls via AccessControl::check, but the connection does not enter PeerCompositeEnv and has no peer-keyed overlay entry. This is the inbound mirror of ADR-034 §2 (the outgoing pure-client X.509 case: ops discovered land in "that connection's Layer 2 overlay" — connection-local, no PeerId). On the inbound WebTransport path, ops the client registers (if any) land in a connection-local Layer 2 overlay on the hub side — same pattern, opposite direction.

The CallAdapter's compose_root_env builds the root OperationContext.env from:

  • the curated base (Layer 0),
  • this connection's local overlay (Layer 2 — connection-scoped, not peer-keyed), and
  • the active session overlay (if any, ADR-024).

There is no PeerCompositeEnv entry because there is no PeerId to key it. An implementer building compose_root_env for a WebTransport session applies the ADR-034 §2 connection-local-overlay pattern (mirror direction) and does not hunt for a PeerId that isn't there.

The case where the WebTransport client is a PeerEntry-bearing alknet peer (a hub or spoke node that prefers WebTransport as its transport) is the symmetric case: the connection has a PeerId (resolved from the bearer token via IdentityProvider::resolve_from_tokenIdentity.id = PeerEntry.peer_id, ADR-030), and ops the peer registers land in the peer-keyed overlay, exactly as they would over alknet/call. The no-PeerId pattern above is the non-peer case; the peer case is unchanged from the alknet/call model. See ADR-043 §3.

Stealth on h3

The h3 handler participates in the same stealth model as h2/ http/1.1 (ADR-010, ADR-036): a client that offers h3 gets the HTTP handler. Unknown WebTransport paths and unknown HTTP/3 paths get the decoy (the same configurable DecoyConfig — fake 404, static site, redirect). Real services use alknet/ssh, alknet/call, etc.

Implementation reference: wtransport

The wtransport crate (/workspace/wtransport/, v0.7.1) is a pure-Rust WebTransport implementation built on quinn + h3/qpack. Its API:

// Server (from the wtransport README):
let config = ServerConfig::builder()
    .with_bind_default(4433)
    .with_identity(&identity)   // X.509 identity
    .build();
let connection = Endpoint::server(config)?
    .accept().await            // await connection
    .await?                    // await session request
    .accept().await?;          // await ready session
let stream = connection.accept_bi().await?;

wtransport is a candidate dependency for the h3 feature gate. The exact WebTransport library choice (wtransport vs a quinn-native HTTP/3

  • WebTransport extension) is a two-way-door implementation detail (ADR-038); the one-way constraint is that h3 is served by this crate as a first-class transport.

Constraints

  • h3 requires X.509. Browsers don't support RFC 7250 (ADR-027). A node serving h3 must have an X.509 identity. Raw-key-only nodes serve h2/http/1.1 but not h3.
  • h3 is behind the h3 feature gate. The wtransport (or quinn HTTP/3 extension) dependency is heavier than h2/http/1.1; non-browser-facing deployments don't compile it.
  • Browsers are not alknet peers. A browser over WebTransport authenticates by bearer token, gets no PeerId (ADR-034 §4).
  • WebTransport streams target one of three destinations (the session's CONNECT path is the routing key): the call protocol (EventEnvelopeDispatcher, bidirectional — both sides can initiate calls), an ALPN handler proxy (→ HandlerRegistry lookup → target handler's handle(), the substrate's non-call-ALPN mechanism), or another sub-protocol. See ADR-040 and ADR-043.
  • The call-protocol WebTransport session is bidirectional. Both sides can initiate calls, inheriting the call protocol's native bidirectionality (ADR-043 §2). The browser case where the client registers no ops is a use-case scoping, not an architectural limitation.
  • Non-peer WebTransport clients use a connection-local overlay. A WebTransport client with no PeerId (browser, or any non-peer client) has its registered ops land in a connection-local Layer 2 overlay, not the peer-keyed PeerCompositeEnv. This is the inbound mirror of ADR-034 §2. See ADR-043 §3.
  • The ALPN-stream-proxy requires Arc<HandlerRegistry> on HttpAdapter. The h3 handler looks up ALPN handlers in the registry; the h2/http/1.1 path does not use it. The registry is static at startup (ADR-010).
  • The HTTP/3 request path uses the same axum Router as h2/ http/1.1. An HTTP/3 request is just another HTTP request from the router's perspective (ADR-036).
  • WebTransport is a draft standard. The wtransport README notes the protocol is not yet standardized; the API may change. The h3 feature gate isolates the risk.

Design Decisions

Note: This table reflects the design as written for revival. ADR-038 is superseded by ADR-044; ADR-040 and ADR-043 are parked (implementation deferred per ADR-044). The decisions revive unchanged when WebTransport revives — see the header note and ADR-044 for the scope rationale and reversal trigger.

Decision ADR Summary
h3/WebTransport is first-class ADR-038 Superseded by ADR-044 (scope deferral); originally "in scope, not deferred; browser streaming uses QUIC streams"
WebTransport is a bidirectional ALPN transport substrate ADR-043 Parked per ADR-044. Carries ALPNs as bidirectional streams; call protocol is the first/canonical target (needs no WASM parser); both sides can initiate calls
WebTransport ALPN-stream-proxy ADR-040 Parked per ADR-044. The substrate's mechanism for non-call ALPNs (SSH, git, SFTP) — browser → WebTransport stream → target ALPN handler via WASM parser
Browsers require X.509 ADR-027 h3 needs X.509 (browser limitation; applies when WebTransport revives)
Browsers are not alknet peers ADR-034 §4 (amended by ADR-044 §5) Bearer token, no PeerId (rationale in ADR-044 §5)
WebTransport streams → call protocol directly ADR-012 Stream-agnostic; WebTransport stream = QUIC bidirectional stream
BiStream is a trait (WASM door) ADR-007 Browser implements BiStream over WebTransport stream; WASM parser speaks the protocol
Stealth on h3 ADR-010 Unknown paths get the decoy
HTTP path = operation path (for HTTP/3 requests) ADR-036 Same axum Router as h2/http1.1

Open Questions

See open-questions.md for full details.

  • OQ-38 (open, scope): WebTransport relay-as-proxy — the standalone relay service (a future alknet-relay crate, fork of iroh-relay with WebTransport-based proxy fallback). This is distinct from the in-process ALPN-stream-proxy (ADR-040, in alknet-http). See OQ-38 for the relay crate boundary question.

References

  • ADR-038 — the decision that h3 is in scope
  • ADR-043 — the substrate framing: WebTransport carries ALPNs as bidirectional streams; call protocol is the first target; bidirectionality; the no-PeerId connection-local overlay
  • ADR-036 — the HTTP-to-call mapping (the HTTP/3 request path uses the same axum Router)
  • overview.md — crate overview, feature gates
  • http-server.md — the h2/http/1.1 companion (§"One-directional projection" — the lossy HTTP/1.1+HTTP/2 surface WebTransport restores bidirectionality for)
  • /workspace/wtransport/ — pure-Rust WebTransport reference implementation (the h3 feature's candidate dependency)