Files
alkhttp/docs/architecture/websocket.md
glm-5.3-flash a9429dfb1d chore(deps): consume alkcall 0.6.0 — OpenHandler gains the plan parameter
Mechanical pass for the alkcall 0.6.0 bump (review 007's
establishment follow-ups sweep: R-01 Establishment plan payload,
R-02 OpenHandler lifetime doc note, R-03 pump_bidi extraction):

- bump alkcall 0.5 -> 0.6 (gateway feature unchanged)
- the OpenHandler signature gained a plan parameter —
  Fn(Value, Option<ChannelPlan>, Connection, AuthContext) ->
  JoinHandle<()> — so the test's echo_open_handler closure gains
  `_plan` (the only closure-construction site in this crate)
- no ferry change: this crate constructs no Establishment and passes
  the Option<OpenEstablisher> through unchanged (OpenableAlpn fields
  and register_openable_with_establisher threading are unchanged in
  0.6.0); the establisher's Establishment.plan now reaches the pump
  handler's second parameter process-locally (ADR-049 amendment 2)
- docs updated: websocket.md (plan threading + pump_bidi pointer),
  OpenableAlpn field docs, with_ws_openable_alpns doc comment
- CHANGELOG [Unreleased] consumption-wave entry

Verification: cargo test (454 passed), cargo test --all-features
(587 passed), clippy -D warnings (default + all-features),
fmt --check, cargo doc --no-deps clean
2026-09-07 09:24:00 +00:00

27 KiB

status, last_updated
status last_updated
draft 2026-09-04

WebSocket — the Browser Bidirectional Path (Channels over WS)

WebSocket is the browser bidirectional path to the call protocol and the channels protocol. A WS session carries the channels protocol (ADR-067): the 8-byte chunk header multiplexes N logical channels over the WS binary message stream; channel 0 is pre-negotiated as alk/call and carries the native call-protocol session, dispatched by the shared Dispatcher. This supersedes the alknet design where WS carried one bare EventEnvelope per message.

A WS session is a native channels session, not the HTTP gateway shape — the gateway endpoints (/search, /schema, /call, /batch, /subscribe, /publish) are the HTTP one-directional projection and do not appear on the WebSocket path (ADR-048, as amended by ADR-067). Discovery is via services/list/services/schema as call-protocol operations on channel 0.

What

The WebSocket path is an axum WS upgrade handler on the same HttpAdapter that serves h2/http/1.1 (http-server.md). A browser (or any WS client — Node, a native app, the from_wss consumer (ADR-070) in reverse) opens an HTTP/1.1 or HTTP/2 request to the upgrade path, authenticates by bearer token on the upgrade request, and the resulting full-duplex WS connection is a channels connection: the 8-byte chunk format (alkcall ADR-034) runs over it exactly as over TCP+TLS.

Why

WebSocket is the HTTP-family transport that restores the alk protocols' native bidirectionality for browsers — HTTP/1.1 + HTTP/2 are request/response (a one-directional projection of the call protocol), while WS is a full-duplex, long-lived, framed-message channel. Carrying the channels protocol (rather than a bare envelope stream, the alknet choice) makes a browser session structurally identical to a Rust in-line channels session (TCP+TLS): one session shape, one dispatch path, data channels included. The decision is ADR-067; the decision that the WS path carries the native session rather than the gateway shape is ADR-048.

The consumer-side mirror: from_wss

The same session serves the outbound direction: a Rust process dials a remote node's WSS endpoint and consumes its operations (ADR-070). Server upgrade and consumer dial share the WS↔byte-stream adapter; the session halves are alkcall's (ChannelsAdapter accept path vs ChannelClient consumer path).

Architecture

The WS upgrade handler

