Files
Toju/agents-docs/features/websocket-envelopes.md
brogeby b19c39208c docs: populate initial cross-context feature docs
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>
2026-05-25 15:36:36 +02:00

11 KiB

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).
  • WebRTC media transport or session orchestration (see voice-signaling — 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 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.tsWsMessage (union over type), ConnectedUser, ConnectionScope.
  • Dispatcher: server/src/websocket/handler.tshandleWebSocketMessage(connectionId, message). Single switch (~16 dedicated handler functions plus forwardRtcMessage).
  • Lifecycle: server/src/websocket/index.tsws 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.tsstale, 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.tshandleConnectedSignalingMessage, 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.tsstatus_update broadcast and profile metadata in user_joined / server_users.
  • server/src/websocket/handler-plugin.spec.tsplugin_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.
  • 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.
  • voice-signaling — consumes offer / answer / ice_candidate / voice_state / direct-call.
  • plugin-system — defines and validates plugin_event.
  • server-directory — 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