// RTCPeerConnection wrapper with comprehensive state monitoring. // Fixes the original SDK's missed-disconnect bug by subscribing to BOTH // connectionstatechange (the modern event) and iceconnectionstatechange // (the legacy fallback) and never letting either go unobserved. // // Emits: 'connected', 'disconnected', 'failed', // 'state-change'(state), 'ice-state-change'(state), // 'gathering-change'(state), 'signaling-change'(state), // 'ice-candidate'(candidate|null), 'track'(track, streams), // 'datachannel'(channel), 'negotiation-needed' import MWSEEventTarget from '../EventTarget.js'; export default class PeerConnection extends MWSEEventTarget { constructor(config) { super(); this._pc = new RTCPeerConnection(config); this._wire(); } // ---- RTCPeerConnection passthrough ---------------------------------- get pc() { return this._pc; } get connectionState() { return this._pc.connectionState; } get iceConnectionState() { return this._pc.iceConnectionState; } get signalingState() { return this._pc.signalingState; } get iceGatheringState() { return this._pc.iceGatheringState; } get localDescription() { return this._pc.localDescription; } get remoteDescription() { return this._pc.remoteDescription; } addTrack(track, ...streams) { return this._pc.addTrack(track, ...streams); } removeTrack(sender) { this._pc.removeTrack(sender); } async createOffer(opts) { return this._pc.createOffer(opts); } async createAnswer() { return this._pc.createAnswer(); } async setLocalDescription(d) { await this._pc.setLocalDescription(d); } async setRemoteDescription(d){ await this._pc.setRemoteDescription(d); } async addIceCandidate(c) { if (!c) return; await this._pc.addIceCandidate(c); } createDataChannel(label, opts) { return this._pc.createDataChannel(label, opts); } async restartIce() { if (typeof this._pc.restartIce === 'function') { this._pc.restartIce(); } else { const offer = await this._pc.createOffer({ iceRestart: true }); await this._pc.setLocalDescription(offer); } } close() { const pc = this._pc; pc.onconnectionstatechange = null; pc.oniceconnectionstatechange = null; pc.onicegatheringstatechange = null; pc.onsignalingstatechange = null; pc.onicecandidate = null; pc.ontrack = null; pc.ondatachannel = null; pc.onnegotiationneeded = null; pc.close(); } // ---- Internal event wiring ------------------------------------------ _wire() { const pc = this._pc; // connectionstatechange is the primary signal on modern browsers. // It reliably reports 'failed' and 'closed' — states that // iceconnectionstatechange can miss under certain NAT conditions. pc.onconnectionstatechange = () => { const s = pc.connectionState; this.emit('state-change', s); if (s === 'connected') this.emit('connected'); if (s === 'disconnected' || s === 'closed') this.emit('disconnected'); if (s === 'failed') this.emit('failed'); }; // Keep iceconnectionstatechange as a fallback for environments where // connectionstatechange never fires (some mobile browsers, old Chrome). pc.oniceconnectionstatechange = () => { const s = pc.iceConnectionState; this.emit('ice-state-change', s); // Only act if connectionState hasn't given us a verdict yet. if (pc.connectionState === 'new' || pc.connectionState === 'connecting') { if (s === 'connected' || s === 'completed') this.emit('connected'); if (s === 'disconnected') this.emit('disconnected'); if (s === 'failed') this.emit('failed'); } }; pc.onicegatheringstatechange = () => { this.emit('gathering-change', pc.iceGatheringState); }; pc.onsignalingstatechange = () => { this.emit('signaling-change', pc.signalingState); }; pc.onicecandidate = ({ candidate }) => { this.emit('ice-candidate', candidate); }; pc.ontrack = ({ track, streams }) => { this.emit('track', track, streams); }; pc.ondatachannel = ({ channel }) => { this.emit('datachannel', channel); }; pc.onnegotiationneeded = () => { this.emit('negotiation-needed'); }; } }