import wrtc from '@roamhq/wrtc' // A thin WebRTC wrapper, ported from commonroom's peer.ts onto @roamhq/wrtc // (node-webrtc). One instance per remote participant: it carries our // placeholder outgoing tracks plus the small control data channel (hello, // mute notices, chat, settings sync). As in the browser client we avoid // "perfect negotiation" glare handling by ensuring only ONE side (the // deterministically chosen initiator = smaller peer ID) ever creates the // offer. // // Changes from the browser version: // - The VIDEO m-line is negotiated 'sendonly' from our side (our placeholder // track, which never produces a frame). The browser answers/offers the // complement (recvonly), so NO video RTP ever flows to the recorder — with // up to 7 participants that saves several Mbit/s of download plus the // decode CPU, and to the browsers we look exactly like a camera-muted // participant. // - The track handler hands over the remote MediaStreamTrack (we attach an // RTCAudioSink per audio track) instead of the MediaStream. // - replaceTrack/setVideoParameters are gone: the recorder never upgrades or // caps media. export type Signal = | {type: 'offer'; sdp: string} | {type: 'answer'; sdp: string} | {type: 'candidate'; candidate: RTCIceCandidateInit} export interface PeerHandlers { signal: (signal: Signal) => void /** Connection reached the 'connected' state. */ connect: () => void /** A remote media track became available (we only consume audio). */ track: (track: MediaStreamTrack) => void /** A string message arrived on the control channel. */ data: (data: string) => void close: () => void } export const ICE_SERVERS: RTCIceServer[] = [ {urls: 'stun:stun.l.google.com:19302'}, {urls: 'stun:stun1.l.google.com:19302'}, {urls: 'stun:stun.cloudflare.com:3478'}, // Free TURN relay (openrelayproject) — needed when direct/STUN pairing // fails (symmetric NAT, hairpinning, host-candidate blocking). { urls: [ 'turn:openrelay.metered.ca:80', 'turn:openrelay.metered.ca:443', 'turns:openrelay.metered.ca:443' ], username: 'openrelayproject', credential: 'openrelayproject' } ] // A media connection can survive a brief network blip: 'disconnected' often // recovers on its own, so only tear down if it persists this long. const DISCONNECT_GRACE_MS = 5000 export class Peer { private pc: RTCPeerConnection private channel: RTCDataChannel | null = null /** Control messages sent before the channel opens; flushed on open. */ private outbox: string[] = [] private handlers: Partial = {} private pendingCandidates: RTCIceCandidateInit[] = [] private disconnectTimer: ReturnType | null = null private closed = false constructor( private initiator: boolean, audioTrack: MediaStreamTrack, videoTrack: MediaStreamTrack ) { this.pc = new wrtc.RTCPeerConnection({iceServers: ICE_SERVERS}) // Both sides add media up front so the initiator's single offer covers // everything. Audio is sendrecv (our track is a silent placeholder — a // muted mic); video is sendonly so the other side never sends us any. this.pc.addTrack(audioTrack) if (initiator) { this.pc.addTransceiver(videoTrack, {direction: 'sendonly'}) } else { // As answerer the transceivers come from the remote offer; // setRemoteDescription associates this track with the video m-line and // signal() flips its direction to sendonly before answering. this.pc.addTrack(videoTrack) } this.pc.ontrack = ({track}) => { this.handlers.track?.(track) } this.pc.onicecandidate = ({candidate}) => { if (candidate) { this.handlers.signal?.({type: 'candidate', candidate: candidate.toJSON()}) } } this.pc.onconnectionstatechange = () => { const s = this.pc.connectionState if (s === 'connected') { this.clearDisconnectTimer() this.handlers.connect?.() } else if (s === 'failed' || s === 'closed') { this.destroy() } else if (s === 'disconnected') { this.clearDisconnectTimer() this.disconnectTimer = setTimeout(() => { if (this.pc.connectionState !== 'connected') this.destroy() }, DISCONNECT_GRACE_MS) } } if (initiator) { this.setupChannel(this.pc.createDataChannel('control')) this.pc.onnegotiationneeded = () => void this.makeOffer() } else { this.pc.ondatachannel = ({channel}) => this.setupChannel(channel) } } setHandlers(handlers: Partial) { Object.assign(this.handlers, handlers) } private clearDisconnectTimer() { if (this.disconnectTimer !== null) { clearTimeout(this.disconnectTimer) this.disconnectTimer = null } } private setupChannel(channel: RTCDataChannel) { this.channel = channel const flush = () => { for (const data of this.outbox.splice(0)) channel.send(data) } if (channel.readyState === 'open') flush() else channel.onopen = flush channel.onclose = () => this.destroy() channel.onmessage = e => { if (typeof e.data === 'string') this.handlers.data?.(e.data) } } private async makeOffer() { if (this.closed) return try { await this.pc.setLocalDescription(await this.pc.createOffer()) this.handlers.signal?.({ type: 'offer', sdp: this.pc.localDescription!.sdp }) } catch { /* ignore */ } } async signal(signal: Signal) { if (this.closed) return try { if (signal.type === 'candidate') { if (this.pc.remoteDescription) { await this.pc.addIceCandidate(signal.candidate) } else { this.pendingCandidates.push(signal.candidate) } return } if (signal.type === 'offer') { if (this.initiator) return // initiators never accept remote offers await this.pc.setRemoteDescription({type: 'offer', sdp: signal.sdp}) // Refuse incoming video: answer that m-line sendonly (our // never-producing placeholder) so the browser doesn't send us any. for (const t of this.pc.getTransceivers()) { if (t.receiver.track?.kind === 'video') t.direction = 'sendonly' } await this.flushCandidates() await this.pc.setLocalDescription(await this.pc.createAnswer()) this.handlers.signal?.({ type: 'answer', sdp: this.pc.localDescription!.sdp }) return } if (signal.type === 'answer') { await this.pc.setRemoteDescription({type: 'answer', sdp: signal.sdp}) await this.flushCandidates() } } catch { /* ignore transient signaling errors */ } } private async flushCandidates() { const queued = this.pendingCandidates.splice(0) for (const c of queued) { try { await this.pc.addIceCandidate(c) } catch { /* ignore */ } } } send(data: string) { if (this.channel?.readyState === 'open') this.channel.send(data) else if (!this.closed) this.outbox.push(data) } get isConnected(): boolean { return this.pc.connectionState === 'connected' } destroy() { if (this.closed) return this.closed = true this.clearDisconnectTimer() try { this.channel?.close() } catch { /* ignore */ } try { this.pc.close() } catch { /* ignore */ } this.handlers.close?.() } }