UNPKG

p2pcf

Version:

WebRTC signalling using Cloudflare Workers

1,238 lines (1,011 loc) 35.5 kB
/** * Peer 2 Peer WebRTC connections with Cloudflare Workers as signalling server * Copyright Greg Fodor <gfodor@gmail.com> * Licensed under MIT */ /* global crypto */ import getBrowserRTC from 'get-browser-rtc' import { EventEmitter } from 'events' import Peer from 'tiny-simple-peer' import { encode as arrayBufferToBase64, decode as base64ToArrayBuffer } from 'base64-arraybuffer' import { hexToBytes } from 'convert-hex' import arrayBufferToHex from 'array-buffer-to-hex' const CONNECT_TIMEOUT = 15000 // Based on Chrome const MAX_MESSAGE_LENGTH_BYTES = 16000 // Custom timeout routine to end trickle ice early const TRICKLE_ICE_TIMEOUT = 3000 const CHUNK_HEADER_LENGTH_BYTES = 12 // 2 magic, 2 msg id, 2 chunk id, 2 for done bit, 4 for length const CHUNK_MAGIC_WORD = 8121 const CHUNK_MAX_LENGTH_BYTES = MAX_MESSAGE_LENGTH_BYTES - CHUNK_HEADER_LENGTH_BYTES // Signalling messages have a 64-bit unique header const SIGNAL_MESSAGE_HEADER_WORDS = [0x82ab, 0x81cd, 0x1295, 0xa1cb] const CANDIDATE_TYPES = { host: 0, srflx: 1, relay: 2 } const CANDIDATE_TCP_TYPES = { active: 0, passive: 1, so: 2 } const CANDIDATE_IDX = { TYPE: 0, PROTOCOL: 1, IP: 2, PORT: 3, RELATED_IP: 4, RELATED_PORT: 5, TCP_TYPE: 6 } const DEFAULT_STUN_ICE = [ { urls: 'stun:stun1.l.google.com:19302' }, { urls: 'stun:global.stun.twilio.com:3478' } ] const DEFAULT_TURN_ICE = [ { urls: 'turn:openrelay.metered.ca:80', username: 'openrelayproject', credential: 'openrelayproject' }, { urls: 'turn:openrelay.metered.ca:443', username: 'openrelayproject', credential: 'openrelayproject' }, { urls: 'turn:openrelay.metered.ca:443?transport=tcp', username: 'openrelayproject', credential: 'openrelayproject' } ] const randomstring = len => { const bytes = crypto.getRandomValues(new Uint8Array(len)) const str = bytes.reduce((accum, v) => accum + String.fromCharCode(v), '') return btoa(str).replaceAll('=', '') } const textDecoder = new TextDecoder('utf-8') const textEncoder = new TextEncoder() const arrToText = textDecoder.decode.bind(textDecoder) const textToArr = textEncoder.encode.bind(textEncoder) const removeInPlace = (a, condition) => { let i = 0; let j = 0 while (i < a.length) { const val = a[i] if (!condition(val, i, a)) a[j++] = val i++ } a.length = j return a } const ua = window.navigator.userAgent const iOS = !!ua.match(/iPad/i) || !!ua.match(/iPhone/i) const webkit = !!ua.match(/WebKit/i) const iOSSafari = !!(iOS && webkit && !ua.match(/CriOS/i)) const isFirefox = !!(navigator?.userAgent.toLowerCase().indexOf('firefox') > -1) const hexToBase64 = hex => arrayBufferToBase64(hexToBytes(hex)) const base64ToHex = b64 => arrayBufferToHex(base64ToArrayBuffer(b64)) function createSdp (isOffer, iceUFrag, icePwd, dtlsFingerprintBase64) { const dtlsHex = base64ToHex(dtlsFingerprintBase64) let dtlsFingerprint = '' for (let i = 0; i < dtlsHex.length; i += 2) { dtlsFingerprint += `${dtlsHex[i]}${dtlsHex[i + 1]}${ i === dtlsHex.length - 2 ? '' : ':' }`.toUpperCase() } const sdp = [ 'v=0', 'o=- 5498186869896684180 2 IN IP4 127.0.0.1', 's=-', 't=0 0', 'a=msid-semantic: WMS', 'm=application 9 UDP/DTLS/SCTP webrtc-datachannel', 'c=IN IP4 0.0.0.0', 'a=mid:0', 'a=sctp-port:5000' ] if (isOffer) { sdp.push('a=setup:actpass') } else { sdp.push('a=setup:active') } sdp.push(`a=ice-ufrag:${iceUFrag}`) sdp.push(`a=ice-pwd:${icePwd}`) sdp.push(`a=fingerprint:sha-256 ${dtlsFingerprint}`) return sdp.join('\r\n') + '\r\n' } // parseCandidate from https://github.com/fippo/sdp const parseCandidate = line => { let parts // Parse both variants. if (line.indexOf('a=candidate:') === 0) { parts = line.substring(12).split(' ') } else { parts = line.substring(10).split(' ') } const candidate = [ CANDIDATE_TYPES[parts[7]], // type parts[2].toLowerCase() === 'udp' ? 0 : 1, // protocol parts[4], // ip parseInt(parts[5], 10) // port ] for (let i = 8; i < parts.length; i += 2) { switch (parts[i]) { case 'raddr': while (candidate.length < 5) candidate.push(null) candidate[4] = parts[i + 1] break case 'rport': while (candidate.length < 6) candidate.push(null) candidate[5] = parseInt(parts[i + 1], 10) break case 'tcptype': while (candidate.length < 7) candidate.push(null) candidate[6] = CANDIDATE_TCP_TYPES[parts[i + 1]] break default: // Unknown extensions are silently ignored. break } } while (candidate.length < 8) candidate.push(null) candidate[7] = parseInt(parts[3], 10) // Priority last return candidate } export default class P2PCF extends EventEmitter { constructor (clientId = '', roomId = '', options = {}) { super() if (!clientId || clientId.length < 4) { throw new Error('Client ID must be at least four characters') } if (!roomId || roomId.length < 4) { throw new Error('Room ID must be at least four characters') } const now = Date.now() this._step = this._step.bind(this) this.peers = new Map() this.msgChunks = new Map() this.connectedSessions = [] this.clientId = clientId this.roomId = roomId this.sessionId = randomstring(20) this.packages = [] this.dataTimestamp = null this.lastPackages = null this.lastProcessedReceivedDataTimestamps = new Map() this.packageReceivedFromPeers = new Set() this.startedAtTimestamp = null this.peerOptions = options.rtcPeerConnectionOptions || {} this.peerProprietaryConstraints = options.rtcPeerConnectionProprietaryConstraints || {} this.peerSdpTransform = options.sdpTransform || ((sdp) => sdp) this.workerUrl = options.workerUrl || 'https://p2pcf.minddrop.workers.dev' if (this.workerUrl.endsWith('/')) { this.workerUrl = this.workerUrl.substring(0, this.workerUrl.length - 1) } this.stunIceServers = options.stunIceServers || DEFAULT_STUN_ICE this.turnIceServers = options.turnIceServers || DEFAULT_TURN_ICE this.networkChangePollIntervalMs = options.networkChangePollIntervalMs || 15000 this.stateExpirationIntervalMs = options.stateExpirationIntervalMs || 2 * 60 * 1000 this.stateHeartbeatWindowMs = options.stateHeartbeatWindowMs || 30000 this.fastPollingDurationMs = options.fastPollingDurationMs || 10000 this.fastPollingRateMs = options.fastPollingRateMs || 1500 this.slowPollingRateMs = options.slowPollingRateMs || 5000 this.idlePollingAfterMs = options.idlePollingAfterMs || Infinity this.idlePollingRateMs = options.idlePollingRateMs || Infinity this.wrtc = getBrowserRTC() this.dtlsCert = null this.udpEnabled = null this.isSymmetric = null this.dtlsFingerprint = null this.reflexiveIps = new Set() // step this.isSending = false this.finished = false this.nextStepTime = -1 this.deleteKey = null this.sentFirstPoll = false this.stopFastPollingAt = now + this.fastPollingDurationMs this.startIdlePollingAt = now + this.idlePollingAfterMs // ContextID is maintained across page refreshes if (!window.history.state?._p2pcfContextId) { window.history.replaceState( { ...window.history.state, _p2pcfContextId: randomstring(20) }, window.location.href ) } this.contextId = window.history.state._p2pcfContextId } async _init () { if (this.dtlsCert === null) { this.dtlsCert = await this.wrtc.RTCPeerConnection.generateCertificate({ name: 'ECDSA', namedCurve: 'P-256' }) } } async _step (finish = false) { const { sessionId, clientId, roomId, contextId, stateExpirationIntervalMs, stateHeartbeatWindowMs, packages, fastPollingDurationMs, fastPollingRateMs, slowPollingRateMs, idlePollingAfterMs, idlePollingRateMs } = this const now = Date.now() if (finish) { if (this.finished) return if (!this.deleteKey) return this.finished = true } else { if (this.nextStepTime > now) return if (this.isSending) return if (this.reflexiveIps.length === 0) return } this.isSending = true try { const localDtlsFingerprintBase64 = hexToBase64( this.dtlsFingerprint.replaceAll(':', '') ) const localPeerInfo = [ sessionId, clientId, this.isSymmetric, localDtlsFingerprintBase64, this.startedAtTimestamp, [...this.reflexiveIps] ] const payload = { r: roomId, k: contextId } if (finish) { payload.dk = this.deleteKey } const expired = this.dataTimestamp === null || now - this.dataTimestamp >= stateExpirationIntervalMs - stateHeartbeatWindowMs const packagesChanged = this.lastPackages !== JSON.stringify(packages) let includePackages = false if (expired || packagesChanged || finish) { // This will force a write this.dataTimestamp = now // Compact packages, expire any of them sent more than a minute ago. // (ICE will timeout by then, even if other latency fails us.) removeInPlace(packages, pkg => { const sentAt = pkg[pkg.length - 2] return now - sentAt > 60 * 1000 }) includePackages = true } if (finish) { includePackages = false } // The first poll should just be a read, no writes, to build up packages before we do a write // to reduce worker I/O. So don't include the data + packages on the first request. if (this.sentFirstPoll) { payload.d = localPeerInfo payload.t = this.dataTimestamp payload.x = this.stateExpirationIntervalMs if (includePackages) { payload.p = packages this.lastPackages = JSON.stringify(packages) } } const body = JSON.stringify(payload) const headers = { 'Content-Type': 'application/json ' } let keepalive = false if (finish) { headers['X-Worker-Method'] = 'DELETE' keepalive = true } const res = await fetch(this.workerUrl, { method: 'POST', headers, body, keepalive }) const { ps: remotePeerDatas, pk: remotePackages, dk } = await res.json() if (dk) { this.deleteKey = dk } if (finish) return // Slight optimization: if the peers are empty on the first poll, immediately publish data to reduce // delay before first peers show up. if (remotePeerDatas.length === 0 && !this.sentFirstPoll) { payload.d = localPeerInfo payload.t = this.dataTimestamp payload.x = this.stateExpirationIntervalMs payload.p = packages this.lastPackages = JSON.stringify(packages) const res = await fetch(this.workerUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) const { dk } = await res.json() if (dk) { this.deleteKey = dk } } this.sentFirstPoll = true const previousPeerSessionIds = [...this.peers.keys()] this._handleWorkerResponse( localPeerInfo, localDtlsFingerprintBase64, packages, remotePeerDatas, remotePackages ) const activeSessionIds = remotePeerDatas.map(p => p[0]) const peersChanged = previousPeerSessionIds.length !== activeSessionIds.length || activeSessionIds.find(c => !previousPeerSessionIds.includes(c)) || previousPeerSessionIds.find(c => !activeSessionIds.includes(c)) // Rate limit requests when room is empty, or look for new joins // Go faster when things are changing to avoid ICE timeouts if (peersChanged) { this.stopFastPollingAt = now + fastPollingDurationMs this.startIdlePollingAt = now + idlePollingAfterMs } if (now < this.stopFastPollingAt) { this.nextStepTime = now + fastPollingRateMs } else if (now > this.startIdlePollingAt) { this.nextStepTime = now + idlePollingRateMs } else { this.nextStepTime = now + slowPollingRateMs } } catch (e) { console.error(e) this.nextStepTime = now + slowPollingRateMs } finally { this.isSending = false } } _handleWorkerResponse ( localPeerData, localDtlsFingerprintBase64, localPackages, remotePeerDatas, remotePackages ) { const localStartedAtTimestamp = this.startedAtTimestamp const { dtlsCert: localDtlsCert, peers, lastProcessedReceivedDataTimestamps, packageReceivedFromPeers, stunIceServers, turnIceServers } = this const [localSessionId, , localSymmetric] = localPeerData const now = Date.now() for (const remotePeerData of remotePeerDatas) { const [ remoteSessionId, remoteClientId, remoteSymmetric, remoteDtlsFingerprintBase64, remoteStartedAtTimestamp, remoteReflexiveIps, remoteDataTimestamp ] = remotePeerData // Don't process the same messages twice. This covers disconnect cases where stale data re-creates a peer too early. if ( lastProcessedReceivedDataTimestamps.get(remoteSessionId) === remoteDataTimestamp ) { continue } // Peer A is: // - if both not symmetric or both symmetric, whoever has the most recent data is peer A, since we want Peer B created faster, // and latency will be lowest with older data. // - if one is and one isn't, the non symmetric one is the only one who has valid candidates, so the symmetric one is peer A const isPeerA = localSymmetric === remoteSymmetric ? localStartedAtTimestamp === remoteStartedAtTimestamp ? localSessionId > remoteSessionId : localStartedAtTimestamp > remoteStartedAtTimestamp : localSymmetric // If either side is symmetric, use TURN and hope we avoid connecting via relays // We can't just use TURN if both sides are symmetric because one side might be port restricted and hence won't connect without a relay. const iceServers = localSymmetric || remoteSymmetric ? turnIceServers : stunIceServers // Firefox answer side is very aggressive with ICE timeouts, so always delay answer set until second candidates received. const delaySetRemoteUntilReceiveCandidates = isFirefox const remotePackage = remotePackages.find(p => p[1] === remoteSessionId) const peerOptions = { ...this.peerOptions, iceServers } if (localDtlsCert) { peerOptions.certificates = [localDtlsCert] } if (isPeerA) { if (peers.has(remoteSessionId)) continue if (!remotePackage) continue lastProcessedReceivedDataTimestamps.set( remoteSessionId, remoteDataTimestamp ) // If we already added the candidates from B, skip. This check is not strictly necessary given the peer will exist. if (packageReceivedFromPeers.has(remoteSessionId)) continue packageReceivedFromPeers.add(remoteSessionId) // - I create PC // - I create an answer SDP, and munge the ufrag // - Set local description with answer // - Set remote description via the received sdp // - Add the ice candidates const [ , , remoteIceUFrag, remoteIcePwd, remoteDtlsFingerprintBase64, localIceUFrag, localIcePwd, , remoteCandidates ] = remotePackage const peer = new Peer({ config: peerOptions, initiator: false, iceCompleteTimeout: Infinity, proprietaryConstraints: this.peerProprietaryConstraints, sdpTransform: sdp => { const lines = [] for (const l of sdp.split('\r\n')) { if (l.startsWith('a=ice-ufrag')) { lines.push(`a=ice-ufrag:${localIceUFrag}`) } else if (l.startsWith('a=ice-pwd')) { lines.push(`a=ice-pwd:${localIcePwd}`) } else { lines.push(l) } } return this.peerSdpTransform(lines.join('\r\n')) } }) peer.id = remoteSessionId peer.client_id = remoteClientId this._wireUpCommonPeerEvents(peer) peers.set(peer.id, peer) // Special case if both behind sym NAT or other hole punching isn't working: peer A needs to send its candidates as well. const pkg = [ remoteSessionId, localSessionId, /* lfrag */ null, /* lpwd */ null, /* ldtls */ null, /* remote ufrag */ null, /* remote Pwd */ null, now, [] ] const pkgCandidates = pkg[pkg.length - 1] let finishIceTimeout = null const finishIce = () => { peer.removeListener('signal', initialCandidateSignalling) if (localPackages.includes(pkg)) return if (pkgCandidates.length === 0) return localPackages.push(pkg) } const initialCandidateSignalling = e => { if (!e.candidate) return clearTimeout(finishIceTimeout) if (e.candidate.candidate) { pkgCandidates.push(e.candidate.candidate) finishIceTimeout = setTimeout(finishIce, TRICKLE_ICE_TIMEOUT) } else { finishIce() } } peer.on('signal', initialCandidateSignalling) setTimeout(() => { if (peer._iceComplete || peer.connected) return console.warn("Peer A didn't connect in time", peer.id) peer._iceComplete = true this._removePeer(peer, true) this._updateConnectedSessions() }, CONNECT_TIMEOUT) const remoteSdp = createSdp( true, remoteIceUFrag, remoteIcePwd, remoteDtlsFingerprintBase64 ) for (const candidate of remoteCandidates) { peer.signal({ candidate: { candidate, sdpMLineIndex: 0 } }) } peer.signal({ type: 'offer', sdp: remoteSdp }) } else { // I am peer B, I need to create a peer first if none exists, and send a package. // - Create PC // - Create offer // - Set local description as-is // - Generate ufrag + pwd // - Generate remote SDP using the dtls fingerprint for A, and my generated ufrag + pwd // - Add an srflx candidate for each of the reflexive IPs for A (on a random port) to hole punch // - Set remote description // so peer reflexive candidates for it show up. // - Let trickle run, then once trickle finishes send a package for A to pick up = [my session id, my offer sdp, generated ufrag/pwd, dtls fingerprint, ice candidates] // - keep the icecandidate listener active, and add the pfrlx candidates when they arrive (but don't send another package) if (!peers.has(remoteSessionId)) { lastProcessedReceivedDataTimestamps.set( remoteSessionId, remoteDataTimestamp ) const remoteUfrag = randomstring(12) const remotePwd = randomstring(32) const peer = new Peer({ config: peerOptions, proprietaryConstraints: this.rtcPeerConnectionProprietaryConstraints, iceCompleteTimeout: Infinity, initiator: true, sdpTransform: this.peerSdpTransform }) peer.id = remoteSessionId peer.client_id = remoteClientId this._wireUpCommonPeerEvents(peer) peers.set(peer.id, peer) // This is the 'package' sent to peer A that it needs to start ICE const pkg = [ remoteSessionId, localSessionId, /* lfrag */ null, /* lpwd */ null, /* ldtls */ null, remoteUfrag, remotePwd, now, [] ] const pkgCandidates = pkg[pkg.length - 1] let finishIceTimeout = null const finishIce = () => { peer.removeListener('signal', initialCandidateSignalling) if (localPackages.includes(pkg)) return if (pkgCandidates.length === 0) return localPackages.push(pkg) } const initialCandidateSignalling = e => { if (!e.candidate) return clearTimeout(finishIceTimeout) // Push package onto the given package list, so it will be sent in next polling step. if (e.candidate.candidate) { pkgCandidates.push(e.candidate.candidate) finishIceTimeout = setTimeout(finishIce, TRICKLE_ICE_TIMEOUT) } else { finishIce() } } peer.on('signal', initialCandidateSignalling) setTimeout(() => { if (peer._iceComplete || peer.connected) return console.warn('Peer B failed to connect in time', peer.id) peer._iceComplete = true this._removePeer(peer, true) this._updateConnectedSessions() }, CONNECT_TIMEOUT) const enqueuePackageFromOffer = e => { if (e.type !== 'offer') return peer.removeListener('signal', enqueuePackageFromOffer) for (const l of e.sdp.split('\r\n')) { switch (l.split(':')[0]) { case 'a=ice-ufrag': pkg[2] = l.substring(12) break case 'a=ice-pwd': pkg[3] = l.substring(10) break case 'a=fingerprint': pkg[4] = hexToBase64(l.substring(22).replaceAll(':', '')) break } } // Peer A posted its reflexive IPs to try to speed up hole punching by B. let remoteSdp = createSdp( false, remoteUfrag, remotePwd, remoteDtlsFingerprintBase64 ) for (let i = 0; i < remoteReflexiveIps.length; i++) { remoteSdp += `a=candidate:0 1 udp ${i + 1} ${ remoteReflexiveIps[i] } 30000 typ srflx\r\n` } if (!delaySetRemoteUntilReceiveCandidates) { peer.signal({ type: 'answer', sdp: remoteSdp }) } else { peer._pendingRemoteSdp = remoteSdp } } peer.once('signal', enqueuePackageFromOffer) } if (!remotePackage) continue // Peer B will also receive candidates in the case where hole punch fails. // If we already added the candidates from A, skip const [, , , , , , , , remoteCandidates] = remotePackage if (packageReceivedFromPeers.has(remoteSessionId)) continue if (!peers.has(remoteSessionId)) continue const peer = peers.get(remoteSessionId) if ( delaySetRemoteUntilReceiveCandidates && !peer._pc.remoteDescription && peer._pendingRemoteSdp ) { if (!peer.connected) { for (const candidate of remoteCandidates) { peer.signal({ candidate: { candidate, sdpMLineIndex: 0 } }) } } peer.signal({ type: 'answer', sdp: peer._pendingRemoteSdp }) delete peer._pendingRemoteSdp packageReceivedFromPeers.add(remoteSessionId) } if ( !delaySetRemoteUntilReceiveCandidates && peer._pc.remoteDescription && remoteCandidates.length > 0 ) { if (!peer.connected) { for (const candidate of remoteCandidates) { peer.signal({ candidate: { candidate, sdpMLineIndex: 0 } }) } } packageReceivedFromPeers.add(remoteSessionId) } } } const remoteSessionIds = remotePeerDatas.map(p => p[0]) // Remove all disconnected peers no longer in the peer list. for (const [sessionId, peer] of peers.entries()) { if (remoteSessionIds.includes(sessionId)) continue if (!peer.connected) { console.warn('Removing unconnected peer not in peer list', peer.id) this._removePeer(peer, true) } } } /** * Connect to network and start discovering peers */ async start () { this.startedAtTimestamp = Date.now() await this._init() const [ udpEnabled, isSymmetric, reflexiveIps, dtlsFingerprint ] = await this._getNetworkSettings(this.dtlsCert) if (this.finished) return this.udpEnabled = udpEnabled this.isSymmetric = isSymmetric this.reflexiveIps = reflexiveIps this.dtlsFingerprint = dtlsFingerprint this.networkSettingsInterval = setInterval(async () => { const [ newUdpEnabled, newIsSymmetric, newReflexiveIps, newDtlsFingerprint ] = await this._getNetworkSettings(this.dtlsCert) if ( newUdpEnabled !== this.udpEnabled || newIsSymmetric !== this.isSymmetric || newDtlsFingerprint !== this.dtlsFingerprint || !![...newReflexiveIps].find(ip => ![...this.reflexiveIps].find(ip2 => ip === ip2)) || !![...reflexiveIps].find(ip => ![...newReflexiveIps].find(ip2 => ip === ip2)) ) { // Network changed, force pushing new data this.dataTimestamp = null } this.udpEnabled = newUdpEnabled this.isSymmetric = newIsSymmetric this.reflexiveIps = newReflexiveIps this.dtlsFingerprint = newDtlsFingerprint }, this.networkChangePollIntervalMs) this._step = this._step.bind(this) this.stepInterval = setInterval(this._step, 500) this.destroyOnUnload = () => this.destroy() for (const ev of iOSSafari ? ['pagehide'] : ['unload']) { window.addEventListener(ev, this.destroyOnUnload) } } _removePeer (peer, destroy = false) { const { packageReceivedFromPeers, packages, peers } = this if (!peers.has(peer.id)) return removeInPlace(packages, pkg => pkg[0] === peer.id) packageReceivedFromPeers.delete(peer.id) peers.delete(peer.id) if (destroy) { peer.destroy() } this.emit('peerclose', peer) } /** * Send a msg and get response for it * @param Peer peer simple-peer object to send msg to * @param string msg Message to send * @param integer msgID ID of message if it's a response to a previous message */ send (peer, msg) { if (!peer.connected) return // if leading byte is zero // next two bytes is message id, then remaining bytes // otherwise its just raw let dataArrBuffer = null let messageId = null if (msg instanceof ArrayBuffer) { dataArrBuffer = msg } else if (msg instanceof Uint8Array) { if (msg.buffer.byteLength === msg.length) { dataArrBuffer = msg.buffer } else { dataArrBuffer = msg.buffer.slice(msg.byteOffset, msg.byteOffset + msg.byteLength) } } else { throw new Error('Unsupported send data type', msg) } // If the magic word happens to be the beginning of this message, chunk it if ( dataArrBuffer.byteLength > MAX_MESSAGE_LENGTH_BYTES || new Uint16Array(dataArrBuffer, 0, 1) === CHUNK_MAGIC_WORD ) { messageId = Math.floor(Math.random() * 256 * 128) } if (messageId !== null) { for ( let offset = 0, chunkId = 0; offset < dataArrBuffer.byteLength; offset += CHUNK_MAX_LENGTH_BYTES, chunkId++ ) { const chunkSize = Math.min( CHUNK_MAX_LENGTH_BYTES, dataArrBuffer.byteLength - offset ) let bufSize = CHUNK_HEADER_LENGTH_BYTES + chunkSize while (bufSize % 4 !== 0) { bufSize++ } const buf = new ArrayBuffer(bufSize) new Uint8Array(buf, CHUNK_HEADER_LENGTH_BYTES).set( new Uint8Array(dataArrBuffer, offset, chunkSize) ) const u16 = new Uint16Array(buf) const u32 = new Uint32Array(buf) u16[0] = CHUNK_MAGIC_WORD u16[1] = messageId u16[2] = chunkId u16[3] = offset + CHUNK_MAX_LENGTH_BYTES >= dataArrBuffer.byteLength ? 1 : 0 u32[2] = dataArrBuffer.byteLength peer.send(buf) } } else { peer.send(dataArrBuffer) } } broadcast (msg) { for (const peer of this.peers.values()) { this.send(peer, msg) } } /** * Destroy object */ destroy () { if (this._step) { this._step(true) } if (this.networkSettingsInterval) { clearInterval(this.networkSettingsInterval) this.networkSettingsInterval = null } if (this.stepInterval) { clearInterval(this.stepInterval) this.stepInterval = null } if (this.destroyOnUnload) { for (const ev of iOSSafari ? ['pagehide'] : ['beforeunload', 'unload']) { window.removeEventListener(ev, this.destroyOnUnload) } this.destroyOnUnload = null } for (const peer of this.peers.values()) { peer.destroy() } } /** * Handle msg chunks. Returns false until the last chunk is received. Finally returns the entire msg * @param object data */ _chunkHandler (data, messageId, chunkId) { let target = null if (!this.msgChunks.has(messageId)) { const totalLength = new Uint32Array(data, 0, 3)[2] target = new Uint8Array(totalLength) this.msgChunks.set(messageId, target) } else { target = this.msgChunks.get(messageId) } const offsetToSet = chunkId * CHUNK_MAX_LENGTH_BYTES const numBytesToSet = Math.min( target.byteLength - offsetToSet, CHUNK_MAX_LENGTH_BYTES ) target.set( new Uint8Array(data, CHUNK_HEADER_LENGTH_BYTES, numBytesToSet), chunkId * CHUNK_MAX_LENGTH_BYTES ) return target.buffer } _updateConnectedSessions () { this.connectedSessions.length = 0 for (const [sessionId, peer] of this.peers) { if (peer.connected) { this.connectedSessions.push(sessionId) continue } } } async _getNetworkSettings () { await this._init() let dtlsFingerprint = null const candidates = [] const reflexiveIps = new Set() const peerOptions = { iceServers: this.stunIceServers } if (this.dtlsCert) { peerOptions.certificates = [this.dtlsCert] } const pc = new this.wrtc.RTCPeerConnection(peerOptions) const dc = pc.createDataChannel('x') const p = new Promise(resolve => { setTimeout(() => resolve(), 5000) pc.onicecandidate = e => { if (!e.candidate) return resolve() if (e.candidate.candidate) { candidates.push(parseCandidate(e.candidate.candidate)) } } }) pc.createOffer().then(offer => { for (const l of offer.sdp.split('\n')) { if (l.indexOf('a=fingerprint') === -1) continue dtlsFingerprint = l.split(' ')[1].trim() } pc.setLocalDescription(offer) }) await p dc.close() pc.close() let isSymmetric = false let udpEnabled = false // Network is not symmetric if we can find a srflx candidate that has a unique related port /* eslint-disable no-labels */ loop: for (const c of candidates) { /* eslint-enable no-labels */ if (c[0] !== CANDIDATE_TYPES.srflx) continue udpEnabled = true reflexiveIps.add(c[CANDIDATE_IDX.IP]) for (const d of candidates) { if (d[0] !== CANDIDATE_TYPES.srflx) continue if (c === d) continue if ( typeof c[CANDIDATE_IDX.RELATED_PORT] === 'number' && typeof d[CANDIDATE_IDX.RELATED_PORT] === 'number' && c[CANDIDATE_IDX.RELATED_PORT] === d[CANDIDATE_IDX.RELATED_PORT] && c[CANDIDATE_IDX.PORT] !== d[CANDIDATE_IDX.PORT] ) { // check port and related port // Symmetric, continue isSymmetric = true break } } } return [udpEnabled, isSymmetric, reflexiveIps, dtlsFingerprint] } _handlePeerError (peer, err) { if ( err.errorDetail === 'sctp-failure' && err.message.indexOf('User-Initiated Abort') >= 0 ) { return // Benign shutdown } console.error(err) } _checkForSignalOrEmitMessage (peer, msg) { if (msg.byteLength < SIGNAL_MESSAGE_HEADER_WORDS.length * 2) { this.emit('msg', peer, msg) return } const u16 = new Uint16Array(msg, 0, SIGNAL_MESSAGE_HEADER_WORDS.length) for (let i = 0; i < SIGNAL_MESSAGE_HEADER_WORDS.length; i++) { if (u16[i] !== SIGNAL_MESSAGE_HEADER_WORDS[i]) { this.emit('msg', peer, msg) return } } const u8 = new Uint8Array(msg, SIGNAL_MESSAGE_HEADER_WORDS.length * 2) let payload = arrToText(u8) // Might have a trailing byte if (payload.endsWith('\0')) { payload = payload.substring(0, payload.length - 1) } peer.signal(payload) } _wireUpCommonPeerEvents (peer) { peer.on('connect', () => { this.emit('peerconnect', peer) // Remove packages for the peer once connected removeInPlace(this.packages, pkg => pkg[0] === peer.id) this._updateConnectedSessions() }) peer.on('data', data => { let messageId = null let u16 = null if (data.byteLength >= CHUNK_HEADER_LENGTH_BYTES) { u16 = new Uint16Array(data, 0, CHUNK_HEADER_LENGTH_BYTES / 2) if (u16[0] === CHUNK_MAGIC_WORD) { messageId = u16[1] } } if (messageId !== null) { try { const chunkId = u16[2] const last = u16[3] !== 0 const msg = this._chunkHandler(data, messageId, chunkId, last) if (last) { this._checkForSignalOrEmitMessage(peer, msg) this.msgChunks.delete(messageId) } } catch (e) { console.error(e) } } else { this._checkForSignalOrEmitMessage(peer, data) } }) peer.on('error', err => { console.warn(err) }) peer.on('close', () => { this._removePeer(peer) this._updateConnectedSessions() }) // Once ICE completes, perform subsequent signalling via the datachannel peer.on('signal', signalData => { const payloadBytes = textToArr( JSON.stringify(signalData) ) let len = payloadBytes.byteLength + SIGNAL_MESSAGE_HEADER_WORDS.length * 2 if (len % 2 !== 0) { len++ } // Add signal header const buf = new ArrayBuffer(len) const u8 = new Uint8Array(buf) const u16 = new Uint16Array(buf) u8.set(payloadBytes, SIGNAL_MESSAGE_HEADER_WORDS.length * 2) for (let i = 0; i < SIGNAL_MESSAGE_HEADER_WORDS.length; i++) { u16[i] = SIGNAL_MESSAGE_HEADER_WORDS[i] } this.send(peer, buf) }) } }