feat: 1:1 voice and video calls over WebRTC - #1
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
otherUsernameis 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=falsetakes 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.