Created
October 26, 2022 13:54
-
-
Save Juice10/316b931c3861e83bf19abc7f281daadb to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/*! simple-peer. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */ | |
const MAX_BUFFERED_AMOUNT = 64 * 1024; | |
const ICECOMPLETE_TIMEOUT = 5 * 1000; | |
const CHANNEL_CLOSING_TIMEOUT = 5 * 1000; | |
function randombytes (size) { | |
const array = new Uint8Array(size); | |
for (let i = 0; i < size; i++) { | |
array[i] = (Math.random() * 256) | 0; | |
} | |
return array | |
} | |
function getBrowserRTC () { | |
if (typeof globalThis === 'undefined') return null | |
const wrtc = { | |
RTCPeerConnection: | |
globalThis.RTCPeerConnection || | |
globalThis.mozRTCPeerConnection || | |
globalThis.webkitRTCPeerConnection, | |
RTCSessionDescription: | |
globalThis.RTCSessionDescription || | |
globalThis.mozRTCSessionDescription || | |
globalThis.webkitRTCSessionDescription, | |
RTCIceCandidate: | |
globalThis.RTCIceCandidate || | |
globalThis.mozRTCIceCandidate || | |
globalThis.webkitRTCIceCandidate | |
}; | |
if (!wrtc.RTCPeerConnection) return null | |
return wrtc | |
} | |
function errCode (err, code) { | |
Object.defineProperty(err, 'code', { | |
value: code, | |
enumerable: true, | |
configurable: true | |
}); | |
return err | |
} | |
// 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. | |
* @param {Object} opts | |
*/ | |
class Peer { | |
constructor (opts = {}) { | |
this._map = new Map(); // for event emitter | |
this._id = randombytes(4).toString('hex').slice(0, 7); | |
this._doDebug = opts.debug; | |
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(); | |
} | |
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|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; | |
} | |
destroy (err) { | |
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._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._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'); | |
}); | |
} | |
_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 = err => { | |
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); | |
} | |
_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 = new Uint8Array(data); | |
this.emit('data', 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 (...args) { | |
if (!this._doDebug) return | |
args[0] = '[' + this._id + '] ' + args[0]; | |
console.log(...args); | |
} | |
// event emitter | |
on (key, listener) { | |
const map = this._map; | |
if (!map.has(key)) map.set(key, new Set()); | |
map.get(key).add(listener); | |
} | |
off (key, listener) { | |
const map = this._map; | |
const listeners = map.get(key); | |
if (!listeners) return | |
listeners.delete(listener); | |
if (listeners.size === 0) map.delete(key); | |
} | |
once (key, listener) { | |
const listener_ = (...args) => { | |
this.off(key, listener_); | |
listener(...args); | |
}; | |
this.on(key, listener_); | |
} | |
emit (key, ...args) { | |
const map = this._map; | |
if (!map.has(key)) return | |
for (const listener of map.get(key)) { | |
try { | |
listener(...args); | |
} catch (err) { | |
console.error(err); | |
} | |
} | |
} | |
} | |
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 = {}; | |
const PLUGIN_NAME = "rrweb/canvas-webrtc@1"; | |
class RRWebPluginCanvasWebRTCRecord { | |
constructor({ | |
signalSendCallback, | |
peer | |
}) { | |
this.peer = null; | |
this.streamMap = /* @__PURE__ */ new Map(); | |
this.signalSendCallback = signalSendCallback; | |
if (peer) | |
this.peer = peer; | |
} | |
initPlugin() { | |
return { | |
name: PLUGIN_NAME, | |
getMirror: (mirror) => { | |
this.mirror = mirror; | |
}, | |
options: {} | |
}; | |
} | |
signalReceive(signal) { | |
var _a; | |
if (!this.peer) | |
this.setupPeer(); | |
(_a = this.peer) == null ? void 0 : _a.signal(signal); | |
} | |
startStream(id, stream) { | |
var _a, _b; | |
if (!this.peer) | |
return this.setupPeer(); | |
const data = { | |
nodeId: id, | |
streamId: stream.id | |
}; | |
(_a = this.peer) == null ? void 0 : _a.send(JSON.stringify(data)); | |
(_b = this.peer) == null ? void 0 : _b.addStream(stream); | |
} | |
setupPeer() { | |
if (!this.peer) { | |
this.peer = new Peer({ | |
initiator: true | |
}); | |
this.peer.on("error", (err) => { | |
this.peer = null; | |
console.log("error", err); | |
}); | |
this.peer.on("close", () => { | |
this.peer = null; | |
console.log("closing"); | |
}); | |
this.peer.on("signal", (data) => { | |
this.signalSendCallback(data); | |
}); | |
this.peer.on("connect", () => { | |
for (const [id, stream] of this.streamMap) { | |
this.startStream(id, stream); | |
} | |
}); | |
} | |
} | |
setupStream(id) { | |
if (id === -1) | |
return false; | |
let stream = this.streamMap.get(id); | |
if (stream) | |
return stream; | |
const el = this.mirror.getNode(id); | |
if (!el || !("captureStream" in el)) | |
return false; | |
stream = el.captureStream(); | |
this.streamMap.set(id, stream); | |
this.setupPeer(); | |
return stream; | |
} | |
} | |
export default { | |
RRWebPluginCanvasWebRTCRecord: RRWebPluginCanvasWebRTCRecord | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment