Files
alkhttp/docs/architecture/websocket.md
T
glm-5.3-flash 4a825d33e7 feat(infra): full-surface integration suite + docs sync + publish prep
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.
2026-08-28 16:07:56 +00:00

21 KiB

status, last_updated
status last_updated
draft 2026-08-27

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.

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.

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.

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)