118 lines
4.4 KiB
JavaScript
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 = [];
|
|
}
|
|
}
|