# 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 |