rtc-taskqueue
Version:
An asynchronous task queue that applies actions to an RTCPeerConnection in the most sensible order
468 lines (378 loc) • 13.1 kB
JavaScript
var detect = require('rtc-core/detect');
var findPlugin = require('rtc-core/plugin');
var PriorityQueue = require('priorityqueuejs');
var Promise = require('es6-promise').Promise;
var pluck = require('whisk/pluck');
var pluckSessionDesc = pluck('sdp', 'type');
// some validation routines
var checkCandidate = require('rtc-validator/candidate');
// the sdp cleaner
var sdpclean = require('rtc-sdpclean');
var parseSdp = require('rtc-sdp');
var PRIORITY_LOW = 100;
var PRIORITY_WAIT = 1000;
// priority order (lower is better)
var DEFAULT_PRIORITIES = [
'createOffer',
'setLocalDescription',
'createAnswer',
'setRemoteDescription',
'addIceCandidate'
];
// define event mappings
var METHOD_EVENTS = {
setLocalDescription: 'setlocaldesc',
setRemoteDescription: 'setremotedesc',
createOffer: 'offer',
createAnswer: 'answer'
};
var MEDIA_MAPPINGS = {
data: 'application'
};
// define states in which we will attempt to finalize a connection on receiving a remote offer
var VALID_RESPONSE_STATES = ['have-remote-offer', 'have-local-pranswer'];
/**
Allows overriding of a function
**/
function pluggable(pluginFn, defaultFn) {
return (pluginFn && typeof pluginFn == 'function' ? pluginFn : defaultFn);
}
/**
# rtc-taskqueue
This is a package that assists with applying actions to an `RTCPeerConnection`
in as reliable order as possible. It is primarily used by the coupling logic
of the [`rtc-tools`](https://github.com/rtc-io/rtc-tools).
## Example Usage
For the moment, refer to the simple coupling test as an example of how to use
this package (see below):
<<< test/couple.js
**/
module.exports = function(pc, opts) {
opts = opts || {};
// create the task queue
var queue = new PriorityQueue(orderTasks);
var tq = require('mbus')('', (opts || {}).logger);
// initialise task importance
var priorities = (opts || {}).priorities || DEFAULT_PRIORITIES;
var queueInterval = (opts || {}).interval || 10;
// check for plugin usage
var plugin = findPlugin((opts || {}).plugins);
// initialise state tracking
var checkQueueTimer = 0;
var defaultFail = tq.bind(tq, 'fail');
// look for an sdpfilter function (allow slight mis-spellings)
var sdpFilter = (opts || {}).sdpfilter || (opts || {}).sdpFilter;
var alwaysParse = (opts.sdpParseMode === 'always');
// initialise session description and icecandidate objects
var RTCSessionDescription = (opts || {}).RTCSessionDescription ||
detect('RTCSessionDescription');
var RTCIceCandidate = (opts || {}).RTCIceCandidate ||
detect('RTCIceCandidate');
// Determine plugin overridable methods
var createIceCandidate = pluggable(plugin && plugin.createIceCandidate, function(data) {
return new RTCIceCandidate(data);
});
var createSessionDescription = pluggable(plugin && plugin.createSessionDescription, function(data) {
return new RTCSessionDescription(data);
});
var qid = tq._qid = Math.floor(Math.random() * 100000);
function abortQueue(err) {
console.error(err);
}
function applyCandidate(task, next) {
var data = task.args[0];
// Allow selective filtering of ICE candidates
if (opts && opts.filterCandidate && !opts.filterCandidate(data)) {
tq('ice.remote.filtered', candidate);
return next();
}
var candidate = data && data.candidate && createIceCandidate(data);
function handleOk() {
tq('ice.remote.applied', candidate);
next();
}
function handleFail(err) {
tq('ice.remote.invalid', candidate);
next(err);
}
// we have a null candidate, we have finished gathering candidates
if (! candidate) {
return next();
}
pc.addIceCandidate(candidate, handleOk, handleFail);
}
function checkQueue() {
// peek at the next item on the queue
var next = (! queue.isEmpty()) && queue.peek();
var ready = next && testReady(next);
// reset the queue timer
checkQueueTimer = 0;
// if we don't have a task ready, then abort
if (! ready) {
// if we have a task and it has expired then dequeue it
if (next && (aborted(next) || expired(next))) {
tq('task.expire', next);
queue.deq();
}
return (! queue.isEmpty()) && isNotClosed(pc) && triggerQueueCheck();
}
// properly dequeue task
next = queue.deq();
// process the task
next.fn(next, function(err) {
var fail = next.fail || defaultFail;
var pass = next.pass;
var taskName = next.name;
// if errored, fail
if (err) {
console.error(taskName + ' task failed: ', err);
return fail(err);
}
if (typeof pass == 'function') {
pass.apply(next, [].slice.call(arguments, 1));
}
// Allow tasks to indicate that processing should continue immediately to the
// following task
if (next.immediate) {
if (checkQueueTimer) clearTimeout(checkQueueTimer);
return checkQueue();
} else {
triggerQueueCheck();
}
});
}
function cleansdp(desc) {
// ensure we have clean sdp
var sdpErrors = [];
var sdp = desc && sdpclean(desc.sdp, { collector: sdpErrors });
// if we don't have a match, log some info
if (desc && sdp !== desc.sdp) {
console.info('invalid lines removed from sdp: ', sdpErrors);
desc.sdp = sdp;
}
// if a filter has been specified, then apply the filter
if (typeof sdpFilter == 'function') {
desc.sdp = sdpFilter(desc.sdp, pc);
}
return desc;
}
function completeConnection() {
// Clean any cached media types now that we have potentially new remote description
if (pc.__mediaIDs || pc.__mediaTypes) {
// Set defined as opposed to delete, for compatibility purposes
pc.__mediaIDs = undefined;
pc.__mediaTypes = undefined;
}
if (VALID_RESPONSE_STATES.indexOf(pc.signalingState) >= 0) {
return tq.createAnswer();
}
}
function emitSdp() {
tq('sdp.local', pluckSessionDesc(this.args[0]));
}
function enqueue(name, handler, opts) {
return function() {
var args = [].slice.call(arguments);
if (opts && typeof opts.processArgs == 'function') {
args = args.map(opts.processArgs);
}
var priority = priorities.indexOf(name);
return new Promise(function(resolve, reject) {
queue.enq({
args: args,
name: name,
fn: handler,
priority: priority >= 0 ? priority : PRIORITY_LOW,
immediate: opts.immediate,
// If aborted, the task will be removed
aborted: false,
// record the time at which the task was queued
start: Date.now(),
// initilaise any checks that need to be done prior
// to the task executing
checks: [ isNotClosed ].concat((opts || {}).checks || []),
// initialise the pass and fail handlers
pass: function() {
if (opts && opts.pass) {
opts.pass.apply(this, arguments);
}
resolve();
},
fail: function() {
if (opts && opts.fail) {
opts.fail.apply(this, arguments);
}
reject();
}
});
triggerQueueCheck();
});
};
}
function execMethod(task, next) {
var fn = pc[task.name];
var eventName = METHOD_EVENTS[task.name] || (task.name || '').toLowerCase();
var cbArgs = [ success, fail ];
var isOffer = task.name === 'createOffer';
function fail(err) {
tq.apply(tq, [ 'negotiate.error', task.name, err ].concat(task.args));
next(err);
}
function success() {
tq.apply(tq, [ ['negotiate', eventName, 'ok'], task.name ].concat(task.args));
next.apply(null, [null].concat([].slice.call(arguments)));
}
if (! fn) {
return next(new Error('cannot call "' + task.name + '" on RTCPeerConnection'));
}
// invoke the function
tq.apply(tq, ['negotiate.' + eventName].concat(task.args));
fn.apply(
pc,
task.args.concat(cbArgs).concat(isOffer ? generateConstraints() : [])
);
}
function expired(task) {
return (typeof task.ttl == 'number') && (task.start + task.ttl < Date.now());
}
function aborted(task) {
return task && task.aborted;
}
function extractCandidateEventData(data) {
// extract nested candidate data (like we will see in an event being passed to this function)
while (data && data.candidate && data.candidate.candidate) {
data = data.candidate;
}
return data;
}
function generateConstraints() {
var allowedKeys = {
offertoreceivevideo: 'OfferToReceiveVideo',
offertoreceiveaudio: 'OfferToReceiveAudio',
icerestart: 'IceRestart',
voiceactivitydetection: 'VoiceActivityDetection'
};
var constraints = {
OfferToReceiveVideo: true,
OfferToReceiveAudio: true
};
// Handle mozillas slightly different constraint requirements that are
// enforced as of FF43
if (detect.moz) {
allowedKeys = {
offertoreceivevideo: 'offerToReceiveVideo',
offertoreceiveaudio: 'offerToReceiveAudio',
icerestart: 'iceRestart',
voiceactivitydetection: 'voiceActivityDetection'
};
constraints = {
offerToReceiveVideo: true,
offerToReceiveAudio: true
};
}
// update known keys to match
Object.keys(opts || {}).forEach(function(key) {
if (allowedKeys[key.toLowerCase()]) {
constraints[allowedKeys[key.toLowerCase()]] = opts[key];
}
});
return (detect.moz ? constraints : { mandatory: constraints });
}
function hasLocalOrRemoteDesc(pc, task) {
return pc.__hasDesc || (pc.__hasDesc = !!pc.remoteDescription);
}
function isNotNegotiating(pc) {
return pc.signalingState !== 'have-local-offer';
}
function isNotClosed(pc) {
return pc.signalingState !== 'closed';
}
function isStable(pc) {
return pc.signalingState === 'stable';
}
function isValidCandidate(pc, data) {
var validCandidate = (data.__valid ||
(data.__valid = checkCandidate(data.args[0]).length === 0));
// If the candidate is not valid, abort
if (!validCandidate) {
data.aborted = true;
}
return validCandidate;
}
function isConnReadyForCandidate(pc, data) {
var sdpMid = data.args[0] && data.args[0].sdpMid;
// remap media types as appropriate
sdpMid = MEDIA_MAPPINGS[sdpMid] || sdpMid;
if (sdpMid === '')
return true;
// Allow parsing of SDP always if required
if (alwaysParse || !pc.__mediaTypes) {
var sdp = parseSdp(pc.remoteDescription && pc.remoteDescription.sdp);
// We only want to cache the SDP media types if we've received them, otherwise
// bad things can happen
var mediaTypes = sdp.getMediaTypes();
if (mediaTypes && mediaTypes.length > 0) {
pc.__mediaTypes = mediaTypes;
}
// Same for media IDs
var mediaIDs = sdp.getMediaIDs();
if (mediaIDs && mediaIDs.length > 0) {
pc.__mediaIDs = mediaIDs;
}
}
// the candidate is valid if the sdpMid matches either a known media
// type, or media ID
var validMediaCandidate =
(pc.__mediaIDs && pc.__mediaIDs.indexOf(sdpMid) >= 0) ||
(pc.__mediaTypes && pc.__mediaTypes.indexOf(sdpMid) >= 0);
// Otherwise we abort the task
if (!validMediaCandidate) {
data.aborted = true;
}
return validMediaCandidate;
}
function orderTasks(a, b) {
// apply each of the checks for each task
var tasks = [a,b];
var readiness = tasks.map(testReady);
var taskPriorities = tasks.map(function(task, idx) {
var ready = readiness[idx];
return ready ? task.priority : PRIORITY_WAIT;
});
return taskPriorities[1] - taskPriorities[0];
}
// check whether a task is ready (does it pass all the checks)
function testReady(task) {
return (task.checks || []).reduce(function(memo, check) {
return memo && check(pc, task);
}, true);
}
function triggerQueueCheck() {
if (checkQueueTimer) return;
checkQueueTimer = setTimeout(checkQueue, queueInterval);
}
// patch in the queue helper methods
tq.addIceCandidate = enqueue('addIceCandidate', applyCandidate, {
processArgs: extractCandidateEventData,
checks: [hasLocalOrRemoteDesc, isValidCandidate, isConnReadyForCandidate ],
// set ttl to 5s
ttl: 5000,
immediate: true
});
tq.setLocalDescription = enqueue('setLocalDescription', execMethod, {
processArgs: cleansdp,
pass: emitSdp
});
tq.setRemoteDescription = enqueue('setRemoteDescription', execMethod, {
processArgs: createSessionDescription,
pass: completeConnection
});
tq.createOffer = enqueue('createOffer', execMethod, {
checks: [ isNotNegotiating ],
pass: tq.setLocalDescription
});
tq.createAnswer = enqueue('createAnswer', execMethod, {
pass: tq.setLocalDescription
});
return tq;
};