webp2p
Version:
The server-less signaling channel for WebRTC
605 lines (469 loc) • 14.4 kB
JavaScript
var EventEmitter = require("events").EventEmitter;
var inherits = require('inherits');
var freeice = require('freeice');
var uuid = require('uuid');
var wrtc = require('wrtc');
var RTCPeerConnection = wrtc.RTCPeerConnection;
var RTCSessionDescription = wrtc.RTCSessionDescription;
require("process-events-shim");
var RpcBuilder = require('rpc-builder');
var HandshakeManager = require('./managers/HandshakeManager');
var PeersManager = require('./managers/PeersManager');
var packer = require('./packer');
const MAX_TTL_DEFAULT = 5;
function noop(error)
{
if(error) console.error(error);
};
/**
* @classdesc Init and connect to the WebP2P.io network
*
* @constructor
*/
function WebP2P(options)
{
var self = this;
options = options || {};
// Internal options
var commonLabels = options.commonLabels || [];
var handshake_servers = options.handshake_servers;
var iceServers = freeice(options.iceServers);
// Read-only options
Object.defineProperty(this, "routingLabel",
{
value: options.routingLabel || "webp2p"
});
Object.defineProperty(this, "sessionID",
{
value: options.sessionID || uuid.v4()
});
var options =
{
max_retries: 3,
peerID: this.sessionID
};
var rpcBuilder = new RpcBuilder(packer, options);
var handshakeManager = new HandshakeManager(rpcBuilder, handshake_servers);
var peersManager = new PeersManager(rpcBuilder, self.routingLabel);
this.__defineGetter__('status', function()
{
if(peersManager.status == 'connected')
return 'connected';
return handshakeManager.status;
});
this.__defineGetter__("peers", function()
{
return peersManager.peers;
});
function onerror(error)
{
self.emit('error', new Error(error));
};
var offers = {};
/**
* Create a new RTCPeerConnection
* @param {UUID} sessionID Identifier of the other peer so later can be accessed.
*
* @return {RTCPeerConnection}
*/
function createPeerConnection(sessionID, callbackType, callback)
{
var pc = new RTCPeerConnection
(
{iceServers: iceServers},
{optional: [{DtlsSrtpKeyAgreement: true}]}
);
pc.addEventListener('icecandidate', function(event)
{
// There's a candidate, ignore it
if(event.candidate) return;
// There's no candidate, send the full SDP
var type = this.localDescription.type;
var sdp = this.localDescription.sdp;
if(type == callbackType)
callback(sdp)
else
console.error(type+" SDP type is not equal to "+callbackType+" callback type");
});
pc.addEventListener('signalingstatechange', function(event)
{
// Add PeerConnection object to available ones when gets open
if(pc.signalingState == 'stable')
{
var channels = pc._channels || [];
peersManager.add(sessionID, pc, channels);
var offer = offers[sessionID];
if(offer)
{
delete offers[sessionID];
offer.callback(null, pc, channels);
};
self.emit('peerconnection', pc, channels);
};
});
// Set ID of the PeerConnection
Object.defineProperty(pc, "sessionID", {value : sessionID});
return pc;
};
//
// Connection methods
//
/**
* Callback to send the offer. If not defined send it to all connected peers.
*
* @callback WebP2P~ConnectToCallback
* @param {Error} error
* @param {RTCPeerConnection} peer - The (newly created) peer
*/
/**
* Connects to another peer based on its UID. If we are already connected,
* it does nothing.
*
* @param {UUID} sessionID - Identifier of the other peer to be connected.
* @param {string[]} [labels] - Per-connection labels
* @param {WebP2P~ConnectToCallback} callback
*/
this.connectTo = function(dest, labels, callback)
{
if(labels instanceof Function)
{
if(callback)
throw new SyntaxError("Nothing can be defined after the callback");
callback = labels;
labels = undefined;
};
// Don't connect to ourself
if(dest == self.sessionID)
return callback(new Error("Connecting to ourself"));
// Check if we are already connected to the requested peer
var pc = peersManager.get(dest);
if(pc)
{
// We are already connected to that peer, ignore the request
var message = "["+self.sessionID+"] Already connected to "+dest;
console.log(message);
// [ToDo] AddChannels(pc, labels);
var channels = pc._channels || [];
callback(null, pc, channels);
return;
};
// Check if we are already trying to connect to that peer
var offer = offers[dest];
if(offer)
{
// We are already trying to connect to that peer, ignore the request
var message = "["+self.sessionID+"] Already requested connection to "+dest;
console.log(message);
var pc = offer.peerConnection;
// [ToDo] AddChannels(pc, labels);
pc.addEventListener('signalingstatechange', function(event)
{
if(pc.signalingState == 'stable')
{
var channels = pc._channels || [];
callback(null, pc, channels);
}
});
return;
};
// Request to connect to a new peer, generate the offer
generateOffer(dest, [handshakeManager, peersManager], labels, callback);
};
//
// Managers
//
function forward(message, connector)
{
var from = message.from;
var dest = message.dest;
// Message was for us, raise error
if(dest == self.sessionID)
return console.error('Ignored message send to us', message);
// Don't forward the message if TTL has been achieved
if(--message.ttl <= 0)
return console.warn("TTL achieved:",message);
var dest = message.dest;
message = message.pack();
// Search the peer between the ones currently connected so we can send the
// message directly to it
for(var i=0, peer; peer=peersManager._connectors[i]; i++)
if(peer.sessionID == dest)
return peer.send(message);
// Peer was not found, forward message to all the connectors except the one
// where we received it
peersManager.send(message, connector);
handshakeManager.send(message, connector);
};
handshakeManager.on('connected', function()
{
self.emit('handshakeManager.connected', handshakeManager);
});
handshakeManager.on('disconnected', function()
{
self.emit('handshakeManager.disconnected', handshakeManager);
});
peersManager.on('connected', function()
{
self.emit('peersManager.connected', peersManager);
});
peersManager.on('disconnected', function()
{
self.emit('peersManager.disconnected', peersManager);
});
/**
* Check if it's a request from a peer we are already connected or trying to
* connect to, so we can prevent to do crossed connections
*
* @param from - ID of the other peer
*
* @returns {Boolean} - If we are already connected or trying to connect
*/
function connectionProcessed(from)
{
// Check if we are already connected to the requester peer
var peer = peersManager.get(from);
if(peer)
{
// [ToDo] AddChannels(peer, labels);
// We are already connected to that peer, send an error to the other end
var message = "["+self.sessionID+"] Already connected to "+from;
console.log(message);
var error =
{
code: -1,
message: message,
ttl: MAX_TTL_DEFAULT
};
return error;
};
// Check if we are already trying to connect to that peer
var offer = offers[from];
if(offer)
{
// [ToDo] AddChannels(offer.peerConnection, labels);
// We are already trying to connect to that peer, ignore the request
var message = "["+self.sessionID+"] Already requested connection to "+from;
console.log(message);
// We have higher precedence, send an error to the other end
if(self.sessionID < from)
{
console.log("["+self.sessionID+"] Higher precedence than "+from+", sending error");
var error =
{
code: -2,
message: message,
ttl: MAX_TTL_DEFAULT
};
return error;
}
// We have less precedence, cancel our connection request and prepare to use the
// incoming one with the previous callback
delete offers[from];
rpcBuilder.cancel(offer.message);
offer.peerConnection.close();
console.debug("["+self.sessionID+"] Lower precedence than "+from+", request canceled");
return offer.callback;
};
// Connection request is a genuinely new one, process it as usual
};
//
// Protocol messages
//
function onpresence(from, connector)
{
// Check if we are already connected to the new peer
var peer = peersManager.get(from);
if(peer) return;
// Check if we are already trying to connect to the new peer
var offer = offers[from];
if(offer) return;
var pc = generateOffer(from, connector);
pc.addEventListener('signalingstatechange', function(event)
{
// If PeerConnection object gets open, increase number of connections
// fetch over this connector so it can decide if it could close
if(pc.signalingState == 'stable')
connector.increaseConnections();
});
};
function onoffer(request, connector)
{
var from = request.from;
var dest = request.dest;
// Message is for us
if(dest == self.sessionID)
{
console.log("["+dest+"] Received connection request from "+from);
// If connection is already processed, send an error to the other end
var processed = connectionProcessed(from);
if(processed && processed.code)
return request.reply(processed);
// Search the peer between the list of currently connected ones,
// or create it if it's not connected
var pc = createPeerConnection(from, "answer", function(answer)
{
var response =
{
sdp: answer,
ttl: MAX_TTL_DEFAULT
};
// Send back the connection request over the same connector, since
// this should be the shortest path to connect both peers
request.reply(null, response);
});
// Process offer & generate answer
pc.setRemoteDescription(new RTCSessionDescription(
{
sdp: request.sdp,
type: 'offer'
}),
function()
{
pc.createAnswer(function(answer)
{
pc.setLocalDescription(answer, null, onerror);
},
onerror)
},
onerror);
if(processed)
pc.addEventListener('signalingstatechange', function(event)
{
if(pc.signalingState == 'stable')
{
var channels = pc._channels || [];
processed(null, pc, channels);
}
});
pc.addEventListener('datachannel', function(event)
{
var channel = event.channel;
var channels = pc._channels || [];
channels.push(channel);
pc._channels = channels;
});
}
// Forward message
else
forward(request, connector);
};
function generateOffer(dest, connector, labels, callback)
{
var pc = createPeerConnection(dest, "offer", function(offer)
{
var message =
{
sdp: offer,
ttl: callback ? MAX_TTL_DEFAULT : 1
};
callback = callback || noop;
var request = rpcBuilder.encode('offer', message, dest,
function(error, result)
{
if(error) return callback(error);
processAnswer(result);
});
offers[dest] =
{
callback: callback,
message: request,
peerConnection: pc
};
if(connector instanceof Array)
connector.forEach(function(c)
{
c.send(request);
});
// Send back the connection request over the same connector, since this
// should be the shortest path to connect both peers
else
connector.send(request);
});
//
// Init PeerConnection
//
// Set channels for this PeerConnection object
labels = [self.routingLabel].concat(commonLabels, labels);
var channels = pc._channels = [];
for(var i=0, label; label=labels[i]; i++)
{
var channel = pc.createDataChannel(label);
channels.push(channel);
channel.addEventListener('close', function(event)
{
channels.splice(channels.indexOf(channel), 1);
});
};
// Send offer
var mediaConstraints =
{
mandatory:
{
OfferToReceiveAudio: false,
OfferToReceiveVideo: false
}
};
pc.createOffer(function(offer)
{
pc.setLocalDescription(offer, null, onerror);
},
onerror,
mediaConstraints);
return pc;
};
function processAnswer(response)
{
var from = response.from;
console.debug('['+self.sessionID+'] Received answer from '+from);
var offer = offers[from];
if(offer)
offer.peerConnection.setRemoteDescription(new RTCSessionDescription(
{
sdp: response.sdp,
type: 'answer'
}),
null, onerror);
else
console.warn("["+self.sessionID+"] Connection with peer '" + from + "' was not previously requested");
};
//
// Manager events
//
function initManagerEvents(manager)
{
manager.on('connected', function()
{
if(handshakeManager.status != peersManager.status)
self.emit('connected');
});
manager.on('disconnected', function()
{
if(handshakeManager.status == peersManager.status)
self.emit('disconnected');
});
// manager.on('error', onerror);
manager.on('offer', onoffer);
manager.on('answer', forward);
manager.on('error', forward);
};
// Init managers events
initManagerEvents(handshakeManager);
initManagerEvents(peersManager);
handshakeManager.on('presence', onpresence);
//
// Clossing functions
//
this.close = function()
{
handshakeManager.close();
peersManager.close();
rpcBuilder.close();
};
// Close all connections when user goes out of the page or app is clossed
process.on('exit', function(code)
{
self.close();
});
};
inherits(WebP2P, EventEmitter);
module.exports = WebP2P;