/ server.js
server.js
  1  import bencode from 'bencode'
  2  import Debug from 'debug'
  3  import dgram from 'dgram'
  4  import EventEmitter from 'events'
  5  import http from 'http'
  6  import peerid from 'bittorrent-peerid'
  7  import series from 'run-series'
  8  import string2compact from 'string2compact'
  9  import { WebSocketServer } from 'ws'
 10  import { hex2bin } from 'uint8-util'
 11  
 12  import common from './lib/common.js'
 13  import Swarm from './lib/server/swarm.js'
 14  import parseHttpRequest from './lib/server/parse-http.js'
 15  import parseUdpRequest from './lib/server/parse-udp.js'
 16  import parseWebSocketRequest from './lib/server/parse-websocket.js'
 17  
 18  const debug = Debug('bittorrent-tracker:server')
 19  const hasOwnProperty = Object.prototype.hasOwnProperty
 20  
 21  /**
 22   * BitTorrent tracker server.
 23   *
 24   * HTTP service which responds to GET requests from torrent clients. Requests include
 25   * metrics from clients that help the tracker keep overall statistics about the torrent.
 26   * Responses include a peer list that helps the client participate in the torrent.
 27   *
 28   * @param {Object}  opts                options object
 29   * @param {Number}  opts.interval       tell clients to announce on this interval (ms)
 30   * @param {Number}  opts.trustProxy     trust 'x-forwarded-for' header from reverse proxy
 31   * @param {boolean|Object} opts.http    start an http server?, or options for http.createServer (default: true)
 32   * @param {boolean|Object} opts.udp     start a udp server?, or extra options for dgram.createSocket (default: true)
 33   * @param {boolean|Object} opts.ws      start a websocket server?, or extra options for new WebSocketServer (default: true)
 34   * @param {boolean} opts.stats          enable web-based statistics? (default: true)
 35   * @param {function} opts.filter        black/whitelist fn for disallowing/allowing torrents
 36   */
 37  class Server extends EventEmitter {
 38    constructor (opts = {}) {
 39      super()
 40      debug('new server %s', JSON.stringify(opts))
 41  
 42      this.intervalMs = opts.interval
 43        ? opts.interval
 44        : 10 * 60 * 1000 // 10 min
 45  
 46      this._trustProxy = !!opts.trustProxy
 47      if (typeof opts.filter === 'function') this._filter = opts.filter
 48  
 49      this.peersCacheLength = opts.peersCacheLength
 50      this.peersCacheTtl = opts.peersCacheTtl
 51  
 52      this._listenCalled = false
 53      this.listening = false
 54      this.destroyed = false
 55      this.torrents = {}
 56  
 57      this.http = null
 58      this.udp4 = null
 59      this.udp6 = null
 60      this.ws = null
 61  
 62      // start an http tracker unless the user explictly says no
 63      if (opts.http !== false) {
 64        this.http = http.createServer(isObject(opts.http) ? opts.http : undefined)
 65        this.http.on('error', err => { this._onError(err) })
 66        this.http.on('listening', onListening)
 67  
 68        // Add default http request handler on next tick to give user the chance to add
 69        // their own handler first. Handle requests untouched by user's handler.
 70        process.nextTick(() => {
 71          this.http.on('request', (req, res) => {
 72            if (res.headersSent) return
 73            this.onHttpRequest(req, res)
 74          })
 75        })
 76      }
 77  
 78      // start a udp tracker unless the user explicitly says no
 79      if (opts.udp !== false) {
 80        this.udp4 = this.udp = dgram.createSocket({
 81          type: 'udp4',
 82          reuseAddr: true,
 83          ...(isObject(opts.udp) ? opts.udp : undefined)
 84        })
 85        this.udp4.on('message', (msg, rinfo) => { this.onUdpRequest(msg, rinfo) })
 86        this.udp4.on('error', err => { this._onError(err) })
 87        this.udp4.on('listening', onListening)
 88  
 89        this.udp6 = dgram.createSocket({
 90          type: 'udp6',
 91          reuseAddr: true,
 92          ...(isObject(opts.udp) ? opts.udp : undefined)
 93        })
 94        this.udp6.on('message', (msg, rinfo) => { this.onUdpRequest(msg, rinfo) })
 95        this.udp6.on('error', err => { this._onError(err) })
 96        this.udp6.on('listening', onListening)
 97      }
 98  
 99      // start a websocket tracker (for WebTorrent) unless the user explicitly says no
100      if (opts.ws !== false) {
101        const noServer = isObject(opts.ws) && opts.ws.noServer
102        if (!this.http && !noServer) {
103          this.http = http.createServer()
104          this.http.on('error', err => { this._onError(err) })
105          this.http.on('listening', onListening)
106  
107          // Add default http request handler on next tick to give user the chance to add
108          // their own handler first. Handle requests untouched by user's handler.
109          process.nextTick(() => {
110            this.http.on('request', (req, res) => {
111              if (res.headersSent) return
112              // For websocket trackers, we only need to handle the UPGRADE http method.
113              // Return 404 for all other request types.
114              res.statusCode = 404
115              res.end('404 Not Found')
116            })
117          })
118        }
119        this.ws = new WebSocketServer({
120          server: noServer ? undefined : this.http,
121          perMessageDeflate: false,
122          clientTracking: false,
123          ...(isObject(opts.ws) ? opts.ws : undefined)
124        })
125  
126        this.ws.address = () => {
127          if (noServer) {
128            throw new Error('address() unavailable with { noServer: true }')
129          }
130          return this.http.address()
131        }
132  
133        this.ws.on('error', err => { this._onError(err) })
134        this.ws.on('connection', (socket, req) => {
135          // Note: socket.upgradeReq was removed in ws@3.0.0, so re-add it.
136          // https://github.com/websockets/ws/pull/1099
137          socket.upgradeReq = req
138          this.onWebSocketConnection(socket)
139        })
140      }
141  
142      if (opts.stats !== false) {
143        if (!this.http) {
144          this.http = http.createServer()
145          this.http.on('error', err => { this._onError(err) })
146          this.http.on('listening', onListening)
147        }
148  
149        // Http handler for '/stats' route
150        this.http.on('request', (req, res) => {
151          if (res.headersSent) return
152  
153          const infoHashes = Object.keys(this.torrents)
154          let activeTorrents = 0
155          const allPeers = {}
156  
157          function countPeers (filterFunction) {
158            let count = 0
159            let key
160  
161            for (key in allPeers) {
162              if (hasOwnProperty.call(allPeers, key) && filterFunction(allPeers[key])) {
163                count++
164              }
165            }
166  
167            return count
168          }
169  
170          function groupByClient () {
171            const clients = {}
172            for (const key in allPeers) {
173              if (hasOwnProperty.call(allPeers, key)) {
174                const peer = allPeers[key]
175  
176                if (!clients[peer.client.client]) {
177                  clients[peer.client.client] = {}
178                }
179                const client = clients[peer.client.client]
180                // If the client is not known show 8 chars from peerId as version
181                const version = peer.client.version || Buffer.from(peer.peerId, 'hex').toString().substring(0, 8)
182                if (!client[version]) {
183                  client[version] = 0
184                }
185                client[version]++
186              }
187            }
188            return clients
189          }
190  
191          function printClients (clients) {
192            let html = '<ul>\n'
193            for (const name in clients) {
194              if (hasOwnProperty.call(clients, name)) {
195                const client = clients[name]
196                for (const version in client) {
197                  if (hasOwnProperty.call(client, version)) {
198                    html += `<li><strong>${name}</strong> ${version} : ${client[version]}</li>\n`
199                  }
200                }
201              }
202            }
203            html += '</ul>'
204            return html
205          }
206  
207          if (req.method === 'GET' && (req.url === '/stats' || req.url === '/stats.json')) {
208            infoHashes.forEach(infoHash => {
209              const peers = this.torrents[infoHash].peers
210              const keys = peers.keys
211              if (keys.length > 0) activeTorrents++
212  
213              keys.forEach(peerId => {
214                // Don't mark the peer as most recently used for stats
215                const peer = peers.peek(peerId)
216                if (peer == null) return // peers.peek() can evict the peer
217  
218                if (!hasOwnProperty.call(allPeers, peerId)) {
219                  allPeers[peerId] = {
220                    ipv4: false,
221                    ipv6: false,
222                    seeder: false,
223                    leecher: false
224                  }
225                }
226  
227                if (peer.ip.includes(':')) {
228                  allPeers[peerId].ipv6 = true
229                } else {
230                  allPeers[peerId].ipv4 = true
231                }
232  
233                if (peer.complete) {
234                  allPeers[peerId].seeder = true
235                } else {
236                  allPeers[peerId].leecher = true
237                }
238  
239                allPeers[peerId].peerId = peer.peerId
240                allPeers[peerId].client = peerid(peer.peerId)
241              })
242            })
243  
244            const isSeederOnly = peer => peer.seeder && peer.leecher === false
245            const isLeecherOnly = peer => peer.leecher && peer.seeder === false
246            const isSeederAndLeecher = peer => peer.seeder && peer.leecher
247            const isIPv4 = peer => peer.ipv4
248            const isIPv6 = peer => peer.ipv6
249  
250            const stats = {
251              torrents: infoHashes.length,
252              activeTorrents,
253              peersAll: Object.keys(allPeers).length,
254              peersSeederOnly: countPeers(isSeederOnly),
255              peersLeecherOnly: countPeers(isLeecherOnly),
256              peersSeederAndLeecher: countPeers(isSeederAndLeecher),
257              peersIPv4: countPeers(isIPv4),
258              peersIPv6: countPeers(isIPv6),
259              clients: groupByClient()
260            }
261  
262            if (req.url === '/stats.json' || req.headers.accept === 'application/json') {
263              res.setHeader('Content-Type', 'application/json')
264              res.end(JSON.stringify(stats))
265            } else if (req.url === '/stats') {
266              res.setHeader('Content-Type', 'text/html')
267              res.end(`
268                <h1>${stats.torrents} torrents (${stats.activeTorrents} active)</h1>
269                <h2>Connected Peers: ${stats.peersAll}</h2>
270                <h3>Peers Seeding Only: ${stats.peersSeederOnly}</h3>
271                <h3>Peers Leeching Only: ${stats.peersLeecherOnly}</h3>
272                <h3>Peers Seeding & Leeching: ${stats.peersSeederAndLeecher}</h3>
273                <h3>IPv4 Peers: ${stats.peersIPv4}</h3>
274                <h3>IPv6 Peers: ${stats.peersIPv6}</h3>
275                <h3>Clients:</h3>
276                ${printClients(stats.clients)}
277              `.replace(/^\s+/gm, '')) // trim left
278            }
279          }
280        })
281      }
282  
283      let num = !!this.http + !!this.udp4 + !!this.udp6
284      const self = this
285      function onListening () {
286        num -= 1
287        if (num === 0) {
288          self.listening = true
289          debug('listening')
290          self.emit('listening')
291        }
292      }
293    }
294  
295    _onError (err) {
296      this.emit('error', err)
297    }
298  
299    listen (...args) /* port, hostname, onlistening */{
300      if (this._listenCalled || this.listening) throw new Error('server already listening')
301      this._listenCalled = true
302  
303      const lastArg = args[args.length - 1]
304      if (typeof lastArg === 'function') this.once('listening', lastArg)
305  
306      const port = toNumber(args[0]) || args[0] || 0
307      const hostname = typeof args[1] !== 'function' ? args[1] : undefined
308  
309      debug('listen (port: %o hostname: %o)', port, hostname)
310  
311      const httpPort = isObject(port) ? (port.http || 0) : port
312      const udpPort = isObject(port) ? (port.udp || 0) : port
313  
314      // binding to :: only receives IPv4 connections if the bindv6only sysctl is set 0,
315      // which is the default on many operating systems
316      const httpHostname = isObject(hostname) ? hostname.http : hostname
317      const udp4Hostname = isObject(hostname) ? hostname.udp : hostname
318      const udp6Hostname = isObject(hostname) ? hostname.udp6 : hostname
319  
320      if (this.http) this.http.listen(httpPort, httpHostname)
321      if (this.udp4) this.udp4.bind(udpPort, udp4Hostname)
322      if (this.udp6) this.udp6.bind(udpPort, udp6Hostname)
323    }
324  
325    close (cb = noop) {
326      debug('close')
327  
328      this.listening = false
329      this.destroyed = true
330  
331      if (this.udp4) {
332        try {
333          this.udp4.close()
334        } catch (err) {}
335      }
336  
337      if (this.udp6) {
338        try {
339          this.udp6.close()
340        } catch (err) {}
341      }
342  
343      if (this.ws) {
344        try {
345          this.ws.close()
346        } catch (err) {}
347      }
348  
349      if (this.http) this.http.close(cb)
350      else cb(null)
351    }
352  
353    createSwarm (infoHash, cb) {
354      if (ArrayBuffer.isView(infoHash)) infoHash = infoHash.toString('hex')
355  
356      process.nextTick(() => {
357        const swarm = this.torrents[infoHash] = new Server.Swarm(infoHash, this)
358        cb(null, swarm)
359      })
360    }
361  
362    getSwarm (infoHash, cb) {
363      if (ArrayBuffer.isView(infoHash)) infoHash = infoHash.toString('hex')
364  
365      process.nextTick(() => {
366        cb(null, this.torrents[infoHash])
367      })
368    }
369  
370    onHttpRequest (req, res, opts = {}) {
371      opts.trustProxy = opts.trustProxy || this._trustProxy
372  
373      let params
374      try {
375        params = parseHttpRequest(req, opts)
376        params.httpReq = req
377        params.httpRes = res
378      } catch (err) {
379        res.end(bencode.encode({
380          'failure reason': err.message
381        }))
382  
383        // even though it's an error for the client, it's just a warning for the server.
384        // don't crash the server because a client sent bad data :)
385        this.emit('warning', err)
386        return
387      }
388  
389      this._onRequest(params, (err, response) => {
390        if (err) {
391          this.emit('warning', err)
392          response = {
393            'failure reason': err.message
394          }
395        }
396        if (this.destroyed) return res.end()
397  
398        delete response.action // only needed for UDP encoding
399        res.end(bencode.encode(response))
400  
401        if (params.action === common.ACTIONS.ANNOUNCE) {
402          this.emit(common.EVENT_NAMES[params.event], params.addr, params)
403        }
404      })
405    }
406  
407    onUdpRequest (msg, rinfo) {
408      let params
409      try {
410        params = parseUdpRequest(msg, rinfo)
411      } catch (err) {
412        this.emit('warning', err)
413        // Do not reply for parsing errors
414        return
415      }
416  
417      this._onRequest(params, (err, response) => {
418        if (err) {
419          this.emit('warning', err)
420          response = {
421            action: common.ACTIONS.ERROR,
422            'failure reason': err.message
423          }
424        }
425        if (this.destroyed) return
426  
427        response.transactionId = params.transactionId
428        response.connectionId = params.connectionId
429  
430        const buf = makeUdpPacket(response)
431  
432        try {
433          const udp = (rinfo.family === 'IPv4') ? this.udp4 : this.udp6
434          udp.send(buf, 0, buf.length, rinfo.port, rinfo.address)
435        } catch (err) {
436          this.emit('warning', err)
437        }
438  
439        if (params.action === common.ACTIONS.ANNOUNCE) {
440          this.emit(common.EVENT_NAMES[params.event], params.addr, params)
441        }
442      })
443    }
444  
445    onWebSocketConnection (socket, opts = {}) {
446      opts.trustProxy = opts.trustProxy || this._trustProxy
447  
448      socket.peerId = null // as hex
449      socket.infoHashes = [] // swarms that this socket is participating in
450      socket.onSend = err => {
451        this._onWebSocketSend(socket, err)
452      }
453  
454      socket.onMessageBound = params => {
455        this._onWebSocketRequest(socket, opts, params)
456      }
457      socket.on('message', socket.onMessageBound)
458  
459      socket.onErrorBound = err => {
460        this._onWebSocketError(socket, err)
461      }
462      socket.on('error', socket.onErrorBound)
463  
464      socket.onCloseBound = () => {
465        this._onWebSocketClose(socket)
466      }
467      socket.on('close', socket.onCloseBound)
468    }
469  
470    _onWebSocketRequest (socket, opts, params) {
471      try {
472        params = parseWebSocketRequest(socket, opts, params)
473      } catch (err) {
474        socket.send(JSON.stringify({
475          'failure reason': err.message
476        }), socket.onSend)
477  
478        // even though it's an error for the client, it's just a warning for the server.
479        // don't crash the server because a client sent bad data :)
480        this.emit('warning', err)
481        return
482      }
483  
484      if (!socket.peerId) socket.peerId = params.peer_id // as hex
485  
486      this._onRequest(params, (err, response) => {
487        if (this.destroyed || socket.destroyed) return
488        if (err) {
489          socket.send(JSON.stringify({
490            action: params.action === common.ACTIONS.ANNOUNCE ? 'announce' : 'scrape',
491            'failure reason': err.message,
492            info_hash: hex2bin(params.info_hash)
493          }), socket.onSend)
494  
495          this.emit('warning', err)
496          return
497        }
498  
499        response.action = params.action === common.ACTIONS.ANNOUNCE ? 'announce' : 'scrape'
500  
501        let peers
502        if (response.action === 'announce') {
503          peers = response.peers
504          delete response.peers
505  
506          if (!socket.infoHashes.includes(params.info_hash)) {
507            socket.infoHashes.push(params.info_hash)
508          }
509  
510          response.info_hash = hex2bin(params.info_hash)
511  
512          // WebSocket tracker should have a shorter interval – default: 2 minutes
513          response.interval = Math.ceil(this.intervalMs / 1000 / 5)
514        }
515  
516        // Skip sending update back for 'answer' announce messages – not needed
517        if (!params.answer) {
518          socket.send(JSON.stringify(response), socket.onSend)
519          debug('sent response %s to %s', JSON.stringify(response), params.peer_id)
520        }
521  
522        if (Array.isArray(params.offers)) {
523          debug('got %s offers from %s', params.offers.length, params.peer_id)
524          debug('got %s peers from swarm %s', peers.length, params.info_hash)
525          peers.forEach((peer, i) => {
526            peer.socket.send(JSON.stringify({
527              action: 'announce',
528              offer: params.offers[i].offer,
529              offer_id: params.offers[i].offer_id,
530              peer_id: hex2bin(params.peer_id),
531              info_hash: hex2bin(params.info_hash)
532            }), peer.socket.onSend)
533            debug('sent offer to %s from %s', peer.peerId, params.peer_id)
534          })
535        }
536  
537        const done = () => {
538          // emit event once the announce is fully "processed"
539          if (params.action === common.ACTIONS.ANNOUNCE) {
540            this.emit(common.EVENT_NAMES[params.event], params.peer_id, params)
541          }
542        }
543  
544        if (params.answer) {
545          debug('got answer %s from %s', JSON.stringify(params.answer), params.peer_id)
546  
547          this.getSwarm(params.info_hash, (err, swarm) => {
548            if (this.destroyed) return
549            if (err) return this.emit('warning', err)
550            if (!swarm) {
551              return this.emit('warning', new Error('no swarm with that `info_hash`'))
552            }
553            // Mark the destination peer as recently used in cache
554            const toPeer = swarm.peers.get(params.to_peer_id)
555            if (!toPeer) {
556              return this.emit('warning', new Error('no peer with that `to_peer_id`'))
557            }
558  
559            toPeer.socket.send(JSON.stringify({
560              action: 'announce',
561              answer: params.answer,
562              offer_id: params.offer_id,
563              peer_id: hex2bin(params.peer_id),
564              info_hash: hex2bin(params.info_hash)
565            }), toPeer.socket.onSend)
566            debug('sent answer to %s from %s', toPeer.peerId, params.peer_id)
567  
568            done()
569          })
570        } else {
571          done()
572        }
573      })
574    }
575  
576    _onWebSocketSend (socket, err) {
577      if (err) this._onWebSocketError(socket, err)
578    }
579  
580    _onWebSocketClose (socket) {
581      debug('websocket close %s', socket.peerId)
582      socket.destroyed = true
583  
584      if (socket.peerId) {
585        socket.infoHashes.slice(0).forEach(infoHash => {
586          const swarm = this.torrents[infoHash]
587          if (swarm) {
588            swarm.announce({
589              type: 'ws',
590              event: 'stopped',
591              numwant: 0,
592              peer_id: socket.peerId
593            })
594          }
595        })
596      }
597  
598      // ignore all future errors
599      socket.onSend = noop
600      socket.on('error', noop)
601  
602      socket.peerId = null
603      socket.infoHashes = null
604  
605      if (typeof socket.onMessageBound === 'function') {
606        socket.removeListener('message', socket.onMessageBound)
607      }
608      socket.onMessageBound = null
609  
610      if (typeof socket.onErrorBound === 'function') {
611        socket.removeListener('error', socket.onErrorBound)
612      }
613      socket.onErrorBound = null
614  
615      if (typeof socket.onCloseBound === 'function') {
616        socket.removeListener('close', socket.onCloseBound)
617      }
618      socket.onCloseBound = null
619    }
620  
621    _onWebSocketError (socket, err) {
622      debug('websocket error %s', err.message || err)
623      this.emit('warning', err)
624      this._onWebSocketClose(socket)
625    }
626  
627    _onRequest (params, cb) {
628      if (params && params.action === common.ACTIONS.CONNECT) {
629        cb(null, { action: common.ACTIONS.CONNECT })
630      } else if (params && params.action === common.ACTIONS.ANNOUNCE) {
631        this._onAnnounce(params, cb)
632      } else if (params && params.action === common.ACTIONS.SCRAPE) {
633        this._onScrape(params, cb)
634      } else {
635        cb(new Error('Invalid action'))
636      }
637    }
638  
639    _onAnnounce (params, cb) {
640      const self = this
641  
642      if (this._filter) {
643        this._filter(params.info_hash, params, err => {
644          // Presence of `err` means that this announce request is disallowed
645          if (err) return cb(err)
646  
647          getOrCreateSwarm((err, swarm) => {
648            if (err) return cb(err)
649            announce(swarm)
650          })
651        })
652      } else {
653        getOrCreateSwarm((err, swarm) => {
654          if (err) return cb(err)
655          announce(swarm)
656        })
657      }
658  
659      // Get existing swarm, or create one if one does not exist
660      function getOrCreateSwarm (cb) {
661        self.getSwarm(params.info_hash, (err, swarm) => {
662          if (err) return cb(err)
663          if (swarm) return cb(null, swarm)
664          self.createSwarm(params.info_hash, (err, swarm) => {
665            if (err) return cb(err)
666            cb(null, swarm)
667          })
668        })
669      }
670  
671      function announce (swarm) {
672        if (!params.event || params.event === 'empty') params.event = 'update'
673        swarm.announce(params, (err, response) => {
674          if (err) return cb(err)
675  
676          if (!response.action) response.action = common.ACTIONS.ANNOUNCE
677          if (!response.interval) response.interval = Math.ceil(self.intervalMs / 1000)
678  
679          if (params.compact === 1) {
680            const peers = response.peers
681  
682            // Find IPv4 peers
683            response.peers = string2compact(peers.filter(peer => common.IPV4_RE.test(peer.ip)).map(peer => `${peer.ip}:${peer.port}`))
684            // Find IPv6 peers
685            response.peers6 = string2compact(peers.filter(peer => common.IPV6_RE.test(peer.ip)).map(peer => `[${peer.ip}]:${peer.port}`))
686          } else if (params.compact === 0) {
687            // IPv6 peers are not separate for non-compact responses
688            response.peers = response.peers.map(peer => ({
689              'peer id': hex2bin(peer.peerId),
690              ip: peer.ip,
691              port: peer.port
692            }))
693          } // else, return full peer objects (used for websocket responses)
694  
695          cb(null, response)
696        })
697      }
698    }
699  
700    _onScrape (params, cb) {
701      if (params.info_hash == null) {
702        // if info_hash param is omitted, stats for all torrents are returned
703        // TODO: make this configurable!
704        params.info_hash = Object.keys(this.torrents)
705      }
706  
707      series(params.info_hash.map(infoHash => cb => {
708        this.getSwarm(infoHash, (err, swarm) => {
709          if (err) return cb(err)
710          if (swarm) {
711            swarm.scrape(params, (err, scrapeInfo) => {
712              if (err) return cb(err)
713              cb(null, {
714                infoHash,
715                complete: (scrapeInfo && scrapeInfo.complete) || 0,
716                incomplete: (scrapeInfo && scrapeInfo.incomplete) || 0
717              })
718            })
719          } else {
720            cb(null, { infoHash, complete: 0, incomplete: 0 })
721          }
722        })
723      }), (err, results) => {
724        if (err) return cb(err)
725  
726        const response = {
727          action: common.ACTIONS.SCRAPE,
728          files: {},
729          flags: { min_request_interval: Math.ceil(this.intervalMs / 1000) }
730        }
731  
732        results.forEach(result => {
733          response.files[hex2bin(result.infoHash)] = {
734            complete: result.complete || 0,
735            incomplete: result.incomplete || 0,
736            downloaded: result.complete || 0 // TODO: this only provides a lower-bound
737          }
738        })
739  
740        cb(null, response)
741      })
742    }
743  }
744  
745  Server.Swarm = Swarm
746  
747  function makeUdpPacket (params) {
748    let packet
749    switch (params.action) {
750      case common.ACTIONS.CONNECT: {
751        packet = Buffer.concat([
752          common.toUInt32(common.ACTIONS.CONNECT),
753          common.toUInt32(params.transactionId),
754          params.connectionId
755        ])
756        break
757      }
758      case common.ACTIONS.ANNOUNCE: {
759        packet = Buffer.concat([
760          common.toUInt32(common.ACTIONS.ANNOUNCE),
761          common.toUInt32(params.transactionId),
762          common.toUInt32(params.interval),
763          common.toUInt32(params.incomplete),
764          common.toUInt32(params.complete),
765          params.peers
766        ])
767        break
768      }
769      case common.ACTIONS.SCRAPE: {
770        const scrapeResponse = [
771          common.toUInt32(common.ACTIONS.SCRAPE),
772          common.toUInt32(params.transactionId)
773        ]
774        for (const infoHash in params.files) {
775          const file = params.files[infoHash]
776          scrapeResponse.push(
777            common.toUInt32(file.complete),
778            common.toUInt32(file.downloaded), // TODO: this only provides a lower-bound
779            common.toUInt32(file.incomplete)
780          )
781        }
782        packet = Buffer.concat(scrapeResponse)
783        break
784      }
785      case common.ACTIONS.ERROR: {
786        packet = Buffer.concat([
787          common.toUInt32(common.ACTIONS.ERROR),
788          common.toUInt32(params.transactionId || 0),
789          Buffer.from(String(params['failure reason']))
790        ])
791        break
792      }
793      default:
794        throw new Error(`Action not implemented: ${params.action}`)
795    }
796    return packet
797  }
798  
799  function isObject (obj) {
800    return typeof obj === 'object' && obj !== null
801  }
802  
803  function toNumber (x) {
804    x = Number(x)
805    return x >= 0 ? x : false
806  }
807  
808  function noop () {}
809  
810  export default Server