import Debug from 'debug' import EventEmitter from 'events' import once from 'once' import parallel from 'run-parallel' import Peer from '@thaunknown/simple-peer/lite.js' import queueMicrotask from 'queue-microtask' import { hex2arr, hex2bin, text2arr, arr2hex, arr2text } from 'uint8-util' import common from './lib/common.js' import HTTPTracker from './lib/client/http-tracker.js' // empty object in browser import UDPTracker from './lib/client/udp-tracker.js' // empty object in browser import WebSocketTracker from './lib/client/websocket-tracker.js' const debug = Debug('bittorrent-tracker:client') /** * BitTorrent tracker client. * * Find torrent peers, to help a torrent client participate in a torrent swarm. * * @param {Object} opts options object * @param {string|Uint8Array} opts.infoHash torrent info hash * @param {string|Uint8Array} 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) * @param {object} opts.proxyOpts proxy options (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 : arr2hex(opts.peerId) this._peerIdBuffer = hex2arr(this.peerId) this._peerIdBinary = hex2bin(this.peerId) this.infoHash = typeof opts.infoHash === 'string' ? opts.infoHash.toLowerCase() : arr2hex(opts.infoHash) this._infoHashBuffer = hex2arr(this.infoHash) this._infoHashBinary = hex2bin(this.infoHash) 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 this._proxyOpts = opts.proxyOpts // 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 => { if (ArrayBuffer.isView(announceUrl)) announceUrl = arr2text(announceUrl) 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 = common.parseUrl(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: text2arr('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) } } }) client.scrape({ infoHash: opts.infoHash }) return client } export default Client