Skip to content

Instantly share code, notes, and snippets.

@maca134
Created December 8, 2024 02:57
Show Gist options
  • Save maca134/07f30209fe9c6892ce38acfc67328e33 to your computer and use it in GitHub Desktop.
Save maca134/07f30209fe9c6892ce38acfc67328e33 to your computer and use it in GitHub Desktop.
"Perfect" WebRTC Negotiation
/**
* 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