bittorrent-tracker/lib/websocket-tracker.js
Feross Aboukhadijeh ac2c3d06d8 client: Re-use WebSocket connections
Use a socket pool, so tracker clients share WebSocket objects for the
same server.

In practice, WebSockets are pretty slow to establish, so this gives a
nice performance boost, and saves browser resources.
2015-05-27 13:09:15 +02:00

280 lines
7.7 KiB
JavaScript

// TODO: cleanup unused Peers when tracker doesn't respond with matches
module.exports = WebSocketTracker
var debug = require('debug')('bittorrent-tracker:websocket-tracker')
var EventEmitter = require('events').EventEmitter
var hat = require('hat')
var inherits = require('inherits')
var Peer = require('simple-peer')
var Socket = require('simple-websocket')
var common = require('./common')
// Use a socket pool, so tracker clients share WebSocket objects for the same server.
// In practice, WebSockets are pretty slow to establish, so this gives a nice performance
// boost, and saves browser resources.
var socketPool = {}
var RECONNECT_VARIANCE = 30 * 1000
var RECONNECT_MINIMUM = 5 * 1000
inherits(WebSocketTracker, EventEmitter)
function WebSocketTracker (client, announceUrl, opts) {
var self = this
EventEmitter.call(self)
debug('new websocket tracker %s', announceUrl)
self.client = client
self.destroyed = false
self._opts = opts
if (announceUrl[announceUrl.length - 1] === '/') {
announceUrl = announceUrl.substring(0, announceUrl.length - 1)
}
self._announceUrl = announceUrl
self._peers = {} // peers (offer id -> peer)
self._socket = null
self._intervalMs = self.client._intervalMs // use client interval initially
self._interval = null
self._openSocket()
}
WebSocketTracker.prototype.announce = function (opts) {
var self = this
if (!self._socket.connected) {
return self._socket.once('connect', self.announce.bind(self, opts))
}
opts.info_hash = self.client._infoHashBinary
opts.peer_id = self.client._peerIdBinary
// Limit number of offers (temporarily)
// TODO: remove this when we cleanup old RTCPeerConnections cleanly
if (opts.numWant > 10) opts.numWant = 10
self._generateOffers(opts.numWant, function (offers) {
opts.offers = offers
if (self._trackerId) {
opts.trackerid = self._trackerId
}
self._send(opts)
})
}
WebSocketTracker.prototype.scrape = function (opts) {
var self = this
self._onSocketError(new Error('scrape not supported ' + self._announceUrl))
}
// TODO: Improve this interface
WebSocketTracker.prototype.setInterval = function (intervalMs) {
var self = this
clearInterval(self._interval)
self._intervalMs = intervalMs
if (intervalMs) {
// HACK
var update = self.announce.bind(self, self.client._defaultAnnounceOpts())
self._interval = setInterval(update, self._intervalMs)
}
}
WebSocketTracker.prototype.destroy = function () {
var self = this
if (self.destroyed) return
self.destroyed = true
self._socket.removeListener('data', self._onSocketDataBound)
self._socket.removeListener('close', self._onSocketCloseBound)
self._socket.removeListener('error', self._onSocketErrorBound)
self._onSocketErrorBound = null
self._onSocketDataBound = null
self._onSocketCloseBound = null
self._socket.on('error', noop) // ignore all future errors
try { self._socket.close() } catch (e) {}
self._socket = null
}
WebSocketTracker.prototype._openSocket = function () {
var self = this
self._onSocketErrorBound = self._onSocketError.bind(self)
self._onSocketDataBound = self._onSocketData.bind(self)
self._onSocketCloseBound = self._onSocketClose.bind(self)
self._socket = socketPool[self._announceUrl]
if (!self._socket) {
self._socket = socketPool[self._announceUrl] = new Socket(self._announceUrl)
}
self._socket.on('data', self._onSocketDataBound)
self._socket.on('close', self._onSocketCloseBound)
self._socket.on('error', self._onSocketErrorBound)
}
WebSocketTracker.prototype._onSocketData = function (data) {
var self = this
if (self.destroyed) return
if (!(typeof data === 'object' && data !== null)) {
return self.client.emit('warning', new Error('Invalid tracker response'))
}
if (data.info_hash !== self.client._infoHashBinary) {
debug(
'ignoring websocket data from %s for %s (looking for %s: reused socket)',
self._announceUrl, common.binaryToHex(data.info_hash), self.client._infoHashHex
)
return
}
if (data.peer_id && data.peer_id === self.client._peerIdBinary) {
// ignore offers/answers from this client
return
}
debug(
'received %s from %s for %s',
JSON.stringify(data), self._announceUrl, self.client._infoHashHex
)
var failure = data['failure reason']
if (failure) return self.client.emit('warning', new Error(failure))
var warning = data['warning message']
if (warning) self.client.emit('warning', new Error(warning))
var interval = data.interval || data['min interval']
if (interval && !self._opts.interval && self._intervalMs !== 0) {
// use the interval the tracker recommends, UNLESS the user manually specifies an
// interval they want to use
self.setInterval(interval * 1000)
}
var trackerId = data['tracker id']
if (trackerId) {
// If absent, do not discard previous trackerId value
self._trackerId = trackerId
}
if (data.complete) {
self.client.emit('update', {
announce: self._announceUrl,
complete: data.complete,
incomplete: data.incomplete
})
}
var peer
if (data.offer && data.peer_id) {
peer = new Peer({
trickle: false,
config: self.client._rtcConfig,
wrtc: self.client._wrtc
})
peer.id = common.binaryToHex(data.peer_id)
peer.once('signal', function (answer) {
var params = {
info_hash: self.client._infoHashBinary,
peer_id: self.client._peerIdBinary,
to_peer_id: data.peer_id,
answer: answer,
offer_id: data.offer_id
}
if (self._trackerId) params.trackerid = self._trackerId
self._send(params)
})
peer.signal(data.offer)
self.client.emit('peer', peer)
}
if (data.answer && data.peer_id) {
peer = self._peers[common.binaryToHex(data.offer_id)]
if (peer) {
peer.id = common.binaryToHex(data.peer_id)
peer.signal(data.answer)
self.client.emit('peer', peer)
} else {
debug('got unexpected answer: ' + JSON.stringify(data.answer))
}
}
}
WebSocketTracker.prototype._onSocketClose = function () {
var self = this
if (self.destroyed) return
self.destroy()
self._startReconnectTimer()
}
WebSocketTracker.prototype._onSocketError = function (err) {
var self = this
if (self.destroyed) return
self.destroy()
// errors will often happen if a tracker is offline, so don't treat it as fatal
self.client.emit('warning', err)
self._startReconnectTimer()
}
WebSocketTracker.prototype._startReconnectTimer = function () {
var self = this
var ms = Math.floor(Math.random() * RECONNECT_VARIANCE) + RECONNECT_MINIMUM
setTimeout(function () {
self.destroyed = false
self._openSocket()
}, ms)
debug('reconnecting socket in %s ms', ms)
}
WebSocketTracker.prototype._send = function (params) {
var self = this
if (self.destroyed) return
var message = JSON.stringify(params)
debug('send %s', message)
self._socket.send(message)
}
WebSocketTracker.prototype._generateOffers = function (numWant, cb) {
var self = this
var offers = []
debug('generating %s offers', numWant)
// TODO: cleanup dead peers and peers that never get a return offer, from self._peers
for (var i = 0; i < numWant; ++i) {
generateOffer()
}
function generateOffer () {
var offerId = hat(160)
var peer = self._peers[offerId] = new Peer({
initiator: true,
trickle: false,
config: self.client._rtcConfig,
wrtc: self.client._wrtc
})
peer.once('signal', function (offer) {
offers.push({
offer: offer,
offer_id: common.hexToBinary(offerId)
})
checkDone()
})
}
function checkDone () {
if (offers.length === numWant) {
debug('generated %s offers', numWant)
cb(offers)
}
}
}
function noop () {}