UNPKG

rtcpeerconnection

Version:

A tiny browser module that normalizes and simplifies the API for WebRTC peer connections.

946 lines (865 loc) 35.3 kB
var util = require('util'); var SJJ = require('sdp-jingle-json'); var WildEmitter = require('wildemitter'); var cloneDeep = require('lodash.clonedeep'); function PeerConnection(config, constraints) { var self = this; var item; WildEmitter.call(this); config = config || {}; config.iceServers = config.iceServers || []; // make sure this only gets enabled in Google Chrome // EXPERIMENTAL FLAG, might get removed without notice this.enableChromeNativeSimulcast = false; if (constraints && constraints.optional && window.chrome && navigator.appVersion.match(/Chromium\//) === null) { constraints.optional.forEach(function (constraint) { if (constraint.enableChromeNativeSimulcast) { self.enableChromeNativeSimulcast = true; } }); } // EXPERIMENTAL FLAG, might get removed without notice this.enableMultiStreamHacks = false; if (constraints && constraints.optional && window.chrome) { constraints.optional.forEach(function (constraint) { if (constraint.enableMultiStreamHacks) { self.enableMultiStreamHacks = true; } }); } // EXPERIMENTAL FLAG, might get removed without notice this.restrictBandwidth = 0; if (constraints && constraints.optional) { constraints.optional.forEach(function (constraint) { if (constraint.andyetRestrictBandwidth) { self.restrictBandwidth = constraint.andyetRestrictBandwidth; } }); } // EXPERIMENTAL FLAG, might get removed without notice // bundle up ice candidates, only works for jingle mode // number > 0 is the delay to wait for additional candidates // ~20ms seems good this.batchIceCandidates = 0; if (constraints && constraints.optional) { constraints.optional.forEach(function (constraint) { if (constraint.andyetBatchIce) { self.batchIceCandidates = constraint.andyetBatchIce; } }); } this.batchedIceCandidates = []; // EXPERIMENTAL FLAG, might get removed without notice // this attemps to strip out candidates with an already known foundation // and type -- i.e. those which are gathered via the same TURN server // but different transports (TURN udp, tcp and tls respectively) if (constraints && constraints.optional && window.chrome) { constraints.optional.forEach(function (constraint) { if (constraint.andyetFasterICE) { self.eliminateDuplicateCandidates = constraint.andyetFasterICE; } }); } // EXPERIMENTAL FLAG, might get removed without notice // when using a server such as the jitsi videobridge we don't need to signal // our candidates if (constraints && constraints.optional) { constraints.optional.forEach(function (constraint) { if (constraint.andyetDontSignalCandidates) { self.dontSignalCandidates = constraint.andyetDontSignalCandidates; } }); } // EXPERIMENTAL FLAG, might get removed without notice this.assumeSetLocalSuccess = false; if (constraints && constraints.optional) { constraints.optional.forEach(function (constraint) { if (constraint.andyetAssumeSetLocalSuccess) { self.assumeSetLocalSuccess = constraint.andyetAssumeSetLocalSuccess; } }); } // EXPERIMENTAL FLAG, might get removed without notice // working around https://bugzilla.mozilla.org/show_bug.cgi?id=1087551 // pass in a timeout for this if (window.navigator.mozGetUserMedia) { if (constraints && constraints.optional) { this.wtFirefox = 0; constraints.optional.forEach(function (constraint) { if (constraint.andyetFirefoxMakesMeSad) { self.wtFirefox = constraint.andyetFirefoxMakesMeSad; if (self.wtFirefox > 0) { self.firefoxcandidatebuffer = []; } } }); } } this.pc = new RTCPeerConnection(config, constraints); if (typeof this.pc.getLocalStreams === 'function') { this.getLocalStreams = this.pc.getLocalStreams.bind(this.pc); } else { this.getLocalStreams = function () { return []; }; } if (typeof this.pc.getSenders === 'function') { this.getSenders = this.pc.getSenders.bind(this.pc); } else { this.getSenders = function () { return []; }; } if (typeof this.pc.getRemoteStreams === 'function') { this.getRemoteStreams = this.pc.getRemoteStreams.bind(this.pc); } else { this.getRemoteStreams = function () { return []; }; } if (typeof this.pc.getReceivers === 'function') { this.getReceivers = this.pc.getReceivers.bind(this.pc); } else { this.getReceivers = function () { return []; }; } this.addStream = this.pc.addStream.bind(this.pc); this.removeStream = function (stream) { if (typeof self.pc.removeStream === 'function') { self.pc.removeStream.apply(self.pc, arguments); } else if (typeof self.pc.removeTrack === 'function') { stream.getTracks().forEach(function (track) { self.pc.removeTrack(track); }); } }; if (typeof this.pc.removeTrack === 'function') { this.removeTrack = this.pc.removeTrack.bind(this.pc); } // proxy some events directly this.pc.onremovestream = this.emit.bind(this, 'removeStream'); this.pc.onremovetrack = this.emit.bind(this, 'removeTrack'); this.pc.onaddstream = this.emit.bind(this, 'addStream'); this.pc.ontrack = this.emit.bind(this, 'addTrack'); this.pc.onnegotiationneeded = this.emit.bind(this, 'negotiationNeeded'); this.pc.oniceconnectionstatechange = this.emit.bind(this, 'iceConnectionStateChange'); this.pc.onsignalingstatechange = this.emit.bind(this, 'signalingStateChange'); // handle ice candidate and data channel events this.pc.onicecandidate = this._onIce.bind(this); this.pc.ondatachannel = this._onDataChannel.bind(this); this.localDescription = { contents: [] }; this.remoteDescription = { contents: [] }; this.config = { debug: false, sid: '', isInitiator: true, sdpSessionID: Date.now(), useJingle: false }; this.iceCredentials = { local: {}, remote: {} }; // apply our config for (item in config) { this.config[item] = config[item]; } if (this.config.debug) { this.on('*', function () { var logger = config.logger || console; logger.log('PeerConnection event:', arguments); }); } this.hadLocalStunCandidate = false; this.hadRemoteStunCandidate = false; this.hadLocalRelayCandidate = false; this.hadRemoteRelayCandidate = false; this.hadLocalIPv6Candidate = false; this.hadRemoteIPv6Candidate = false; // keeping references for all our data channels // so they dont get garbage collected // can be removed once the following bugs have been fixed // https://crbug.com/405545 // https://bugzilla.mozilla.org/show_bug.cgi?id=964092 // to be filed for opera this._remoteDataChannels = []; this._localDataChannels = []; this._candidateBuffer = []; } util.inherits(PeerConnection, WildEmitter); Object.defineProperty(PeerConnection.prototype, 'signalingState', { get: function () { return this.pc.signalingState; } }); Object.defineProperty(PeerConnection.prototype, 'iceConnectionState', { get: function () { return this.pc.iceConnectionState; } }); PeerConnection.prototype._role = function () { return this.isInitiator ? 'initiator' : 'responder'; }; // Add a stream to the peer connection object PeerConnection.prototype.addStream = function (stream) { this.localStream = stream; stream.getTracks().forEach( function(track) { this.pc.addTrack( track, stream ); } ); }; // helper function to check if a remote candidate is a stun/relay // candidate or an ipv6 candidate PeerConnection.prototype._checkLocalCandidate = function (candidate) { var cand = SJJ.toCandidateJSON(candidate); if (cand.type == 'srflx') { this.hadLocalStunCandidate = true; } else if (cand.type == 'relay') { this.hadLocalRelayCandidate = true; } if (cand.ip.indexOf(':') != -1) { this.hadLocalIPv6Candidate = true; } }; // helper function to check if a remote candidate is a stun/relay // candidate or an ipv6 candidate PeerConnection.prototype._checkRemoteCandidate = function (candidate) { var cand = SJJ.toCandidateJSON(candidate); if (cand.type == 'srflx') { this.hadRemoteStunCandidate = true; } else if (cand.type == 'relay') { this.hadRemoteRelayCandidate = true; } if (cand.ip.indexOf(':') != -1) { this.hadRemoteIPv6Candidate = true; } }; // Init and add ice candidate object with correct constructor PeerConnection.prototype.processIce = function (update, cb) { cb = cb || function () {}; var self = this; // ignore any added ice candidates to avoid errors. why does the // spec not do this? if (this.pc.signalingState === 'closed') return cb(); if (update.contents || (update.jingle && update.jingle.contents)) { var contentNames = this.remoteDescription.contents.map(function (c) { return c.name; }); var contents = update.contents || update.jingle.contents; contents.forEach(function (content) { var transport = content.transport || {}; var candidates = transport.candidates || []; var mline = contentNames.indexOf(content.name); var mid = content.name; var remoteContent = self.remoteDescription.contents.find(function (c) { return c.name === content.name; }); // process candidates as a callback, in case we need to // update ufrag and pwd with offer/answer var processCandidates = function () { candidates.forEach( function (candidate) { var iceCandidate = SJJ.toCandidateSDP(candidate); self.pc.addIceCandidate( new RTCIceCandidate({ candidate: iceCandidate, sdpMLineIndex: mline, sdpMid: mid }) ).then( function () { // well, this success callback is pretty meaningless }, function (err) { self.emit('error', err); } ); self._checkRemoteCandidate(iceCandidate); }); cb(); }; if (self.iceCredentials.remote[content.name] && transport.ufrag && self.iceCredentials.remote[content.name].ufrag !== transport.ufrag) { if (remoteContent) { remoteContent.transport.ufrag = transport.ufrag; remoteContent.transport.pwd = transport.pwd; var offer = { type: 'offer', jingle: self.remoteDescription }; offer.sdp = SJJ.toSessionSDP(offer.jingle, { sid: self.config.sdpSessionID, role: self._role(), direction: 'incoming' }); self.pc.setRemoteDescription( new RTCSessionDescription(offer) ).then( function () { processCandidates(); }, function (err) { self.emit('error', err); } ); } else { self.emit('error', 'ice restart failed to find matching content'); } } else { processCandidates(); } }); } else { // working around https://code.google.com/p/webrtc/issues/detail?id=3669 if (update.candidate && update.candidate.candidate.indexOf('a=') !== 0) { update.candidate.candidate = 'a=' + update.candidate.candidate; } if (this.wtFirefox && this.firefoxcandidatebuffer !== null) { // we cant add this yet due to https://bugzilla.mozilla.org/show_bug.cgi?id=1087551 if (this.pc.localDescription && this.pc.localDescription.type === 'offer') { this.firefoxcandidatebuffer.push(update.candidate); return cb(); } } self.pc.addIceCandidate( new RTCIceCandidate(update.candidate) ).then( function () { }, function (err) { self.emit('error', err); } ); self._checkRemoteCandidate(update.candidate.candidate); cb(); } }; // Generate and emit an offer with the given constraints PeerConnection.prototype.offer = function (constraints, cb) { var self = this; var hasConstraints = arguments.length === 2; var mediaConstraints = hasConstraints && constraints ? constraints : { offerToReceiveAudio: 1, offerToReceiveVideo: 1 }; cb = hasConstraints ? cb : constraints; cb = cb || function () {}; if (this.pc.signalingState === 'closed') return cb('Already closed'); // Actually generate the offer this.pc.createOffer( mediaConstraints ).then( function (offer) { // does not work for jingle, but jingle.js doesn't need // this hack... var expandedOffer = { type: 'offer', sdp: offer.sdp }; if (self.assumeSetLocalSuccess) { self.emit('offer', expandedOffer); cb(null, expandedOffer); } self._candidateBuffer = []; self.pc.setLocalDescription(offer).then( function () { var jingle; if (self.config.useJingle) { jingle = SJJ.toSessionJSON(offer.sdp, { role: self._role(), direction: 'outgoing' }); jingle.sid = self.config.sid; self.localDescription = jingle; // Save ICE credentials jingle.contents.forEach(function (content) { var transport = content.transport || {}; if (transport.ufrag) { self.iceCredentials.local[content.name] = { ufrag: transport.ufrag, pwd: transport.pwd }; } }); expandedOffer.jingle = jingle; } expandedOffer.sdp.split(/\r?\n/).forEach(function (line) { if (line.indexOf('a=candidate:') === 0) { self._checkLocalCandidate(line); } }); if (!self.assumeSetLocalSuccess) { self.emit('offer', expandedOffer); cb(null, expandedOffer); } }, function (err) { self.emit('error', err); cb(err); } ); }, function (err) { self.emit('error', err); cb(err); } ); }; // Process an incoming offer so that ICE may proceed before deciding // to answer the request. PeerConnection.prototype.handleOffer = function (offer, cb) { cb = cb || function () {}; var self = this; offer.type = 'offer'; if (offer.jingle) { if (this.enableChromeNativeSimulcast) { offer.jingle.contents.forEach(function (content) { if (content.name === 'video') { content.application.googConferenceFlag = true; } }); } if (this.enableMultiStreamHacks) { // add a mixed video stream as first stream offer.jingle.contents.forEach(function (content) { if (content.name === 'video') { var sources = content.application.sources || []; if (sources.length === 0 || sources[0].ssrc !== "3735928559") { sources.unshift({ ssrc: "3735928559", // 0xdeadbeef parameters: [ { key: "cname", value: "deadbeef" }, { key: "msid", value: "mixyourfecintothis please" } ] }); content.application.sources = sources; } } }); } if (self.restrictBandwidth > 0) { if (offer.jingle.contents.length >= 2 && offer.jingle.contents[1].name === 'video') { var content = offer.jingle.contents[1]; var hasBw = content.application && content.application.bandwidth && content.application.bandwidth.bandwidth; if (!hasBw) { offer.jingle.contents[1].application.bandwidth = { type: 'AS', bandwidth: self.restrictBandwidth.toString() }; offer.sdp = SJJ.toSessionSDP(offer.jingle, { sid: self.config.sdpSessionID, role: self._role(), direction: 'outgoing' }); } } } // Save ICE credentials offer.jingle.contents.forEach(function (content) { var transport = content.transport || {}; if (transport.ufrag) { self.iceCredentials.remote[content.name] = { ufrag: transport.ufrag, pwd: transport.pwd }; } }); offer.sdp = SJJ.toSessionSDP(offer.jingle, { sid: self.config.sdpSessionID, role: self._role(), direction: 'incoming' }); self.remoteDescription = offer.jingle; } offer.sdp.split(/\r?\n/).forEach(function (line) { if (line.indexOf('a=candidate:') === 0) { self._checkRemoteCandidate(line); } }); self.pc.setRemoteDescription( new RTCSessionDescription(offer) ).then( function () { cb(); }, cb ); }; // Answer an offer with audio only PeerConnection.prototype.answerAudioOnly = function (cb) { var mediaConstraints = { mandatory: { OfferToReceiveAudio: true, OfferToReceiveVideo: false } }; this._answer(mediaConstraints, cb); }; // Answer an offer without offering to recieve PeerConnection.prototype.answerBroadcastOnly = function (cb) { var mediaConstraints = { mandatory: { OfferToReceiveAudio: false, OfferToReceiveVideo: false } }; this._answer(mediaConstraints, cb); }; // Answer an offer with given constraints default is audio/video PeerConnection.prototype.answer = function (constraints, cb) { var hasConstraints = arguments.length === 2; var callback = hasConstraints ? cb : constraints; var mediaConstraints = hasConstraints && constraints ? constraints : { mandatory: { OfferToReceiveAudio: true, OfferToReceiveVideo: true } }; this._answer(mediaConstraints, callback); }; // Process an answer PeerConnection.prototype.handleAnswer = function (answer, cb) { cb = cb || function () {}; var self = this; if (answer.jingle) { answer.sdp = SJJ.toSessionSDP(answer.jingle, { sid: self.config.sdpSessionID, role: self._role(), direction: 'incoming' }); self.remoteDescription = answer.jingle; // Save ICE credentials answer.jingle.contents.forEach(function (content) { var transport = content.transport || {}; if (transport.ufrag) { self.iceCredentials.remote[content.name] = { ufrag: transport.ufrag, pwd: transport.pwd }; } }); } answer.sdp.split(/\r?\n/).forEach(function (line) { if (line.indexOf('a=candidate:') === 0) { self._checkRemoteCandidate(line); } }); self.pc.setRemoteDescription( new RTCSessionDescription(answer) ).then( function () { if (self.wtFirefox) { window.setTimeout(function () { self.firefoxcandidatebuffer.forEach(function (candidate) { // add candidates later self.pc.addIceCandidate( new RTCIceCandidate(candidate) ).then( function () { }, function (err) { self.emit('error', err); } ); self._checkRemoteCandidate(candidate.candidate); }); self.firefoxcandidatebuffer = null; }, self.wtFirefox); } cb(null); }, cb ); }; // Close the peer connection PeerConnection.prototype.close = function () { this.pc.close(); this._localDataChannels = []; this._remoteDataChannels = []; this.emit('close'); }; // Internal code sharing for various types of answer methods PeerConnection.prototype._answer = function (constraints, cb) { cb = cb || function () {}; var self = this; if (!this.pc.remoteDescription) { // the old API is used, call handleOffer throw new Error('remoteDescription not set'); } if (this.pc.signalingState === 'closed') return cb('Already closed'); self.pc.createAnswer( constraints ).then( function (answer) { var sim = []; if (self.enableChromeNativeSimulcast) { // native simulcast part 1: add another SSRC answer.jingle = SJJ.toSessionJSON(answer.sdp, { role: self._role(), direction: 'outgoing' }); if (answer.jingle.contents.length >= 2 && answer.jingle.contents[1].name === 'video') { var groups = answer.jingle.contents[1].application.sourceGroups || []; var hasSim = false; groups.forEach(function (group) { if (group.semantics == 'SIM') hasSim = true; }); if (!hasSim && answer.jingle.contents[1].application.sources.length) { var newssrc = JSON.parse(JSON.stringify(answer.jingle.contents[1].application.sources[0])); newssrc.ssrc = '' + Math.floor(Math.random() * 0xffffffff); // FIXME: look for conflicts answer.jingle.contents[1].application.sources.push(newssrc); sim.push(answer.jingle.contents[1].application.sources[0].ssrc); sim.push(newssrc.ssrc); groups.push({ semantics: 'SIM', sources: sim }); // also create an RTX one for the SIM one var rtxssrc = JSON.parse(JSON.stringify(newssrc)); rtxssrc.ssrc = '' + Math.floor(Math.random() * 0xffffffff); // FIXME: look for conflicts answer.jingle.contents[1].application.sources.push(rtxssrc); groups.push({ semantics: 'FID', sources: [newssrc.ssrc, rtxssrc.ssrc] }); answer.jingle.contents[1].application.sourceGroups = groups; answer.sdp = SJJ.toSessionSDP(answer.jingle, { sid: self.config.sdpSessionID, role: self._role(), direction: 'outgoing' }); } } } var expandedAnswer = { type: 'answer', sdp: answer.sdp }; if (self.assumeSetLocalSuccess) { // not safe to do when doing simulcast mangling var copy = cloneDeep(expandedAnswer); self.emit('answer', copy); cb(null, copy); } self._candidateBuffer = []; self.pc.setLocalDescription(answer).then( function () { if (self.config.useJingle) { var jingle = SJJ.toSessionJSON(answer.sdp, { role: self._role(), direction: 'outgoing' }); jingle.sid = self.config.sid; self.localDescription = jingle; expandedAnswer.jingle = jingle; } if (self.enableChromeNativeSimulcast) { // native simulcast part 2: // signal multiple tracks to the receiver // for anything in the SIM group if (!expandedAnswer.jingle) { expandedAnswer.jingle = SJJ.toSessionJSON(answer.sdp, { role: self._role(), direction: 'outgoing' }); } expandedAnswer.jingle.contents[1].application.sources.forEach(function (source, idx) { // the floor idx/2 is a hack that relies on a particular order // of groups, alternating between sim and rtx source.parameters = source.parameters.map(function (parameter) { if (parameter.key === 'msid') { parameter.value += '-' + Math.floor(idx / 2); } return parameter; }); }); expandedAnswer.sdp = SJJ.toSessionSDP(expandedAnswer.jingle, { sid: self.sdpSessionID, role: self._role(), direction: 'outgoing' }); } expandedAnswer.sdp.split(/\r?\n/).forEach(function (line) { if (line.indexOf('a=candidate:') === 0) { self._checkLocalCandidate(line); } }); if (!self.assumeSetLocalSuccess) { var copy = cloneDeep(expandedAnswer); self.emit('answer', copy); cb(null, copy); } }, function (err) { self.emit('error', err); cb(err); } ); }, function (err) { self.emit('error', err); cb(err); } ); }; // Internal method for emitting ice candidates on our peer object PeerConnection.prototype._onIce = function (event) { var self = this; if (event.candidate) { if (this.dontSignalCandidates) return; var ice = event.candidate; var expandedCandidate = { candidate: { candidate: ice.candidate, sdpMid: ice.sdpMid, sdpMLineIndex: ice.sdpMLineIndex } }; this._checkLocalCandidate(ice.candidate); var cand = SJJ.toCandidateJSON(ice.candidate); var already; var idx; if (this.eliminateDuplicateCandidates && cand.type === 'relay') { // drop candidates with same foundation, component // take local type pref into account so we don't ignore udp // ones when we know about a TCP one. unlikely but... already = this._candidateBuffer.filter( function (c) { return c.type === 'relay'; }).map(function (c) { return c.foundation + ':' + c.component; } ); idx = already.indexOf(cand.foundation + ':' + cand.component); // remember: local type pref of udp is 0, tcp 1, tls 2 if (idx > -1 && ((cand.priority >> 24) >= (already[idx].priority >> 24))) { // drop it, same foundation with higher (worse) type pref return; } } if (this.config.bundlePolicy === 'max-bundle') { // drop candidates which are duplicate for audio/video/data // duplicate means same host/port but different sdpMid already = this._candidateBuffer.filter( function (c) { return cand.type === c.type; }).map(function (cand) { return cand.address + ':' + cand.port; } ); idx = already.indexOf(cand.address + ':' + cand.port); if (idx > -1) return; } // also drop rtcp candidates since we know the peer supports RTCP-MUX // this is a workaround until browsers implement this natively if (this.config.rtcpMuxPolicy === 'require' && cand.component === '2') { return; } this._candidateBuffer.push(cand); if (self.config.useJingle) { if (!ice.sdpMid) { // firefox doesn't set this if (self.pc.remoteDescription && self.pc.remoteDescription.type === 'offer') { // preserve name from remote ice.sdpMid = self.remoteDescription.contents[ice.sdpMLineIndex].name; } else { ice.sdpMid = self.localDescription.contents[ice.sdpMLineIndex].name; } } if (!self.iceCredentials.local[ice.sdpMid]) { var jingle = SJJ.toSessionJSON(self.pc.localDescription.sdp, { role: self._role(), direction: 'outgoing' }); jingle.contents.forEach(function (content) { var transport = content.transport || {}; if (transport.ufrag) { self.iceCredentials.local[content.name] = { ufrag: transport.ufrag, pwd: transport.pwd }; } }); } expandedCandidate.jingle = { contents: [{ name: ice.sdpMid, creator: self._role(), transport: { transportType: 'iceUdp', ufrag: self.iceCredentials.local[ice.sdpMid].ufrag, pwd: self.iceCredentials.local[ice.sdpMid].pwd, candidates: [ cand ] } }] }; if (self.batchIceCandidates > 0) { if (self.batchedIceCandidates.length === 0) { window.setTimeout(function () { var contents = {}; self.batchedIceCandidates.forEach(function (content) { content = content.contents[0]; if (!contents[content.name]) contents[content.name] = content; contents[content.name].transport.candidates.push(content.transport.candidates[0]); }); var newCand = { jingle: { contents: [] } }; Object.keys(contents).forEach(function (name) { newCand.jingle.contents.push(contents[name]); }); self.batchedIceCandidates = []; self.emit('ice', newCand); }, self.batchIceCandidates); } self.batchedIceCandidates.push(expandedCandidate.jingle); return; } } this.emit('ice', expandedCandidate); } else { this.emit('endOfCandidates'); } }; // Internal method for processing a new data channel being added by the // other peer. PeerConnection.prototype._onDataChannel = function (event) { // make sure we keep a reference so this doesn't get garbage collected var channel = event.channel; this._remoteDataChannels.push(channel); this.emit('addChannel', channel); }; // Create a data channel spec reference: // http://dev.w3.org/2011/webrtc/editor/webrtc.html#idl-def-RTCDataChannelInit PeerConnection.prototype.createDataChannel = function (name, opts) { var channel = this.pc.createDataChannel(name, opts); // make sure we keep a reference so this doesn't get garbage collected this._localDataChannels.push(channel); return channel; }; PeerConnection.prototype.getStats = function () { if (typeof arguments[0] === 'function') { var cb = arguments[0]; this.pc.getStats().then(function (res) { cb(null, res); }, function (err) { cb(err); }); } else { return this.pc.getStats.apply(this.pc, arguments); } }; module.exports = PeerConnection;