Streaming over the multiplexed WebSocket
GET /v1/stream. One WebSocket carrying every server-push channel (chat, notifications, preferences, deposit/withdraw watches, workflow, data-studio previews), with per-channel sequence numbers and reconnect replay.
GET /v1/stream (WebSocket upgrade)
Every long-lived server-push stream the gateway emits (chat turns, notifications, preference saves, deposit/withdraw watches, workflow runs, Data Studio previews) rides one multiplexed WebSocket. Folding them onto a single socket keeps them out of the browser's ~6-connection-per-origin HTTP/1.1 pool, so a busy or stuck stream in one tab can't starve other tabs' plain requests.
| Method | GET (HTTP Upgrade: websocket) |
| Path | /v1/stream |
| Auth | ?token=<GATEWAY_AUTH_TOKEN> query param when auth is enabled (a browser WebSocket can't set an Authorization header) |
Reference client: createMultiplexClient
in @minara/gateway-client. It owns reconnect, resume, and the connection
timeout, so callers only write subscribe(channel, key, { onEvent }).
Connecting
Open a WebSocket to /v1/stream, deriving ws:// / wss:// from the
gateway origin. When GATEWAY_AUTH_TOKEN is set, append ?token=<token>;
a bad or missing token is rejected at the handshake with 401.
wss://<gateway-host>/v1/stream?token=<GATEWAY_AUTH_TOKEN>The connection multiplexes many logical streams. A channel is the stream
type; a key scopes one subscription within a channel (a chat session id,
a withdraw operation id, a workflow instance id, …). Singleton channels
(notifications, prefs) use an empty-string key.
Channels
| Channel | Key | Replay | Source |
|---|---|---|---|
chat | session_id | yes (per-turn buffer) | a turn started by POST /v1/chat/stream |
notifications | "" | no (live-only) | global notification bus |
prefs | "" | no (live-only) | runtime-preference saves |
deposit | chain:asset:address | yes | on-demand deposit watch |
withdraw | operationId | yes | on-demand withdraw watch |
workflow | instanceId | yes | workflow run bus |
data-studio | session_id | yes | Data Studio live preview |
Live-only channels (notifications, prefs) deliver only events that
occur after a subscription attaches. Replaying the backlog to a freshly
opened tab would re-toast old notifications. Reconnect gaps on those are
covered by each feature's REST reload.
On-demand channels (deposit, withdraw, workflow, data-studio)
start their upstream when the first subscriber for a (channel, key)
attaches and stop it when the last one leaves (the hub ref-counts), so a
deposit watch's polling runs only while a tab is watching it.
Client → server frames
Send JSON control frames to subscribe and unsubscribe. Re-subscribing the
same (channel, key) replaces the previous attachment.
{ "op": "subscribe", "channel": "chat", "key": "chat_abc", "fromSeq": 0 }{ "op": "unsubscribe", "channel": "chat", "key": "chat_abc" }fromSeq is the reconnect cursor: the server replays every buffered event
with seq strictly greater than it. Omit it (or send 0) for a fresh
subscription that wants the full backlog.
Server → client frames
Each event is one JSON frame. type and data are carried verbatim from
the channel's own event union (e.g. text_delta for chat), so a client
that already parsed the pre-WebSocket streams reuses its reducers unchanged.
{ "channel": "chat", "key": "chat_abc", "seq": 12, "type": "text_delta", "data": { "text": "…" } }| Field | Meaning |
|---|---|
channel / key | which subscription the frame belongs to |
seq | monotonic per-(channel, key) sequence from 1; the reconnect cursor |
type | the channel's own event discriminator |
data | the channel's own payload |
Control frames
Two out-of-band frames the multiplex layer itself emits carry seq: 0 and
never participate in replay:
type | Meaning |
|---|---|
__error | the subscription was rejected or has no live stream (e.g. a chat session with no in-flight turn). data.message is human-readable; the client falls back to a REST reload. |
__truncated | the replay buffer overflowed and dropped events older than the delivered backlog, so the client can warn that some history was lost. |
Reconnect & resume
On disconnect the reference client reconnects with exponential backoff and
re-subscribes every live channel from the highest seq it saw, so the
server replays only what was missed. Resume relies on the per-(channel, key) buffer: it is process-local and does not survive a gateway
restart, after which a chat re-subscribe returns __error ("no active
stream") and the client reloads from REST.
Failure is bounded so a client never hangs: a socket that never reaches the open state is force-closed and retried, and once the reconnect budget is exhausted every live subscription's error handler fires once (rather than spinning silently) while the client keeps retrying at the capped backoff.
Keep-alive
The gateway pings every open socket on a fixed interval. A WebSocket ping is real traffic the browser auto-pongs, so it stops an idle proxy in front of the gateway from reaping a quiet connection, and it reaches sockets with no active subscription, which a channel event never would.