// Perfect-negotiation (RFC 8829 §5.2) for WebRTC offer/answer exchange. // // Each peer is either "polite" or "impolite". When both peers generate an // offer simultaneously (collision), the polite peer rolls back and accepts // the impolite peer's offer. Politeness is decided by the caller — typically // by comparing the two socket IDs so exactly one side is polite per pair. // // ICE candidates are queued until the remote description is in place so they // are never applied to an uninitialised peer connection. export default class Negotiator { constructor(pc, { polite, onSend }) { this._pc = pc; // PeerConnection wrapper this.polite = polite; this._send = onSend; // fn(signalingObject) → relays to remote peer this._pendingCandidates = []; this._settingRemote = false; this._makingOffer = false; this._ignoreOffer = false; } // Attach to PeerConnection events. Returns `this` for chaining. attach() { this._pc.on('negotiation-needed', () => this._offer()); this._pc.on('ice-candidate', cand => { // null candidate = end-of-candidates; always forward. this._send({ type: 'ice', candidate: cand }); }); return this; } // Handle an incoming signaling message from the remote peer. // msg.type: 'offer' | 'answer' | 'ice' async receive(msg) { try { if (msg.type === 'offer') await this._onOffer(msg.sdp); else if (msg.type === 'answer') await this._onAnswer(msg.sdp); else if (msg.type === 'ice') await this._onICE(msg.candidate); } catch (err) { // Surface on caller — the engine will re-emit as 'error'. throw err; } } // ---- Internal ------------------------------------------------------- async _offer() { if (this._makingOffer) return; this._makingOffer = true; try { // Modern browsers support the implicit description form. await this._pc.setLocalDescription(); this._send({ type: 'offer', sdp: this._pc.localDescription.sdp }); } catch (err) { // setLocalDescription can fail if the signaling state rolled back // under us; safe to ignore since the other side's offer will win. if (this.polite) return; throw err; } finally { this._makingOffer = false; } } async _onOffer(sdp) { const collision = this._makingOffer || this._pc.signalingState !== 'stable'; this._ignoreOffer = !this.polite && collision; if (this._ignoreOffer) return; this._settingRemote = true; try { // Polite peer: rollback own offer, accept incoming. if (this._pc.signalingState !== 'stable') { await Promise.all([ this._pc.setLocalDescription({ type: 'rollback' }), this._pc.setRemoteDescription({ type: 'offer', sdp }) ]); } else { await this._pc.setRemoteDescription({ type: 'offer', sdp }); } } finally { this._settingRemote = false; } await this._pc.setLocalDescription(); // implicit answer this._send({ type: 'answer', sdp: this._pc.localDescription.sdp }); await this._flushCandidates(); } async _onAnswer(sdp) { if (this._pc.signalingState === 'stable') return; // already settled await this._pc.setRemoteDescription({ type: 'answer', sdp }); await this._flushCandidates(); } async _onICE(candidate) { if (!candidate) return; // end-of-candidates const ready = this._pc.remoteDescription && !this._settingRemote; if (!ready) { this._pendingCandidates.push(candidate); return; } try { await this._pc.addIceCandidate(candidate); } catch (err) { // Silently drop if we're ignoring an offer collision on the // impolite side — those candidates belong to the rolled-back offer. if (!this._ignoreOffer) throw err; } } async _flushCandidates() { for (const c of this._pendingCandidates) { await this._pc.addIceCandidate(c).catch(() => {}); } this._pendingCandidates = []; } }