const debug = require('debug')('bittorrent-tracker:client') const EventEmitter = require('events') const once = require('once') const parallel = require('run-parallel') const Peer = require('simple-peer') const queueMicrotask = require('queue-microtask') const common = require('./lib/common') const HTTPTracker = require('./lib/client/http-tracker') // empty object in browser const UDPTracker = require('./lib/client/udp-tracker') // empty object in browser const WebSocketTracker = require('./lib/client/websocket-tracker') /** * BitTorrent tracker client. * * Find torrent peers, to help a torrent client participate in a torrent swarm. * * @param {Object} opts options object * @param {string|Buffer} opts.infoHash torrent info hash * @param {string|Buffer} opts.peerId peer id * @param {string|Array.} opts.announce announce * @param {number} opts.port torrent client listening port * @param {function} opts.getAnnounceOpts callback to provide data to tracker * @param {number} opts.rtcConfig RTCPeerConnection configuration object * @param {number} opts.userAgent User-Agent header for http requests * @param {number} opts.wrtc custom webrtc impl (useful in node.js) */ class Client extends EventEmitter { constructor (opts = {}) { super() if (!opts.peerId) throw new Error('Option `peerId` is required') if (!opts.infoHash) throw new Error('Option `infoHash` is required') if (!opts.announce) throw new Error('Option `announce` is required') if (!process.browser && !opts.port) throw new Error('Option `port` is required') this.peerId = typeof opts.peerId === 'string' ? opts.peerId : opts.peerId.toString('hex') this._peerIdBuffer = Buffer.from(this.peerId, 'hex') this._peerIdBinary = this._peerIdBuffer.toString('binary') this.infoHash = typeof opts.infoHash === 'string' ? opts.infoHash.toLowerCase() : opts.infoHash.toString('hex') this._infoHashBuffer = Buffer.from(this.infoHash, 'hex') this._infoHashBinary = this._infoHashBuffer.toString('binary') debug('new client %s', this.infoHash) this.destroyed = false this._port = opts.port this._getAnnounceOpts = opts.getAnnounceOpts this._rtcConfig = opts.rtcConfig this._userAgent = opts.userAgent // Support lazy 'wrtc' module initialization // See: https://github.com/webtorrent/webtorrent-hybrid/issues/46 this._wrtc = typeof opts.wrtc === 'function' ? opts.wrtc() : opts.wrtc let announce = typeof opts.announce === 'string' ? [opts.announce] : opts.announce == null ? [] : opts.announce // Remove trailing slash from trackers to catch duplicates announce = announce.map(announceUrl => { announceUrl = announceUrl.toString() if (announceUrl[announceUrl.length - 1] === '/') { announceUrl = announceUrl.substring(0, announceUrl.length - 1) } return announceUrl }) // remove duplicates by converting to Set and back announce = Array.from(new Set(announce)) const webrtcSupport = this._wrtc !== false && (!!this._wrtc || Peer.WEBRTC_SUPPORT) const nextTickWarn = err => { queueMicrotask(() => { this.emit('warning', err) }) } this._trackers = announce .map(announceUrl => { let parsedUrl try { parsedUrl = new URL(announceUrl) } catch (err) { nextTickWarn(new Error(`Invalid tracker URL: ${announceUrl}`)) return null } const port = parsedUrl.port if (port < 0 || port > 65535) { nextTickWarn(new Error(`Invalid tracker port: ${announceUrl}`)) return null } const protocol = parsedUrl.protocol if ((protocol === 'http:' || protocol === 'https:') && typeof HTTPTracker === 'function') { return new HTTPTracker(this, announceUrl) } else if (protocol === 'udp:' && typeof UDPTracker === 'function') { return new UDPTracker(this, announceUrl) } else if ((protocol === 'ws:' || protocol === 'wss:') && webrtcSupport) { // Skip ws:// trackers on https:// sites because they throw SecurityError if (protocol === 'ws:' && typeof window !== 'undefined' && window.location.protocol === 'https:') { nextTickWarn(new Error(`Unsupported tracker protocol: ${announceUrl}`)) return null } return new WebSocketTracker(this, announceUrl) } else { nextTickWarn(new Error(`Unsupported tracker protocol: ${announceUrl}`)) return null } }) .filter(Boolean) } /** * Send a `start` announce to the trackers. * @param {Object} opts * @param {number=} opts.uploaded * @param {number=} opts.downloaded * @param {number=} opts.left (if not set, calculated automatically) */ start (opts) { opts = this._defaultAnnounceOpts(opts) opts.event = 'started' debug('send `start` %o', opts) this._announce(opts) // start announcing on intervals this._trackers.forEach(tracker => { tracker.setInterval() }) } /** * Send a `stop` announce to the trackers. * @param {Object} opts * @param {number=} opts.uploaded * @param {number=} opts.downloaded * @param {number=} opts.numwant * @param {number=} opts.left (if not set, calculated automatically) */ stop (opts) { opts = this._defaultAnnounceOpts(opts) opts.event = 'stopped' debug('send `stop` %o', opts) this._announce(opts) } /** * Send a `complete` announce to the trackers. * @param {Object} opts * @param {number=} opts.uploaded * @param {number=} opts.downloaded * @param {number=} opts.numwant * @param {number=} opts.left (if not set, calculated automatically) */ complete (opts) { if (!opts) opts = {} opts = this._defaultAnnounceOpts(opts) opts.event = 'completed' debug('send `complete` %o', opts) this._announce(opts) } /** * Send a `update` announce to the trackers. * @param {Object} opts * @param {number=} opts.uploaded * @param {number=} opts.downloaded * @param {number=} opts.numwant * @param {number=} opts.left (if not set, calculated automatically) */ update (opts) { opts = this._defaultAnnounceOpts(opts) if (opts.event) delete opts.event debug('send `update` %o', opts) this._announce(opts) } _announce (opts) { this._trackers.forEach(tracker => { // tracker should not modify `opts` object, it's passed to all trackers tracker.announce(opts) }) } /** * Send a scrape request to the trackers. * @param {Object} opts */ scrape (opts) { debug('send `scrape`') if (!opts) opts = {} this._trackers.forEach(tracker => { // tracker should not modify `opts` object, it's passed to all trackers tracker.scrape(opts) }) } setInterval (intervalMs) { debug('setInterval %d', intervalMs) this._trackers.forEach(tracker => { tracker.setInterval(intervalMs) }) } destroy (cb) { if (this.destroyed) return this.destroyed = true debug('destroy') const tasks = this._trackers.map(tracker => cb => { tracker.destroy(cb) }) parallel(tasks, cb) this._trackers = [] this._getAnnounceOpts = null } _defaultAnnounceOpts (opts = {}) { if (opts.numwant == null) opts.numwant = common.DEFAULT_ANNOUNCE_PEERS if (opts.uploaded == null) opts.uploaded = 0 if (opts.downloaded == null) opts.downloaded = 0 if (this._getAnnounceOpts) opts = Object.assign({}, opts, this._getAnnounceOpts()) return opts } } /** * Simple convenience function to scrape a tracker for an info hash without needing to * create a Client, pass it a parsed torrent, etc. Support scraping a tracker for multiple * torrents at the same time. * @params {Object} opts * @param {string|Array.} opts.infoHash * @param {string} opts.announce * @param {function} cb */ Client.scrape = (opts, cb) => { cb = once(cb) if (!opts.infoHash) throw new Error('Option `infoHash` is required') if (!opts.announce) throw new Error('Option `announce` is required') const clientOpts = Object.assign({}, opts, { infoHash: Array.isArray(opts.infoHash) ? opts.infoHash[0] : opts.infoHash, peerId: Buffer.from('01234567890123456789'), // dummy value port: 6881 // dummy value }) const client = new Client(clientOpts) client.once('error', cb) client.once('warning', cb) let len = Array.isArray(opts.infoHash) ? opts.infoHash.length : 1 const results = {} client.on('scrape', data => { len -= 1 results[data.infoHash] = data if (len === 0) { client.destroy() const keys = Object.keys(results) if (keys.length === 1) { cb(null, results[keys[0]]) } else { cb(null, results) } } }) opts.infoHash = Array.isArray(opts.infoHash) ? opts.infoHash.map(infoHash => { return Buffer.from(infoHash, 'hex') }) : Buffer.from(opts.infoHash, 'hex') client.scrape({ infoHash: opts.infoHash }) return client } module.exports = Client