9813683Serverless group video calls: rooms, WebRTC mesh, shared settingsJeremy Magland 1// A thin WebRTC wrapper, ported from commoncall's peer.ts. One instance per
2// remote participant: it carries the local audio/video tracks plus one small
3// control data channel (hello, mute notices, settings sync). As in the sibling
4// projects we avoid "perfect negotiation" glare handling by ensuring only ONE
5// side (a deterministically chosen initiator) ever creates the offer.
7export type Signal =
8 | {type: 'offer'; sdp: string}
9 | {type: 'answer'; sdp: string}
10 | {type: 'candidate'; candidate: RTCIceCandidateInit}
12/** Caps for the outgoing video encoding; an undefined field CLEARS that cap. */
13export interface VideoSendParams {
14 maxBitrate?: number
15 scaleResolutionDownBy?: number
16 maxFramerate?: number
17 degradationPreference?: 'balanced' | 'maintain-framerate' | 'maintain-resolution'
18}
20export interface PeerHandlers {
21 signal: (signal: Signal) => void
22 /** Connection reached the 'connected' state. */
23 connect: () => void
24 /** Remote media stream became available. */
25 track: (stream: MediaStream) => void
26 /** A string message arrived on the control channel. */
27 data: (data: string) => void
28 close: () => void
29}
31export const ICE_SERVERS: RTCIceServer[] = [
32 {urls: 'stun:stun.l.google.com:19302'},
33 {urls: 'stun:stun1.l.google.com:19302'},
34 {urls: 'stun:stun.cloudflare.com:3478'},
35 // Free TURN relay (openrelayproject) — needed when direct/STUN pairing
36 // fails (symmetric NAT, hairpinning, host-candidate blocking).
37 {
38 urls: [
39 'turn:openrelay.metered.ca:80',
40 'turn:openrelay.metered.ca:443',
41 'turns:openrelay.metered.ca:443'
42 ],
43 username: 'openrelayproject',
44 credential: 'openrelayproject'
45 }
46]
48// A media connection can survive a brief network blip: 'disconnected' often
49// recovers on its own, so only tear down if it persists this long.
50const DISCONNECT_GRACE_MS = 5000
52export class Peer {
53 private pc: RTCPeerConnection
54 private channel: RTCDataChannel | null = null
55 /** Control messages sent before the channel opens; flushed on open. */
56 private outbox: string[] = []
57 private handlers: Partial<PeerHandlers> = {}
58 private pendingCandidates: RTCIceCandidateInit[] = []
59 private disconnectTimer: number | null = null
60 private closed = false
62 constructor(private initiator: boolean, localStream: MediaStream) {
63 this.pc = new RTCPeerConnection({iceServers: ICE_SERVERS})
65 // Both sides add their tracks up front: the initiator's single offer then
66 // covers all media, and the answerer's tracks ride back in the answer.
67 // (Every participant always has one audio + one video track — real or a
68 // synthetic placeholder — so the m-lines are always symmetric.)
69 for (const track of localStream.getTracks()) {
70 this.pc.addTrack(track, localStream)
71 }
73 this.pc.ontrack = ({streams}) => {
74 if (streams[0]) this.handlers.track?.(streams[0])
75 }
77 this.pc.onicecandidate = ({candidate}) => {
78 if (candidate) {
79 this.handlers.signal?.({type: 'candidate', candidate: candidate.toJSON()})
80 }
81 }
83 this.pc.onconnectionstatechange = () => {
84 const s = this.pc.connectionState
85 if (s === 'connected') {
86 this.clearDisconnectTimer()
87 this.handlers.connect?.()
88 } else if (s === 'failed' || s === 'closed') {
89 this.destroy()
90 } else if (s === 'disconnected') {
91 this.clearDisconnectTimer()
92 this.disconnectTimer = window.setTimeout(() => {
93 if (this.pc.connectionState !== 'connected') this.destroy()
94 }, DISCONNECT_GRACE_MS)
95 }
96 }
98 if (initiator) {
99 this.setupChannel(this.pc.createDataChannel('control'))
100 this.pc.onnegotiationneeded = () => void this.makeOffer()
101 } else {
102 this.pc.ondatachannel = ({channel}) => this.setupChannel(channel)
103 }
104 }
106 setHandlers(handlers: Partial<PeerHandlers>) {
107 Object.assign(this.handlers, handlers)
108 }
110 private clearDisconnectTimer() {
111 if (this.disconnectTimer !== null) {
112 clearTimeout(this.disconnectTimer)
113 this.disconnectTimer = null
114 }
115 }
117 private setupChannel(channel: RTCDataChannel) {
118 this.channel = channel
119 const flush = () => {
120 for (const data of this.outbox.splice(0)) channel.send(data)
121 }
122 if (channel.readyState === 'open') flush()
123 else channel.onopen = flush
124 channel.onclose = () => this.destroy()
125 channel.onmessage = e => {
126 if (typeof e.data === 'string') this.handlers.data?.(e.data)
127 }
128 }
130 private async makeOffer() {
131 if (this.closed) return
132 try {
133 await this.pc.setLocalDescription(await this.pc.createOffer())
134 this.handlers.signal?.({
135 type: 'offer',
136 sdp: this.pc.localDescription!.sdp
137 })
138 } catch {
139 /* ignore */
140 }
141 }
143 async signal(signal: Signal) {
144 if (this.closed) return
145 try {
146 if (signal.type === 'candidate') {
147 if (this.pc.remoteDescription) {
148 await this.pc.addIceCandidate(signal.candidate)
149 } else {
150 this.pendingCandidates.push(signal.candidate)
151 }
152 return
153 }
155 if (signal.type === 'offer') {
156 if (this.initiator) return // initiators never accept remote offers
157 await this.pc.setRemoteDescription({type: 'offer', sdp: signal.sdp})
158 await this.flushCandidates()
159 await this.pc.setLocalDescription(await this.pc.createAnswer())
160 this.handlers.signal?.({
161 type: 'answer',
162 sdp: this.pc.localDescription!.sdp
163 })
164 return
165 }
167 if (signal.type === 'answer') {
168 await this.pc.setRemoteDescription({type: 'answer', sdp: signal.sdp})
169 await this.flushCandidates()
170 }
171 } catch {
172 /* ignore transient signaling errors */
173 }
174 }
176 private async flushCandidates() {
177 const queued = this.pendingCandidates.splice(0)
178 for (const c of queued) {
179 try {
180 await this.pc.addIceCandidate(c)
181 } catch {
182 /* ignore */
183 }
184 }
185 }
187 send(data: string) {
188 if (this.channel?.readyState === 'open') this.channel.send(data)
189 else if (!this.closed) this.outbox.push(data)
190 }
192 /** Swap an outgoing track in place (camera ↔ screen, placeholder → real
193 * device). A same-kind replaceTrack does not trigger renegotiation, so no
194 * signaling is needed and the one-offer design is preserved. */
195 async replaceTrack(
196 kind: 'audio' | 'video',
197 track: MediaStreamTrack
198 ): Promise<boolean> {
199 if (this.closed) return false
200 const sender = this.pc.getSenders().find(s => s.track?.kind === kind)
201 if (!sender) return false
202 try {
203 await sender.replaceTrack(track)
204 return true
205 } catch {
206 return false
207 }
208 }
210 /** Cap (or uncap) the outgoing video encoding. Like replaceTrack,
211 * setParameters applies live with no renegotiation, so it fits the
212 * one-offer design. */
213 async setVideoParameters(opts: VideoSendParams): Promise<boolean> {
214 if (this.closed) return false
215 const sender = this.pc.getSenders().find(s => s.track?.kind === 'video')
216 if (!sender) return false
217 const params = sender.getParameters()
218 const enc = params.encodings[0]
219 if (!enc) return false // no negotiated encoding yet
220 if (opts.maxBitrate === undefined) delete enc.maxBitrate
221 else enc.maxBitrate = opts.maxBitrate
222 if (opts.scaleResolutionDownBy === undefined) {
223 delete enc.scaleResolutionDownBy
224 } else {
225 enc.scaleResolutionDownBy = opts.scaleResolutionDownBy
226 }
227 if (opts.maxFramerate === undefined) delete enc.maxFramerate
228 else enc.maxFramerate = opts.maxFramerate
229 // Not in all TS dom typings, but supported by Chrome/Safari; harmless
230 // where ignored.
231 const p = params as {degradationPreference?: string}
232 if (opts.degradationPreference === undefined) delete p.degradationPreference
233 else p.degradationPreference = opts.degradationPreference
234 try {
235 await sender.setParameters(params)
236 return true
237 } catch {
238 return false
239 }
240 }
242 get isConnected(): boolean {
243 return this.pc.connectionState === 'connected'
244 }
246 destroy() {
247 if (this.closed) return
248 this.closed = true
249 this.clearDisconnectTimer()
250 try {
251 this.channel?.close()
252 } catch {
253 /* ignore */
254 }
255 try {
256 this.pc.close()
257 } catch {
258 /* ignore */
259 }
260 this.handlers.close?.()
261 }
262}