/ concept-collection / commoncall
Sign in
concept-collection / commoncall
commoncall / src / p2p / network.ts
526 lines · 15.5 KBBlameHistoryRaw
1import {selfId} from './identity'
2import {Nostr, peerTopic, rootTopic} from './nostr'
3import {Peer, type Signal} from './peer'
5// ---------------------------------------------------------------------------
6// CommonCall network layer.
7//
8// Presence: everyone who has entered an ID announces {peerId, name, busy} on
9// the root topic every few seconds; entries expire when announcements stop.
10//
11// Calls: clicking a peer publishes a call-request on that peer's personal
12// topic. The callee must explicitly accept (call-accept) before either side
13// touches getUserMedia or WebRTC — both users must agree. After acceptance the
14// two sides exchange offer/answer/ICE via {t:'signal'} messages on the same
15// per-peer topics, exactly the technique used by commonview, and the media
16// flows peer-to-peer.
17//
18// Messages are authenticated by the nostr layer: every event is schnorr-signed
19// and the sender's pubkey IS the peer ID, so `from` cannot be spoofed.
20// ---------------------------------------------------------------------------
22interface Announcement {
23 peerId: string
24 name: string
25 busy: boolean
28type PeerMsg =
29 | {t: 'call-request'; name: string}
30 | {t: 'call-accept'; name: string}
31 | {t: 'call-decline'; busy?: boolean}
32 | {t: 'call-cancel'}
33 | {t: 'hang-up'}
34 | {t: 'signal'; signal: Signal}
36const ROOM_ID = 'default'
37const ANNOUNCE_INTERVAL_MS = 5000
38const PRESENCE_TTL_MS = 15000
39// Nostr events are ephemeral and relays are flaky, so re-publish the ring
40// while it's pending; the event-id dedup on the far side absorbs repeats.
41const RING_RESEND_MS = 4000
42const RING_TIMEOUT_MS = 45000
43const CONNECT_TIMEOUT_MS = 45000
45const NAME_KEY = 'commoncall:name'
47export type CallPhase = 'outgoing' | 'incoming' | 'connecting' | 'connected'
49interface Call {
50 phase: CallPhase
51 peerId: string
52 peerName: string
53 peer: Peer | null
54 localStream: MediaStream | null
55 remoteStream: MediaStream | null
56 /** Set while the outgoing video track is a screen capture, not the camera. */
57 screenStream: MediaStream | null
58 /** Signals that arrived before our getUserMedia resolved. */
59 pendingSignals: Signal[]
60 ringInterval: number | null
61 ringTimeout: number | null
62 connectTimeout: number | null
65export interface RosterEntry {
66 peerId: string
67 name: string
68 busy: boolean
71export interface CallInfo {
72 phase: CallPhase
73 peerId: string
74 peerName: string
75 localStream: MediaStream | null
76 remoteStream: MediaStream | null
77 screenStream: MediaStream | null
80export interface Snapshot {
81 selfId: string
82 name: string | null
83 roster: RosterEntry[]
84 call: CallInfo | null
85 notice: string | null
88export class Network {
89 private nostr = new Nostr()
90 private rootReady: Promise<string>
91 private presence = new Map<
92 string,
93 {name: string; busy: boolean; lastSeen: number}
94 >()
95 private name: string | null = null
96 private call: Call | null = null
97 private notice: string | null = null
99 private snapshot!: Snapshot
100 private listeners = new Set<() => void>()
102 /** Last name used on this browser, for prefilling the join form. */
103 readonly savedName: string = localStorage.getItem(NAME_KEY) ?? ''
105 constructor() {
106 this.rebuildSnapshot()
107 this.rootReady = rootTopic(ROOM_ID)
108 void this.start()
109 if (this.savedName) this.join(this.savedName)
110 }
112 private async start() {
113 const root = await this.rootReady
115 // Call requests + WebRTC signaling addressed to us.
116 const selfTopic = await peerTopic(root, selfId)
117 this.nostr.subscribe(selfTopic, (content, from) => {
118 if (from === selfId) return
119 let msg: PeerMsg
120 try {
121 msg = JSON.parse(content)
122 } catch {
123 return
124 }
125 this.handlePeerMsg(from, msg)
126 })
128 // Presence announcements.
129 this.nostr.subscribe(root, (content, from) => {
130 if (from === selfId) return
131 let ann: Partial<Announcement>
132 try {
133 ann = JSON.parse(content)
134 } catch {
135 return
136 }
137 if (ann.peerId !== from || typeof ann.name !== 'string') return
138 const prev = this.presence.get(from)
139 const busy = ann.busy === true
140 this.presence.set(from, {name: ann.name, busy, lastSeen: Date.now()})
141 if (!prev || prev.name !== ann.name || prev.busy !== busy) {
142 this.rebuildSnapshot()
143 }
144 })
146 setInterval(() => void this.announce(), ANNOUNCE_INTERVAL_MS)
147 setInterval(() => this.sweepPresence(), ANNOUNCE_INTERVAL_MS)
149 window.addEventListener('online', () => void this.announce())
150 }
152 private async announce() {
153 if (!this.name) return
154 const root = await this.rootReady
155 const ann: Announcement = {
156 peerId: selfId,
157 name: this.name,
158 busy: this.call !== null
159 }
160 void this.nostr.publish(root, JSON.stringify(ann))
161 }
163 private sweepPresence() {
164 const cutoff = Date.now() - PRESENCE_TTL_MS
165 let changed = false
166 for (const [peerId, p] of this.presence) {
167 if (p.lastSeen < cutoff) {
168 this.presence.delete(peerId)
169 changed = true
170 }
171 }
172 if (changed) this.rebuildSnapshot()
173 }
175 private async sendToPeer(peerId: string, msg: PeerMsg) {
176 const root = await this.rootReady
177 const topic = await peerTopic(root, peerId)
178 void this.nostr.publish(topic, JSON.stringify(msg))
179 }
181 // ---- incoming messages ------------------------------------------------
183 private handlePeerMsg(from: string, msg: PeerMsg) {
184 switch (msg.t) {
185 case 'call-request': {
186 if (this.call) {
187 if (this.call.peerId !== from) {
188 // Busy with someone else.
189 void this.sendToPeer(from, {t: 'call-decline', busy: true})
190 } else if (this.call.phase === 'outgoing') {
191 // Glare: we each called the other — that's mutual agreement.
192 this.beginConnecting()
193 } else if (
194 this.call.phase === 'connecting' ||
195 this.call.phase === 'connected'
196 ) {
197 // Their resent ring means our accept was lost; send it again.
198 void this.sendToPeer(from, {
199 t: 'call-accept',
200 name: this.name ?? ''
201 })
202 }
203 // phase 'incoming': duplicate ring, ignore.
204 return
205 }
206 if (!this.name) {
207 // Not joined; we shouldn't be getting calls — turn them away.
208 void this.sendToPeer(from, {t: 'call-decline', busy: true})
209 return
210 }
211 this.notice = null
212 this.call = this.newCall('incoming', from, msg.name)
213 this.rebuildSnapshot()
214 void this.announce()
215 return
216 }
218 case 'call-accept': {
219 if (this.call?.phase === 'outgoing' && this.call.peerId === from) {
220 if (msg.name) this.call.peerName = msg.name
221 this.beginConnecting()
222 }
223 return
224 }
226 case 'call-decline': {
227 if (this.call?.peerId === from && this.call.phase === 'outgoing') {
228 const who = this.call.peerName
229 this.teardown(msg.busy ? `${who} is busy.` : `${who} declined.`)
230 }
231 return
232 }
234 case 'call-cancel': {
235 if (this.call?.peerId === from) {
236 this.teardown(`${this.call.peerName} canceled the call.`)
237 }
238 return
239 }
241 case 'hang-up': {
242 if (this.call?.peerId === from) {
243 this.teardown(`${this.call.peerName} hung up.`)
244 }
245 return
246 }
248 case 'signal': {
249 const call = this.call
250 if (!call || call.peerId !== from) return
251 if (call.phase !== 'connecting' && call.phase !== 'connected') return
252 if (call.peer) void call.peer.signal(msg.signal)
253 else call.pendingSignals.push(msg.signal)
254 return
255 }
256 }
257 }
259 // ---- call lifecycle ---------------------------------------------------
261 private newCall(phase: CallPhase, peerId: string, peerName: string): Call {
262 return {
263 phase,
264 peerId,
265 peerName,
266 peer: null,
267 localStream: null,
268 remoteStream: null,
269 screenStream: null,
270 pendingSignals: [],
271 ringInterval: null,
272 ringTimeout: null,
273 connectTimeout: null
274 }
275 }
277 private clearCallTimers(call: Call) {
278 if (call.ringInterval !== null) clearInterval(call.ringInterval)
279 if (call.ringTimeout !== null) clearTimeout(call.ringTimeout)
280 if (call.connectTimeout !== null) clearTimeout(call.connectTimeout)
281 call.ringInterval = null
282 call.ringTimeout = null
283 call.connectTimeout = null
284 }
286 /** Both sides agreed: get the camera/mic and bring up the WebRTC call. */
287 private beginConnecting() {
288 const call = this.call
289 if (!call || call.phase === 'connecting' || call.phase === 'connected') {
290 return
291 }
292 this.clearCallTimers(call)
293 call.phase = 'connecting'
294 call.connectTimeout = window.setTimeout(() => {
295 if (this.call === call && call.phase === 'connecting') {
296 void this.sendToPeer(call.peerId, {t: 'hang-up'})
297 this.teardown('Could not establish a connection.')
298 }
299 }, CONNECT_TIMEOUT_MS)
300 this.rebuildSnapshot()
301 void this.startMedia(call)
302 }
304 private async startMedia(call: Call) {
305 let stream: MediaStream
306 try {
307 stream = await navigator.mediaDevices.getUserMedia({
308 video: true,
309 audio: true
310 })
311 } catch {
312 if (this.call === call) {
313 void this.sendToPeer(call.peerId, {t: 'hang-up'})
314 this.teardown('Could not access your camera/microphone.')
315 }
316 return
317 }
318 if (this.call !== call || call.phase !== 'connecting') {
319 // The call went away while we were waiting for permission.
320 for (const track of stream.getTracks()) track.stop()
321 return
322 }
324 call.localStream = stream
325 // Deterministic initiator (no glare): the smaller peer ID makes the offer.
326 const peer = new Peer(selfId < call.peerId, stream)
327 call.peer = peer
328 peer.setHandlers({
329 signal: signal => {
330 void this.sendToPeer(call.peerId, {t: 'signal', signal})
331 },
332 track: remote => {
333 if (this.call !== call) return
334 call.remoteStream = remote
335 this.rebuildSnapshot()
336 },
337 connect: () => {
338 if (this.call !== call) return
339 call.phase = 'connected'
340 this.clearCallTimers(call)
341 this.rebuildSnapshot()
342 },
343 data: raw => {
344 let msg: {t?: string}
345 try {
346 msg = JSON.parse(raw)
347 } catch {
348 return
349 }
350 if (msg.t === 'hang-up' && this.call === call) {
351 this.teardown(`${call.peerName} hung up.`)
352 }
353 },
354 close: () => {
355 if (this.call === call) this.teardown('Call ended.')
356 }
357 })
358 for (const signal of call.pendingSignals.splice(0)) {
359 void peer.signal(signal)
360 }
361 this.rebuildSnapshot()
362 }
364 private teardown(notice: string | null) {
365 const call = this.call
366 if (!call) return
367 this.call = null // cleared first so the peer's close handler no-ops
368 this.clearCallTimers(call)
369 call.peer?.destroy()
370 if (call.localStream) {
371 for (const track of call.localStream.getTracks()) track.stop()
372 }
373 if (call.screenStream) {
374 for (const track of call.screenStream.getTracks()) track.stop()
375 }
376 this.notice = notice
377 this.rebuildSnapshot()
378 void this.announce()
379 }
381 // ---- public API -------------------------------------------------------
383 join(name: string) {
384 const trimmed = name.trim().slice(0, 40)
385 if (!trimmed) return
386 this.name = trimmed
387 localStorage.setItem(NAME_KEY, trimmed)
388 this.notice = null
389 this.rebuildSnapshot()
390 void this.announce()
391 }
393 leave() {
394 if (this.call) this.endCall()
395 this.name = null
396 this.rebuildSnapshot()
397 // Others will drop us from their rosters when announcements stop.
398 }
400 callPeer(peerId: string) {
401 if (!this.name || this.call || peerId === selfId) return
402 const peerName = this.presence.get(peerId)?.name ?? peerId.slice(0, 8)
403 this.notice = null
404 const call = this.newCall('outgoing', peerId, peerName)
405 this.call = call
406 const ring = () => void this.sendToPeer(peerId, {
407 t: 'call-request',
408 name: this.name ?? ''
409 })
410 ring()
411 call.ringInterval = window.setInterval(ring, RING_RESEND_MS)
412 call.ringTimeout = window.setTimeout(() => {
413 if (this.call === call && call.phase === 'outgoing') {
414 void this.sendToPeer(peerId, {t: 'call-cancel'})
415 this.teardown(`${call.peerName} did not answer.`)
416 }
417 }, RING_TIMEOUT_MS)
418 this.rebuildSnapshot()
419 void this.announce()
420 }
422 accept() {
423 const call = this.call
424 if (!call || call.phase !== 'incoming') return
425 void this.sendToPeer(call.peerId, {t: 'call-accept', name: this.name ?? ''})
426 this.beginConnecting()
427 }
429 decline() {
430 const call = this.call
431 if (!call || call.phase !== 'incoming') return
432 void this.sendToPeer(call.peerId, {t: 'call-decline'})
433 this.teardown(null)
434 }
436 endCall() {
437 const call = this.call
438 if (!call) return
439 if (call.phase === 'outgoing') {
440 void this.sendToPeer(call.peerId, {t: 'call-cancel'})
441 } else if (call.phase === 'incoming') {
442 void this.sendToPeer(call.peerId, {t: 'call-decline'})
443 } else {
444 // Belt and braces: the control channel may not be open yet.
445 call.peer?.send(JSON.stringify({t: 'hang-up'}))
446 void this.sendToPeer(call.peerId, {t: 'hang-up'})
447 }
448 this.teardown(null)
449 }
451 /** Swap the outgoing camera track for a screen capture. The remote side
452 * sees the screen in place of the camera; no renegotiation involved. */
453 async startScreenShare() {
454 const call = this.call
455 if (!call || !call.peer || call.screenStream) return
456 let stream: MediaStream
457 try {
458 stream = await navigator.mediaDevices.getDisplayMedia({video: true})
459 } catch {
460 return // user canceled the picker (or capture is unsupported)
461 }
462 const track = stream.getVideoTracks()[0]
463 const ok =
464 this.call === call && call.peer && track
465 ? await call.peer.replaceVideoTrack(track)
466 : false
467 if (!ok || this.call !== call) {
468 for (const t of stream.getTracks()) t.stop()
469 return
470 }
471 call.screenStream = stream
472 // The browser's own "Stop sharing" bar ends the track; swap back then.
473 track.onended = () => void this.stopScreenShare()
474 this.rebuildSnapshot()
475 }
477 async stopScreenShare() {
478 const call = this.call
479 if (!call || !call.screenStream) return
480 const screen = call.screenStream
481 call.screenStream = null
482 const camTrack = call.localStream?.getVideoTracks()[0]
483 if (call.peer && camTrack) await call.peer.replaceVideoTrack(camTrack)
484 for (const t of screen.getTracks()) t.stop()
485 if (this.call === call) this.rebuildSnapshot()
486 }
488 dismissNotice() {
489 this.notice = null
490 this.rebuildSnapshot()
491 }
493 getSnapshot = (): Snapshot => this.snapshot
495 subscribe = (listener: () => void): (() => void) => {
496 this.listeners.add(listener)
497 return () => this.listeners.delete(listener)
498 }
500 private rebuildSnapshot() {
501 const roster: RosterEntry[] = [...this.presence.entries()]
502 .map(([peerId, p]) => ({peerId, name: p.name, busy: p.busy}))
503 .sort(
504 (a, b) =>
505 a.name.localeCompare(b.name) || a.peerId.localeCompare(b.peerId)
506 )
507 const call: CallInfo | null = this.call
508 ? {
509 phase: this.call.phase,
510 peerId: this.call.peerId,
511 peerName: this.call.peerName,
512 localStream: this.call.localStream,
513 remoteStream: this.call.remoteStream,
514 screenStream: this.call.screenStream
515 }
516 : null
517 this.snapshot = {
518 selfId,
519 name: this.name,
520 roster,
521 call,
522 notice: this.notice
523 }
524 for (const l of this.listeners) l()
525 }
moveopenescclose