1cec293Serverless p2p video calls: presence + mutual-consent WebRTC over nostr signalingJeremy Magland 1// A thin WebRTC wrapper, distilled from commonview's peer.ts. Instead of a
2// data-only connection it carries the local audio/video tracks plus one small
3// control data channel (hang-up, mute notices). As in commonview we avoid
4// "perfect negotiation" glare handling by ensuring only ONE side (a
5// 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}
1eb9884video quality settingsJeremy Magland 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}
1cec293Serverless p2p video calls: presence + mutual-consent WebRTC over nostr signalingJeremy Magland 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 call can survive a brief network blip: 'disconnected' often recovers
49// 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
1eb9884video quality settingsJeremy Magland 55 /** Control messages sent before the channel opens; flushed on open. */
56 private outbox: string[] = []
1cec293Serverless p2p video calls: presence + mutual-consent WebRTC over nostr signalingJeremy Magland 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 for (const track of localStream.getTracks()) {
68 this.pc.addTrack(track, localStream)
69 }
71 this.pc.ontrack = ({streams}) => {
72 if (streams[0]) this.handlers.track?.(streams[0])
73 }
75 this.pc.onicecandidate = ({candidate}) => {
76 if (candidate) {
77 this.handlers.signal?.({type: 'candidate', candidate: candidate.toJSON()})
78 }
79 }
81 this.pc.onconnectionstatechange = () => {
82 const s = this.pc.connectionState
83 if (s === 'connected') {
84 this.clearDisconnectTimer()
85 this.handlers.connect?.()
86 } else if (s === 'failed' || s === 'closed') {
87 this.destroy()
88 } else if (s === 'disconnected') {
89 this.clearDisconnectTimer()
90 this.disconnectTimer = window.setTimeout(() => {
91 if (this.pc.connectionState !== 'connected') this.destroy()
92 }, DISCONNECT_GRACE_MS)
93 }
94 }
96 if (initiator) {
97 this.setupChannel(this.pc.createDataChannel('control'))
98 this.pc.onnegotiationneeded = () => void this.makeOffer()
99 } else {
100 this.pc.ondatachannel = ({channel}) => this.setupChannel(channel)
101 }
102 }
104 setHandlers(handlers: Partial<PeerHandlers>) {
105 Object.assign(this.handlers, handlers)
106 }
108 private clearDisconnectTimer() {
109 if (this.disconnectTimer !== null) {
110 clearTimeout(this.disconnectTimer)
111 this.disconnectTimer = null
112 }
113 }
115 private setupChannel(channel: RTCDataChannel) {
116 this.channel = channel
118 for (const data of this.outbox.splice(0)) channel.send(data)
119 }
120 if (channel.readyState === 'open') flush()
121 else channel.onopen = flush
1cec293Serverless p2p video calls: presence + mutual-consent WebRTC over nostr signalingJeremy Magland 122 channel.onclose = () => this.destroy()
123 channel.onmessage = e => {
124 if (typeof e.data === 'string') this.handlers.data?.(e.data)
125 }
126 }
128 private async makeOffer() {
129 if (this.closed) return
130 try {
131 await this.pc.setLocalDescription(await this.pc.createOffer())
132 this.handlers.signal?.({
133 type: 'offer',
134 sdp: this.pc.localDescription!.sdp
135 })
136 } catch {
137 /* ignore */
138 }
139 }
141 async signal(signal: Signal) {
142 if (this.closed) return
143 try {
144 if (signal.type === 'candidate') {
145 if (this.pc.remoteDescription) {
146 await this.pc.addIceCandidate(signal.candidate)
147 } else {
148 this.pendingCandidates.push(signal.candidate)
149 }
150 return
151 }
153 if (signal.type === 'offer') {
154 if (this.initiator) return // initiators never accept remote offers
155 await this.pc.setRemoteDescription({type: 'offer', sdp: signal.sdp})
156 await this.flushCandidates()
157 await this.pc.setLocalDescription(await this.pc.createAnswer())
158 this.handlers.signal?.({
159 type: 'answer',
160 sdp: this.pc.localDescription!.sdp
161 })
162 return
163 }
165 if (signal.type === 'answer') {
166 await this.pc.setRemoteDescription({type: 'answer', sdp: signal.sdp})
167 await this.flushCandidates()
168 }
169 } catch {
170 /* ignore transient signaling errors */
171 }
172 }
174 private async flushCandidates() {
175 const queued = this.pendingCandidates.splice(0)
176 for (const c of queued) {
177 try {
178 await this.pc.addIceCandidate(c)
179 } catch {
180 /* ignore */
181 }
182 }
183 }
185 send(data: string) {
186 if (this.channel?.readyState === 'open') this.channel.send(data)
1cec293Serverless p2p video calls: presence + mutual-consent WebRTC over nostr signalingJeremy Magland 188 }
558b70bAdd screen sharing via in-place video track swap (no renegotiation)Jeremy Magland 190 /** Swap the outgoing video track in place (camera ↔ screen). A same-kind
191 * replaceTrack does not trigger renegotiation, so no signaling is needed
192 * and the one-offer design is preserved. */
193 async replaceVideoTrack(track: MediaStreamTrack): Promise<boolean> {
194 if (this.closed) return false
195 const sender = this.pc.getSenders().find(s => s.track?.kind === 'video')
196 if (!sender) return false
197 try {
198 await sender.replaceTrack(track)
199 return true
200 } catch {
201 return false
202 }
203 }
1eb9884video quality settingsJeremy Magland 205 /** Cap (or uncap) the outgoing video encoding. Like replaceTrack,
206 * setParameters applies live with no renegotiation, so it fits the
207 * one-offer design. */
208 async setVideoParameters(opts: VideoSendParams): Promise<boolean> {
209 if (this.closed) return false
210 const sender = this.pc.getSenders().find(s => s.track?.kind === 'video')
211 if (!sender) return false
212 const params = sender.getParameters()
213 const enc = params.encodings[0]
214 if (!enc) return false // no negotiated encoding yet
215 if (opts.maxBitrate === undefined) delete enc.maxBitrate
216 else enc.maxBitrate = opts.maxBitrate
217 if (opts.scaleResolutionDownBy === undefined) {
218 delete enc.scaleResolutionDownBy
219 } else {
220 enc.scaleResolutionDownBy = opts.scaleResolutionDownBy
221 }
222 if (opts.maxFramerate === undefined) delete enc.maxFramerate
223 else enc.maxFramerate = opts.maxFramerate
224 // Not in all TS dom typings, but supported by Chrome/Safari; harmless
225 // where ignored.
226 const p = params as {degradationPreference?: string}
227 if (opts.degradationPreference === undefined) delete p.degradationPreference
228 else p.degradationPreference = opts.degradationPreference
229 try {
230 await sender.setParameters(params)
231 return true
232 } catch {
233 return false
234 }
235 }
1cec293Serverless p2p video calls: presence + mutual-consent WebRTC over nostr signalingJeremy Magland 237 get isConnected(): boolean {
238 return this.pc.connectionState === 'connected'
239 }
241 destroy() {
242 if (this.closed) return
243 this.closed = true
244 this.clearDisconnectTimer()
245 try {
246 this.channel?.close()
247 } catch {
248 /* ignore */
249 }
250 try {
251 this.pc.close()
252 } catch {
253 /* ignore */
254 }
255 this.handlers.close?.()
256 }
257}