@aicacia/peer
Version:
simple peer to peer for webrtc
475 lines (474 loc) • 16.2 kB
JavaScript
import { v4 as uuidv4 } from "uuid";
import { EventEmitter, } from "eventemitter3";
const defaultMaxChannelMessageSize = 16384;
const DEFAULT_WEBRTC = {
RTCPeerConnection: typeof RTCPeerConnection === "undefined"
? null
: RTCPeerConnection,
RTCSessionDescription: typeof RTCSessionDescription === "undefined"
? null
: RTCSessionDescription,
RTCIceCandidate: typeof RTCIceCandidate === "undefined"
? null
: RTCIceCandidate,
};
export class Peer extends EventEmitter {
constructor(options = {}) {
super();
this.initiator = false;
this.maxChannelMessageSize = defaultMaxChannelMessageSize;
this.sdpTransform = sdpTransform;
this.config = { iceServers: [] };
this.pendingCandidates = [];
this.webrtc = DEFAULT_WEBRTC;
this.onConnectionStateChange = () => {
if (!this.connection) {
return;
}
console.debug(`${this.id}: connection state ${this.connection.connectionState}`);
switch (this.connection.connectionState) {
case "failed":
case "disconnected":
case "closed":
this.internalClose(true);
break;
}
};
this.onNegotiationNeeded = () => {
if (!this.connection) {
return;
}
return this.negotiate();
};
this.onICEConnectionStateChange = () => {
if (!this.connection) {
return;
}
console.debug(`${this.id}: ice connection state ${this.connection.iceConnectionState}`);
};
this.onICEGatheringStateChange = () => {
if (!this.connection) {
return;
}
console.debug(`${this.id}: ice gathering state ${this.connection.iceGatheringState}`);
};
this.onSignalingStateChange = () => {
if (!this.connection) {
return;
}
console.debug(`${this.id}: signaling state ${this.connection.signalingState}`);
};
this.onICECandidate = (event) => {
if (event.candidate) {
this.internalSignal({
type: "candidate",
candidate: event.candidate,
});
}
};
this.onTrackRemote = (event) => {
this.emit("track", event);
};
this.onDataChannel = (event) => {
const channel = event.channel;
channel.addEventListener("open", this.onDataChannelOpen);
channel.addEventListener("message", this.onDataChannelMessage);
channel.addEventListener("error", this.onDataChannelError);
this.channel = channel;
};
this.onDataChannelOpen = () => {
console.debug(`${this.id}: data channel open`);
this.emit("connect");
};
this.onDataChannelMessage = (event) => {
this.emit("data", event.data);
};
this.onDataChannelError = (event) => {
this.emit("error", new Error("DataChannel error", { cause: event }));
};
this.id = options.id || uuidv4();
this.channelName = options.channelName || uuidv4();
if (options.channelConfig) {
this.channelConfig = options.channelConfig;
}
if (options.sdpTransform) {
this.sdpTransform = options.sdpTransform;
}
if (options.config) {
this.config = options.config;
}
if (options.offerConfig) {
this.offerConfig = options.offerConfig;
}
if (options.answerConfig) {
this.answerConfig = options.answerConfig;
}
if (options.maxChannelMessageSize && options.maxChannelMessageSize > 0) {
this.maxChannelMessageSize = options.maxChannelMessageSize;
}
if (options.webrtc) {
this.webrtc = options.webrtc;
}
}
getId() {
return this.id;
}
getConnection() {
return this.connection;
}
getChannel() {
return this.channel;
}
isReady() {
return this.channel && this.channel.readyState === "open";
}
isClosed() {
return !this.connection || this.connection.connectionState !== "connected";
}
ready() {
if (this.isReady()) {
return Promise.resolve();
}
return this.waitOnce("connect");
}
data() {
return this.waitOnce("data");
}
closed() {
return this.waitOnce("close");
}
isInitiator() {
return this.initiator;
}
init() {
this.initiator = true;
return this.createPeer();
}
close() {
return this.internalClose(true);
}
send(chunk) {
if (!this.channel) {
throw new Error("Channel not initialized");
}
this.channel.send(chunk);
return this;
}
write(chunk) {
if (!this.channel) {
throw new Error("Channel not initialized");
}
return write(this.channel, chunk, this.maxChannelMessageSize);
}
writableStream() {
if (!this.channel) {
throw new Error("Channel not initialized");
}
return writableStreamFromChannel(this.channel, this.maxChannelMessageSize);
}
readableStream() {
if (!this.channel) {
throw new Error("Channel not initialized");
}
return readableStreamFromChannel(this.channel);
}
async signal(message) {
var _a, _b;
if (!this.connection) {
await this.createPeer();
}
console.debug(`${this.id}: received signal message=${message.type}`);
switch (message.type) {
case "renegotiate": {
return this.negotiate();
}
case "transceiverRequest": {
if (!this.initiator) {
throw new Error("Invalid signal state");
}
const transceiverRequest = message.transceiverRequest;
if (!transceiverRequest) {
throw new Error("Invalid signal message");
}
await this.addTransceiverFromKind(transceiverRequest.kind, transceiverRequest.init);
return this;
}
case "candidate": {
if (!this.connection) {
throw new Error("Connection not initialized");
}
const candidateJSON = message.candidate;
if (!candidateJSON) {
throw new Error("Invalid signal message");
}
const candidate = new this.webrtc.RTCIceCandidate(candidateJSON);
if (this.connection.remoteDescription == null) {
this.pendingCandidates.push(candidate);
}
else {
await this.connection.addIceCandidate(candidate);
}
return this;
}
case "answer":
case "offer":
case "pranswer":
case "rollback": {
if (!this.connection) {
throw new Error("Connection not initialized");
}
const sdp = message.sdp;
if (!sdp) {
throw new Error("Invalid signal message");
}
const sessionDescription = new this.webrtc.RTCSessionDescription({
type: message.type,
sdp,
});
await this.connection.setRemoteDescription(sessionDescription);
for (const candidate of this.pendingCandidates) {
await this.connection.addIceCandidate(candidate);
}
this.pendingCandidates.length = 0;
if (((_b = (_a = this.connection) === null || _a === void 0 ? void 0 : _a.remoteDescription) === null || _b === void 0 ? void 0 : _b.type) === "offer") {
await this.createAnswer();
}
this.emit("negotiated");
console.debug(`${this.id}: set remote sdp`);
return this;
}
default: {
console.debug(`${this.id}: invalid signal type: ${message}`);
throw new Error("Invalid signal message type");
}
}
}
waitOnce(event) {
return new Promise((resolve) => {
this.once(event, (...args) => {
switch (args.length) {
case 0:
resolve(undefined);
break;
case 1:
resolve(args[0]);
break;
default:
resolve(args);
break;
}
});
});
}
addTransceiverFromKind(kind, init) {
if (!this.connection) {
throw new Error("Connection not initialized");
}
if (this.initiator) {
const transceiver = this.connection.addTransceiver(kind, init);
this.emit("transceiver", transceiver);
return transceiver;
}
this.internalSignal({
type: "transceiverRequest",
transceiverRequest: {
kind,
init,
},
});
return null;
}
addTrack(track) {
if (!this.connection) {
throw new Error("Connection not initialized");
}
const sender = this.connection.addTrack(track);
return sender;
}
removeTrack(sender) {
if (!this.connection) {
throw new Error("Connection not initialized");
}
this.connection.removeTrack(sender);
return this;
}
internalSignal(message) {
this.emit("signal", message);
return this;
}
async negotiate() {
if (this.initiator) {
await this.createOffer();
}
else {
this.internalSignal({ type: "renegotiate", renegotiate: true });
}
return this;
}
async createOffer() {
if (!this.connection) {
throw new Error("Connection not initialized");
}
const offer = await this.connection.createOffer(this.offerConfig);
offer.sdp = this.sdpTransform(offer.sdp);
await this.connection.setLocalDescription(offer);
this.internalSignal({ type: offer.type, sdp: offer.sdp });
return this;
}
async createAnswer() {
if (!this.connection) {
throw new Error("Connection not initialized");
}
const answer = await this.connection.createAnswer(this.answerConfig);
answer.sdp = this.sdpTransform(answer.sdp);
await this.connection.setLocalDescription(answer);
this.internalSignal({ type: answer.type, sdp: answer.sdp });
return this;
}
createPeer() {
this.internalClose(false);
this.connection = new this.webrtc.RTCPeerConnection(this.config);
this.connection.addEventListener("negotiationneeded", this.onNegotiationNeeded);
this.connection.addEventListener("iceconnectionstatechange", this.onICEConnectionStateChange);
this.connection.addEventListener("icegatheringstatechange", this.onICEGatheringStateChange);
this.connection.addEventListener("connectionstatechange", this.onConnectionStateChange);
this.connection.addEventListener("icecandidate", this.onICECandidate);
this.connection.addEventListener("signalingstatechange", this.onSignalingStateChange);
this.connection.addEventListener("track", this.onTrackRemote);
if (this.initiator) {
const channel = this.connection.createDataChannel(this.channelName, this.channelConfig);
channel.addEventListener("open", this.onDataChannelOpen);
channel.addEventListener("message", this.onDataChannelMessage);
channel.addEventListener("error", this.onDataChannelError);
this.channel = channel;
}
else {
this.connection.addEventListener("datachannel", this.onDataChannel);
}
return this;
}
internalClose(triggerCallbacks = true) {
if (this.channel) {
this.channel.close();
this.channel = undefined;
}
if (this.connection) {
this.connection.close();
this.connection = undefined;
}
if (triggerCallbacks) {
this.emit("close");
}
return this;
}
}
function sdpTransform(sdp) {
return sdp;
}
function write(channel, chunk, maxChannelMessageSize) {
if (typeof chunk === "string") {
if (chunk.length < maxChannelMessageSize) {
channel.send(chunk);
}
else {
let offset = 0;
while (offset < chunk.length) {
const length = Math.min(maxChannelMessageSize, chunk.length - offset);
channel.send(chunk.substring(offset, offset + length));
offset += length;
}
}
}
else if (chunk instanceof Blob) {
if (chunk.size < maxChannelMessageSize) {
channel.send(chunk);
}
else {
let offset = 0;
while (offset < chunk.size) {
const length = Math.min(maxChannelMessageSize, chunk.size - offset);
channel.send(chunk.slice(offset, offset + length));
offset += length;
}
}
}
else {
let buffer;
if (chunk instanceof ArrayBuffer) {
buffer = chunk;
}
else {
buffer = chunk.buffer;
}
if (buffer.byteLength < maxChannelMessageSize) {
channel.send(buffer);
}
else {
let offset = 0;
while (offset < buffer.byteLength) {
const length = Math.min(maxChannelMessageSize, buffer.byteLength - offset);
channel.send(buffer.slice(offset, offset + length));
offset += length;
}
}
}
}
export function writableStreamFromChannel(channel, maxChannelMessageSize) {
return new WritableStream({
write(chunk) {
write(channel, chunk, maxChannelMessageSize);
},
});
}
export function readableStreamFromChannel(channel) {
let closed = false;
let closedController = false;
const queue = [];
const pullQueue = [];
function pull() {
return new Promise((resolve, reject) => pullQueue.push([resolve, reject]));
}
function onMessage(event) {
if (pullQueue.length) {
// biome-ignore lint/style/noNonNullAssertion: checked above
const [resolve, _reject] = pullQueue.shift();
resolve(event.data);
}
else {
queue.push(event.data);
}
}
channel.addEventListener("message", onMessage);
const onClose = () => {
if (closed) {
return;
}
channel.removeEventListener("message", onMessage);
channel.removeEventListener("close", onClose);
closed = true;
for (const [_resolve, reject] of pullQueue) {
reject(new Error("Stream closed"));
}
pullQueue.length = 0;
queue.length = 0;
};
channel.addEventListener("close", onClose);
return new ReadableStream({
async pull(controller) {
if (closed) {
if (!closedController) {
closedController = true;
controller.close();
}
return;
}
if (queue.length) {
controller.enqueue(queue.shift());
}
else {
controller.enqueue(await pull());
}
},
cancel: onClose,
});
}