Skip to content

feat: 1:1 voice and video calls over WebRTC - #1

Merged
olavgg merged 1 commit into
mainfrom
feat/webrtc-calls
Aug 1, 2026
Merged

feat: 1:1 voice and video calls over WebRTC#1
olavgg merged 1 commit into
mainfrom
feat/webrtc-calls

Conversation

@olavgg

@olavgg olavgg commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Calls are placed from the header of a direct message and ring every device the callee has open. Signalling rides the WebSocket that already exists — pairwise, on /user/queue/calls, never a topic, because an SDP offer is addressed to exactly one peer. The media never touches this process: it goes through coturn, which forwards encrypted UDP it has no way to read.

Four decisions worth stating, because none is reversible cheaply.

DM-only. Not policy — the transport is a single peer connection and a third participant has nowhere to go. Group calls need an SFU, which is a service to operate rather than a feature to switch on, so offering the button in a channel would promise what the media cannot do. The rule lives in CallService.requireCallable and the template mirrors it by rendering the buttons only where otherUsername is non-null, which is null for a GROUP and null again for a note-to-self.

Relay everything, by default. Direct connections are cheaper and fail for a minority of users — symmetric NAT, corporate firewalls — and they fail late, after the ringing UI has already promised a call. Relaying makes the behaviour uniform: it works for everybody or it is a configuration error found once. It also stops each participant learning the other's IP address, which matters for the deployments this project is aimed at. force-relay=false takes the standard trade back.

Calls are off until TURN is configured, and the buttons are not rendered. With force-relay on there is no media path without a relay, so an enabled-by-default call button would be a control that cannot work. TURN credentials are minted per call and expire; a static credential in a page is an open relay, and the bandwidth it gives away is the operator's.

A declined call is archived as "Missed call". The caller is told they were declined in the moment, because that is useful then. The permanent record does not say it — writing a verdict into someone else's message history on their behalf is not a chat archive's business.

The client is split so the SFU can land later without a rewrite: call-transport.js is the only file that may touch an RTCPeerConnection, and calls.js drives ringing, the UI and the DND gate through six methods. Perfect negotiation is implemented properly (the callee is always the polite peer, by role rather than by comparison), VP9 is preferred via setCodecPreferences with the full list reordered rather than filtered, and the single deliberate SDP edit is the Opus usedtx/useinbandfec fmtp line, because no API exposes either.

coturn is the first second process this project has taken on, and the README now says so where it claims one JVM and one database, rather than leaving the qualification in a config table.

Verified: 1032 tests pass, 33 of them new. CallRegistryTest covers the ring state machine including racing answers from two tabs, session-scoped disconnects and the busy index; CallFlowIT covers the archive line as a durable row against Testcontainers Postgres. scripts/e2e-call.py drives two Chromium contexts with fake media through a real call and asserts both sides reach connected, that coturn logged new relay allocations — which is what proves force-relay is actually applied rather than the peers going direct — and that exactly one archive line is added. The website screenshot is a photograph of that call rather than a mock-up, which is why capture.py now needs a TURN server.

Not covered: no second node. Ring state is per-process like the rate limiter, so calls work on one instance and need the shared state the rest of horizontal scaling does.

Calls are placed from the header of a direct message and ring every device the callee has open.
Signalling rides the WebSocket that already exists — pairwise, on /user/queue/calls, never a topic,
because an SDP offer is addressed to exactly one peer. The media never touches this process: it
goes through coturn, which forwards encrypted UDP it has no way to read.

Four decisions worth stating, because none is reversible cheaply.

DM-only. Not policy — the transport is a single peer connection and a third participant has nowhere
to go. Group calls need an SFU, which is a service to operate rather than a feature to switch on,
so offering the button in a channel would promise what the media cannot do. The rule lives in
CallService.requireCallable and the template mirrors it by rendering the buttons only where
`otherUsername` is non-null, which is null for a GROUP and null again for a note-to-self.

Relay everything, by default. Direct connections are cheaper and fail for a minority of users —
symmetric NAT, corporate firewalls — and they fail late, after the ringing UI has already promised
a call. Relaying makes the behaviour uniform: it works for everybody or it is a configuration error
found once. It also stops each participant learning the other's IP address, which matters for the
deployments this project is aimed at. `force-relay=false` takes the standard trade back.

Calls are off until TURN is configured, and the buttons are not rendered. With force-relay on there
is no media path without a relay, so an enabled-by-default call button would be a control that
cannot work. TURN credentials are minted per call and expire; a static credential in a page is an
open relay, and the bandwidth it gives away is the operator's.

A declined call is archived as "Missed call". The caller is told they were declined in the moment,
because that is useful then. The permanent record does not say it — writing a verdict into someone
else's message history on their behalf is not a chat archive's business.

The client is split so the SFU can land later without a rewrite: call-transport.js is the only file
that may touch an RTCPeerConnection, and calls.js drives ringing, the UI and the DND gate through
six methods. Perfect negotiation is implemented properly (the callee is always the polite peer, by
role rather than by comparison), VP9 is preferred via setCodecPreferences with the full list
reordered rather than filtered, and the single deliberate SDP edit is the Opus usedtx/useinbandfec
fmtp line, because no API exposes either.

coturn is the first second process this project has taken on, and the README now says so where it
claims one JVM and one database, rather than leaving the qualification in a config table.

Verified: 1032 tests pass, 33 of them new. CallRegistryTest covers the ring state machine including
racing answers from two tabs, session-scoped disconnects and the busy index; CallFlowIT covers the
archive line as a durable row against Testcontainers Postgres. scripts/e2e-call.py drives two
Chromium contexts with fake media through a real call and asserts both sides reach connected, that
coturn logged new relay allocations — which is what proves force-relay is actually applied rather
than the peers going direct — and that exactly one archive line is added. The website screenshot is
a photograph of that call rather than a mock-up, which is why capture.py now needs a TURN server.

Not covered: no second node. Ring state is per-process like the rate limiter, so calls work on one
instance and need the shared state the rest of horizontal scaling does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
@olavgg
olavgg merged commit 6de58f9 into main Aug 1, 2026
1 check passed
@olavgg
olavgg deleted the feat/webrtc-calls branch August 1, 2026 09:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant