/ client.js
client.js
  1  import Debug from 'debug'
  2  import EventEmitter from 'events'
  3  import once from 'once'
  4  import parallel from 'run-parallel'
  5  import Peer from '@thaunknown/simple-peer/lite.js'
  6  import queueMicrotask from 'queue-microtask'
  7  import { hex2arr, hex2bin, text2arr, arr2hex, arr2text } from 'uint8-util'
  8  
  9  import common from './lib/common.js'
 10  import HTTPTracker from './lib/client/http-tracker.js' // empty object in browser
 11  import UDPTracker from './lib/client/udp-tracker.js' // empty object in browser
 12  import WebSocketTracker from './lib/client/websocket-tracker.js'
 13  
 14  const debug = Debug('bittorrent-tracker:client')
 15  
 16  /**
 17   * BitTorrent tracker client.
 18   *
 19   * Find torrent peers, to help a torrent client participate in a torrent swarm.
 20   *
 21   * @param {Object} opts                          options object
 22   * @param {string|Uint8Array} opts.infoHash          torrent info hash
 23   * @param {string|Uint8Array} opts.peerId            peer id
 24   * @param {string|Array.<string>} opts.announce  announce
 25   * @param {number} opts.port                     torrent client listening port
 26   * @param {function} opts.getAnnounceOpts        callback to provide data to tracker
 27   * @param {number} opts.rtcConfig                RTCPeerConnection configuration object
 28   * @param {number} opts.userAgent                User-Agent header for http requests
 29   * @param {number} opts.wrtc                     custom webrtc impl (useful in node.js)
 30   * @param {object} opts.proxyOpts                proxy options (useful in node.js)
 31   */
 32  class Client extends EventEmitter {
 33    constructor (opts = {}) {
 34      super()
 35  
 36      if (!opts.peerId) throw new Error('Option `peerId` is required')
 37      if (!opts.infoHash) throw new Error('Option `infoHash` is required')
 38      if (!opts.announce) throw new Error('Option `announce` is required')
 39      if (!process.browser && !opts.port) throw new Error('Option `port` is required')
 40  
 41      this.peerId = typeof opts.peerId === 'string'
 42        ? opts.peerId
 43        : arr2hex(opts.peerId)
 44      this._peerIdBuffer = hex2arr(this.peerId)
 45      this._peerIdBinary = hex2bin(this.peerId)
 46  
 47      this.infoHash = typeof opts.infoHash === 'string'
 48        ? opts.infoHash.toLowerCase()
 49        : arr2hex(opts.infoHash)
 50      this._infoHashBuffer = hex2arr(this.infoHash)
 51      this._infoHashBinary = hex2bin(this.infoHash)
 52  
 53      debug('new client %s', this.infoHash)
 54  
 55      this.destroyed = false
 56  
 57      this._port = opts.port
 58      this._getAnnounceOpts = opts.getAnnounceOpts
 59      this._rtcConfig = opts.rtcConfig
 60      this._userAgent = opts.userAgent
 61      this._proxyOpts = opts.proxyOpts
 62  
 63      // Support lazy 'wrtc' module initialization
 64      // See: https://github.com/webtorrent/webtorrent-hybrid/issues/46
 65      this._wrtc = typeof opts.wrtc === 'function' ? opts.wrtc() : opts.wrtc
 66  
 67      let announce = typeof opts.announce === 'string'
 68        ? [opts.announce]
 69        : opts.announce == null ? [] : opts.announce
 70  
 71      // Remove trailing slash from trackers to catch duplicates
 72      announce = announce.map(announceUrl => {
 73        if (ArrayBuffer.isView(announceUrl)) announceUrl = arr2text(announceUrl)
 74        if (announceUrl[announceUrl.length - 1] === '/') {
 75          announceUrl = announceUrl.substring(0, announceUrl.length - 1)
 76        }
 77        return announceUrl
 78      })
 79      // remove duplicates by converting to Set and back
 80      announce = Array.from(new Set(announce))
 81  
 82      const webrtcSupport = this._wrtc !== false && (!!this._wrtc || Peer.WEBRTC_SUPPORT)
 83  
 84      const nextTickWarn = err => {
 85        queueMicrotask(() => {
 86          this.emit('warning', err)
 87        })
 88      }
 89  
 90      this._trackers = announce
 91        .map(announceUrl => {
 92          let parsedUrl
 93          try {
 94            parsedUrl = common.parseUrl(announceUrl)
 95          } catch (err) {
 96            nextTickWarn(new Error(`Invalid tracker URL: ${announceUrl}`))
 97            return null
 98          }
 99  
100          const port = parsedUrl.port
101          if (port < 0 || port > 65535) {
102            nextTickWarn(new Error(`Invalid tracker port: ${announceUrl}`))
103            return null
104          }
105  
106          const protocol = parsedUrl.protocol
107          if ((protocol === 'http:' || protocol === 'https:') &&
108              typeof HTTPTracker === 'function') {
109            return new HTTPTracker(this, announceUrl)
110          } else if (protocol === 'udp:' && typeof UDPTracker === 'function') {
111            return new UDPTracker(this, announceUrl)
112          } else if ((protocol === 'ws:' || protocol === 'wss:') && webrtcSupport) {
113            // Skip ws:// trackers on https:// sites because they throw SecurityError
114            if (protocol === 'ws:' && typeof window !== 'undefined' &&
115                window.location.protocol === 'https:') {
116              nextTickWarn(new Error(`Unsupported tracker protocol: ${announceUrl}`))
117              return null
118            }
119            return new WebSocketTracker(this, announceUrl)
120          } else {
121            nextTickWarn(new Error(`Unsupported tracker protocol: ${announceUrl}`))
122            return null
123          }
124        })
125        .filter(Boolean)
126    }
127  
128    /**
129     * Send a `start` announce to the trackers.
130     * @param {Object} opts
131     * @param {number=} opts.uploaded
132     * @param {number=} opts.downloaded
133     * @param {number=} opts.left (if not set, calculated automatically)
134     */
135    start (opts) {
136      opts = this._defaultAnnounceOpts(opts)
137      opts.event = 'started'
138      debug('send `start` %o', opts)
139      this._announce(opts)
140  
141      // start announcing on intervals
142      this._trackers.forEach(tracker => {
143        tracker.setInterval()
144      })
145    }
146  
147    /**
148     * Send a `stop` announce to the trackers.
149     * @param {Object} opts
150     * @param {number=} opts.uploaded
151     * @param {number=} opts.downloaded
152     * @param {number=} opts.numwant
153     * @param {number=} opts.left (if not set, calculated automatically)
154     */
155    stop (opts) {
156      opts = this._defaultAnnounceOpts(opts)
157      opts.event = 'stopped'
158      debug('send `stop` %o', opts)
159      this._announce(opts)
160    }
161  
162    /**
163     * Send a `complete` announce to the trackers.
164     * @param {Object} opts
165     * @param {number=} opts.uploaded
166     * @param {number=} opts.downloaded
167     * @param {number=} opts.numwant
168     * @param {number=} opts.left (if not set, calculated automatically)
169     */
170    complete (opts) {
171      if (!opts) opts = {}
172      opts = this._defaultAnnounceOpts(opts)
173      opts.event = 'completed'
174      debug('send `complete` %o', opts)
175      this._announce(opts)
176    }
177  
178    /**
179     * Send a `update` announce to the trackers.
180     * @param {Object} opts
181     * @param {number=} opts.uploaded
182     * @param {number=} opts.downloaded
183     * @param {number=} opts.numwant
184     * @param {number=} opts.left (if not set, calculated automatically)
185     */
186    update (opts) {
187      opts = this._defaultAnnounceOpts(opts)
188      if (opts.event) delete opts.event
189      debug('send `update` %o', opts)
190      this._announce(opts)
191    }
192  
193    _announce (opts) {
194      this._trackers.forEach(tracker => {
195        // tracker should not modify `opts` object, it's passed to all trackers
196        tracker.announce(opts)
197      })
198    }
199  
200    /**
201     * Send a scrape request to the trackers.
202     * @param {Object} opts
203     */
204    scrape (opts) {
205      debug('send `scrape`')
206      if (!opts) opts = {}
207      this._trackers.forEach(tracker => {
208        // tracker should not modify `opts` object, it's passed to all trackers
209        tracker.scrape(opts)
210      })
211    }
212  
213    setInterval (intervalMs) {
214      debug('setInterval %d', intervalMs)
215      this._trackers.forEach(tracker => {
216        tracker.setInterval(intervalMs)
217      })
218    }
219  
220    destroy (cb) {
221      if (this.destroyed) return
222      this.destroyed = true
223      debug('destroy')
224  
225      const tasks = this._trackers.map(tracker => cb => {
226        tracker.destroy(cb)
227      })
228  
229      parallel(tasks, cb)
230  
231      this._trackers = []
232      this._getAnnounceOpts = null
233    }
234  
235    _defaultAnnounceOpts (opts = {}) {
236      if (opts.numwant == null) opts.numwant = common.DEFAULT_ANNOUNCE_PEERS
237  
238      if (opts.uploaded == null) opts.uploaded = 0
239      if (opts.downloaded == null) opts.downloaded = 0
240  
241      if (this._getAnnounceOpts) opts = Object.assign({}, opts, this._getAnnounceOpts())
242  
243      return opts
244    }
245  }
246  
247  /**
248   * Simple convenience function to scrape a tracker for an info hash without needing to
249   * create a Client, pass it a parsed torrent, etc. Support scraping a tracker for multiple
250   * torrents at the same time.
251   * @params {Object} opts
252   * @param  {string|Array.<string>} opts.infoHash
253   * @param  {string} opts.announce
254   * @param  {function} cb
255   */
256  Client.scrape = (opts, cb) => {
257    cb = once(cb)
258  
259    if (!opts.infoHash) throw new Error('Option `infoHash` is required')
260    if (!opts.announce) throw new Error('Option `announce` is required')
261  
262    const clientOpts = Object.assign({}, opts, {
263      infoHash: Array.isArray(opts.infoHash) ? opts.infoHash[0] : opts.infoHash,
264      peerId: text2arr('01234567890123456789'), // dummy value
265      port: 6881 // dummy value
266    })
267  
268    const client = new Client(clientOpts)
269    client.once('error', cb)
270    client.once('warning', cb)
271  
272    let len = Array.isArray(opts.infoHash) ? opts.infoHash.length : 1
273    const results = {}
274    client.on('scrape', data => {
275      len -= 1
276      results[data.infoHash] = data
277      if (len === 0) {
278        client.destroy()
279        const keys = Object.keys(results)
280        if (keys.length === 1) {
281          cb(null, results[keys[0]])
282        } else {
283          cb(null, results)
284        }
285      }
286    })
287  
288    client.scrape({ infoHash: opts.infoHash })
289    return client
290  }
291  
292  export default Client