/*! simple-peer. MIT License. Feross Aboukhadijeh */ const debug = require('debug')('simple-peer') const getBrowserRTC = require('get-browser-rtc') const randombytes = require('randombytes') const stream = require('readable-stream') const queueMicrotask = require('queue-microtask') // TODO: remove when Node 10 is not supported const errCode = require('err-code') const { Buffer } = require('buffer') const MAX_BUFFERED_AMOUNT = 64 * 1024 const ICECOMPLETE_TIMEOUT = 5 * 1000 const CHANNEL_CLOSING_TIMEOUT = 5 * 1000 // HACK: Filter trickle lines when trickle is disabled #354 function filterTrickle (sdp) { return sdp.replace(/a=ice-options:trickle\s\n/g, '') } function warn (message) { console.warn(message) } /** * WebRTC peer connection. Same API as node core `net.Socket`, plus a few extra methods. * Duplex stream. * @param {Object} opts */ class Peer extends stream.Duplex { constructor (opts) { opts = Object.assign({ allowHalfOpen: false }, opts) super(opts) this._id = randombytes(4).toString('hex').slice(0, 7) this._debug('new peer %o', opts) this.channelName = opts.initiator ? opts.channelName || randombytes(20).toString('hex') : null this.initiator = opts.initiator || false this.channelConfig = opts.channelConfig || Peer.channelConfig this.channelNegotiated = this.channelConfig.negotiated this.config = Object.assign({}, Peer.config, opts.config) this.offerOptions = opts.offerOptions || {} this.answerOptions = opts.answerOptions || {} this.sdpTransform = opts.sdpTransform || (sdp => sdp) this.streams = opts.streams || (opts.stream ? [opts.stream] : []) // support old "stream" option this.trickle = opts.trickle !== undefined ? opts.trickle : true this.allowHalfTrickle = opts.allowHalfTrickle !== undefined ? opts.allowHalfTrickle : false this.iceCompleteTimeout = opts.iceCompleteTimeout || ICECOMPLETE_TIMEOUT this.destroyed = false this.destroying = false this._connected = false this.remoteAddress = undefined this.remoteFamily = undefined this.remotePort = undefined this.localAddress = undefined this.localFamily = undefined this.localPort = undefined this._wrtc = (opts.wrtc && typeof opts.wrtc === 'object') ? opts.wrtc : getBrowserRTC() if (!this._wrtc) { if (typeof window === 'undefined') { throw errCode(new Error('No WebRTC support: Specify `opts.wrtc` option in this environment'), 'ERR_WEBRTC_SUPPORT') } else { throw errCode(new Error('No WebRTC support: Not a supported browser'), 'ERR_WEBRTC_SUPPORT') } } this._pcReady = false this._channelReady = false this._iceComplete = false // ice candidate trickle done (got null candidate) this._iceCompleteTimer = null // send an offer/answer anyway after some timeout this._channel = null this._pendingCandidates = [] this._isNegotiating = false // is this peer waiting for negotiation to complete? this._firstNegotiation = true this._batchedNegotiation = false // batch synchronous negotiations this._queuedNegotiation = false // is there a queued negotiation request? this._sendersAwaitingStable = [] this._senderMap = new Map() this._closingInterval = null this._remoteTracks = [] this._remoteStreams = [] this._chunk = null this._cb = null this._interval = null try { this._pc = new (this._wrtc.RTCPeerConnection)(this.config) } catch (err) { this.destroy(errCode(err, 'ERR_PC_CONSTRUCTOR')) return } // We prefer feature detection whenever possible, but sometimes that's not // possible for certain implementations. this._isReactNativeWebrtc = typeof this._pc._peerConnectionId === 'number' this._pc.oniceconnectionstatechange = () => { this._onIceStateChange() } this._pc.onicegatheringstatechange = () => { this._onIceStateChange() } this._pc.onconnectionstatechange = () => { this._onConnectionStateChange() } this._pc.onsignalingstatechange = () => { this._onSignalingStateChange() } this._pc.onicecandidate = event => { this._onIceCandidate(event) } // HACK: Fix for odd Firefox behavior, see: https://github.com/feross/simple-peer/pull/783 if (typeof this._pc.peerIdentity === 'object') { this._pc.peerIdentity.catch(err => { this.destroy(errCode(err, 'ERR_PC_PEER_IDENTITY')) }) } // Other spec events, unused by this implementation: // - onconnectionstatechange // - onicecandidateerror // - onfingerprintfailure // - onnegotiationneeded if (this.initiator || this.channelNegotiated) { this._setupData({ channel: this._pc.createDataChannel(this.channelName, this.channelConfig) }) } else { this._pc.ondatachannel = event => { this._setupData(event) } } if (this.streams) { this.streams.forEach(stream => { this.addStream(stream) }) } this._pc.ontrack = event => { this._onTrack(event) } this._debug('initial negotiation') this._needsNegotiation() this._onFinishBound = () => { this._onFinish() } this.once('finish', this._onFinishBound) } get bufferSize () { return (this._channel && this._channel.bufferedAmount) || 0 } // HACK: it's possible channel.readyState is "closing" before peer.destroy() fires // https://bugs.chromium.org/p/chromium/issues/detail?id=882743 get connected () { return (this._connected && this._channel.readyState === 'open') } address () { return { port: this.localPort, family: this.localFamily, address: this.localAddress } } signal (data) { if (this.destroying) return if (this.destroyed) throw errCode(new Error('cannot signal after peer is destroyed'), 'ERR_DESTROYED') if (typeof data === 'string') { try { data = JSON.parse(data) } catch (err) { data = {} } } this._debug('signal()') if (data.renegotiate && this.initiator) { this._debug('got request to renegotiate') this._needsNegotiation() } if (data.transceiverRequest && this.initiator) { this._debug('got request for transceiver') this.addTransceiver(data.transceiverRequest.kind, data.transceiverRequest.init) } if (data.candidate) { if (this._pc.remoteDescription && this._pc.remoteDescription.type) { this._addIceCandidate(data.candidate) } else { this._pendingCandidates.push(data.candidate) } } if (data.sdp) { this._pc.setRemoteDescription(new (this._wrtc.RTCSessionDescription)(data)) .then(() => { if (this.destroyed) return this._pendingCandidates.forEach(candidate => { this._addIceCandidate(candidate) }) this._pendingCandidates = [] if (this._pc.remoteDescription.type === 'offer') this._createAnswer() }) .catch(err => { this.destroy(errCode(err, 'ERR_SET_REMOTE_DESCRIPTION')) }) } if (!data.sdp && !data.candidate && !data.renegotiate && !data.transceiverRequest) { this.destroy(errCode(new Error('signal() called with invalid signal data'), 'ERR_SIGNALING')) } } _addIceCandidate (candidate) { const iceCandidateObj = new this._wrtc.RTCIceCandidate(candidate) this._pc.addIceCandidate(iceCandidateObj) .catch(err => { if (!iceCandidateObj.address || iceCandidateObj.address.endsWith('.local')) { warn('Ignoring unsupported ICE candidate.') } else { this.destroy(errCode(err, 'ERR_ADD_ICE_CANDIDATE')) } }) } /** * Send text/binary data to the remote peer. * @param {ArrayBufferView|ArrayBuffer|Buffer|string|Blob} chunk */ send (chunk) { if (this.destroying) return if (this.destroyed) throw errCode(new Error('cannot send after peer is destroyed'), 'ERR_DESTROYED') this._channel.send(chunk) } /** * Add a Transceiver to the connection. * @param {String} kind * @param {Object} init */ addTransceiver (kind, init) { if (this.destroying) return if (this.destroyed) throw errCode(new Error('cannot addTransceiver after peer is destroyed'), 'ERR_DESTROYED') this._debug('addTransceiver()') if (this.initiator) { try { this._pc.addTransceiver(kind, init) this._needsNegotiation() } catch (err) { this.destroy(errCode(err, 'ERR_ADD_TRANSCEIVER')) } } else { this.emit('signal', { // request initiator to renegotiate type: 'transceiverRequest', transceiverRequest: { kind, init } }) } } /** * Add a MediaStream to the connection. * @param {MediaStream} stream */ addStream (stream) { if (this.destroying) return if (this.destroyed) throw errCode(new Error('cannot addStream after peer is destroyed'), 'ERR_DESTROYED') this._debug('addStream()') stream.getTracks().forEach(track => { this.addTrack(track, stream) }) } /** * Add a MediaStreamTrack to the connection. * @param {MediaStreamTrack} track * @param {MediaStream} stream */ addTrack (track, stream) { if (this.destroying) return if (this.destroyed) throw errCode(new Error('cannot addTrack after peer is destroyed'), 'ERR_DESTROYED') this._debug('addTrack()') const submap = this._senderMap.get(track) || new Map() // nested Maps map [track, stream] to sender let sender = submap.get(stream) if (!sender) { sender = this._pc.addTrack(track, stream) submap.set(stream, sender) this._senderMap.set(track, submap) this._needsNegotiation() } else if (sender.removed) { throw errCode(new Error('Track has been removed. You should enable/disable tracks that you want to re-add.'), 'ERR_SENDER_REMOVED') } else { throw errCode(new Error('Track has already been added to that stream.'), 'ERR_SENDER_ALREADY_ADDED') } } /** * Replace a MediaStreamTrack by another in the connection. * @param {MediaStreamTrack} oldTrack * @param {MediaStreamTrack} newTrack * @param {MediaStream} stream */ replaceTrack (oldTrack, newTrack, stream) { if (this.destroying) return if (this.destroyed) throw errCode(new Error('cannot replaceTrack after peer is destroyed'), 'ERR_DESTROYED') this._debug('replaceTrack()') const submap = this._senderMap.get(oldTrack) const sender = submap ? submap.get(stream) : null if (!sender) { throw errCode(new Error('Cannot replace track that was never added.'), 'ERR_TRACK_NOT_ADDED') } if (newTrack) this._senderMap.set(newTrack, submap) if (sender.replaceTrack != null) { sender.replaceTrack(newTrack) } else { this.destroy(errCode(new Error('replaceTrack is not supported in this browser'), 'ERR_UNSUPPORTED_REPLACETRACK')) } } /** * Remove a MediaStreamTrack from the connection. * @param {MediaStreamTrack} track * @param {MediaStream} stream */ removeTrack (track, stream) { if (this.destroying) return if (this.destroyed) throw errCode(new Error('cannot removeTrack after peer is destroyed'), 'ERR_DESTROYED') this._debug('removeSender()') const submap = this._senderMap.get(track) const sender = submap ? submap.get(stream) : null if (!sender) { throw errCode(new Error('Cannot remove track that was never added.'), 'ERR_TRACK_NOT_ADDED') } try { sender.removed = true this._pc.removeTrack(sender) } catch (err) { if (err.name === 'NS_ERROR_UNEXPECTED') { this._sendersAwaitingStable.push(sender) // HACK: Firefox must wait until (signalingState === stable) https://bugzilla.mozilla.org/show_bug.cgi?id=1133874 } else { this.destroy(errCode(err, 'ERR_REMOVE_TRACK')) } } this._needsNegotiation() } /** * Remove a MediaStream from the connection. * @param {MediaStream} stream */ removeStream (stream) { if (this.destroying) return if (this.destroyed) throw errCode(new Error('cannot removeStream after peer is destroyed'), 'ERR_DESTROYED') this._debug('removeSenders()') stream.getTracks().forEach(track => { this.removeTrack(track, stream) }) } _needsNegotiation () { this._debug('_needsNegotiation') if (this._batchedNegotiation) return // batch synchronous renegotiations this._batchedNegotiation = true queueMicrotask(() => { this._batchedNegotiation = false if (this.initiator || !this._firstNegotiation) { this._debug('starting batched negotiation') this.negotiate() } else { this._debug('non-initiator initial negotiation request discarded') } this._firstNegotiation = false }) } negotiate () { if (this.destroying) return if (this.destroyed) throw errCode(new Error('cannot negotiate after peer is destroyed'), 'ERR_DESTROYED') if (this.initiator) { if (this._isNegotiating) { this._queuedNegotiation = true this._debug('already negotiating, queueing') } else { this._debug('start negotiation') setTimeout(() => { // HACK: Chrome crashes if we immediately call createOffer this._createOffer() }, 0) } } else { if (this._isNegotiating) { this._queuedNegotiation = true this._debug('already negotiating, queueing') } else { this._debug('requesting negotiation from initiator') this.emit('signal', { // request initiator to renegotiate type: 'renegotiate', renegotiate: true }) } } this._isNegotiating = true } // TODO: Delete this method once readable-stream is updated to contain a default // implementation of destroy() that automatically calls _destroy() // See: https://github.com/nodejs/readable-stream/issues/283 destroy (err) { this._destroy(err, () => {}) } _destroy (err, cb) { if (this.destroyed || this.destroying) return this.destroying = true this._debug('destroying (error: %s)', err && (err.message || err)) queueMicrotask(() => { // allow events concurrent with the call to _destroy() to fire (see #692) this.destroyed = true this.destroying = false this._debug('destroy (error: %s)', err && (err.message || err)) this.readable = this.writable = false if (!this._readableState.ended) this.push(null) if (!this._writableState.finished) this.end() this._connected = false this._pcReady = false this._channelReady = false this._remoteTracks = null this._remoteStreams = null this._senderMap = null clearInterval(this._closingInterval) this._closingInterval = null clearInterval(this._interval) this._interval = null this._chunk = null this._cb = null if (this._onFinishBound) this.removeListener('finish', this._onFinishBound) this._onFinishBound = null if (this._channel) { try { this._channel.close() } catch (err) {} // allow events concurrent with destruction to be handled this._channel.onmessage = null this._channel.onopen = null this._channel.onclose = null this._channel.onerror = null } if (this._pc) { try { this._pc.close() } catch (err) {} // allow events concurrent with destruction to be handled this._pc.oniceconnectionstatechange = null this._pc.onicegatheringstatechange = null this._pc.onsignalingstatechange = null this._pc.onicecandidate = null this._pc.ontrack = null this._pc.ondatachannel = null } this._pc = null this._channel = null if (err) this.emit('error', err) this.emit('close') cb() }) } _setupData (event) { if (!event.channel) { // In some situations `pc.createDataChannel()` returns `undefined` (in wrtc), // which is invalid behavior. Handle it gracefully. // See: https://github.com/feross/simple-peer/issues/163 return this.destroy(errCode(new Error('Data channel event is missing `channel` property'), 'ERR_DATA_CHANNEL')) } this._channel = event.channel this._channel.binaryType = 'arraybuffer' if (typeof this._channel.bufferedAmountLowThreshold === 'number') { this._channel.bufferedAmountLowThreshold = MAX_BUFFERED_AMOUNT } this.channelName = this._channel.label this._channel.onmessage = event => { this._onChannelMessage(event) } this._channel.onbufferedamountlow = () => { this._onChannelBufferedAmountLow() } this._channel.onopen = () => { this._onChannelOpen() } this._channel.onclose = () => { this._onChannelClose() } this._channel.onerror = event => { const err = event.error instanceof Error ? event.error : new Error(`Datachannel error: ${event.message} ${event.filename}:${event.lineno}:${event.colno}`) this.destroy(errCode(err, 'ERR_DATA_CHANNEL')) } // HACK: Chrome will sometimes get stuck in readyState "closing", let's check for this condition // https://bugs.chromium.org/p/chromium/issues/detail?id=882743 let isClosing = false this._closingInterval = setInterval(() => { // No "onclosing" event if (this._channel && this._channel.readyState === 'closing') { if (isClosing) this._onChannelClose() // closing timed out: equivalent to onclose firing isClosing = true } else { isClosing = false } }, CHANNEL_CLOSING_TIMEOUT) } _read () {} _write (chunk, encoding, cb) { if (this.destroyed) return cb(errCode(new Error('cannot write after peer is destroyed'), 'ERR_DATA_CHANNEL')) if (this._connected) { try { this.send(chunk) } catch (err) { return this.destroy(errCode(err, 'ERR_DATA_CHANNEL')) } if (this._channel.bufferedAmount > MAX_BUFFERED_AMOUNT) { this._debug('start backpressure: bufferedAmount %d', this._channel.bufferedAmount) this._cb = cb } else { cb(null) } } else { this._debug('write before connect') this._chunk = chunk this._cb = cb } } // When stream finishes writing, close socket. Half open connections are not // supported. _onFinish () { if (this.destroyed) return // Wait a bit before destroying so the socket flushes. // TODO: is there a more reliable way to accomplish this? const destroySoon = () => { setTimeout(() => this.destroy(), 1000) } if (this._connected) { destroySoon() } else { this.once('connect', destroySoon) } } _startIceCompleteTimeout () { if (this.destroyed) return if (this._iceCompleteTimer) return this._debug('started iceComplete timeout') this._iceCompleteTimer = setTimeout(() => { if (!this._iceComplete) { this._iceComplete = true this._debug('iceComplete timeout completed') this.emit('iceTimeout') this.emit('_iceComplete') } }, this.iceCompleteTimeout) } _createOffer () { if (this.destroyed) return this._pc.createOffer(this.offerOptions) .then(offer => { if (this.destroyed) return if (!this.trickle && !this.allowHalfTrickle) offer.sdp = filterTrickle(offer.sdp) offer.sdp = this.sdpTransform(offer.sdp) const sendOffer = () => { if (this.destroyed) return const signal = this._pc.localDescription || offer this._debug('signal') this.emit('signal', { type: signal.type, sdp: signal.sdp }) } const onSuccess = () => { this._debug('createOffer success') if (this.destroyed) return if (this.trickle || this._iceComplete) sendOffer() else this.once('_iceComplete', sendOffer) // wait for candidates } const onError = err => { this.destroy(errCode(err, 'ERR_SET_LOCAL_DESCRIPTION')) } this._pc.setLocalDescription(offer) .then(onSuccess) .catch(onError) }) .catch(err => { this.destroy(errCode(err, 'ERR_CREATE_OFFER')) }) } _requestMissingTransceivers () { if (this._pc.getTransceivers) { this._pc.getTransceivers().forEach(transceiver => { if (!transceiver.mid && transceiver.sender.track && !transceiver.requested) { transceiver.requested = true // HACK: Safari returns negotiated transceivers with a null mid this.addTransceiver(transceiver.sender.track.kind) } }) } } _createAnswer () { if (this.destroyed) return this._pc.createAnswer(this.answerOptions) .then(answer => { if (this.destroyed) return if (!this.trickle && !this.allowHalfTrickle) answer.sdp = filterTrickle(answer.sdp) answer.sdp = this.sdpTransform(answer.sdp) const sendAnswer = () => { if (this.destroyed) return const signal = this._pc.localDescription || answer this._debug('signal') this.emit('signal', { type: signal.type, sdp: signal.sdp }) if (!this.initiator) this._requestMissingTransceivers() } const onSuccess = () => { if (this.destroyed) return if (this.trickle || this._iceComplete) sendAnswer() else this.once('_iceComplete', sendAnswer) } const onError = err => { this.destroy(errCode(err, 'ERR_SET_LOCAL_DESCRIPTION')) } this._pc.setLocalDescription(answer) .then(onSuccess) .catch(onError) }) .catch(err => { this.destroy(errCode(err, 'ERR_CREATE_ANSWER')) }) } _onConnectionStateChange () { if (this.destroyed) return if (this._pc.connectionState === 'failed') { this.destroy(errCode(new Error('Connection failed.'), 'ERR_CONNECTION_FAILURE')) } } _onIceStateChange () { if (this.destroyed) return const iceConnectionState = this._pc.iceConnectionState const iceGatheringState = this._pc.iceGatheringState this._debug( 'iceStateChange (connection: %s) (gathering: %s)', iceConnectionState, iceGatheringState ) this.emit('iceStateChange', iceConnectionState, iceGatheringState) if (iceConnectionState === 'connected' || iceConnectionState === 'completed') { this._pcReady = true this._maybeReady() } if (iceConnectionState === 'failed') { this.destroy(errCode(new Error('Ice connection failed.'), 'ERR_ICE_CONNECTION_FAILURE')) } if (iceConnectionState === 'closed') { this.destroy(errCode(new Error('Ice connection closed.'), 'ERR_ICE_CONNECTION_CLOSED')) } } getStats (cb) { // statreports can come with a value array instead of properties const flattenValues = report => { if (Object.prototype.toString.call(report.values) === '[object Array]') { report.values.forEach(value => { Object.assign(report, value) }) } return report } // Promise-based getStats() (standard) if (this._pc.getStats.length === 0 || this._isReactNativeWebrtc) { this._pc.getStats() .then(res => { const reports = [] res.forEach(report => { reports.push(flattenValues(report)) }) cb(null, reports) }, err => cb(err)) // Single-parameter callback-based getStats() (non-standard) } else if (this._pc.getStats.length > 0) { this._pc.getStats(res => { // If we destroy connection in `connect` callback this code might happen to run when actual connection is already closed if (this.destroyed) return const reports = [] res.result().forEach(result => { const report = {} result.names().forEach(name => { report[name] = result.stat(name) }) report.id = result.id report.type = result.type report.timestamp = result.timestamp reports.push(flattenValues(report)) }) cb(null, reports) }, err => cb(err)) // Unknown browser, skip getStats() since it's anyone's guess which style of // getStats() they implement. } else { cb(null, []) } } _maybeReady () { this._debug('maybeReady pc %s channel %s', this._pcReady, this._channelReady) if (this._connected || this._connecting || !this._pcReady || !this._channelReady) return this._connecting = true // HACK: We can't rely on order here, for details see https://github.com/js-platform/node-webrtc/issues/339 const findCandidatePair = () => { if (this.destroyed) return this.getStats((err, items) => { if (this.destroyed) return // Treat getStats error as non-fatal. It's not essential. if (err) items = [] const remoteCandidates = {} const localCandidates = {} const candidatePairs = {} let foundSelectedCandidatePair = false items.forEach(item => { // TODO: Once all browsers support the hyphenated stats report types, remove // the non-hypenated ones if (item.type === 'remotecandidate' || item.type === 'remote-candidate') { remoteCandidates[item.id] = item } if (item.type === 'localcandidate' || item.type === 'local-candidate') { localCandidates[item.id] = item } if (item.type === 'candidatepair' || item.type === 'candidate-pair') { candidatePairs[item.id] = item } }) const setSelectedCandidatePair = selectedCandidatePair => { foundSelectedCandidatePair = true let local = localCandidates[selectedCandidatePair.localCandidateId] if (local && (local.ip || local.address)) { // Spec this.localAddress = local.ip || local.address this.localPort = Number(local.port) } else if (local && local.ipAddress) { // Firefox this.localAddress = local.ipAddress this.localPort = Number(local.portNumber) } else if (typeof selectedCandidatePair.googLocalAddress === 'string') { // TODO: remove this once Chrome 58 is released local = selectedCandidatePair.googLocalAddress.split(':') this.localAddress = local[0] this.localPort = Number(local[1]) } if (this.localAddress) { this.localFamily = this.localAddress.includes(':') ? 'IPv6' : 'IPv4' } let remote = remoteCandidates[selectedCandidatePair.remoteCandidateId] if (remote && (remote.ip || remote.address)) { // Spec this.remoteAddress = remote.ip || remote.address this.remotePort = Number(remote.port) } else if (remote && remote.ipAddress) { // Firefox this.remoteAddress = remote.ipAddress this.remotePort = Number(remote.portNumber) } else if (typeof selectedCandidatePair.googRemoteAddress === 'string') { // TODO: remove this once Chrome 58 is released remote = selectedCandidatePair.googRemoteAddress.split(':') this.remoteAddress = remote[0] this.remotePort = Number(remote[1]) } if (this.remoteAddress) { this.remoteFamily = this.remoteAddress.includes(':') ? 'IPv6' : 'IPv4' } this._debug( 'connect local: %s:%s remote: %s:%s', this.localAddress, this.localPort, this.remoteAddress, this.remotePort ) } items.forEach(item => { // Spec-compliant if (item.type === 'transport' && item.selectedCandidatePairId) { setSelectedCandidatePair(candidatePairs[item.selectedCandidatePairId]) } // Old implementations if ( (item.type === 'googCandidatePair' && item.googActiveConnection === 'true') || ((item.type === 'candidatepair' || item.type === 'candidate-pair') && item.selected) ) { setSelectedCandidatePair(item) } }) // Ignore candidate pair selection in browsers like Safari 11 that do not have any local or remote candidates // But wait until at least 1 candidate pair is available if (!foundSelectedCandidatePair && (!Object.keys(candidatePairs).length || Object.keys(localCandidates).length)) { setTimeout(findCandidatePair, 100) return } else { this._connecting = false this._connected = true } if (this._chunk) { try { this.send(this._chunk) } catch (err) { return this.destroy(errCode(err, 'ERR_DATA_CHANNEL')) } this._chunk = null this._debug('sent chunk from "write before connect"') const cb = this._cb this._cb = null cb(null) } // If `bufferedAmountLowThreshold` and 'onbufferedamountlow' are unsupported, // fallback to using setInterval to implement backpressure. if (typeof this._channel.bufferedAmountLowThreshold !== 'number') { this._interval = setInterval(() => this._onInterval(), 150) if (this._interval.unref) this._interval.unref() } this._debug('connect') this.emit('connect') }) } findCandidatePair() } _onInterval () { if (!this._cb || !this._channel || this._channel.bufferedAmount > MAX_BUFFERED_AMOUNT) { return } this._onChannelBufferedAmountLow() } _onSignalingStateChange () { if (this.destroyed) return if (this._pc.signalingState === 'stable') { this._isNegotiating = false // HACK: Firefox doesn't yet support removing tracks when signalingState !== 'stable' this._debug('flushing sender queue', this._sendersAwaitingStable) this._sendersAwaitingStable.forEach(sender => { this._pc.removeTrack(sender) this._queuedNegotiation = true }) this._sendersAwaitingStable = [] if (this._queuedNegotiation) { this._debug('flushing negotiation queue') this._queuedNegotiation = false this._needsNegotiation() // negotiate again } else { this._debug('negotiated') this.emit('negotiated') } } this._debug('signalingStateChange %s', this._pc.signalingState) this.emit('signalingStateChange', this._pc.signalingState) } _onIceCandidate (event) { if (this.destroyed) return if (event.candidate && this.trickle) { this.emit('signal', { type: 'candidate', candidate: { candidate: event.candidate.candidate, sdpMLineIndex: event.candidate.sdpMLineIndex, sdpMid: event.candidate.sdpMid } }) } else if (!event.candidate && !this._iceComplete) { this._iceComplete = true this.emit('_iceComplete') } // as soon as we've received one valid candidate start timeout if (event.candidate) { this._startIceCompleteTimeout() } } _onChannelMessage (event) { if (this.destroyed) return let data = event.data if (data instanceof ArrayBuffer) data = Buffer.from(data) this.push(data) } _onChannelBufferedAmountLow () { if (this.destroyed || !this._cb) return this._debug('ending backpressure: bufferedAmount %d', this._channel.bufferedAmount) const cb = this._cb this._cb = null cb(null) } _onChannelOpen () { if (this._connected || this.destroyed) return this._debug('on channel open') this._channelReady = true this._maybeReady() } _onChannelClose () { if (this.destroyed) return this._debug('on channel close') this.destroy() } _onTrack (event) { if (this.destroyed) return event.streams.forEach(eventStream => { this._debug('on track') this.emit('track', event.track, eventStream) this._remoteTracks.push({ track: event.track, stream: eventStream }) if (this._remoteStreams.some(remoteStream => { return remoteStream.id === eventStream.id })) return // Only fire one 'stream' event, even though there may be multiple tracks per stream this._remoteStreams.push(eventStream) queueMicrotask(() => { this._debug('on stream') this.emit('stream', eventStream) // ensure all tracks have been added }) }) } _debug () { const args = [].slice.call(arguments) args[0] = '[' + this._id + '] ' + args[0] debug.apply(null, args) } } Peer.WEBRTC_SUPPORT = !!getBrowserRTC() /** * Expose peer and data channel config for overriding all Peer * instances. Otherwise, just set opts.config or opts.channelConfig * when constructing a Peer. */ Peer.config = { iceServers: [ { urls: [ 'stun:stun.l.google.com:19302', 'stun:global.stun.twilio.com:3478' ] } ], sdpSemantics: 'unified-plan' } Peer.channelConfig = {} module.exports = Peer