Skip to content

Instantly share code, notes, and snippets.

@Juice10
Created October 26, 2022 13:54
Show Gist options
  • Save Juice10/316b931c3861e83bf19abc7f281daadb to your computer and use it in GitHub Desktop.
Save Juice10/316b931c3861e83bf19abc7f281daadb to your computer and use it in GitHub Desktop.
/*! 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