@corwin.amber/webrtc-swarm
Version:
Create a swarm of p2p connections using webrtc and a signalhub
303 lines (257 loc) • 8.68 kB
JavaScript
var SimplePeer = require('simple-peer')
var inherits = require('inherits')
var events = require('events')
var through = require('through2')
var cuid = require('cuid')
var once = require('once')
var debug = require('debug')('webrtc-swarm')
var debug_peers = debug.extend('peers')
var debug_heartbeat = debug.extend('heartbeat')
var either = (d1, d2) => d1.enabled ? d1 : d2,
debug_p = (...a) => either(debug_peers, debug)(...a)
module.exports = WebRTCSwarm
ANNOUNCE_INTERVAL = {lone: 3000, team: 13000, uncertainty: 2000}
GIVEUP_TIMEOUT = 8 * 1000
SUSPENSION_DURATION = 600 * 1000
function WebRTCSwarm (hub, opts) {
if (!(this instanceof WebRTCSwarm)) return new WebRTCSwarm(hub, opts)
if (!hub) throw new Error('SignalHub instance required')
if (!opts) opts = {}
events.EventEmitter.call(this)
this.setMaxListeners(0)
this.hub = hub
this.wrtc = opts.wrtc
this.channelConfig = opts.channelConfig
this.config = opts.config
this.stream = opts.stream
this.wrap = opts.wrap || function (data) { return data }
this.unwrap = opts.unwrap || function (data) { return data }
this.offerConstraints = opts.offerConstraints || {}
this.maxPeers = opts.maxPeers || Infinity
this.announceInterval = opts.announceInterval || ANNOUNCE_INTERVAL
this.giveupTimeout = opts.giveupTimeout || GIVEUP_TIMEOUT
this.suspensionDuration = opts.suspensionDuration || SUSPENSION_DURATION
this.me = opts.uuid || cuid()
debug('my uuid:', this.me)
this.remotes = {}
this.peers = []
this.closed = false
this.suspended = new Set
subscribe(this, hub)
}
inherits(WebRTCSwarm, events.EventEmitter)
WebRTCSwarm.WEBRTC_SUPPORT = SimplePeer.WEBRTC_SUPPORT
WebRTCSwarm.prototype.close = function (cb) {
if (this.closed) return
this.closed = true
if (cb) this.once('close', cb)
var closePeers = function () {
var len = self.peers.length
if (len > 0) {
var closed = 0
self.peers.forEach(function (peer) {
peer.once('close', function () {
if (++closed === len) {
self.emit('close')
}
})
process.nextTick(function () {
peer.destroy()
})
})
} else {
self.emit('close')
}
}
var self = this
if (this.hub.opened) {
this.hub.close(function () {
closePeers()
})
} else {
closePeers()
}
}
WebRTCSwarm.prototype.suspendPeer = function (id, duration) {
this.suspended.add(id);
duration = duration || this.suspensionDuration
var self = this
setTimeout(function () { self.suspended.delete(id) }, duration)
}
WebRTCSwarm.prototype.unsuspendPeer = function (id) {
this.suspended.delete(id)
}
WebRTCSwarm.prototype.beg = function () {
const rand = cuid().slice(-12)
this.suspended.clear()
announce(this, this.hub, {rand, prithee: true})
}
function setup(swarm, peer, id) {
peer._everConnected = false
peer._iceObtained = new Set
peer.on('connect', function () {
debug_p('connected to peer', id)
peer._everConnected = true
swarm.peers.push(peer)
swarm.emit('peer', peer, id)
swarm.emit('connect', peer, id)
})
var onclose = once(function (err) {
debug_p('disconnected from peer', id, err)
if (swarm.remotes[id] === peer) delete swarm.remotes[id]
var i = swarm.peers.indexOf(peer)
if (i > -1) swarm.peers.splice(i, 1)
// Suspend peer if never connected and did not send proper ICE candidate(s)
if (!peer._everConnected && ![...peer._iceObtained].some(ty => ty != 'host')) {
debug_p('peer did not send any srflx/relay candidates and is put on hiatus', id)
swarm.suspendPeer(id)
}
swarm.emit('disconnect', peer, id)
if (!swarm.closed)
unannounce(swarm, swarm.hub, id);
})
var signals = []
var sending = false
function kick () {
if (swarm.closed || sending || !signals.length) return
sending = true
var data = {from: swarm.me, signal: signals.shift()}
data = swarm.wrap(data, id)
debug('sending signal', data.signal);
swarm.hub.broadcast(id, data, function () {
sending = false
kick()
})
}
peer.on('signal', function (sig) {
signals.push(sig)
kick()
})
peer.on('iceStateChange', (connectState, gatherState) => {
if (connectState === 'failed') onclose('ice connection failed')
})
peer.on('error', onclose)
peer.once('close', onclose)
setTimeout(() => {
if (swarm.remotes[id] === peer && !peer.connected) {
debug('still not connected, giving up', id);
onclose('give-up timeout');
process.nextTick(() => peer.destroy('give-up timeout'))
}
}, swarm.giveupTimeout);
}
function subscribe (swarm, hub) {
const rand = cuid().slice(-12);
hub.subscribe('all').pipe(through.obj(function (data, enc, cb) {
data = swarm.unwrap(data, 'all')
if (swarm.closed || !data) return cb()
debug_heartbeat('/all', data)
if (data.from === swarm.me) {
debug_heartbeat('skipping self', data.from)
return cb()
}
if (data.type === 'connect') {
if (swarm.peers.length >= swarm.maxPeers) {
debug_heartbeat('skipping because maxPeers is met', data.from)
return cb()
}
if (swarm.remotes[data.from]) {
debug_heartbeat('skipping existing remote', data.from)
return cb()
}
if (data.prithee) {
swarm.unsuspendPeer(data.from)
}
else if (swarm.suspended.has(data.from)) {
debug_heartbeat('skipping suspended peer (connect)', data.from)
return cb()
}
if (rand < data.rand) {
// the other end should be the initiator. nudge it.
debug_heartbeat('rolling initiative to peer', data.from)
announce(swarm, hub, {rand});
return cb()
}
debug_p('connecting to new peer (as initiator)', data.from)
var peer = new SimplePeer({
wrtc: swarm.wrtc,
initiator: true,
channelConfig: swarm.channelConfig,
config: swarm.config,
stream: swarm.stream,
offerConstraints: swarm.offerConstraints
})
setup(swarm, peer, data.from)
swarm.remotes[data.from] = peer
}
cb()
}))
hub.subscribe(swarm.me).once('open', connect.bind(null, swarm, hub, rand)).pipe(through.obj(function (data, enc, cb) {
data = swarm.unwrap(data, swarm.me)
if (swarm.closed || !data) return cb()
debug('/me', data)
if (data.type === 'disconnect') {
var id = data.from, peer = swarm.remotes[id];
if (peer) {
delete swarm.remotes[id]
var i = swarm.peers.indexOf(peer)
if (i > -1) swarm.peers.splice(i, 1)
if (!peer.destroyed) peer.destroy()
swarm.emit('disconnect', peer, id)
}
return cb()
}
var peer = swarm.remotes[data.from]
if (!peer) {
if (!data.signal || data.signal.type !== 'offer') {
debug('skipping non-offer', data)
return cb()
}
if (swarm.suspended.has(data.from)) {
debug_heartbeat('skipping suspended peer (offer)', data.from)
return cb()
}
debug_p('connecting to new peer (not initiator)', data.from)
peer = swarm.remotes[data.from] = new SimplePeer({
wrtc: swarm.wrtc,
channelConfig: swarm.channelConfig,
config: swarm.config,
stream: swarm.stream,
offerConstraints: swarm.offerConstraints
})
setup(swarm, peer, data.from)
}
if (data.signal) {
if (data.signal.candidate) {
var csdp = data.signal.candidate.candidate;
if (csdp === "") return cb(); // Firefox hack; https://github.com/feross/simple-peer/issues/503, https://github.com/webrtcHacks/adapter/issues/863
// keep track of which candidates were obtained
var mo = csdp && csdp.match(/\btyp (\w+)\b/)
if (mo)
peer._iceObtained.add(mo[1])
}
debug('received signal', data.from, data.signal)
peer.signal(data.signal)
}
cb()
}))
}
function announce(swarm, hub, props, cb) {
var data = {type: 'connect', from: swarm.me, ...props}
data = swarm.wrap(data, 'all')
hub.broadcast('all', data, cb)
}
function unannounce(swarm, hub, to) {
var data = {type: 'disconnect', from: swarm.me}
data = swarm.wrap(data, to)
hub.broadcast(to, data)
}
function connect (swarm, hub, rand) {
if (swarm.closed || swarm.peers.length >= swarm.maxPeers) return
announce(swarm, hub, {rand}, function () {
var iv = swarm.announceInterval,
baseWait = iv[swarm.peers.length ? 'lone' : 'team'],
perturb = Math.floor(Math.random() * iv.uncertainty)
setTimeout(connect.bind(null, swarm, hub, rand), baseWait + perturb)
})
}