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