webtorrent
Version:
Streaming torrent client
1,616 lines (1,354 loc) • 68 kB
JavaScript
import EventEmitter from 'events'
import fs from 'fs'
import net from 'net' // browser exclude
import os from 'os' // browser exclude
import path from 'path'
import addrToIPPort from 'addr-to-ip-port'
import BitField from 'bitfield'
import CacheChunkStore from 'cache-chunk-store'
import { chunkStoreWrite } from 'chunk-store-iterator'
import cpus from 'cpus'
import debugFactory from 'debug'
import Discovery from 'torrent-discovery'
import FSChunkStore from 'fs-chunk-store' // browser: `fsa-chunk-store`
import fetch from 'cross-fetch-ponyfill'
import ImmediateChunkStore from 'immediate-chunk-store'
import ltDontHave from 'lt_donthave'
import MemoryChunkStore from 'memory-chunk-store'
import joinIterator from 'join-async-iterator'
import parallel from 'run-parallel'
import parallelLimit from 'run-parallel-limit'
import parseTorrent, { toMagnetURI, toTorrentFile, remote } from 'parse-torrent'
import Piece from 'torrent-piece'
import queueMicrotask from 'queue-microtask'
import randomIterate from 'random-iterate'
import { hash, arr2hex } from 'uint8-util'
import throughput from 'throughput'
import utMetadata from 'ut_metadata'
import utPex from 'ut_pex' // browser exclude
import File from './file.js'
import Peer from './peer.js'
import RarityMap from './rarity-map.js'
import utp from './utp.cjs' // browser exclude
import WebConn from './webconn.js'
import { Selections } from './selections.js'
import VERSION from '../version.cjs'
// The following JSDoc comments are global types that can be used across the project
/**
* This callback is called with an optional error, if the error is a falsy value the operation was executed successfully
* @callback callbackWithError
* @param {Error=} error
*/
/**
* @typedef TorrentOpts
* @type {object}
* @property {Array<string>=} announce - Torrent trackers to use (added to list in .torrent or magnet uri)
* @property {Array<string>=} urlList - Array of web seeds
* @property {string=} path - Folder to download files to (default=`/tmp/webtorrent/`)
* @property {boolean=} addUID - (Node.js only) If true, the torrent will be stored in it's infoHash folder to prevent file name collisions (default=false)
* @property {FileSystemDirectoryHandle=} rootDir - *(browser only)* if supported by the browser, allows the user to specify a custom directory to stores the files in, retaining the torrent's folder and file structure
* @property {boolean=} skipVerify - If true, client will skip verification of pieces for existing store and assume it's correct
* @property {Uint8Array|ArrayLike<number>=} bitfield - Preloaded numerical array/buffer to use to know what pieces are already downloaded (any type accepted by UInt8Array constructor is valid)
* @property {FSChunkStore|MemoryChunkStore|Function=} store - Custom chunk store
* @property {FSChunkStore|MemoryChunkStore|Function=} preloadedStore - Custom, pre-loaded chunk store
* @property {number=} storeCacheSlots - Number of chunk store entries (torrent pieces) to cache in memory [default=20]; 0 to disable caching
* @property {boolean=} destroyStoreOnDestroy - If truthy, client will delete the torrent's chunk store (e.g. files on disk) when the torrent is destroyed
* @property {object=} storeOpts - Custom options passed to the store
* @property {boolean=} alwaysChokeSeeders - If true, client will automatically choke seeders if it's seeding. (default=true)
* @property {function(): object=} getAnnounceOpts - Custom callback to allow sending extra parameters to the tracker
* @property {boolean=} private - If true, client will not share the hash with the DHT nor with PEX (default is the privacy of the parsed torrent)
* @property {'rarest'|'sequential'=} strategy - Piece selection strategy, `rarest` or `sequential`(defaut=`sequential`)
* @property {number=} maxWebConns - Max number of simultaneous connections per web seed [default=4]
* @property {number|false=} uploads - [default=10]
* @property {number=} noPeersIntervalTime - The amount of time (in seconds) to wait between each check of the `noPeers` event (default=30)
* @property {boolean=} deselect - If true, create the torrent with no pieces selected (default=false)
* @property {boolean=} paused - If true, create the torrent in a paused state (default=false)
* @property {Array<number>=} fileModtimes - An array containing a UNIX timestamp indicating the last change for each file of the torrent
*/
// End of JSDoc global declarations
const debug = debugFactory('webtorrent:torrent')
const MAX_BLOCK_LENGTH = 128 * 1024
const PIECE_TIMEOUT = 30_000
const CHOKE_TIMEOUT = 5_000
const SPEED_THRESHOLD = 3 * Piece.BLOCK_LENGTH
const PIPELINE_MIN_DURATION = 0.5
const PIPELINE_MAX_DURATION = 1
const RECHOKE_INTERVAL = 10_000 // 10 seconds
const RECHOKE_OPTIMISTIC_DURATION = 2 // 30 seconds
const DEFAULT_NO_PEERS_INTERVAL = 30_000 // 30 seconds
// IndexedDB chunk stores used in the browser benefit from high concurrency
const FILESYSTEM_CONCURRENCY = process.browser ? cpus().length : 2
const RECONNECT_WAIT = [1_000, 5_000, 15_000]
const USER_AGENT = `WebTorrent/${VERSION} (https://webtorrent.io)`
// if nodejs or browser that supports FSA
const SUPPORTS_FSA = globalThis.navigator?.storage?.getDirectory && globalThis.FileSystemFileHandle?.prototype?.createWritable
const FALLBACK_STORE = !process.browser || SUPPORTS_FSA
? FSChunkStore // Node or browser with FSA
: MemoryChunkStore
let TMP
try {
TMP = path.join(fs.statSync('/tmp') && '/tmp', 'webtorrent')
} catch (err) {
TMP = path.join(typeof os.tmpdir === 'function' ? os.tmpdir() : '/', 'webtorrent')
}
const IDLE_CALLBACK = typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function' && window.requestIdleCallback
export default class Torrent extends EventEmitter {
/**
* Start downloading a new torrent.
* @param {string|ArrayBufferView|Object} torrentId
* @param {import('../index.js').default} client
* @param {TorrentOpts} opts
*/
constructor (torrentId, client, opts) {
super()
this._debugId = 'unknown infohash'
this.client = client
this.announce = opts.announce
this.urlList = opts.urlList
this.path = opts.path || TMP
this.addUID = opts.addUID || false
this.rootDir = opts.rootDir || null
this.skipVerify = !!opts.skipVerify
this._startupBitfield = opts.bitfield
this._store = opts.store || FALLBACK_STORE
this._preloadedStore = opts.preloadedStore || null
this._storeCacheSlots = opts.storeCacheSlots !== undefined ? opts.storeCacheSlots : 20
this._destroyStoreOnDestroy = opts.destroyStoreOnDestroy || false
this.store = null
this.storeOpts = opts.storeOpts
this.alwaysChokeSeeders = opts.alwaysChokeSeeders ?? true
this._getAnnounceOpts = opts.getAnnounceOpts
// if defined, `opts.private` overrides default privacy of torrent
if (typeof opts.private === 'boolean') this.private = opts.private
this.strategy = opts.strategy || 'sequential'
this.maxWebConns = opts.maxWebConns || 4
this._rechokeNumSlots = (opts.uploads === false || opts.uploads === 0)
? 0
: (+opts.uploads || 10)
this._rechokeOptimisticWire = null
this._rechokeOptimisticTime = 0
this._rechokeIntervalId = null
this._noPeersIntervalId = null
this._noPeersIntervalTime = opts.noPeersIntervalTime ? opts.noPeersIntervalTime * 1000 : DEFAULT_NO_PEERS_INTERVAL
this._startAsDeselected = opts.deselect || false
this.ready = false
this.destroyed = false
this.paused = opts.paused || false
this.done = false
this.metadata = null
/**
* Files of the torrent
* @type {File[]}
*/
this.files = []
/**
* Pieces that need to be downloaded, indexed by piece index
* @type {Array<Piece|null>}
*/
this.pieces = []
this._amInterested = false
this._selections = new Selections()
this._critical = []
/**
* open wires (added *after* handshake)
* @type {import('bittorrent-protocol').default[]}
*/
this.wires = []
this._queue = [] // queue of outgoing tcp peers to connect to
/**
* connected peers (addr/peerId -> Peer)
* @type {Map<string, Peer>}
*/
this._peers = new Map()
this._peersLength = 0 // number of elements in `this._peers` (cache, for perf)
// stats
this.received = 0
this.uploaded = 0
this._downloadSpeed = throughput()
this._uploadSpeed = throughput()
// for cleanup
this._servers = []
this._xsRequests = []
// TODO: remove this and expose a hook instead
// optimization: don't recheck every file if it hasn't changed
this._fileModtimes = opts.fileModtimes
if (torrentId !== null) this._onTorrentId(torrentId)
this._debug('new torrent')
}
get timeRemaining () {
if (this.done) return 0
if (this.downloadSpeed === 0) return Infinity
return ((this.length - this.downloaded) / this.downloadSpeed) * 1000
}
get downloaded () {
if (!this.bitfield) return 0
let downloaded = 0
for (let index = 0, len = this.pieces.length; index < len; ++index) {
if (this.bitfield.get(index)) { // verified data
downloaded += (index === len - 1) ? this.lastPieceLength : this.pieceLength
} else { // "in progress" data
const piece = this.pieces[index]
downloaded += (piece.length - piece.missing)
}
}
return downloaded
}
// TODO: re-enable this. The number of missing pieces. Used to implement 'end game' mode.
// Object.defineProperty(Storage.prototype, 'numMissing', {
// get: function () {
// var self = this
// var numMissing = self.pieces.length
// for (var index = 0, len = self.pieces.length; index < len; index++) {
// numMissing -= self.bitfield.get(index)
// }
// return numMissing
// }
// })
get downloadSpeed () { return this._downloadSpeed() }
get uploadSpeed () { return this._uploadSpeed() }
get progress () { return this.length ? this.downloaded / this.length : 0 }
get ratio () { return this.uploaded / (this.received || this.length) }
get numPeers () { return this.wires.length }
get torrentFileBlob () {
if (!this.torrentFile) return null
return new Blob([this.torrentFile], { type: 'application/x-bittorrent' })
}
get _numQueued () {
return this._queue.length + (this._peersLength - this._numConns)
}
_numConns = 0
/**
* Parse a torrent from its magnet/torrent file/remote url and kickstart downloading it
* @param {string|ArrayBufferView|Object} torrentId
* @returns {Promise<void>}
* @private
*/
async _onTorrentId (torrentId) {
if (this.destroyed) return
let parsedTorrent
try { parsedTorrent = await parseTorrent(torrentId) } catch (err) {}
if (parsedTorrent) {
// Attempt to set infoHash property synchronously
this.infoHash = parsedTorrent.infoHash
this._debugId = arr2hex(parsedTorrent.infoHash).substring(0, 7)
queueMicrotask(() => {
if (this.destroyed) return
this._onParsedTorrent(parsedTorrent)
})
} else {
// If torrentId failed to parse, it could be in a form that requires an async
// operation, i.e. http/https link, filesystem path, or Blob.
remote(torrentId, (err, parsedTorrent) => {
if (this.destroyed) return
if (err) return this._destroy(err)
this._onParsedTorrent(parsedTorrent)
})
}
}
_onParsedTorrent (parsedTorrent) {
if (this.destroyed) return
this._processParsedTorrent(parsedTorrent)
if (!this.infoHash) {
return this._destroy(new Error('Malformed torrent data: No info hash'))
}
this._rechokeIntervalId = setInterval(() => {
this._rechoke()
}, RECHOKE_INTERVAL)
if (this._rechokeIntervalId.unref) this._rechokeIntervalId.unref()
// Private 'infoHash' event allows client.add to check for duplicate torrents and
// destroy them before the normal 'infoHash' event is emitted. Prevents user
// applications from needing to deal with duplicate 'infoHash' events.
this.emit('_infoHash', this.infoHash)
if (this.destroyed) return
this.emit('infoHash', this.infoHash)
if (this.destroyed) return // user might destroy torrent in event handler
if (this.client.listening) {
this._onListening()
} else {
this.client.once('listening', () => {
this._onListening()
})
}
}
_processParsedTorrent (parsedTorrent) {
this._debugId = arr2hex(parsedTorrent.infoHash).substring(0, 7)
if (typeof this.private !== 'undefined') {
// `private` option overrides default, only if it's defined
parsedTorrent.private = this.private
}
if (Array.isArray(this.announce)) {
// Allow specifying trackers via `opts` parameter
parsedTorrent.announce = parsedTorrent.announce.concat(this.announce)
}
if (this.client.tracker && Array.isArray(this.client.tracker.announce) && !parsedTorrent.private) {
// If the client has a default tracker, add it to the announce list if torrent is not private
parsedTorrent.announce = parsedTorrent.announce.concat(this.client.tracker.announce)
}
if (this.client.tracker && global.WEBTORRENT_ANNOUNCE && !parsedTorrent.private) {
// So `webtorrent-hybrid` can force specific trackers to be used
parsedTorrent.announce = parsedTorrent.announce.concat(global.WEBTORRENT_ANNOUNCE)
}
if (this.urlList) {
// Allow specifying web seeds via `opts` parameter
parsedTorrent.urlList = parsedTorrent.urlList.concat(this.urlList)
}
// remove duplicates by converting to Set and back
parsedTorrent.announce = Array.from(new Set(parsedTorrent.announce))
parsedTorrent.urlList = Array.from(new Set(parsedTorrent.urlList))
Object.assign(this, parsedTorrent)
this.magnetURI = toMagnetURI(parsedTorrent)
this.torrentFile = toTorrentFile(parsedTorrent)
}
_onListening () {
if (this.destroyed) return
if (this.info) {
// if full metadata was included in initial torrent id, use it immediately. Otherwise,
// wait for torrent-discovery to find peers and ut_metadata to get the metadata.
this._onMetadata(this)
} else {
if (this.xs) this._getMetadataFromServer()
this._startDiscovery()
}
}
_startDiscovery () {
if (this.discovery || this.destroyed) return
let trackerOpts = this.client.tracker
if (trackerOpts) {
trackerOpts = Object.assign({}, this.client.tracker, {
getAnnounceOpts: () => {
if (this.destroyed) return
const opts = {
uploaded: this.uploaded,
downloaded: this.downloaded,
left: Math.max(this.length - this.downloaded, 0)
}
if (this.client.tracker.getAnnounceOpts) {
Object.assign(opts, this.client.tracker.getAnnounceOpts())
}
if (this._getAnnounceOpts) {
// TODO: consider deprecating this, as it's redundant with the former case
Object.assign(opts, this._getAnnounceOpts())
}
return opts
}
})
}
// add BEP09 peer-address
if (this.peerAddresses) {
this.peerAddresses.forEach(peer => this.addPeer(peer, Peer.SOURCE_MANUAL))
}
// begin discovering peers via DHT and trackers
this.discovery = new Discovery({
infoHash: this.infoHash,
announce: this.announce,
peerId: this.client.peerId,
dht: !this.private && this.client.dht,
tracker: trackerOpts,
port: this.client.torrentPort,
userAgent: USER_AGENT,
lsd: this.client.lsd
})
this.discovery.on('error', (err) => {
this._destroy(err)
})
this.discovery.on('peer', (peer, source) => {
this._debug('peer %s discovered via %s', peer, source)
// Don't create new outgoing connections when torrent is done and seedOutgoingConnections is false.
if (!this.client.seedOutgoingConnections && this.done) {
this._debug('discovery ignoring peer %s: torrent is done and seedOutgoingConnections is false', peer)
return
}
this.addPeer(peer, source)
})
this.discovery.on('trackerAnnounce', () => {
this.emit('trackerAnnounce')
})
this.discovery.on('dhtAnnounce', () => {
this.emit('dhtAnnounce')
})
this.discovery.on('warning', (err) => {
this.emit('warning', err)
})
this._noPeersIntervalId = setInterval(() => {
if (this.destroyed) return
const counters = {
[Peer.SOURCE_TRACKER]: {
enabled: !!this.client.tracker,
numPeers: 0
},
[Peer.SOURCE_DHT]: {
enabled: !!this.client.dht,
numPeers: 0
},
[Peer.SOURCE_LSD]: {
enabled: !!this.client.lsd,
numPeers: 0
},
[Peer.SOURCE_UT_PEX]: {
enabled: (this.client.utPex && typeof utPex === 'function'),
numPeers: 0
}
}
for (const peer of this._peers.values()) {
const counter = counters[peer.source]
if (typeof counter !== 'undefined') counter.numPeers++
}
for (const source of Object.keys(counters)) {
const counter = counters[source]
if (counter.enabled && counter.numPeers === 0) this.emit('noPeers', source)
}
}, this._noPeersIntervalTime)
if (this._noPeersIntervalId.unref) this._noPeersIntervalId.unref()
}
_getMetadataFromServer () {
// to allow function hoisting
const self = this
const urls = Array.isArray(this.xs) ? this.xs : [this.xs]
self._xsRequestsController = new AbortController()
const signal = self._xsRequestsController.signal
const tasks = urls.map(url => cb => {
getMetadataFromURL(url, cb)
})
parallel(tasks)
async function getMetadataFromURL (url, cb) {
if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
self.emit('warning', new Error(`skipping non-http xs param: ${url}`))
return cb(null)
}
const opts = {
method: 'GET',
headers: {
'user-agent': USER_AGENT
},
signal
}
let res
try {
res = await fetch(url, opts)
} catch (err) {
self.emit('warning', new Error(`http error from xs param: ${url}`))
return cb(null)
}
if (self.destroyed) return cb(null)
if (self.metadata) return cb(null)
if (res.status !== 200) {
self.emit('warning', new Error(`non-200 status code ${res.status} from xs param: ${url}`))
return cb(null)
}
let torrent
try {
torrent = new Uint8Array(await res.arrayBuffer())
} catch (e) {
self.emit('warning', e)
return cb(null)
}
let parsedTorrent
try {
parsedTorrent = await parseTorrent(torrent)
} catch (err) {}
if (!parsedTorrent) {
self.emit('warning', new Error(`got invalid torrent file from xs param: ${url}`))
return cb(null)
}
if (parsedTorrent.infoHash !== self.infoHash) {
self.emit('warning', new Error(`got torrent file with incorrect info hash from xs param: ${url}`))
return cb(null)
}
self._onMetadata(parsedTorrent)
cb(null)
}
}
/**
* Called when the full torrent metadata is received.
*/
async _onMetadata (metadata) {
if (this.metadata || this.destroyed) return
this._debug('got metadata')
this._xsRequestsController?.abort()
this._xsRequestsController = null
let parsedTorrent
if (metadata && metadata.infoHash) {
// `metadata` is a parsed torrent (from parse-torrent module)
parsedTorrent = metadata
} else {
try {
parsedTorrent = await parseTorrent(metadata)
} catch (err) {
return this._destroy(err)
}
}
this._processParsedTorrent(parsedTorrent)
this.metadata = this.torrentFile
// add web seed urls (BEP19)
if (this.client.enableWebSeeds) {
this.urlList.forEach(url => {
this.addWebSeed(url)
})
}
this._rarityMap = new RarityMap(this)
this.files = this.files.map(file => new File(this, file))
let rawStore = this._preloadedStore
if (!rawStore) {
rawStore = new this._store(this.pieceLength, {
...this.storeOpts,
torrent: this,
path: this.path,
files: this.files,
length: this.length,
name: this.name + ' - ' + this.infoHash.slice(0, 8),
addUID: this.addUID,
rootDir: this.rootDir,
max: this._storeCacheSlots
})
}
// don't use the cache if the store is already in memory
if (this._storeCacheSlots > 0 && !(rawStore instanceof MemoryChunkStore)) {
rawStore = new CacheChunkStore(rawStore, {
max: this._storeCacheSlots
})
}
this.store = new ImmediateChunkStore(
rawStore
)
// Select only specified files (BEP53) http://www.bittorrent.org/beps/bep_0053.html
if (this.so && !this._startAsDeselected) {
this.files.forEach((v, i) => {
if (this.so.includes(i)) {
this.files[i].select()
}
})
} else {
// start off selecting the entire torrent with low priority
if (this.pieces.length !== 0 && !this._startAsDeselected) {
this.select(0, this.pieces.length - 1)
}
}
this._hashes = this.pieces
// A startup bitfield can be used only when all the conditions are right:
// - It exists
// - It's the correct size ( rounded to the first byte )
// - It will not be rewritten by _markAllVerified
this._hasStartupBitfield = this._startupBitfield && this._startupBitfield.length === Math.ceil(this.pieces.length / 8) && !this.skipVerify
this.bitfield = new BitField(this._hasStartupBitfield ? new Uint8Array(this._startupBitfield) : this.pieces.length)
this._reservations = this._hasStartupBitfield
? this.pieces.map((_, index) => this.bitfield.get(index) ? null : [])
: this.pieces.map(() => [])
this.pieces = this.pieces.map((hash, i) => {
if (this._hasStartupBitfield && this.bitfield.get(i)) {
return null
}
const pieceLength = (i === this.pieces.length - 1)
? this.lastPieceLength
: this.pieceLength
return new Piece(pieceLength)
})
// Emit 'metadata' before 'ready' and 'done'
this.emit('metadata')
// User might destroy torrent in response to 'metadata' event
if (this.destroyed) return
if (this.skipVerify) {
// Skip verifying exisitng data and just assume it's correct
this._markAllVerified()
this._onStore()
} else {
const onPiecesVerified = (err) => {
if (err) return this._destroy(err)
this._debug('done verifying')
this._onStore()
}
this._debug('verifying existing torrent data')
if (this._fileModtimes && this._store === FSChunkStore) {
// don't verify if the files haven't been modified since we last checked
this.getFileModtimes((err, fileModtimes) => {
if (err) return this._destroy(err)
const unchanged = this.files.map((_, index) => fileModtimes[index] === this._fileModtimes[index]).every(x => x)
if (unchanged) {
this._markAllVerified()
this._onStore()
} else {
this._verifyPieces(onPiecesVerified)
}
})
} else {
this._verifyPieces(onPiecesVerified)
}
}
}
/*
* TODO: remove this
* Gets the last modified time of every file on disk for this torrent.
* Only valid in Node, not in the browser.
*/
getFileModtimes (cb) {
const ret = []
parallelLimit(this.files.map((file, index) => cb => {
const filePath = this.addUID ? path.join(this.name + ' - ' + this.infoHash.slice(0, 8)) : path.join(this.path, file.path)
fs.stat(filePath, (err, stat) => {
if (err && err.code !== 'ENOENT') return cb(err)
ret[index] = stat && stat.mtime.getTime()
cb(null)
})
}), FILESYSTEM_CONCURRENCY, err => {
this._debug('done getting file modtimes')
cb(err, ret)
})
}
/**
* Callback called after a piece is verified
* @callback verifyPieceCallback
* @param {Error=} error
* @param {boolean=} isValid
*/
/**
* Verify a single piece using hashing
* @param index
* @param {verifyPieceCallback} cb
* @private
*/
_verifyPiece (index, cb) {
if (this.destroyed) return cb(new Error('torrent is destroyed'))
const getOpts = {}
// Specify length for the last piece in case it is zero-padded
if (index === this.pieces.length - 1) {
getOpts.length = this.lastPieceLength
}
this.store.get(index, getOpts, async (err, buf) => {
if (this.destroyed) return cb(new Error('torrent is destroyed'))
if (err) return queueMicrotask(() => cb(null, false)) // ignore error
const hex = await hash(buf, 'hex')
if (this.destroyed) return cb(new Error('torrent is destroyed'))
cb(null, hex === this._hashes[index])
})
}
/**
* Verify pieces using bitfield, in case of in-congruences it will re-verify the file using hashing
* @param {callbackWithError} cb
* @private
*/
_verifyPiecesUsingBitfield (cb) {
const piecesToCheck = new Set()
const piecesToFilesMap = new Map()
// First step, optimistically mark what is verified and what is not by blindly trusting the bitfield
// and construct a list of pieces to verify ( max 1 piece for each file, in some edge cases that piece could overlap )
for (const file of this.files) {
let checkFile = 2
let pieceToCheckForThisFile = null
for (let i = file._startPiece; i <= file._endPiece; ++i) {
if (this.bitfield.get(i)) {
if (checkFile) {
pieceToCheckForThisFile = i
checkFile--
}
if (!piecesToFilesMap.has(i)) {
piecesToFilesMap.set(i, [])
}
piecesToFilesMap.get(i).push(file)
}
}
if (pieceToCheckForThisFile !== null) {
piecesToCheck.add(pieceToCheckForThisFile)
}
}
// Second step, for each piece that needs to be verified we verify it using hashing
this._verifyPiecesUsingHash([...piecesToCheck], (err) => {
if (err) {
return cb(err)
}
const filesToCheck = new Set()
for (const piece of piecesToCheck) {
if (!this.bitfield.get(piece)) {
const filesOnPiece = piecesToFilesMap.get(piece)
for (const file of filesOnPiece) {
filesToCheck.add(file)
}
}
}
// Third step, if we need to recheck files we are going to fully recheck them
if (filesToCheck.size) {
const piecesToRecheck = []
for (const file of filesToCheck) {
for (let i = file._startPiece; i <= file._endPiece; ++i) {
if (piecesToRecheck.indexOf(i) === -1) {
piecesToRecheck.push(i)
}
}
}
return this._verifyPiecesUsingHash(piecesToRecheck, cb)
}
cb(null)
})
}
/**
* Verifies the pieces of the torrent using hashing ( it can be very slow )
* @param {Array<number>} pieces
* @param {callbackWithError} cb
* @private
*/
_verifyPiecesUsingHash (pieces, cb) {
parallelLimit(pieces.map((piece, index) => cb => {
const target = Number.isInteger(piece) ? piece : index
this._verifyPiece(target, (err, isVerified) => {
if (err) return cb(err)
if (isVerified) {
this._debug('piece verified %s', target)
this._markVerified(target)
} else {
this._markUnverified(target)
this._debug('piece invalid %s', target)
}
cb(null)
})
}), FILESYSTEM_CONCURRENCY, cb)
}
/**
* Verifies the pieces of the torrent, if a startup bitfield is provided it will be used for verification
* @param {callbackWithError} cb
* @private
*/
_verifyPieces (cb) {
if (this._hasStartupBitfield) {
return this._verifyPiecesUsingBitfield(cb)
}
this._verifyPiecesUsingHash(this.pieces, cb)
}
/**
* Verify the hashes of all pieces in the store and update the bitfield for any new valid
* pieces. Useful if data has been added to the store outside WebTorrent, e.g. if another
* process puts a valid file in the right place. Once the scan is complete,
* `callback(null)` will be called (if provided), unless the torrent was destroyed during
* the scan, in which case `callback` will be called with an error.
* @param {callbackWithError=} cb
*/
rescanFiles (cb) {
if (this.destroyed) throw new Error('torrent is destroyed')
if (!cb) cb = noop
this._verifyPiecesUsingHash(this.pieces, (err) => {
if (err) {
this._destroy(err)
return cb(err)
}
this._checkDone()
cb(null)
})
}
/**
* Mark the entire torrent as verified ( i.e. fully downloaded )
* @private
*/
_markAllVerified () {
for (let index = 0; index < this.pieces.length; index++) {
this._markVerified(index)
}
}
/**
* Mark one piece as verified
* @param {number} index
* @private
*/
_markVerified (index) {
this.pieces[index] = null
this._reservations[index] = null
this.bitfield.set(index, true)
this.emit('verified', index)
}
/**
* Mark one piece as unverified
* @param {number} index
* @private
*/
_markUnverified (index) {
const len = (index === this.pieces.length - 1)
? this.lastPieceLength
: this.pieceLength
this.pieces[index] = new Piece(len)
this.bitfield.set(index, false)
if (!this._startAsDeselected) this.select(index, index)
for (const file of this.files) {
if (file.done && file.includes(index)) file.done = false
}
}
_hasAllPieces () {
for (let index = 0; index < this.pieces.length; index++) {
if (!this.bitfield.get(index)) return false
}
return true
}
_hasNoPieces () {
return !this._hasMorePieces(0)
}
_hasMorePieces (threshold) {
let count = 0
for (let index = 0; index < this.pieces.length; index++) {
if (this.bitfield.get(index)) {
count += 1
if (count > threshold) return true
}
}
return false
}
/**
* Called when the metadata, listening server, and underlying chunk store is initialized.
*/
_onStore () {
if (this.destroyed) return
this._debug('on store')
// Start discovery before emitting 'ready'
this._startDiscovery()
this.ready = true
this.emit('ready')
// Files may start out done if the file was already in the store
this._checkDone()
// In case any selections were made before torrent was ready
this._updateSelections()
// Start requesting pieces after we have initially verified them
this.wires.forEach(wire => {
// If we didn't have the metadata at the time ut_metadata was initialized for this
// wire, we still want to make it available to the peer in case they request it.
if (wire.ut_metadata) wire.ut_metadata.setMetadata(this.metadata)
this._onWireWithMetadata(wire)
})
}
destroy (opts, cb) {
if (typeof opts === 'function') return this.destroy(null, opts)
this._destroy(null, opts, cb)
}
_destroy (err, opts, cb) {
if (typeof opts === 'function') return this._destroy(err, null, opts)
if (this.destroyed) return
this.destroyed = true
this._debug('destroy')
this.client._remove(this)
this._selections.clear()
clearInterval(this._rechokeIntervalId)
clearInterval(this._noPeersIntervalId)
this._xsRequestsController?.abort()
if (this._rarityMap) {
this._rarityMap.destroy()
}
for (const id of this._peers.keys()) {
this.removePeer(id)
}
for (const file of this.files) {
if (file instanceof File) file._destroy()
}
const tasks = this._servers.map(server => cb => {
server.destroy(cb)
})
if (this.discovery) {
tasks.push(cb => {
this.discovery.destroy(cb)
})
}
if (this.store) {
let destroyStore = this._destroyStoreOnDestroy
if (opts && opts.destroyStore !== undefined) {
destroyStore = opts.destroyStore
}
tasks.push(cb => {
if (destroyStore) {
this.store.destroy(cb)
} else {
this.store.close(cb)
}
})
}
parallel(tasks, cb)
if (err) {
// Torrent errors are emitted at `torrent.on('error')`. If there are no 'error'
// event handlers on the torrent instance, then the error will be emitted at
// `client.on('error')`. This prevents throwing an uncaught exception
// (unhandled 'error' event), but it makes it impossible to distinguish client
// errors versus torrent errors. Torrent errors are not fatal, and the client
// is still usable afterwards. Therefore, always listen for errors in both
// places (`client.on('error')` and `torrent.on('error')`).
if (this.listenerCount('error') === 0) {
this.client.emit('error', err)
} else {
this.emit('error', err)
}
}
this.emit('close')
this.client = null
this.files = []
this.discovery = null
this.store = null
this._rarityMap = null
this._peers.clear()
this._peers = null
this._servers = null
this._xsRequests = null
this._queue = null
}
addPeer (peer, source) {
if (this.destroyed) throw new Error('torrent is destroyed')
if (!this.infoHash) throw new Error('addPeer() must not be called before the `infoHash` event')
let host
if (typeof peer === 'string') {
let parts
try {
parts = addrToIPPort(peer)
} catch (e) {
this._debug('ignoring peer: invalid %s', peer)
this.emit('invalidPeer', peer)
return false
}
host = parts[0]
} else if (typeof peer.remoteAddress === 'string') {
host = peer.remoteAddress
}
if (this.client.blocked && host && this.client.blocked.contains(host)) {
this._debug('ignoring peer: blocked %s', peer)
if (typeof peer !== 'string') peer.destroy()
this.emit('blockedPeer', peer)
return false
}
// if the utp connection fails to connect, then it is replaced with a tcp connection to the same ip:port
const type = (this.client.utp && this._isIPv4(host)) ? 'utp' : 'tcp'
const wasAdded = !!this._addPeer(peer, type, source)
if (wasAdded) {
this.emit('peer', peer)
} else {
this.emit('invalidPeer', peer)
}
return wasAdded
}
_addPeer (peer, type, source) {
if (this.destroyed) {
if (typeof peer !== 'string') peer.destroy()
return null
}
if (typeof peer === 'string' && !this._validAddr(peer)) {
this._debug('ignoring peer: invalid %s', peer)
return null
}
const id = (peer && peer.id) || peer
if (this._peers.has(id)) {
this._debug('ignoring peer: duplicate (%s)', id)
if (typeof peer !== 'string') peer.destroy()
return null
}
if (this.paused) {
this._debug('ignoring peer: torrent is paused')
if (typeof peer !== 'string') peer.destroy()
return null
}
this._debug('add peer %s', id)
let newPeer
if (typeof peer === 'string') {
// `peer` is an addr ("ip:port" string)
newPeer = type === 'utp'
? Peer.createUTPOutgoingPeer(peer, this, this.client.throttleGroups, source)
: Peer.createTCPOutgoingPeer(peer, this, this.client.throttleGroups, source)
} else {
// `peer` is a WebRTC connection (simple-peer)
newPeer = Peer.createWebRTCPeer(peer, this, this.client.throttleGroups, source)
}
this._registerPeer(newPeer)
if (typeof peer === 'string') {
// `peer` is an addr ("ip:port" string)
this._queue.push(newPeer)
this._drain()
}
return newPeer
}
addWebSeed (urlOrConn) {
if (this.destroyed) throw new Error('torrent is destroyed')
let id
let conn
if (typeof urlOrConn === 'string') {
id = urlOrConn
if (!/^https?:\/\/.+/.test(id)) {
this.emit('warning', new Error(`ignoring invalid web seed: ${id}`))
this.emit('invalidPeer', id)
return
}
if (this._peers.has(id)) {
this.emit('warning', new Error(`ignoring duplicate web seed: ${id}`))
this.emit('invalidPeer', id)
return
}
conn = new WebConn(id, this)
} else if (urlOrConn && typeof urlOrConn.connId === 'string') {
conn = urlOrConn
id = conn.connId
if (this._peers.has(id)) {
this.emit('warning', new Error(`ignoring duplicate web seed: ${id}`))
this.emit('invalidPeer', id)
return
}
} else {
this.emit('warning', new Error('addWebSeed must be passed a string or connection object with id property'))
return
}
this._debug('add web seed %s', id)
const newPeer = Peer.createWebSeedPeer(conn, id, this, this.client.throttleGroups)
this._registerPeer(newPeer)
this.emit('peer', id)
}
/**
* Called whenever a new incoming TCP peer connects to this torrent swarm. Called with a
* peer that has already sent a handshake.
*/
_addIncomingPeer (peer) {
if (this.destroyed) return peer.destroy(new Error('torrent is destroyed'))
if (this.paused) return peer.destroy(new Error('torrent is paused'))
this._debug('add incoming peer %s', peer.id)
this._registerPeer(peer)
}
/**
* @param {Peer} newPeer
*/
_registerPeer (newPeer) {
newPeer.on('download', downloaded => {
if (this.destroyed) return
this.received += downloaded
this._downloadSpeed(downloaded)
this.client._downloadSpeed(downloaded)
this.emit('download', downloaded)
if (this.destroyed) return
this.client.emit('download', downloaded)
})
newPeer.on('upload', uploaded => {
if (this.destroyed) return
this.uploaded += uploaded
this._uploadSpeed(uploaded)
this.client._uploadSpeed(uploaded)
this.emit('upload', uploaded)
if (this.destroyed) return
this.client.emit('upload', uploaded)
})
if (newPeer.connected) {
this._numConns += 1
} else {
newPeer.once('connect', () => {
if (this.destroyed) return
this._numConns += 1
})
}
newPeer.once('disconnect', () => {
this._numConns -= 1
})
this._peers.set(newPeer.id, newPeer)
this._peersLength += 1
}
removePeer (peer) {
const id = peer?.id || peer
if (peer && !peer.id) peer = this._peers?.get(id)
if (!peer) return
peer.destroy()
if (this.destroyed) return
this._debug('removePeer %s', id)
this._peers.delete(id)
this._peersLength -= 1
// If torrent swarm was at capacity before, try to open a new connection now
this._drain()
}
_select (start = 0, end = this.pieces.length - 1, priority, notify, isStreamSelection = false) {
if (this.destroyed) throw new Error('torrent is destroyed')
if (start < 0 || end < start || this.pieces.length <= end) {
throw new Error(`invalid selection ${start} : ${end}`)
}
priority = Number(priority) || 0
this._debug('select %s-%s (priority %s)', start, end, priority)
this._selections.insert({
from: start,
to: end,
offset: 0,
priority,
notify,
isStreamSelection
})
this._selections.sort((a, b) => b.priority - a.priority)
this._updateSelections()
}
select (start, end, priority, notify) {
this._select(start, end, priority, notify, false)
}
_deselect (from, to, isStreamSelection = false) {
if (this.destroyed) throw new Error('torrent is destroyed')
this._debug('deselect %s-%s', from, to)
this._selections.remove({ from, to, isStreamSelection })
this._updateSelections()
}
deselect (start, end) {
this._deselect(start, end, false)
}
critical (start, end) {
if (this.destroyed) throw new Error('torrent is destroyed')
this._debug('critical %s-%s', start, end)
for (let i = start; i <= end; ++i) {
this._critical[i] = true
}
this._updateSelections()
}
_onWire (wire, addr) {
this._debug('got wire %s (%s)', wire._debugId, addr || 'Unknown')
this.wires.push(wire)
if (addr) {
// Sometimes RTCPeerConnection.getStats() doesn't return an ip:port for peers
const parts = addrToIPPort(addr)
wire.remoteAddress = parts[0]
wire.remotePort = parts[1]
}
// When peer sends PORT message, add that DHT node to routing table
if (this.client.dht && this.client.dht.listening) {
wire.on('port', port => {
if (this.destroyed || this.client.dht.destroyed) {
return
}
if (!wire.remoteAddress) {
return this._debug('ignoring PORT from peer with no address')
}
if (port === 0 || port > 65536) {
return this._debug('ignoring invalid PORT from peer')
}
this._debug('port: %s (from %s)', port, addr)
this.client.dht.addNode({ host: wire.remoteAddress, port })
})
}
wire.on('timeout', () => {
this._debug('wire timeout (%s)', addr)
// TODO: this might be destroying wires too eagerly
wire.destroy()
})
// Timeout for piece requests to this peer
if (wire.type !== 'webSeed') { // webseeds always send 'unhave' on http timeout
wire.setTimeout(PIECE_TIMEOUT, true)
}
// Send KEEP-ALIVE (every 60s) so peers will not disconnect the wire
wire.setKeepAlive(true)
// use ut_metadata extension
wire.use(utMetadata(this.metadata))
wire.ut_metadata.on('warning', err => {
this._debug('ut_metadata warning: %s', err.message)
})
if (!this.metadata) {
wire.ut_metadata.on('metadata', metadata => {
this._debug('got metadata via ut_metadata')
this._onMetadata(metadata)
})
wire.ut_metadata.fetch()
}
// use ut_pex extension if the torrent is not flagged as private
if (this.client.utPex && typeof utPex === 'function' && !this.private) {
wire.use(utPex())
wire.ut_pex.on('peer', peer => {
// Only add potential new peers when torrent is done and seedOutgoingConnections is false.
if (!this.client.seedOutgoingConnections && this.done) {
this._debug('ut_pex ignoring peer %s: torrent is done and seedOutgoingConnections is false', peer)
return
}
this._debug('ut_pex: got peer: %s (from %s)', peer, addr)
this.addPeer(peer, Peer.SOURCE_UT_PEX)
})
wire.ut_pex.on('dropped', peer => {
// the remote peer believes a given peer has been dropped from the torrent swarm.
// if we're not currently connected to it, then remove it from the queue.
const peerObj = this._peers.get(peer)
if (peerObj && !peerObj.connected) {
this._debug('ut_pex: dropped peer: %s (from %s)', peer, addr)
this.removePeer(peer)
}
})
wire.once('close', () => {
// Stop sending updates to remote peer
wire.ut_pex.reset()
})
}
wire.use(ltDontHave())
// Hook to allow user-defined `bittorrent-protocol` extensions
// More info: https://github.com/webtorrent/bittorrent-protocol#extension-api
this.emit('wire', wire, addr)
if (this.ready) {
queueMicrotask(() => {
// This allows wire.handshake() to be called (by Peer.onHandshake) before any
// messages get sent on the wire
this._onWireWithMetadata(wire)
})
}
}
/**
* @param {import('bittorrent-protocol').default} wire
*/
_onWireWithMetadata (wire) {
let timeoutId = null
const onChokeTimeout = () => {
if (this.destroyed || wire.destroyed) return
if (this._numQueued > 2 * (this._numConns - this.numPeers) &&
wire.amInterested) {
wire.destroy()
} else {
timeoutId = setTimeout(onChokeTimeout, CHOKE_TIMEOUT)
if (timeoutId.unref) timeoutId.unref()
}
}
let i
const updateSeedStatus = () => {
// bittorrent-protocol will set use fake bitfield if it gets a have-all message, which means its a seeder
if (wire.peerPieces.buffer.length && !!wire.peerPieces.setAll) {
if (wire.peerPieces.buffer.length !== this.bitfield.buffer.length) return
for (i = 0; i < this.pieces.length; ++i) {
if (!wire.peerPieces.get(i)) return
}
}
wire.isSeeder = true
if (this.alwaysChokeSeeders) wire.choke() // always choke seeders
}
wire.on('bitfield', () => {
updateSeedStatus()
this._update()
this._updateWireInterest(wire)
})
wire.on('have', () => {
updateSeedStatus()
this._update()
this._updateWireInterest(wire)
})
wire.lt_donthave.on('donthave', () => {
updateSeedStatus()
this._update()
this._updateWireInterest(wire)
})
// fast extension (BEP6)
wire.on('have-all', () => {
wire.isSeeder = true
if (this.alwaysChokeSeeders) wire.choke() // always choke seeders
this._update()
this._updateWireInterest(wire)
})
// fast extension (BEP6)
wire.on('have-none', () => {
wire.isSeeder = false
this._update()
this._updateWireInterest(wire)
})
// fast extension (BEP6)
wire.on('allowed-fast', (index) => {
this._update()
})
wire.once('interested', () => {
wire.unchoke()
})
wire.once('close', () => {
clearTimeout(timeoutId)
})
wire.on('choke', () => {
clearTimeout(timeoutId)
timeoutId = setTimeout(onChokeTimeout, CHOKE_TIMEOUT)
if (timeoutId.unref) timeoutId.unref()
})
wire.on('unchoke', () => {
clearTimeout(timeoutId)
this._update()
})
wire.on('request', (index, offset, length, cb) => {
if (length > MAX_BLOCK_LENGTH) {
// Per spec, disconnect from peers that request >128KB
return wire.destroy()
}
if (this.pieces[index]) return
this.store.get(index, { offset, length }, cb)
})
// always send bitfield or equivalent fast extension message (required)
if (wire.hasFast && this._hasAllPieces()) wire.haveAll()
else if (wire.hasFast && this._hasNoPieces()) wire.haveNone()
else wire.bitfield(this.bitfield)
// initialize interest in case bitfield message was already received before above handler was registered
this._updateWireInterest(wire)
// Send PORT message to peers that support DHT
if (wire.peerExtensions.dht && this.client.dht && this.client.dht.listening) {
wire.port(this.client.dht.address().port)
}
if (wire.type !== 'webSeed') { // do not choke on webseeds
timeoutId = setTimeout(onChokeTimeout, CHOKE_TIMEOUT)
if (timeoutId.unref) timeoutId.unref()
}
wire.isSeeder = false
updateSeedStatus()
}
/**
* Called on selection changes.
*/
_updateSelections () {
if (!this.ready || this.destroyed) return
queueMicrotask(() => {
this._gcSelections()
})
this._updateInterest()
this._update()
}
/**
* Garbage collect selections with respect to the store's current state.
*/
_gcSelections () {
for (const s of this._selections) {
const oldOffset = s.offset
// check for newly downloaded pieces in selection
while (this.bitfield.get(s.from + s.offset) && s.from + s.offset < s.to) {
s.offset += 1
}
if (oldOffset !== s.offset) s.notify?.()
if (s.to !== s.from + s.offset) continue
if (!this.bitfield.get(s.from + s.offset)) continue
s.remove() // remove fully downloaded selection
s.notify?.()
this._updateInterest()
}
if (!this._selections.length) this.emit('idle')
}
/**
* Update interested status for all peers.
*/
_updateInterest () {
const prev = this._amInterested
this._amInterested = !!this._selections.length
this.wires.forEach(wire => this._updateWireInterest(wire))
if (prev === this._amInterested) return
if (this._amInterested) this.emit('interested')
else this.emit('uninterested')
}
_updateWireInterest (wire) {
let interested = false
for (let index = 0; index < this.pieces.length; ++index) {
if (this.pieces[index] && wire.peerPieces.get(index)) {
interested = true
break
}
}
if (interested) wire.interested()
else wire.uninterested()
}
/**
* Heartbeat to update all peers and their requests.
*/
_update () {
if (IDLE_CALLBACK) {
IDLE_CALLBACK(() => this._updateWireWrapper(), { timeout: 250 })
} else {
this._updateWireWrapper()
}
}
_updateWireWrapper () {
if (this.destroyed) return
// update wires in random order for better request distribution
const ite = randomIterate(this.wires)
let wire
while ((wire = ite())) {
this._updateWire(wire)
}
}
/**
* Attempts to update a peer's requests
* @param {import('bittorrent-protocol').default} wire
*/
_updateWire (wire) {
if (wire.destroyed) return false
// to allow function hoisting
const self = this
const minOutstandingRequests = getBlockPipelineLength(wire, PIPELINE_MIN_DURATION)
if (wire.requests.length >= minOutstandingRequests) return
const maxOutstandingRequests = getBlockPipelineLength(wire, PIPELINE_MAX_DURATION)
if (wire.peerChoking) {
if (wire.hasFast && wire.peerAllowedFastSet.length > 0 &&
!this._hasMorePieces(wire.peerAllowedFastSet.length - 1)) {
requestAllowedFastS