Created
December 8, 2024 02:57
-
-
Save maca134/07f30209fe9c6892ce38acfc67328e33 to your computer and use it in GitHub Desktop.
"Perfect" WebRTC Negotiation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* The `PeerConnection` class manages a WebRTC peer-to-peer connection. | |
* It handles signaling, ICE candidates, and connection state changes. | |
* | |
* Based on: https://github.com/webrtc/samples/blob/gh-pages/src/content/peerconnection/perfect-negotiation/js/peer.js | |
* | |
* @example | |
* ```typescript | |
* const connection = new PeerConnection({ primary: true, configuration: {} }); | |
* const socket = io(); | |
* connection.addEventHandler('signal', (event) => { | |
* socket.emit('webrtc-signal', event); | |
* }); | |
* socket.on('webrtc-signal', (event) => { | |
* connection.signal(event); | |
* }); | |
* ``` | |
*/ | |
export class PeerConnection { | |
private readonly _handlers = new Set<PeerSignalHandler>(); | |
private readonly _primary: boolean; | |
private readonly _peer: RTCPeerConnection; | |
private _ignoreOffer = false; | |
private _makingOffer = false; | |
private _srdAnswerPending = false; | |
constructor({ | |
primary, | |
configuration, | |
}: { | |
configuration?: RTCConfiguration; | |
primary: boolean; | |
}) { | |
this._primary = primary; | |
this._peer = new RTCPeerConnection(configuration); | |
this._peer.addEventListener("negotiationneeded", this.onNegotiationNeeded); | |
this._peer.addEventListener("icecandidate", this.onIceCandidate); | |
this.log("starting peer connection"); | |
} | |
/** | |
* Gets the current peer connection instance. | |
* | |
* @returns {RTCPeerConnection} The current peer connection instance. | |
*/ | |
get peer(): RTCPeerConnection { | |
return this._peer; | |
} | |
/** | |
* Adds an event handler for the 'signal' event. | |
* | |
* @param type - The event type, which is 'signal'. | |
* @param handler - The handler function to be added for the 'signal' event. | |
*/ | |
addEventHandler(_: 'signal', handler: PeerSignalHandler) { | |
this._handlers.add(handler); | |
} | |
/** | |
* Removes an event handler for the 'signal' event. | |
* | |
* @param type - The event type, which is 'signal'. | |
* @param handler - The handler function to be removed for the 'signal' event | |
*/ | |
removeEventHandler(_: 'signal', handler: PeerSignalHandler) { | |
this._handlers.delete(handler); | |
} | |
/** | |
* Closes the peer connection. | |
*/ | |
close() { | |
this.log("closing peer connection"); | |
this._peer.removeEventListener("negotiationneeded", this.onNegotiationNeeded); | |
this._peer.removeEventListener("icecandidate", this.onIceCandidate); | |
this._peer.close(); | |
} | |
/** | |
* Handles signaling events for the peer connection. | |
* | |
* @param event - The signaling event containing the type and data. | |
* @returns A promise that resolves when the signaling event has been processed. | |
*/ | |
async signal(event: PeerSignalEvent) { | |
switch (event.type) { | |
case "offer": | |
await this.offer(event.data); | |
break; | |
case "ice": | |
await this.ice(event.data); | |
break; | |
} | |
} | |
private async offer(description: RTCSessionDescriptionInit) { | |
const isStable = | |
this._peer.signalingState === "stable" || | |
(this._peer.signalingState === "have-local-offer" && | |
this._srdAnswerPending); | |
this._ignoreOffer = | |
description.type == "offer" && | |
this._primary && | |
(this._makingOffer || !isStable); | |
if (this._ignoreOffer) { | |
this.log("glare - ignoring offer"); | |
return; | |
} | |
this._srdAnswerPending = description.type == "answer"; | |
await this._peer.setRemoteDescription(description); | |
this._srdAnswerPending = false; | |
if (description.type === "offer") { | |
if (this._peer.signalingState !== "have-remote-offer") { | |
this.log("[ERROR]", "SRD did not set signaling state"); | |
return; | |
} | |
if (this._peer.remoteDescription?.type !== "offer") { | |
this.log("[ERROR]", "SRD did not set remote description"); | |
return; | |
} | |
await this._peer.setLocalDescription(); | |
// @ts-expect-error: signal state can change above | |
if (this._peer.signalingState !== "stable") { | |
this.log("[ERROR]", "SLD did not set signaling state"); | |
return; | |
} | |
if (this._peer.localDescription?.type !== "answer") { | |
this.log("[ERROR]", "SLD did not set local description"); | |
return; | |
} | |
this.emit({ | |
type: "offer", | |
data: this._peer.localDescription, | |
}); | |
return; | |
} | |
if (this._peer.signalingState !== "stable") { | |
this.log("[ERROR]", "signaling state is not stable"); | |
return; | |
} | |
if (this._peer.remoteDescription?.type !== "answer") { | |
this.log("[ERROR]", "remote description is not answer"); | |
return; | |
} | |
} | |
private async ice(candidate: RTCIceCandidate | null) { | |
if (!candidate) { | |
return; | |
} | |
try { | |
await this._peer.addIceCandidate(candidate); | |
} catch (err) { | |
if (!this._ignoreOffer) { | |
this.log("[ERROR]", "error adding ice candidate", err); | |
throw err; | |
} | |
} | |
} | |
private onNegotiationNeeded = async () => { | |
try { | |
if (this._peer.signalingState !== "stable") { | |
this.log("[ERROR]", "negotiationneeded always fires in stable state"); | |
return; | |
} | |
if (this._makingOffer) { | |
this.log("[ERROR]", "negotiationneeded not already in progress"); | |
return; | |
} | |
this._makingOffer = true; | |
await this._peer.setLocalDescription(); | |
// @ts-expect-error: signal state can change above | |
if (this._peer.signalingState !== "have-local-offer") { | |
this.log("[ERROR]", "negotiationneeded did not set SLD"); | |
return; | |
} | |
if (this._peer.localDescription?.type !== "offer") { | |
this.log("[ERROR]", "negotiationneeded SLD worked"); | |
return; | |
} | |
this.emit({ | |
type: "offer", | |
data: this._peer.localDescription, | |
}); | |
} catch (err) { | |
this.log("[ERROR]", err); | |
} finally { | |
this._makingOffer = false; | |
} | |
} | |
private onIceCandidate = (event: RTCPeerConnectionIceEvent) => { | |
this.emit({ | |
type: "ice", | |
data: event.candidate, | |
}); | |
} | |
private log(...args: unknown[]) { | |
console.log("[PeerConnection] ", ...args); | |
} | |
private emit(signal: PeerSignalEvent) { | |
for (const handler of this._handlers) { | |
handler(signal); | |
} | |
} | |
} | |
export type PeerSignalEvent = | |
| { | |
type: "offer"; | |
data: RTCSessionDescriptionInit; | |
} | |
| { | |
type: "ice"; | |
data: RTCIceCandidate | null; | |
}; | |
export type PeerSignalHandler = (signal: PeerSignalEvent) => void; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment