Add area-level documentation for the five most significant cross-context feature areas under agents-docs/features/: - websocket-envelopes: full envelope catalogue, lifecycle, dispatcher - ipc-bridge: window.electronAPI surface, IPC channels, CQRS dispatch - plugin-system: manifest contract, runtime, capabilities, plugin-support API - server-directory: REST endpoints, CQRS, entities, business rules - voice-signaling: mesh signaling, RNNoise pipeline, domain split Update agents-docs/FEATURES.md index alphabetically and remove the "no cross-context feature docs" placeholder. Each doc records honest TODOs for verified gaps (stale signaling-contracts.ts, window.api vs window.electronAPI mismatch, IPC error envelope drift from CONTEXT.md, missing OpenAPI coverage for server-directory routes, no envelope round-trip test). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
165 lines
11 KiB
Markdown
165 lines
11 KiB
Markdown
# WebSocket Envelopes
|
|
|
|
> **Area:** websocket-envelopes
|
|
> **Status:** Active
|
|
> **Last updated:** 2026-05-25
|
|
|
|
## Overview
|
|
|
|
The WebSocket envelope contract is the realtime wire-format boundary between the signaling server and every connected client. Every realtime concern in Toju — presence, chat broadcasts, typing indicators, voice state, WebRTC offer/answer/ICE forwarding, direct messages, server icon P2P sync, and plugin events — travels as a typed envelope over a single WebSocket connection per client. Drift between the server definition and the client-side mirror is treated as a wire-protocol break.
|
|
|
|
## Responsibilities
|
|
|
|
- Define the canonical shape of every realtime message exchanged between `toju-app` (renderer) and `server`.
|
|
- Route incoming envelopes to a single dedicated handler on the server.
|
|
- Provide a stable identity for the connection (`connectionId`, `oderId`, `connectionScope`) and a lazy authorization model on `join_server`.
|
|
- Forward peer-targeted envelopes (WebRTC signaling, direct messages, server-icon peer transfers) without inspecting their payload.
|
|
|
|
This area does **not** own:
|
|
|
|
- The HTTP/REST surface (see [server-directory](./server-directory.md)).
|
|
- WebRTC media transport or session orchestration (see [voice-signaling](./voice-signaling.md) — the envelope contract is shared, but session lifecycle lives there).
|
|
- Persistence (server entities are owned by the server subdomain; the envelope is the contract, not the entity).
|
|
|
|
## Key concepts
|
|
|
|
- **Envelope** — a `{ type, ...payload }` message routed by `type`. Defined in `server/src/websocket/types.ts`.
|
|
- **ConnectedUser** — server-side state record per WebSocket: `connectionId`, `oderId`, `connectionScope`, `displayName`, `description`, `status`, `serverIds`, `lastPong`.
|
|
- **`oderId`** — opaque user identity. Set by the client in `identify`; falls back to a UUID if absent. Multiple connections may share an `oderId` (e.g. multiple devices) — broadcasts are deduplicated per `oderId`.
|
|
- **`connectionScope`** — typically the signal URL; disambiguates several connections from the same `oderId`.
|
|
- **Handler** — server-side function mapped to one envelope `type` in `server/src/websocket/handler.ts`.
|
|
- **Forwarded envelope** — peer-to-peer envelopes the server relays untouched to a specific `targetUserId` (offer / answer / ice_candidate / direct-call / direct-message family / server_icon_peer_*).
|
|
|
|
---
|
|
|
|
## Envelope catalogue
|
|
|
|
Defined on the server in `server/src/websocket/types.ts` and dispatched by the switch in `server/src/websocket/handler.ts`. Groups below match the dispatch shape, not a literal grouping in code.
|
|
|
|
### Connection & presence
|
|
|
|
- `identify` — client → server. Profile + `connectionScope`. Required before any other envelope is meaningful.
|
|
- `connected` — server → client. Sent automatically on connect: `{ connectionId, serverTime }`.
|
|
- `keepalive` — client ↔ server. Resets `lastPong`. See lifecycle below.
|
|
- `status_update` — broadcast presence: `online | away | busy | offline`.
|
|
- `access_denied` — server → client when `join_server` authorization fails.
|
|
|
|
### Server membership
|
|
|
|
- `join_server` — client requests membership for a `serverId`. Authorization checked via `authorizeWebSocketJoin` (`server/src/services/server-access.service.ts`). Response includes `server_users` + `plugin_requirements`.
|
|
- `view_server` — client marks a server as viewed (fetch roster + plugin requirements without joining).
|
|
- `leave_server` — client leaves; broadcasts `user_left` to remaining members.
|
|
- `server_users` — server → client. Full peer roster for a joined server (used as the seed for P2P offers).
|
|
- `user_joined` / `user_left` — broadcast presence changes.
|
|
|
|
### Chat & typing
|
|
|
|
- `chat_message` — broadcast to a server. Payload: `{ message, senderId, senderName, timestamp }`.
|
|
- `typing` — broadcast: `{ isTyping, channelId, oderId, displayName }`.
|
|
|
|
### Voice presence
|
|
|
|
- `voice_state` — broadcast user voice state (mute/deafen/room metadata). Pure signaling — the server does not store voice room membership.
|
|
|
|
### WebRTC signaling (forwarded)
|
|
|
|
- `offer` / `answer` / `ice_candidate` — forwarded to `targetUserId` via `forwardRtcMessage()`.
|
|
- `direct-call` — forwarded; semantic call lifecycle lives in the `direct-call` product-client domain.
|
|
|
|
### Direct messages (forwarded)
|
|
|
|
- `direct-message`, `direct-message-status`, `direct-message-mutation`, `direct-message-sync`, `direct-message-sync-request` — forwarded to `targetUserId`.
|
|
|
|
### Server icon P2P sync
|
|
|
|
- `server_icon_available` — client announces it has an icon at version `iconUpdatedAt`.
|
|
- `server_icon_sync_request` — client asks the server which peers have a newer icon.
|
|
- `server_icon_sync_peers` — server → client. Peer list offering newer icons.
|
|
- `server_icon_peer_request` / `server_icon_peer_data` — P2P transfer, forwarded.
|
|
|
|
### Plugins
|
|
|
|
- `plugin_event` — validated against the plugin's registered event schema (see [plugin-system](./plugin-system.md) and `server/src/services/plugin-support.service.ts`), then broadcast within the server scope. Payload: `{ serverId, pluginId, eventName, payload, sourcePluginUserId, sourceUserId, emittedAt }`.
|
|
|
|
---
|
|
|
|
## Connection lifecycle
|
|
|
|
Implemented in `server/src/websocket/index.ts`.
|
|
|
|
1. Client opens WebSocket → server generates `connectionId` (UUID), creates the `ConnectedUser` record, sends `{ type: 'connected', connectionId, serverTime }`.
|
|
2. Client sends `identify` with `oderId`, `displayName`, `connectionScope`, optional `description` / `profileUpdatedAt`. Server normalizes and stores.
|
|
3. Client sends `join_server` (or `view_server`) per server they care about. Each `join_server` is authorized independently.
|
|
4. Heartbeat: server pings every **30 s** (`PING_INTERVAL_MS`). Any incoming message also refreshes `lastPong`. Connections without a pong for **45 s** (`PONG_TIMEOUT_MS`) are terminated.
|
|
5. On close: server emits `user_left` to every server the connection had joined. Broadcasts are **deduplicated by `oderId`**, so multi-device users only generate one departure event per logical identity.
|
|
|
|
---
|
|
|
|
## Authentication model
|
|
|
|
There is no bearer token or signed envelope. Identity is whatever the client claims in `identify`. Authorization is **per-`join_server`**, evaluated by `authorizeWebSocketJoin` against persisted server access rules (private flag, password hash, bans, invite/join-request state). `access_denied` is returned when authorization fails; the connection itself stays open.
|
|
|
|
---
|
|
|
|
## Technical implementation
|
|
|
|
### Server
|
|
|
|
- **Types**: `server/src/websocket/types.ts` — `WsMessage` (union over `type`), `ConnectedUser`, `ConnectionScope`.
|
|
- **Dispatcher**: `server/src/websocket/handler.ts` — `handleWebSocketMessage(connectionId, message)`. Single switch (~16 dedicated handler functions plus `forwardRtcMessage`).
|
|
- **Lifecycle**: `server/src/websocket/index.ts` — `ws` server, ping/pong, connection registry, dead-connection reaping.
|
|
- **Plugin event validation**: `server/src/services/plugin-support.service.ts` — async `validatePluginEventEnvelope()` (runtime schema check).
|
|
|
|
### Client (renderer)
|
|
|
|
- **Shared types**: `toju-app/src/app/shared-kernel/signaling-contracts.ts` — **stale**, only declares a generic `SignalingMessage` and an obsolete `SignalingMessageType` enum. Not the active wire-format definition.
|
|
- **Active envelope shapes** are defined inline as `IncomingSignalingMessage` in `toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.ts`.
|
|
- **Constants**: `toju-app/src/app/infrastructure/realtime/realtime.constants.ts` — every envelope `type` string lives here as `SIGNALING_TYPE_*`.
|
|
- **Transport**: `toju-app/src/app/infrastructure/realtime/signaling/signaling-transport-handler.ts` — socket lifecycle, sends `identify`, `join_server`, raw envelopes.
|
|
- **Coordinator**: `toju-app/src/app/infrastructure/realtime/signaling/server-signaling-coordinator.ts` — maps `serverId` to signal URL (Toju supports multiple federated signaling endpoints).
|
|
- **Inbound dispatch**: `signaling-message-handler.ts` — `handleConnectedSignalingMessage`, `handleServerUsersSignalingMessage`, `handleUserJoinedSignalingMessage`, `handleUserLeftSignalingMessage`, `handleOfferSignalingMessage`, `handleAnswerSignalingMessage`, `handleIceCandidateSignalingMessage`, `handleAccessDeniedSignalingMessage`. Domain envelopes (chat/typing/direct-message/etc.) are consumed in the respective product-client domains, not in this central adapter — TODO: enumerate exact subscription points.
|
|
|
|
### Versioning
|
|
|
|
No `version` field on envelopes. No `Accept-Version` header. Drift between server and client is enforced only by code review (per `server/CONTEXT.md` invariants).
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
- `server/src/websocket/handler-status.spec.ts` — `status_update` broadcast and profile metadata in `user_joined` / `server_users`.
|
|
- `server/src/websocket/handler-plugin.spec.ts` — `plugin_event` validation and broadcast.
|
|
- `toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.spec.ts` — inbound handler unit tests (notably `user_left` preserving peers under voice).
|
|
- **TODO**: no round-trip envelope-shape test between server `WsMessage` and client `IncomingSignalingMessage`. Drift can only be caught by E2E or manual review today.
|
|
|
|
## Security considerations
|
|
|
|
- No transport-level auth — identity is self-asserted via `identify`. The server trusts `oderId` for routing but checks authorization on every `join_server`.
|
|
- WebRTC signaling envelopes (`offer` / `answer` / `ice_candidate`) are forwarded **without inspection**. The server does not verify that the sender is a member of the same server as the target — TODO: confirm whether `forwardRtcMessage` enforces server-membership before forwarding.
|
|
- `plugin_event` payloads are bounded by the plugin's declared `maxPayloadBytes` (default 64 KB) and validated against the plugin's declared event schema. See [plugin-system](./plugin-system.md).
|
|
- Multi-connection identities: a single `oderId` may have many open sockets. Broadcasts dedupe by `oderId`, but per-connection state (e.g. `voice_state`) does not — TODO: document the cross-connection invariants.
|
|
|
|
## Performance considerations
|
|
|
|
- Single WebSocket per client. No fan-out worker; broadcast is in-process via the in-memory connection map.
|
|
- Ping cadence 30 s / pong timeout 45 s. Reaping is per-connection on next tick.
|
|
- TODO: no documented soft cap on connected users per signaling server.
|
|
|
|
## Known issues and limitations
|
|
|
|
- **Stale shared-kernel contract.** `toju-app/src/app/shared-kernel/signaling-contracts.ts` does not enumerate the live envelope set; client code uses `IncomingSignalingMessage` in `signaling-message-handler.ts` instead. Update or replace this file when adjacent work touches the wire format.
|
|
- **No envelope versioning.** Any field rename is an immediate break for older clients.
|
|
- **TODO — operator concerns**: rate limits, max-message-size, and backpressure are not documented.
|
|
|
|
## Related features
|
|
|
|
- **[voice-signaling](./voice-signaling.md)** — consumes `offer` / `answer` / `ice_candidate` / `voice_state` / `direct-call`.
|
|
- **[plugin-system](./plugin-system.md)** — defines and validates `plugin_event`.
|
|
- **[server-directory](./server-directory.md)** — REST counterpart for server discovery, joining, and moderation; `join_server` envelope authorization reuses the same access rules.
|
|
|
|
## Changelog
|
|
|
|
| Date | Change |
|
|
|------|--------|
|
|
| 2026-05-25 | Initial documentation |
|