UNPKG

node-datachannel

Version:

WebRTC For Node.js and Electron. libdatachannel node bindings.

505 lines (419 loc) 19.4 kB
/* eslint-disable @typescript-eslint/no-explicit-any */ import { SelectedCandidateInfo } from '../lib/types'; import { PeerConnection } from '../lib/index'; import RTCSessionDescription from './RTCSessionDescription'; import RTCDataChannel from './RTCDataChannel'; import RTCIceCandidate from './RTCIceCandidate'; import { RTCDataChannelEvent, RTCPeerConnectionIceEvent } from './Events'; import RTCSctpTransport from './RTCSctpTransport'; import * as exceptions from './Exception'; import RTCCertificate from './RTCCertificate'; // extend RTCConfiguration with peerIdentity interface RTCConfiguration extends globalThis.RTCConfiguration { peerIdentity?: string; } export default class RTCPeerConnection extends EventTarget implements globalThis.RTCPeerConnection { static async generateCertificate(): Promise<RTCCertificate> { throw new DOMException('Not implemented'); } #peerConnection: PeerConnection; #localOffer: any; #localAnswer: any; #dataChannels: Set<RTCDataChannel>; #dataChannelsClosed = 0; #config: RTCConfiguration; #canTrickleIceCandidates: boolean | null; #sctp: RTCSctpTransport; #localCandidates: RTCIceCandidate[] = []; #remoteCandidates: RTCIceCandidate[] = []; // events onconnectionstatechange: ((this: RTCPeerConnection, ev: Event) => any) | null; ondatachannel: ((this: RTCPeerConnection, ev: RTCDataChannelEvent) => any) | null; onicecandidate: ((this: RTCPeerConnection, ev: RTCPeerConnectionIceEvent) => any) | null; onicecandidateerror: ((this: RTCPeerConnection, ev: Event) => any) | null; oniceconnectionstatechange: ((this: RTCPeerConnection, ev: Event) => any) | null; onicegatheringstatechange: ((this: RTCPeerConnection, ev: Event) => any) | null; onnegotiationneeded: ((this: RTCPeerConnection, ev: Event) => any) | null; onsignalingstatechange: ((this: RTCPeerConnection, ev: Event) => any) | null; ontrack: ((this: RTCPeerConnection, ev: globalThis.RTCTrackEvent) => any) | null; private _checkConfiguration(config: RTCConfiguration): void { if (config && config.iceServers === undefined) config.iceServers = []; if (config && config.iceTransportPolicy === undefined) config.iceTransportPolicy = 'all'; if (config?.iceServers === null) throw new TypeError('IceServers cannot be null'); // Check for all the properties of iceServers if (Array.isArray(config?.iceServers)) { for (let i = 0; i < config.iceServers.length; i++) { if (config.iceServers[i] === null) throw new TypeError('IceServers cannot be null'); if (config.iceServers[i] === undefined) throw new TypeError('IceServers cannot be undefined'); if (Object.keys(config.iceServers[i]).length === 0) throw new TypeError('IceServers cannot be empty'); // If iceServers is string convert to array if (typeof config.iceServers[i].urls === 'string') config.iceServers[i].urls = [config.iceServers[i].urls as string]; // urls can not be empty if ((config.iceServers[i].urls as string[])?.some((url) => url == '')) throw new exceptions.SyntaxError('IceServers urls cannot be empty'); // urls should be valid URLs and match the protocols "stun:|turn:|turns:" if ( (config.iceServers[i].urls as string[])?.some( (url) => { try { const parsedURL = new URL(url) return !/^(stun:|turn:|turns:)$/.test(parsedURL.protocol) } catch (error) { return true } }, ) ) throw new exceptions.SyntaxError('IceServers urls wrong format'); // If this is a turn server check for username and credential if ((config.iceServers[i].urls as string[])?.some((url) => url.startsWith('turn'))) { if (!config.iceServers[i].username) throw new exceptions.InvalidAccessError('IceServers username cannot be null'); if (!config.iceServers[i].credential) throw new exceptions.InvalidAccessError('IceServers username cannot be undefined'); } // length of urls can not be 0 if (config.iceServers[i].urls?.length === 0) throw new exceptions.SyntaxError('IceServers urls cannot be empty'); } } if ( config && config.iceTransportPolicy && config.iceTransportPolicy !== 'all' && config.iceTransportPolicy !== 'relay' ) throw new TypeError('IceTransportPolicy must be either "all" or "relay"'); } setConfiguration(config: RTCConfiguration): void { this._checkConfiguration(config); this.#config = config; } constructor(config: RTCConfiguration = { iceServers: [], iceTransportPolicy: 'all' }) { super(); this._checkConfiguration(config); this.#config = config; this.#localOffer = createDeferredPromise(); this.#localAnswer = createDeferredPromise(); this.#dataChannels = new Set(); this.#canTrickleIceCandidates = null; try { const peerIdentity = (config as any)?.peerIdentity ?? `peer-${getRandomString(7)}`; this.#peerConnection = new PeerConnection(peerIdentity, { ...config, iceServers: config?.iceServers ?.map((server) => { const urls = Array.isArray(server.urls) ? server.urls : [server.urls]; return urls.map((url) => { if (server.username && server.credential) { const [protocol, rest] = url.split(/:(.*)/); return `${protocol}:${server.username}:${server.credential}@${rest}`; } return url; }); }) .flat() ?? [], }, ); } catch (error) { if (!error || !error.message) throw new exceptions.NotFoundError('Unknown error'); throw new exceptions.SyntaxError(error.message); } // forward peerConnection events this.#peerConnection.onStateChange(() => { this.dispatchEvent(new Event('connectionstatechange')); }); this.#peerConnection.onIceStateChange(() => { this.dispatchEvent(new Event('iceconnectionstatechange')); }); this.#peerConnection.onSignalingStateChange(() => { this.dispatchEvent(new Event('signalingstatechange')); }); this.#peerConnection.onGatheringStateChange(() => { this.dispatchEvent(new Event('icegatheringstatechange')); }); this.#peerConnection.onDataChannel((channel) => { const dc = new RTCDataChannel(channel); this.#dataChannels.add(dc); this.dispatchEvent(new RTCDataChannelEvent('datachannel', { channel: dc })); }); this.#peerConnection.onLocalDescription((sdp, type) => { if (type === 'offer') { this.#localOffer.resolve({ sdp, type }); } if (type === 'answer') { this.#localAnswer.resolve({ sdp, type }); } }); this.#peerConnection.onLocalCandidate((candidate, sdpMid) => { if (sdpMid === 'unspec') { this.#localAnswer.reject(new Error(`Invalid description type ${sdpMid}`)); return; } this.#localCandidates.push(new RTCIceCandidate({ candidate, sdpMid })); this.dispatchEvent(new RTCPeerConnectionIceEvent(new RTCIceCandidate({ candidate, sdpMid }))); }); // forward events to properties this.addEventListener('connectionstatechange', (e) => { if (this.onconnectionstatechange) this.onconnectionstatechange(e); }); this.addEventListener('signalingstatechange', (e) => { if (this.onsignalingstatechange) this.onsignalingstatechange(e); }); this.addEventListener('iceconnectionstatechange', (e) => { if (this.oniceconnectionstatechange) this.oniceconnectionstatechange(e); }); this.addEventListener('icegatheringstatechange', (e) => { if (this.onicegatheringstatechange) this.onicegatheringstatechange(e); }); this.addEventListener('datachannel', (e) => { if (this.ondatachannel) this.ondatachannel(e as RTCDataChannelEvent); }); this.addEventListener('icecandidate', (e) => { if (this.onicecandidate) this.onicecandidate(e as RTCPeerConnectionIceEvent); }); this.#sctp = new RTCSctpTransport({ pc: this, extraFunctions: { maxDataChannelId: (): number => { return this.#peerConnection.maxDataChannelId(); }, maxMessageSize: (): number => { return this.#peerConnection.maxMessageSize(); }, localCandidates: (): RTCIceCandidate[] => { return this.#localCandidates; }, remoteCandidates: (): RTCIceCandidate[] => { return this.#remoteCandidates; }, selectedCandidatePair: (): { local: SelectedCandidateInfo; remote: SelectedCandidateInfo } | null => { return this.#peerConnection.getSelectedCandidatePair(); }, }, }); } get canTrickleIceCandidates(): boolean | null { return this.#canTrickleIceCandidates; } get connectionState(): globalThis.RTCPeerConnectionState { return this.#peerConnection.state(); } get iceConnectionState(): globalThis.RTCIceConnectionState { let state = this.#peerConnection.iceState(); // libdatachannel uses 'completed' instead of 'connected' // see /webrtc/getstats.html if (state == 'completed') state = 'connected'; return state; } get iceGatheringState(): globalThis.RTCIceGatheringState { return this.#peerConnection.gatheringState(); } get currentLocalDescription(): RTCSessionDescription { return new RTCSessionDescription(this.#peerConnection.localDescription() as any); } get currentRemoteDescription(): RTCSessionDescription { return new RTCSessionDescription(this.#peerConnection.remoteDescription() as any); } get localDescription(): RTCSessionDescription { return new RTCSessionDescription(this.#peerConnection.localDescription() as any); } get pendingLocalDescription(): RTCSessionDescription { return new RTCSessionDescription(this.#peerConnection.localDescription() as any); } get pendingRemoteDescription(): RTCSessionDescription { return new RTCSessionDescription(this.#peerConnection.remoteDescription() as any); } get remoteDescription(): RTCSessionDescription { return new RTCSessionDescription(this.#peerConnection.remoteDescription() as any); } get sctp(): RTCSctpTransport { return this.#sctp; } get signalingState(): globalThis.RTCSignalingState { return this.#peerConnection.signalingState(); } async addIceCandidate(candidate?: globalThis.RTCIceCandidateInit | RTCIceCandidate): Promise<void> { if (!candidate || !candidate.candidate) { return; } if (candidate.sdpMid === null && candidate.sdpMLineIndex === null) { throw new TypeError('sdpMid must be set'); } if (candidate.sdpMid === undefined && candidate.sdpMLineIndex == undefined) { throw new TypeError('sdpMid must be set'); } // Reject if sdpMid format is not valid // ?? if (candidate.sdpMid && candidate.sdpMid.length > 3) { // console.log(candidate.sdpMid); throw new exceptions.OperationError('Invalid sdpMid format'); } // We don't care about sdpMLineIndex, just for test if (!candidate.sdpMid && candidate.sdpMLineIndex > 1) { throw new exceptions.OperationError('This is only for test case.'); } try { this.#peerConnection.addRemoteCandidate(candidate.candidate, candidate.sdpMid || '0'); this.#remoteCandidates.push( new RTCIceCandidate({ candidate: candidate.candidate, sdpMid: candidate.sdpMid || '0' }), ); } catch (error) { if (!error || !error.message) throw new exceptions.NotFoundError('Unknown error'); // Check error Message if contains specific message if (error.message.includes('remote candidate without remote description')) throw new exceptions.InvalidStateError(error.message); if (error.message.includes('Invalid candidate format')) throw new exceptions.OperationError(error.message); throw new exceptions.NotFoundError(error.message); } } // eslint-disable-next-line @typescript-eslint/no-unused-vars addTrack(_track, ..._streams): globalThis.RTCRtpSender { throw new DOMException('Not implemented'); } // eslint-disable-next-line @typescript-eslint/no-unused-vars addTransceiver(_trackOrKind, _init): globalThis.RTCRtpTransceiver { throw new DOMException('Not implemented'); } close(): void { // close all channels before shutting down this.#dataChannels.forEach((channel) => { channel.close(); this.#dataChannelsClosed++; }); this.#peerConnection.close(); } createAnswer(): Promise<globalThis.RTCSessionDescriptionInit | any> { return this.#localAnswer; } createDataChannel(label, opts = {}): RTCDataChannel { const channel = this.#peerConnection.createDataChannel(label, opts); const dataChannel = new RTCDataChannel(channel, opts); // ensure we can close all channels when shutting down this.#dataChannels.add(dataChannel); dataChannel.addEventListener('close', () => { this.#dataChannels.delete(dataChannel); this.#dataChannelsClosed++; }); return dataChannel; } createOffer(): Promise<globalThis.RTCSessionDescriptionInit | any> { return this.#localOffer; } getConfiguration(): globalThis.RTCConfiguration { return this.#config; } getReceivers(): globalThis.RTCRtpReceiver[] { throw new DOMException('Not implemented'); } getSenders(): globalThis.RTCRtpSender[] { throw new DOMException('Not implemented'); } getStats(): Promise<globalThis.RTCStatsReport> { return new Promise((resolve) => { const report = new Map(); const cp = this.#peerConnection?.getSelectedCandidatePair(); const bytesSent = this.#peerConnection?.bytesSent(); const bytesReceived = this.#peerConnection?.bytesReceived(); const rtt = this.#peerConnection?.rtt(); if(!cp) { return resolve(report); } const localIdRs = getRandomString(8); const localId = 'RTCIceCandidate_' + localIdRs; report.set(localId, { id: localId, type: 'local-candidate', timestamp: Date.now(), candidateType: cp.local.type, ip: cp.local.address, port: cp.local.port, }); const remoteIdRs = getRandomString(8); const remoteId = 'RTCIceCandidate_' + remoteIdRs; report.set(remoteId, { id: remoteId, type: 'remote-candidate', timestamp: Date.now(), candidateType: cp.remote.type, ip: cp.remote.address, port: cp.remote.port, }); const candidateId = 'RTCIceCandidatePair_' + localIdRs + '_' + remoteIdRs; report.set(candidateId, { id: candidateId, type: 'candidate-pair', timestamp: Date.now(), localCandidateId: localId, remoteCandidateId: remoteId, state: 'succeeded', nominated: true, writable: true, bytesSent: bytesSent, bytesReceived: bytesReceived, totalRoundTripTime: rtt, currentRoundTripTime: rtt, }); const transportId = 'RTCTransport_0_1'; report.set(transportId, { id: transportId, timestamp: Date.now(), type: 'transport', bytesSent: bytesSent, bytesReceived: bytesReceived, dtlsState: 'connected', selectedCandidatePairId: candidateId, selectedCandidatePairChanges: 1, }); // peer-connection' report.set('P', { id: 'P', type: 'peer-connection', timestamp: Date.now(), dataChannelsOpened: this.#dataChannels.size, dataChannelsClosed: this.#dataChannelsClosed, }); return resolve(report); }); } getTransceivers(): globalThis.RTCRtpTransceiver[] { return []; // throw new DOMException('Not implemented'); } removeTrack(): void { throw new DOMException('Not implemented'); } restartIce(): Promise<void> { throw new DOMException('Not implemented'); } async setLocalDescription(description: globalThis.RTCSessionDescriptionInit): Promise<void> { if (description?.type !== 'offer') { // any other type causes libdatachannel to throw return; } this.#peerConnection.setLocalDescription(description?.type as any); } async setRemoteDescription(description: globalThis.RTCSessionDescriptionInit): Promise<void> { if (description.sdp == null) { throw new DOMException('Remote SDP must be set'); } this.#peerConnection.setRemoteDescription(description.sdp, description.type as any); } } function createDeferredPromise(): any { let resolve: any, reject: any; const promise = new Promise(function (_resolve, _reject) { resolve = _resolve; reject = _reject; }); (promise as any).resolve = resolve; (promise as any).reject = reject; return promise; } function getRandomString(length): string { return Math.random() .toString(36) .substring(2, 2 + length); }