mirror of
https://github.com/webtorrent/bittorrent-tracker.git
synced 2024-12-12 18:36:28 +00:00
293 lines
9.1 KiB
JavaScript
293 lines
9.1 KiB
JavaScript
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.<string>} 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.<string>} 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
|