import BitField from 'bitfield' import debugFactory from 'debug' import fetch from 'cross-fetch-ponyfill' import ltDontHave from 'lt_donthave' import { hash, concat } from 'uint8-util' import Wire from 'bittorrent-protocol' import once from 'once' import VERSION from '../version.cjs' const debug = debugFactory('webtorrent:webconn') const SOCKET_TIMEOUT = 60000 const RETRY_DELAY = 10000 /** * Converts requests for torrent blocks into http range requests. * @param {string} url web seed url * @param {Object} torrent */ export default class WebConn extends Wire { constructor (url, torrent) { super() this.url = url this.connId = url // Unique id to deduplicate web seeds this._torrent = torrent this._init(url) } _init (url) { this.setKeepAlive(true) this.use(ltDontHave()) this.once('handshake', async (infoHash, peerId) => { const hex = await hash(url, 'hex') // Used as the peerId for this fake remote peer if (this.destroyed) return this.handshake(infoHash, hex) const numPieces = this._torrent.pieces.length const bitfield = new BitField(numPieces) for (let i = 0; i <= numPieces; i++) { bitfield.set(i, true) } this.bitfield(bitfield) }) this.once('interested', () => { debug('interested') this.unchoke() }) this.on('uninterested', () => { debug('uninterested') }) this.on('choke', () => { debug('choke') }) this.on('unchoke', () => { debug('unchoke') }) this.on('bitfield', () => { debug('bitfield') }) this.lt_donthave.on('donthave', () => { debug('donthave') }) this.on('request', (pieceIndex, offset, length, callback) => { debug('request pieceIndex=%d offset=%d length=%d', pieceIndex, offset, length) this.httpRequest(pieceIndex, offset, length, (err, data) => { if (err) { // Cancel all in progress requests for this piece this.lt_donthave.donthave(pieceIndex) // Wait a little while before saying the webseed has the failed piece again const retryTimeout = setTimeout(() => { if (this.destroyed) return this.have(pieceIndex) }, RETRY_DELAY) if (retryTimeout.unref) retryTimeout.unref() } callback(err, data) }) }) } async httpRequest (pieceIndex, offset, length, cb) { cb = once(cb) const pieceOffset = pieceIndex * this._torrent.pieceLength const rangeStart = pieceOffset + offset /* offset within whole torrent */ const rangeEnd = rangeStart + length - 1 // Web seed URL format: // For single-file torrents, make HTTP range requests directly to the web seed URL // For multi-file torrents, add the torrent folder and file name to the URL const files = this._torrent.files let requests if (files.length <= 1) { requests = [{ url: this.url, start: rangeStart, end: rangeEnd }] } else { const requestedFiles = files.filter(file => file.offset <= rangeEnd && (file.offset + file.length) > rangeStart) if (requestedFiles.length < 1) { return cb(new Error('Could not find file corresponding to web seed range request')) } requests = requestedFiles.map(requestedFile => { const fileEnd = requestedFile.offset + requestedFile.length - 1 const url = this.url + (this.url[this.url.length - 1] === '/' ? '' : '/') + requestedFile.path.replace(this._torrent.path, '') return { url, fileOffsetInRange: Math.max(requestedFile.offset - rangeStart, 0), start: Math.max(rangeStart - requestedFile.offset, 0), end: Math.min(fileEnd, rangeEnd - requestedFile.offset) } }) } let chunks try { chunks = await Promise.all(requests.map(async ({ start, end, url }) => { debug( 'Requesting url=%s pieceIndex=%d offset=%d length=%d start=%d end=%d', url, pieceIndex, offset, length, start, end ) const res = await fetch(url, { cache: 'no-store', method: 'GET', headers: { 'Cache-Control': 'no-store', 'user-agent': `WebTorrent/${VERSION} (https://webtorrent.io)`, range: `bytes=${start}-${end}` }, signal: AbortSignal.timeout(SOCKET_TIMEOUT) }) if (!res.ok) throw new Error(`Unexpected HTTP status code ${res.status}`) const data = new Uint8Array(await res.arrayBuffer()) debug('Got data of length %d', data.length) return data })) } catch (e) { return cb(e) } cb(null, concat(chunks)) } destroy () { super.destroy() this._torrent = null } }