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

395 lines
21 KiB
Markdown

---
status: draft
last_updated: 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](decisions/067-websocket-carries-channels.md)): 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](decisions/048-websocket-native-session-not-gateway.md), 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](http-server.md)). A browser (or any WS client — Node,
a native app, the `from_wss` consumer
([ADR-070](decisions/070-from-wss-consumer-adapter.md)) 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](decisions/067-websocket-carries-channels.md); the decision
that the WS path carries the native session rather than the gateway
shape is [ADR-048](decisions/048-websocket-native-session-not-gateway.md).
### 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](decisions/070-from-wss-consumer-adapter.md)). 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](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](decisions/046-assembly-layer-custom-http-routes.md)'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](decisions/048-websocket-native-session-not-gateway.md)'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 `Message`s, and writes are
whole `Message`s. 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](decisions/070-from-wss-consumer-adapter.md)) — 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](decisions/015-privilege-model-and-authority-context.md).
### 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](decisions/042-openapi-gateway-pattern.md)); 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](decisions/068-gateway-publish-endpoint.md)) 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](decisions/034-outgoing-only-x509-and-three-peer-roles.md) §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](decisions/004-auth-as-shared-core.md);
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](decisions/014-secret-material-flow-and-capability-injection.md)).
## Constraints
- **The WS path is the channels session, not the gateway shape
([ADR-067](decisions/067-websocket-carries-channels.md),
[ADR-048](decisions/048-websocket-native-session-not-gateway.md)).**
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: Bearer`
`resolve_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](decisions/048-websocket-native-session-not-gateway.md)
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](decisions/046-assembly-layer-custom-http-routes.md)).**
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](decisions/067-websocket-carries-channels.md) | 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](decisions/048-websocket-native-session-not-gateway.md) | 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](decisions/044-defer-webtransport-browsers-use-websocket.md) | Stands; WebTransport removed from alkhttp scope ([ADR-069](decisions/069-webtransport-out-of-scope.md)) |
| 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](decisions/034-outgoing-only-x509-and-three-peer-roles.md) §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](decisions/004-auth-as-shared-core.md) | WS upgrade request credential source (same as HTTP; mechanism: alkcall ADR-003) |
| Browsers require X.509 (TLS) | [ADR-027](decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | 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](decisions/010-alpn-router-and-endpoint.md) | The WS upgrade route is on `HttpAdapter`'s default surface |
| Custom routes collision rule | [ADR-046](decisions/046-assembly-layer-custom-http-routes.md) | The WS upgrade route must not collide with reserved default-surface paths |
| `from_wss` consumer adapter | [ADR-070](decisions/070-from-wss-consumer-adapter.md) | The outbound mirror: same adapter, `ChannelClient` consumer half, `wss` feature |
## Open Questions
See [open-questions.md](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](decisions/067-websocket-carries-channels.md) — the
channels-over-WS decision (this document's specification target)
- [ADR-048](decisions/048-websocket-native-session-not-gateway.md) —
the native-session decision (amended by ADR-067 for framing and path)
- [ADR-070](decisions/070-from-wss-consumer-adapter.md) — the consumer
side
- [ADR-044](decisions/044-defer-webtransport-browsers-use-websocket.md)
— WS as the browser path (stands); [ADR-069](decisions/069-webtransport-out-of-scope.md)
— WebTransport out of scope
- [http-server.md](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)