Skip to content

Commit

Permalink
add support for webtorrent (websocket) trackers
Browse files Browse the repository at this point in the history
  • Loading branch information
feross committed Mar 24, 2015
1 parent d759456 commit 1ff5769
Show file tree
Hide file tree
Showing 6 changed files with 283 additions and 73 deletions.
18 changes: 9 additions & 9 deletions client.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ var once = require('once')
var url = require('url')

var common = require('./lib/common')
var HTTPTracker = require('./lib/http-tracker')
var UDPTracker = require('./lib/udp-tracker')
var HTTPTracker = require('./lib/http-tracker') // empty object in browser
var UDPTracker = require('./lib/udp-tracker') // empty object in browser
var WebSocketTracker = require('./lib/websocket-tracker')

inherits(Client, EventEmitter)
Expand Down Expand Up @@ -47,24 +47,24 @@ function Client (peerId, port, torrent, opts) {
debug('new client %s', self._infoHash.toString('hex'))

if (typeof torrent.announce === 'string') torrent.announce = [ torrent.announce ]
if (torrent.announce == null) torrent.announce = []

self._trackers = (torrent.announce || [])
.filter(function (announceUrl) {
var protocol = url.parse(announceUrl).protocol
return [ 'udp:', 'http:', 'https:', 'ws:', 'wss:' ].indexOf(protocol) !== -1
})
self._trackers = torrent.announce
.map(function (announceUrl) {
var trackerOpts = { interval: self._intervalMs }
var protocol = url.parse(announceUrl).protocol

if (protocol === 'http:' || protocol === 'https:') {
if ((protocol === 'http:' || protocol === 'https:') &&
typeof HTTPTracker === 'function') {
return new HTTPTracker(self, announceUrl, trackerOpts)
} else if (protocol === 'udp:') {
} else if (protocol === 'udp:' && typeof UDPTracker === 'function') {
return new UDPTracker(self, announceUrl, trackerOpts)
} else if (protocol === 'ws:' || protocol === 'wss:') {
return new WebSocketTracker(self, announceUrl, trackerOpts)
}
return null
})
.filter(Boolean)
}

/**
Expand Down
64 changes: 64 additions & 0 deletions lib/common-node.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Functions/constants needed by both the client and server (but only in node).
* These are separate from common.js so they can be skipped when bundling for the browser.
*/

var querystring = require('querystring')

exports.IPV4_RE = /^[\d\.]+$/
exports.IPV6_RE = /^[\da-fA-F:]+$/

exports.CONNECTION_ID = Buffer.concat([ toUInt32(0x417), toUInt32(0x27101980) ])
exports.ACTIONS = { CONNECT: 0, ANNOUNCE: 1, SCRAPE: 2, ERROR: 3 }
exports.EVENTS = { update: 0, completed: 1, started: 2, stopped: 3 }
exports.EVENT_IDS = {
0: 'update',
1: 'completed',
2: 'started',
3: 'stopped'
}
exports.EVENT_NAMES = {
update: 'update',
completed: 'complete',
started: 'start',
stopped: 'stop'
}

function toUInt32 (n) {
var buf = new Buffer(4)
buf.writeUInt32BE(n, 0)
return buf
}
exports.toUInt32 = toUInt32

/**
* `querystring.parse` using `unescape` instead of decodeURIComponent, since bittorrent
* clients send non-UTF8 querystrings
* @param {string} q
* @return {Object}
*/
exports.querystringParse = function (q) {
var saved = querystring.unescape
querystring.unescape = unescape // global
var ret = querystring.parse(q)
querystring.unescape = saved
return ret
}

/**
* `querystring.stringify` using `escape` instead of encodeURIComponent, since bittorrent
* clients send non-UTF8 querystrings
* @param {Object} obj
* @return {string}
*/
exports.querystringStringify = function (obj) {
var saved = querystring.escape
querystring.escape = escape // global
var ret = querystring.stringify(obj)
ret = ret.replace(/[\@\*\/\+]/g, function (char) {
// `escape` doesn't encode the characters @*/+ so we do it manually
return '%' + char.charCodeAt(0).toString(16).toUpperCase()
})
querystring.escape = saved
return ret
}
61 changes: 3 additions & 58 deletions lib/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,13 @@
* Functions/constants needed by both the client and server.
*/

var querystring = require('querystring')

exports.IPV4_RE = /^[\d\.]+$/
exports.IPV6_RE = /^[\da-fA-F:]+$/
var extend = require('xtend/mutable')

exports.DEFAULT_ANNOUNCE_INTERVAL = 30 * 60 * 1000 // 30 minutes

exports.DEFAULT_ANNOUNCE_PEERS = 50
exports.MAX_ANNOUNCE_PEERS = 82

exports.CONNECTION_ID = Buffer.concat([ toUInt32(0x417), toUInt32(0x27101980) ])
exports.ACTIONS = { CONNECT: 0, ANNOUNCE: 1, SCRAPE: 2, ERROR: 3 }
exports.EVENTS = { update: 0, completed: 1, started: 2, stopped: 3 }
exports.EVENT_IDS = {
0: 'update',
1: 'completed',
2: 'started',
3: 'stopped'
}
exports.EVENT_NAMES = {
update: 'update',
completed: 'complete',
started: 'start',
stopped: 'stop'
}

function toUInt32 (n) {
var buf = new Buffer(4)
buf.writeUInt32BE(n, 0)
return buf
}
exports.toUInt32 = toUInt32

exports.binaryToHex = function (str) {
return new Buffer(str, 'binary').toString('hex')
}
Expand All @@ -43,34 +17,5 @@ exports.hexToBinary = function (str) {
return new Buffer(str, 'hex').toString('binary')
}

/**
* `querystring.parse` using `unescape` instead of decodeURIComponent, since bittorrent
* clients send non-UTF8 querystrings
* @param {string} q
* @return {Object}
*/
exports.querystringParse = function (q) {
var saved = querystring.unescape
querystring.unescape = unescape // global
var ret = querystring.parse(q)
querystring.unescape = saved
return ret
}

/**
* `querystring.stringify` using `escape` instead of encodeURIComponent, since bittorrent
* clients send non-UTF8 querystrings
* @param {Object} obj
* @return {string}
*/
exports.querystringStringify = function (obj) {
var saved = querystring.escape
querystring.escape = escape // global
var ret = querystring.stringify(obj)
ret = ret.replace(/[\@\*\/\+]/g, function (char) {
// `escape` doesn't encode the characters @*/+ so we do it manually
return '%' + char.charCodeAt(0).toString(16).toUpperCase()
})
querystring.escape = saved
return ret
}
var config = require('./common-node')
extend(exports, config)
10 changes: 8 additions & 2 deletions lib/http-tracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,14 @@ HTTPTracker.prototype._request = function (requestUrl, opts, cb) {

get.concat(u, function (err, data, res) {
if (err) return self.client.emit('warning', err)
if (res.statusCode !== 200) return self.client.emit('warning', new Error('Non-200 response code ' + res.statusCode + ' from ' + self.a))
if (!data || data.length === 0) return
if (res.statusCode !== 200) {
return self.client.emit('warning', new Error('Non-200 response code ' +
res.statusCode + ' from ' + self._announceUrl))
}
if (!data || data.length === 0) {
return self.client.emit('warning', new Error('Invalid tracker response from' +
self._announceUrl))
}

try {
data = bencode.decode(data)
Expand Down
189 changes: 189 additions & 0 deletions lib/websocket-tracker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// TODO: destroy the websocket

module.exports = WebSocketTracker

var debug = require('debug')('bittorrent-tracker:http-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')

// It turns out that you can't open multiple websockets to the same server within one
// browser tab, so let's reuse them.
var socketPool = {}

inherits(WebSocketTracker, EventEmitter)

function WebSocketTracker (client, announceUrl, opts) {
var self = this
EventEmitter.call(self)
debug('new websocket tracker %s', announceUrl)

self.client = client

self._announceUrl = announceUrl
self._peers = {} // peers (offer id -> peer)
self._ready = false
self._socket = null
self._intervalMs = self.client._intervalMs // use client interval initially
self._interval = null

if (socketPool[announceUrl]) self._socket = socketPool[announceUrl]
else self._socket = socketPool[announceUrl] = new Socket(announceUrl)

self._socket.on('warning', self._onSocketWarning.bind(self))
self._socket.on('error', self._onSocketWarning.bind(self)) // TODO: handle error
self._socket.on('message', self._onSocketMessage.bind(self))
}

WebSocketTracker.prototype.announce = function (opts) {
var self = this
if (!self._socket.ready) return self._socket.on('ready', self.announce.bind(self, opts))

opts.info_hash = self.client._infoHash.toString('binary')
opts.peer_id = self.client._peerId.toString('binary')

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.client.emit('error', new Error('scrape not supported ' + self._announceUrl))
return
}

// 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._onSocketWarning = function (err) {
debug('tracker warning %s', err.message)
}

WebSocketTracker.prototype._onSocketMessage = function (data) {
var self = this

if (!(typeof data === 'object' && data !== null)) {
return self.client.emit('warning', new Error('Invalid tracker response'))
}

if (data.info_hash !== self.client._infoHash.toString('binary')) return

debug('received %s from %s', JSON.stringify(data), self._announceUrl)

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) {
peer = new Peer({ trickle: false, config: self._opts.rtcConfig })
peer.id = common.binaryToHex(data.peer_id)
peer.once('signal', function (answer) {
var opts = {
info_hash: self.client._infoHash.toString('binary'),
peer_id: self.client._peerId.toString('binary'),
to_peer_id: data.peer_id,
answer: answer,
offer_id: data.offer_id
}
if (self._trackerId) opts.trackerid = self._trackerId
self._send(opts)
})
peer.signal(data.offer)
self.client.emit('peer', peer)
}

if (data.answer) {
peer = self._peers[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._send = function (opts) {
var self = this
debug('send %s', JSON.stringify(opts))
self._socket.send(opts)
}

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._opts.rtcConfig
})
peer.once('signal', function (offer) {
offers.push({
offer: offer,
offer_id: offerId
})
checkDone()
})
}

function checkDone () {
if (offers.length === numWant) {
debug('generated %s offers', numWant)
cb(offers)
}
}
}
Loading

0 comments on commit 1ff5769

Please sign in to comment.