Spike against alkcall source resolved ADR-067 assumptions: - write_chunk issues header+payload as separate write_alls; channel 0's write_frame issues prefix+body separately — a logical write can surface as multiple chunks, so the WS adapter must parse outgoing chunk boundaries (byte-stream treatment both directions), not assume write-per-chunk or message-per-chunk - MAX_CHUNK_LEN is 16 MiB; the WS path needs a practical message cap with oversized chunks split across messages - install_channel_zero + run_loop_single_stream confirmed as the exact server-side seam; EOF/teardown invariants already specified by alkcall (REQ-CH-01/02) Corrections applied to websocket.md, ADR-067, OQ-01. docs/plans/implementation.md: scoped plan guiding task decomposition — spike findings, 4-phase build order, OQ dispositions, task conventions.
175 lines
8.0 KiB
Markdown
175 lines
8.0 KiB
Markdown
# ADR-067: WebSocket Carries the Channels Protocol
|
|
|
|
## Status
|
|
|
|
Accepted
|
|
|
|
## Context
|
|
|
|
The alknet design (ADR-044, ADR-048 there) made the WebSocket path a
|
|
bare call-protocol session: one `EventEnvelope` JSON object per binary
|
|
WS message, handed directly to the shared `Dispatcher`. That design
|
|
predates the channels protocol — the 8-byte chunk multiplexer (alkcall
|
|
ADR-034/035) with channel 0 pre-negotiated as `alk/call` (alkcall
|
|
ADR-036) — and was specified when browsers needed only the call
|
|
protocol.
|
|
|
|
alkhttp is extracted onto alkcall, where the call protocol and the
|
|
channels protocol are one crate, one connection model, and one wire
|
|
story. Two problems remain with the bare-envelope WS design:
|
|
|
|
1. **No data channels for browsers.** A browser session over WS could
|
|
reach the call protocol but could never open a data channel (TTY,
|
|
tunnel, a WASM SSH client's transport). Every downstream protocol
|
|
crate that rides channels would need a separate browser path.
|
|
2. **Two connection shapes, one stack.** A browser WS session and a
|
|
Rust in-line channels session (TCP+TLS) would be structurally
|
|
different sessions at the dispatch layer: the browser one a raw
|
|
envelope stream, the Rust one a channels connection with a
|
|
`ChannelManager`. Hub code that wants to treat browser sessions and
|
|
Rust spokes uniformly would branch on the transport.
|
|
|
|
## Decision
|
|
|
|
**The WebSocket path carries the channels protocol, not a bare
|
|
envelope stream.** A WS session is an **in-line channels substrate**
|
|
(alkcall ADR-034 §substrate modes): the WS connection's binary message
|
|
stream is the transport; the 8-byte chunk header demultiplexes N
|
|
logical channels over it; **channel 0 is pre-negotiated as `alk/call`**
|
|
(alkcall ADR-036) and carries the native call-protocol session — the
|
|
shared `Dispatcher` runs on it unchanged.
|
|
|
|
### Upgrade path
|
|
|
|
The default WS upgrade path is **`/alk/channels`** (was `/alknet/call`
|
|
in the alknet design). The path is an axum route on the `HttpAdapter`
|
|
router, subject to the same reserved-path collision rule as any
|
|
default-surface route ([ADR-046](046-assembly-layer-custom-http-routes.md)).
|
|
|
|
### Framing: the chunk header is the boundary, not the WS message
|
|
|
|
The alknet design's "one `EventEnvelope` = one binary WS message, no
|
|
length prefix" framing is **superseded**. The WS binary message stream
|
|
is treated as a byte stream; the 8-byte chunk header is the only
|
|
framing. Call-protocol envelopes ride inside channel 0 as
|
|
length-prefixed JSON (alkcall ADR-014 frame format), the same as any
|
|
other in-line channels transport. A chunk may span WS messages and a
|
|
WS message may carry chunk fragments (in practice the write path
|
|
usually produces one chunk per message, but nothing may depend on
|
|
it — channel 0's frame writer issues two writes, prefix then body,
|
|
which can surface as two chunks).
|
|
|
|
Layering on the wire, for a call frame over WS:
|
|
|
|
```
|
|
WS binary message(s) — byte stream
|
|
└── chunk header [channel_id: u32 BE][length: u32 BE] (8 bytes)
|
|
└── payload = frame [len: u32 BE][EventEnvelope JSON] (channel 0)
|
|
```
|
|
|
|
A data channel's chunks are `chunk header + opaque payload` — the
|
|
handler (TTY, tunnel, WASM client) owns its framing inside the payload,
|
|
exactly as over TCP+TLS (alkcall ADR-035: no `stream_type`, the
|
|
handler owns its sub-stream multiplexing).
|
|
|
|
The WS↔byte-stream adaptation (how the message-oriented WS stream
|
|
presents as the byte stream the channels demux reads, and how the mux's
|
|
byte writes become WS messages) is the implementation's core piece and
|
|
its buffering semantics are tracked in OQ-01.
|
|
|
|
### Dispatch: channel 0 = the shared `Dispatcher`, unchanged
|
|
|
|
On upgrade, the handler:
|
|
|
|
1. Resolves the caller's identity from the `Authorization: Bearer`
|
|
header via `IdentityProvider::resolve_from_token()` — the same auth
|
|
path as any HTTP request ([ADR-004](004-auth-as-shared-core.md)).
|
|
No token → `401`. The resolved identity is carried on the session
|
|
for observability and `AccessControl`.
|
|
2. Wraps the WS stream as a `Connection` (`Connection::from_bidi`, ALPN
|
|
`alk/channels`).
|
|
3. Runs the channels accept path — alkcall's `ChannelsAdapter`
|
|
in-line demux loop — installing channel 0 via the
|
|
`install_channel_zero` hook: construct channel 0's `CallConnection`
|
|
and run `Dispatcher::run_loop_single_stream` on it, exactly as the
|
|
TCP+TLS in-line substrate does.
|
|
4. Data channels (1..N) are routed to whatever the deployment
|
|
registered as openable ALPNs — for a hub, the same openable ALPNs a
|
|
Rust spoke can reach. The browser opens them via the per-ALPN open
|
|
ops on channel 0 (alkcall ADR-047), the same mechanism as any
|
|
consumer.
|
|
|
|
Everything ADR-048 says about dispatch — `call.requested` →
|
|
`Dispatcher::dispatch_requested` with `AccessControl::check` gating,
|
|
`call.responded`/`call.completed`/`call.aborted` correlated by `id` via
|
|
the pending map, text WS messages rejected with a protocol-level close
|
|
— applies to channel 0 verbatim. The only framing change is the
|
|
envelope's position in the layering (above).
|
|
|
|
### Bidirectionality, overlay, browsers-are-not-peers: unchanged
|
|
|
|
- **Both sides can initiate calls** on channel 0 (alkcall ADR-015's
|
|
stream-agnostic correlation). The browser calls hub ops; the hub can
|
|
call browser-registered ops over the same session.
|
|
- **Browser-registered ops land in the connection-local Layer 2
|
|
overlay** (alkcall ADR-019) and die when the WS connection drops.
|
|
- **Browsers are not peers** ([ADR-034](034-outgoing-only-x509-and-three-peer-roles.md)
|
|
§4): bearer token, no `PeerId`, not in the peer graph. The connection
|
|
handle, not a `PeerRef`, is how the hub reaches browser ops.
|
|
|
|
### What this gives the browser
|
|
|
|
A browser session is now structurally the same session as a Rust
|
|
in-line channels session:
|
|
|
|
- Call protocol operations (channel 0) — both directions.
|
|
- Data channels for anything the deployment registers as openable —
|
|
a WASM SSH client, a TTY, a tunnel — with no browser-specific
|
|
protocol work.
|
|
- The same discovery ops (`services/list`, `services/schema`) as any
|
|
consumer.
|
|
|
|
## Consequences
|
|
|
|
**Positive:**
|
|
|
|
- One session shape across browser and Rust in-line transports; hub
|
|
code is transport-blind.
|
|
- Browsers get data channels for free, riding alkcall's channels
|
|
machinery rather than a bespoke browser protocol.
|
|
- The `from_wss` consumer adapter ([ADR-070](070-from-wss-consumer-adapter.md))
|
|
becomes the mirror image of the server path: the same WS byte-stream
|
|
adaptation, then alkcall's `ChannelClient`.
|
|
- The WS framing delta from alknet is contained to alkhttp's WS adapter;
|
|
the Dispatcher, registry, and channels layers are untouched (they are
|
|
alkcall's).
|
|
|
|
**Negative:**
|
|
|
|
- Browsers must speak the channels framing (8-byte header, channel 0
|
|
open ops) rather than bare JSON envelopes. The JS client for this
|
|
lives outside alkhttp; the framing is small and the alkcall BAST
|
|
document (`chunk-header.bast.json`) is the cross-language contract.
|
|
- The WS↔byte-stream adapter is new, unproven code with backpressure
|
|
and partial-write hazards (OQ-01).
|
|
- Existing alknet-era WS clients (bare envelope per message) break. No
|
|
such client ships outside the mono-repo; the break is intentional and
|
|
one-way.
|
|
|
|
## References
|
|
|
|
- [websocket.md](../websocket.md) — the full WS session spec
|
|
- [ADR-048](048-websocket-native-session-not-gateway.md) — the native
|
|
session (not gateway shape) decision this ADR amends (channel 0
|
|
framing; upgrade path)
|
|
- [ADR-044](044-defer-webtransport-browsers-use-websocket.md) — WS as
|
|
the browser bidirectional path (stands); deferral mechanics
|
|
superseded by [ADR-069](069-webtransport-out-of-scope.md)
|
|
- [ADR-070](070-from-wss-consumer-adapter.md) — the consumer-side
|
|
mirror of this decision
|
|
- alkcall ADR-014 (EventEnvelope framing), ADR-015 (stream model),
|
|
ADR-034/035 (channels wire format, pure multiplexing), ADR-036
|
|
(channel 0 pre-negotiated `alk/call`), ADR-039 (ChannelsAdapter),
|
|
ADR-043 (ChannelClient), ADR-047 (openable ALPNs are operations)
|
|
- alkcall `docs/architecture/channels-wire.md` — the 8-byte chunk
|
|
format and wire invariants |