The WS upgrade is an HTTP/1.1 or HTTP/2 request handled by an axum route on HttpAdapter's router. The handler:

  1. Receives the HTTP upgrade request (axum's WebSocketUpgrade extractor).
  2. Resolves the caller's identity from the Authorization: Bearer header via identity_provider.resolve_from_token(&AuthToken { raw: token_bytes }) (AuthToken is in alkcall::core::auth) — the same auth path as any HTTP request (http-server.md §"Auth"). The upgrade is rejected (401) if no token is present; insufficient scopes for any op the browser later calls surface as FORBIDDEN at call time, not at upgrade time (the upgrade doesn't know which ops the browser will call).
  3. Upgrades to WebSocket (axum's WebSocketUpgrade::on_upgrade), producing a full-duplex WebSocket stream.
  4. Adapts the WS message stream to a byte stream (see §"Framing" below — the adapter is this spec's core new piece).
  5. Wraps the byte stream as a Connection (Connection::from_bidi, ALPN alk/channels).
  6. Runs the channels accept path: the in-line demux loop (alkcall ChannelsAdapter) installs channel 0 via the install_channel_zero hook — constructing channel 0's CallConnection (identity attached) and running Dispatcher::run_loop_single_stream on it.
  7. Data channels (1..N) route per the deployment's openable-ALPN registrations; the browser opens them via the per-ALPN open ops on channel 0 (alkcall ADR-047), the same mechanism any consumer uses. Wired 2026-09-04 (review 006 Unit 2+3): the hook registers the deployment's openables — HttpAdapter::with_ws_openable_alpns (default: none) or the OpenableAlpns request-extension fallback — plus the generic channel ops, the bootstrap discovery set, and op/register, on the per-session fork (alkcall ADR-047 §4 amendment #2).

The default upgrade path is /alk/channels (was /alknet/call in the alknet design). The path must not collide with the reserved gateway//healthz//openapi.json/MCP/custom-route paths per ADR-046's collision rule; /alk/channels namespaces away from the reserved set naturally. The upgrade runs over HTTP/1.1 (RFC 6455) or HTTP/2 (extended CONNECT, RFC 8441); axum/hyper supports both, and the handler does not branch on which — the WS frame stream is the same once the upgrade completes.

Framing: WS messages carry chunks; channel 0 carries length-prefixed envelopes

The wire layering:

WS binary message (message boundary = transport frame)
└── chunk header [channel_id: u32 BE][length: u32 BE]        (8 bytes)
    └── payload
        channel 0: [len: u32 BE][EventEnvelope JSON]          (call frame)
        channel N: handler-owned framing (opaque to channels)
  • WS messages are transport frames, not protocol boundaries. The WS binary message stream is treated as a byte stream; the 8-byte chunk header is the only framing. A chunk may span WS messages and a WS message may carry chunk fragments — the adapter (below) is the seam. (In practice the write path usually emits one chunk per message, but nothing may depend on it: channel 0's frame writer issues two writes — length prefix, then body — which the mux can deliver as two mpsc payloads.)
  • Channel 0's payload is the call protocol's frame format (alkcall ADR-014): a 4-byte big-endian length prefix + UTF-8 JSON EventEnvelope. This is exactly the framing channel 0 uses over TCP+TLS; the WS path is not special at this layer. Chunk ≠ frame: a single frame may arrive as multiple chunks (the frame writer issues prefix and body as separate writes through the mux — live-confirmed in the ws-byte-adapter POC), so channel-0 consumers MUST reassemble length-prefixed frames from the channel-0 byte stream, never parse per-chunk. The request payload's operation name key is operationId.
  • Data-channel payloads are opaque — the handler owns its framing (alkcall ADR-035: no stream_type anywhere in the channels layer).
  • Text WS messages are rejected with a protocol-level close (code 1002). All frames are binary.
  • The alknet design's "one envelope = one WS message, no length prefix" rule is superseded; see ADR-048's status amendment.

The WS ↔ byte-stream adapter

The channels machinery reads and writes bytes (AsyncRead + AsyncWrite): the demux does read_exact on the 8-byte header and payload; the mux writes chunk bytes. A WebSocket is message-oriented: axum yields whole Messages, and writes are whole Messages. The adapter bridges the two, in both directions:

  • Inbound (WS → bytes): a background task pops WS binary messages and appends their bytes into a shared buffer; the AsyncRead half drains the buffer. Backpressure: the reader task awaits a bounded buffer slot before admitting the next message (bound: OQ-01).
  • Outbound (bytes → WS): the AsyncWrite half accumulates bytes into a pending buffer; a background task scans the pending bytes for complete chunks (8-byte header → payload length) and emits each complete chunk as one WS binary message, carrying any partial tail until its chunk completes. The adapter parses the outgoing byte stream to find chunk boundaries — it does not assume one write equals one chunk (verified against alkcall: write_chunk issues header+payload as separate writes, and channel 0's write_frame issues prefix+body as separate writes; each can surface as separate mux payloads). Oversized chunks (a single chunk exceeding the WS-message cap, up to MAX_CHUNK_LEN = 16 MiB) are split across multiple WS messages — legal, since the receiver's boundary is the chunk header, not the message. Flush semantics are an OQ-01 item.
  • Close mapping: WS close (either side) → transport EOF → the demux clears all channels (REQ-CH-02: every handler sees EOF) and channel 0's dispatch loop fails outstanding pendings with connection closed. AsyncWrite::shutdown maps to the zero-length EOF sentinel (REQ-CH-01) followed by a WS Close frame.

The adapter is shared with the from_wss consumer path (ADR-070) — one implementation, both directions.

Idle-read timeout: progress semantics + the no-keepalive decision (WS-01, WS-13)

The WS pumps carry an idle-read eviction knob (HttpAdapter::with_ws_idle_timeout, default DEFAULT_WS_IDLE_TIMEOUT = 60 s, disable with None). Its semantics, decided in review-002 (WS-13), are progress-based, deliberately strict:

  • The deadline resets on demux progress — bytes actually forwarded into the byte stream that complete an inbound chunk (a full 8-byte header + its declared payload). WS message arrival resets nothing.
  • Therefore the dribble stall (declare a chunk, deliver its payload a byte per message) hits the deadline and is evicted with a 1001 (GoingAway) close — even though messages keep arriving — while a peer delivering complete chunks, however slowly per message, re-arms the window with each chunk and survives.
  • There is no WS ping/pong keepalive, on purpose. A keepalive rescues app-silence only by re-arming the deadline — and a pong is indistinguishable from the dribble's almost-invisible arrivals, so adding one would reopen the stall it exists to seal. The recorded decision (module doc of src/websocket/byte_adapter.rs, option (b) of the two WS-13 alternatives): 60 s of no chunk progress is an intentional eviction line, even for a silent subscription.
  • A deployment running long-lived silent-but-alive sessions (quiet subscriptions that outlast the window) disables the knob with with_ws_idle_timeout(None) and leans on the remaining levers: the session registry's forced-eviction (WsSessions::abort), the inbound caps (WS-06), and the write-side caps (WS-04/05/06). This is the same deployment posture as from_wss's drop monitor (ADR-070): the idle knob bounds demux parking, not app liveness.
  • Data channels sharpen this knob's bite (surfaced with the data-channel wiring, review 006): a silent-but-alive data channel (an idle SSH-style session, an open-but-quiet tunnel) forwards no chunks, so the deadline runs out and the whole WS session is evicted with 1001 — exactly the same semantics as before the wiring (this is not a new rule), but the 60 s default now hits more often because sessions live longer and carry quiet channels. A deployment serving long-lived interactive channels should set with_ws_idle_timeout(None) at assembly and lean on the same levers as above; the idle knob remains a demux-parking bound, not an application keepalive.
  • Layered note (the WS-13/FWD-15 interaction recorded here): the HTTP SSE path (/subscribe) sends server-side keep-alive comment frames every 15 s — see http-server.md) — because its idle enemy is LB/proxy timeouts, and its keep-alive does not reset any progress deadline, it cannot reopen the WS-13 hole. The two live at different layers: SSE keep-alive fights transport fires; the WS idle knob bounds demux parking. Both documented in the same pass per review-002 Unit-2 sequencing.

Dispatch: channel 0 = the shared Dispatcher, unchanged

Channel 0's session is the alknet design's native session, verbatim:

  • For call.requested: runs AccessControl::check(identity) against the op's AccessControl, dispatches via the registry if allowed, returns FORBIDDEN (→ call.error) before the handler runs if not.
  • For call.responded/call.completed/call.aborted: correlates by id via the pending map (keyed by request ID, not by transport — alkcall ADR-015).
  • For call.published (initiator-side Pub ops): routed to the matching sink (alkcall ADR-046).
  • Writes response frames back through channel 0's shared writer.

Peer authorization flows through AccessControl::check against the resolved identity — an op with AccessControl::default() is callable by any authenticated browser; an op with required_scopes only by identities whose scopes satisfy them; an op with Visibility::Internal is never callable from the wire (NOT_FOUND before ACL). This is alkcall ADR-017's model; see also ADR-015.

Data channels for browsers

A browser opens a data channel exactly as any channels consumer:

  1. Calls the per-ALPN open op on channel 0 (channels/<alpn>/sub or channels/<alpn>/pub — alkcall ADR-047).
  2. The producer side (the hub) checks the open against the registered openable ALPNs and check_open(identity), allocates the channel_id, and returns it.
  3. Chunks with that channel_id flow over WS messages; both sides reassemble the BiStream.

The deployment decides which ALPNs are openable — the browser reaches exactly what a Rust consumer on an in-line connection would reach. This is what makes the WASM-SSH-client-in-a-browser use case (the one that motivated the alknet WebTransport track) workable over WS: the SSH byte stream rides a data channel.

The openable set is declared with HttpAdapter::with_ws_openable_alpns (each OpenableAlpn { spec, open_handler, establisher, establisher_timeout }; the ALPN-specific handlers and establishers stay in the ALPN crates — alkhttp ferries the registrations). The establisher (alkcall 0.5.0 / ADR-049) is the awaited establishment phase of the open op: None (the default) keeps the pre-0.5 shape — the open replies as soon as the pump handler spawns; Some semantically-validating hook dials/prepares the backend before the reply, and a bounded failure resolves channel:open_failed with details.reason (the channel never exists consumer-side). The per-registration timeout override bounds the establisher when the dispatch carries no deadline. The establisher's Establishment.plan payload (alkcall 0.6.0 / ADR-049 amendment 2 — a typed-opaque ChannelPlan) is threaded process-locally to the pump handler's second parameter: the establisher and the handler agree on the concrete type, so ALPN crates dial/allocate in the establisher and deliver the live handle to their handler without a side-channel handoff. Data-plane handlers that pump a channel stream against a peer's split halves can use alkcall's channels::pump_bidi helper (alkcall 0.6.0 / ADR-050). The OpenableAlpns request-extension fallback is available for bare-registry/custom upgrade routes. Cap policy is the ChannelsPolicy extension (one instance consulted by both the open wrappers and the demux teardown). Peer-announced ops (op/register) land in the connection-local overlay; discovery: services/list (the session's own surface) and services/list-peers (peer-announced ops, alkcall 0.3.1). E2E gates for the whole flow live in tests/ws_upgrade_session.rs.

WS-session discovery is the bootstrap set: the hook registers alkcall's services/list / services/schema / services/list-peers closed over the session's fork (so the listing sees the session's own openables — the F-06 shape), which shadows by design any services/* operation the deployment registered on its base registry (register inserts by name, so the per-session fork's bootstrap registrations overwrite the deployment's on the WS path). A deployment with a custom services/list keeps it on every other transport and gets alkcall's bootstrap listing on WS sessions — recorded here so the asymmetry is a decision, not a surprise (review 007 WS-31).

Bidirectionality

The WS channels session inherits both protocols' native bidirectionality (alkcall ADR-015 for calls; the channels protocol is symmetric by construction):

  • Calls: both sides can send call.requested on channel 0. The browser calls hub ops; the hub can call browser-registered ops over the same session, same pending map, same framing.
  • Channels: the browser can open data channels (via open ops); the hub can open them toward the browser if the browser side registers openable ALPNs (a browser that is also a producer).

The common case — a browser that registers no ops and opens no channels — is a use-case scoping, not an architectural limitation.

Connection-local overlay

A browser over WS has no PeerId on the hub's side. Any ops the browser registers land in a connection-local Layer 2 overlay (alkcall ADR-019; the mechanism the hub reaches via the live connection handle's overlay_env()). When the WS connection closes (browser closes the tab, network drops), the overlay and all its registered ops drop — no explicit deregistration. The hub reaches browser ops through the connection handle, not through PeerRef::Specific (the browser is not a peer — see below).

Streaming: native call.responded events, no SSE

A Sub operation invoked on channel 0 streams call.responded frames as channel-0 chunks — no SSE framing. SSE is the h2/http1.1 streaming projection (the gateway's /subscribe endpoint per ADR-042); on WS it is unnecessary because WS is already a framed full-duplex channel. The browser receives call.responded events one per frame, with the same id correlating them to the original call.requested; call.completed closes the subscription; call.aborted closes it with an error frame. This is identical to how subscriptions work over any in-line channels transport.

A Pub operation (browser as initiator) publishes call.published frames from the browser into channel 0 — the HTTP gateway's /publish (ADR-068) has no WS equivalent because the native mechanism already exists.

On WS client disconnect (the browser closes the tab mid-subscription), the session teardown detects the close and aborts in-flight subscriptions — the abort cascade runs per alkcall ADR-020.

Browsers are not alk peers

A browser over WS authenticates by bearer token, gets no PeerId, does not enter PeerCompositeEnv, and its registered ops (if any) land in the connection-local overlay. The rationale (ADR-034 §4, amended by alknet ADR-044 §5) is a load-bearing distinction:

"Peer" means an addressable node in the call-protocol peer graph — a stable PeerId, reachable via PeerRef::Specific, whose identity is stable across reconnects. It does not mean "any endpoint that exchanges calls during a live session." A browser is the second thing but not the first:

  1. No stable cryptographic identity of its own. A peer entry is anchored to fingerprints the peer presents and the local node pins. A browser presents a bearer token the hub issued — hub bookkeeping, not pinnable identity. There is nothing to put in fingerprints.
  2. Ephemeral. Close the tab → connection dies → the connection-local overlay dies with it. A peer entry keyed to a browser would be permanently dead within seconds.
  3. Not addressable from other nodes. PeerRef::Specific resolves through a peer entry; another node has no way to reach "the browser currently connected to hub-A." The hub holds that connection as a live handle, not a peer-graph entry.

The browser is a bidirectional call target during a live session, not a peer-graph member; the connection-local overlay is what gives the former without the latter.

Auth: bearer token on the upgrade request

Inbound WS auth is Authorization: Bearer <token> on the HTTP upgrade request, resolved via IdentityProvider::resolve_from_token() — the same path as any HTTP request (ADR-004; mechanism owned by alkcall ADR-003). Bearer-only is the auth mechanism; other schemes would be added as axum middleware on the upgrade route (two-way door). The resolved identity drives AccessControl::check on every call.requested the browser sends and check_open on every channel-open op — per-privilege filtering is free via services/list's existing AccessControl filtering.

For the consumer side (from_wss), the same shape applies outbound: the adapter presents the token on its dial, sourced from Capabilities (the no-env-vars path, ADR-014).

Constraints

  • The WS path is the channels session, not the gateway shape (ADR-067, ADR-048). The 6 gateway endpoints are HTTP-only and do not appear on WS. Discovery via services/list/services/schema as call-protocol ops; streaming as native frames, not SSE.
  • Bearer-only auth on the upgrade request. Authorization: Bearerresolve_from_token. The resolved identity drives AccessControl::check on calls and check_open on channel opens.
  • Browsers are not alk peers. Bearer token, no PeerId, connection-local Layer 2 overlay for browser-registered ops.
  • One WS binary message = one chunk; channel 0 frames are length-prefixed JSON inside the chunk payload. Text messages are rejected. The alknet "one envelope per message" rule is superseded (ADR-048 status amendment).
  • The shared Dispatcher runs on channel 0 unchanged (alkcall ADR-015). The dispatch half is one implementation across all in-line transports; only connection establishment differs.
  • The default upgrade path is /alk/channels; it must not collide with reserved paths (ADR-046). Overridable via extra_routes.
  • The WS↔byte-stream adapter is the single seam between axum's WS and alkcall's byte-oriented channels machinery — shared with from_wss, semantics tracked in OQ-01.

Design Decisions

Decision ADR Summary
WS carries the channels protocol (channel 0 = alk/call) ADR-067 WS = in-line channels substrate; 8-byte chunk demux; upgrade path /alk/channels; supersedes bare-envelope framing
WS carries the native session, not the gateway shape ADR-048 Channel 0 carries the native call session; gateway endpoints are HTTP-only; discovery via services/list/services/schema
Call protocol stream model alkcall ADR-015 Stream-agnostic correlation; the pending map and dispatch loop run over any transport
WS as the browser bidirectional path ADR-044 Stands; WebTransport removed from alkhttp scope (ADR-069)
Operation registry layering (connection-local overlay) alkcall ADR-019 Browser-registered ops land in a per-connection overlay that dies with the WS connection
Browsers are not alk peers ADR-034 §4 Bearer token, no PeerId; addressability vs bidirectionality rationale
Abort cascade on disconnect alkcall ADR-020 WS close mid-subscription aborts in-flight ops, cascading to descendants
Bearer auth via resolve_from_token ADR-004 WS upgrade request credential source (same as HTTP; mechanism: alkcall ADR-003)
Browsers require X.509 (TLS) ADR-027 The WS upgrade runs over the same TLS as h2/http1.1; TLS provisioning is an alknet concern
Stealth: HTTP handler on standard ALPNs serves WS upgrade ADR-010 The WS upgrade route is on HttpAdapter's default surface
Custom routes collision rule ADR-046 The WS upgrade route must not collide with reserved default-surface paths
from_wss consumer adapter ADR-070 The outbound mirror: same adapter, ChannelClient consumer half, wss feature

Open Questions

See open-questions.md for full details.

  • OQ-01 (resolved): WS ↔ byte-stream adaptation — resolved by the production adapter (byte_adapter.rs): inbound bounded mpsc (64 slots, backpressure), outbound chunk parser with 1 MiB message cap, flush no-op, close → EOF mapping; validated end-to-end in both directions (server upgrade path + from_wss client).
  • OQ-04 (open): browser client library ownership — the JS/TS client speaking channels-over-WS (chunk framing, channel 0 open ops, envelope handling) is needed for browser consumers; it lives outside this crate, but the BAST chunk-header contract (alkcall chunk-header.bast.json) is the shared reference.

References

  • ADR-067 — the channels-over-WS decision (this document's specification target)
  • ADR-048 — the native-session decision (amended by ADR-067 for framing and path)
  • ADR-070 — the consumer side
  • ADR-044 — WS as the browser path (stands); ADR-069 — WebTransport out of scope
  • http-server.md — the HttpAdapter hosting the upgrade route
  • The alkcall crate's docs/architecture/ — call protocol wire format (ADR-014), stream model (ADR-015), registry layering (ADR-019), abort cascade (ADR-020), channels wire format (ADR-034/035), channel 0 pre-negotiation (ADR-036), ChannelsAdapter (ADR-039), ChannelClient (ADR-043), openable ALPNs (ADR-047); channels-wire.md for the chunk format and wire invariants (REQ-CH-01/02/04/05)
  • The alknet mono-repo's @alkdev/pubsub WS client/server — prior art for the browser WS path lineage (the envelope shape the call protocol refined)