UNPKG

@corwin.amber/webrtc-swarm

Version:

Create a swarm of p2p connections using webrtc and a signalhub

303 lines (257 loc) 8.68 kB
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) }) }