MWSE/sdk/webrtc/Negotiator.js

118 lines
4.4 KiB
JavaScript

// 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 = [];
}
}