p2pcf
Version:
WebRTC signalling using Cloudflare Workers
1,238 lines (1,011 loc) • 35.5 kB
JavaScript
/**
* 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)
})
}
}