Skip to main content

WebRTC 1:1 Calls

The Chat module supports peer-to-peer audio and video calls using WebRTC technology.

Architecture

sequenceDiagram
participant A as Caller
participant AP as AuthProxy (SSE)
participant Chat as Chat Module
participant B as Callee

A->>Chat: POST /msg/v1/call/initiate
Chat->>AP: SSE call_incoming
AP->>B: EventSource push
B->>Chat: POST /msg/v1/call/answer
Chat->>AP: SSE call_answered
Note over A,B: WebRTC peer connection established
Note over A,B: Media flows directly P2P via TURN/STUN

How It Works

  • Signaling: Goes through existing SSE EventHub (AuthProxy — zero changes needed)
  • Media: Direct peer-to-peer via WebRTC
  • TURN relay: coturn Docker container for NAT traversal
  • STUN: Google public STUN servers (free), or optional embedded STUN in AuthProxy (see below)
  • Signaling data (SDP/ICE) is ephemeral — not stored in database

In-Call Features

Screen Share

During an active call the user can toggle screen share on and off. When screen share is activated the camera track is replaced with the screen capture track. When sharing stops the camera is automatically restored. Screen share works in both audio-only and video calls.

Audio Controls

The browser requests hardware echo cancellation (AEC), noise suppression (NS), and automatic gain control (AGC) via WebRTC audio constraints. In addition to microphone mute, the caller can mute the speaker (remote audio output) independently.

Camera Switching

When the device has multiple video inputs (e.g. front and rear cameras on a phone) the user can switch between them mid-call without dropping the connection.

No-Camera Fallback

If a video call is initiated but the device has no camera available, the call silently falls back to audio-only mode. The user can still attach screen share later, and the remote party will receive the screen track as video.

Stale Call Cleanup

Calls that are not properly terminated (e.g. browser crash, network loss) are cleaned up automatically:

  • Server heartbeat: Chat tracks SSE presence through OnlineTracker. If both parties go offline beyond the heartbeat threshold, the call is ended server-side.
  • Browser pagehide: chat.pwa sends a best-effort end_call on the pagehide event, covering tab close and navigation away. This applies to both regular and anonymous call flows.

API Endpoints

MethodEndpointDescription
POST/msg/v1/call/initiateStart a call (creates Ringing state)
POST/msg/v1/call/answerAnswer incoming call
POST/msg/v1/call/rejectReject incoming call
POST/msg/v1/call/endEnd active call
POST/msg/v1/call/send_signalExchange SDP/ICE candidates
GET/msg/v1/call/historyCall history for user

SSE Events

Event TypeWhen
call_incomingNew call initiated
call_answeredCall accepted
call_rejectedCall declined
call_endedCall terminated
call_missedRing timeout (30s)
call_signalSDP/ICE exchange

Call State Machine

stateDiagram-v2
[*] --> Ringing: initiate
Ringing --> Connected: answer
Ringing --> Rejected: reject
Ringing --> Missed: 30s timeout
Connected --> Ended: end
Rejected --> [*]
Missed --> [*]
Ended --> [*]

TURN Configuration

# docker-compose.yml
coturn:
image: coturn/coturn:latest
ports:
- "3478:3478/tcp"
- "3478:3478/udp"
- "49152-65535:49152-65535/udp"
environment:
- TURN_SECRET=your-shared-secret

TURN credentials are generated using HMAC-SHA1 time-limited tokens (RFC 5766).

Embedded STUN (AuthProxy)

AuthProxy can optionally run an embedded STUN responder on the same UDP port as HTTPS. This removes the dependency on external Google STUN servers for NAT traversal (ICE candidate gathering).

Key properties:

  • IPv4 only, soft-fail (if the UDP socket cannot bind, AuthProxy starts normally without STUN)
  • Per-IP rate limiting and temporary block on invalid or excessive requests
  • Non-STUN UDP noise (port probes, random packets) is filtered before accounting, preventing false bans
  • The STUN responder accepts standard RFC5389 Binding Requests with or without a STUN FINGERPRINT; when the attribute is present, it is validated

When embedded STUN is active, chat.pwa can include the AuthProxy origin in its ICE server list alongside (or instead of) Google STUN.

Call Diagnostics

chat.pwa includes an optional diagnostics panel that surfaces live ICE candidate gathering results, connection state, and selected candidate pair. The panel is accessible from the active call UI and is useful for debugging connectivity issues on specific networks or NAT configurations. Long ICE candidate lists scroll within a constrained viewport area.

Storage model

Calls are stored as ordinary chat messages, not in a dedicated video_calls table (the legacy table was dropped in 2026-04-29 by migration 003-drop-video-calls.sql). Each call is a chat_mess row with:

  • flags |= ChatMessFlags.Call (512)
  • content = JSON CallMessageContent (caller, callee, started/ended timestamps, end reason, duration)

Direct (1:1) call runtime state lives in-memory in CallService._activeCallsSendSignal and HeartbeatCall are zero-SQL on the hot path. Call history queries simply filter chat_mess by the Call flag, so reactions, replies, and search work the same way as with text messages.

API contracts (CallHistoryOut, CallEventOut) and SSE event names are unchanged from the pre-2026-04 version; only the persistence layer moved.

Limitations

  • 1:1 calls only (group calls planned for future — requires SFU like mediasoup/Janus).
  • Requires coturn for reliable NAT traversal; embedded STUN covers candidate gathering only, not media relay.
  • No ICE restart on mid-call network change (e.g. WiFi to LTE switch).