diff --git a/client.js b/client.js index 7f0d2ea..49eaad1 100644 --- a/client.js +++ b/client.js @@ -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) @@ -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) } /** diff --git a/lib/common-node.js b/lib/common-node.js new file mode 100644 index 0000000..12c45b2 --- /dev/null +++ b/lib/common-node.js @@ -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 +} diff --git a/lib/common.js b/lib/common.js index bde4424..a6f191a 100644 --- a/lib/common.js +++ b/lib/common.js @@ -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') } @@ -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) diff --git a/lib/http-tracker.js b/lib/http-tracker.js index 4210990..6095e77 100644 --- a/lib/http-tracker.js +++ b/lib/http-tracker.js @@ -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) diff --git a/lib/websocket-tracker.js b/lib/websocket-tracker.js index e69de29..6e9d30f 100644 --- a/lib/websocket-tracker.js +++ b/lib/websocket-tracker.js @@ -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) + } + } +} diff --git a/package.json b/package.json index 671e453..9e9b34b 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,15 @@ "email": "feross@feross.org", "url": "http://feross.org/" }, - "browser": { - "./server.js": false - }, "bin": { "bittorrent-tracker": "./bin/cmd.js" }, + "browser": { + "./lib/common-node": false, + "./lib/http-tracker": false, + "./lib/udp-tracker": false, + "./server": false + }, "bugs": { "url": "https://github.com/feross/bittorrent-tracker/issues" }, @@ -29,7 +32,10 @@ "once": "^1.3.0", "run-series": "^1.0.2", "simple-get": "^1.3.0", - "string2compact": "^1.1.1" + "simple-peer": "4.0.4", + "simple-websocket": "1.0.4", + "string2compact": "^1.1.1", + "xtend": "4.0.0" }, "devDependencies": { "magnet-uri": "^4.0.0",