/ 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