UNPKG

ledown

Version:

A library for nodejs to download http large file in pauseable and resumable way

697 lines (579 loc) 16.5 kB
import { close, open, readFile, write, writeFile } from 'fs'; import path from 'path'; import EventEmitter from 'events'; import { promisify } from 'util'; import uuid from 'uuid'; import { request } from 'http'; import { parse } from 'url'; var asyncGenerator = function () { function AwaitValue(value) { this.value = value; } function AsyncGenerator(gen) { var front, back; function send(key, arg) { return new Promise(function (resolve, reject) { var request$$1 = { key: key, arg: arg, resolve: resolve, reject: reject, next: null }; if (back) { back = back.next = request$$1; } else { front = back = request$$1; resume(key, arg); } }); } function resume(key, arg) { try { var result = gen[key](arg); var value = result.value; if (value instanceof AwaitValue) { Promise.resolve(value.value).then(function (arg) { resume("next", arg); }, function (arg) { resume("throw", arg); }); } else { settle(result.done ? "return" : "normal", result.value); } } catch (err) { settle("throw", err); } } function settle(type, value) { switch (type) { case "return": front.resolve({ value: value, done: true }); break; case "throw": front.reject(value); break; default: front.resolve({ value: value, done: false }); break; } front = front.next; if (front) { resume(front.key, front.arg); } else { back = null; } } this._invoke = send; if (typeof gen.return !== "function") { this.return = undefined; } } if (typeof Symbol === "function" && Symbol.asyncIterator) { AsyncGenerator.prototype[Symbol.asyncIterator] = function () { return this; }; } AsyncGenerator.prototype.next = function (arg) { return this._invoke("next", arg); }; AsyncGenerator.prototype.throw = function (arg) { return this._invoke("throw", arg); }; AsyncGenerator.prototype.return = function (arg) { return this._invoke("return", arg); }; return { wrap: function (fn) { return function () { return new AsyncGenerator(fn.apply(this, arguments)); }; }, await: function (value) { return new AwaitValue(value); } }; }(); var asyncToGenerator = function (fn) { return function () { var gen = fn.apply(this, arguments); return new Promise(function (resolve, reject) { function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { return Promise.resolve(value).then(function (value) { step("next", value); }, function (err) { step("throw", err); }); } } return step("next"); }); }; }; var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; function fetch(url$$1, options) { return new Promise((resolve, reject) => { const { path: path$$1, port, protocol, hostname } = parse(url$$1); const req = request(_extends({}, options, { port, hostname, protocol, path: path$$1 }), resolve); req.on('error', reject); req.end(); }); } const cloneByteRange = data => { if (Array.isArray(data)) { return data.map(cloneByteRange); } return data; }; class ByteSheet { /** z * @property indicates the range of bytes which are being downloading */ constructor(arg = 0) { this.remaining = []; this.downloading = []; this.completed = []; if (typeof arg === 'number') { this.remaining.push([0, arg]); } else { const clone = cloneByteRange(arg); this.remaining = clone[0]; this.downloading = []; this.completed = clone[1]; } } /** * @property indicates the range of bytes which are finished downloading */ /** * @property indicates the range of bytes which remain to be download */ get completedByteCount() { return this.getByteCountOf(this.completed); } get downloadingByteCount() { return this.getByteCountOf(this.downloading); } get remainingByteCount() { return this.getByteCountOf(this.remaining); } getByteCountOf(section) { return section.reduce((bytes, item) => { return bytes + item[1] - item[0]; }, 0); } toJSONObject() { const remaining = cloneByteRange(this.remaining); const downloading = cloneByteRange(this.downloading); const completed = cloneByteRange(this.completed); return [this.resolveRange([...remaining, ...downloading]), completed]; } /** * resolve an array of byte range */ resolveRange(ranges) { return ranges.filter(range => range.length > 0 && range[1] > range[0]).sort((prev, next) => prev[0] - next[0]).reduce((sum, range) => { const last = sum[sum.length - 1]; if (last && last[1] >= range[0]) { last[1] = last[1] > range[1] ? last[1] : range[1]; } else { sum.push(range); } return sum; }, []); } /** * take range of size from remaining byte range * @param {number} size */ allocRangeToDownload(size) { const firstRange = this.remaining[0]; if (!firstRange) return null; const start = firstRange[0]; const end = Math.min(start + size, firstRange[1]); const range = [start, end]; this.remaining = this.cutSection(range, this.remaining); this.downloading = this.resolveRange([...this.downloading, range]); return range; } setRangeAsCompleted(range) { this.downloading = this.cutSection(range, this.downloading); this.completed = this.resolveRange([...this.completed, range]); } cutSection(cut, section) { return this.resolveRange(section.reduce((sec, item) => { let range = [...item]; sec.push(range); // Range: |--------| // Cut: |---| if (cut[0] > range[0] && cut[1] < range[1]) { sec.push([cut[1], range[1]]); range[1] = cut[0]; } // Range: |--------| // Cut: |----| // Cut: |------------| if (cut[0] <= range[0] && cut[1] > range[0]) { range[0] = cut[1]; } // Range: |--------| // Cut: |----| // Cut: |-------------| if (cut[1] >= range[1] && cut[0] < range[1]) { range[1] = cut[0]; } return sec; }, [])); } resetDownloading() { this.remaining = this.resolveRange([...this.remaining, ...this.downloading]); this.downloading = []; } } class TaskMeta { constructor(param) { this.url = ''; this.createDate = null; this.mimeType = ''; this.downloadPath = ''; this.totalBytes = 0; this.resumable = false; this.byteSheet = null; Object.assign(this, param); const { createDate, byteSheet } = param; if (createDate) { this.createDate = new Date(createDate); } else { this.createDate = new Date(); } if (byteSheet && this.resumable) { this.byteSheet = new ByteSheet(byteSheet); } else { this.byteSheet = new ByteSheet(param.totalBytes); } } allocRange(size) { if (!this.resumable) { size = this.totalBytes; } return this.byteSheet.allocRangeToDownload(size); } completeRange(range) { return this.byteSheet.setRangeAsCompleted(range); } resetDownloadingRange() { return this.byteSheet.resetDownloading(); } get progress() { return this.completedByteCount / this.totalBytes; } get remainingByteCount() { return this.byteSheet.remainingByteCount; } get completedByteCount() { return this.byteSheet.completedByteCount; } get downloadingByteCount() { return this.byteSheet.downloadingByteCount; } toJSONObject() { return { url: this.url, refererUrl: this.refererUrl, mimeType: this.mimeType, resumable: this.resumable, downloadPath: this.downloadPath, totalBytes: this.totalBytes, createDate: this.createDate.valueOf(), byteSheet: this.byteSheet.toJSONObject() }; } } const writeAsync = promisify(write); class TaskThread extends EventEmitter { constructor(taskMeta, options) { var _this; _this = super(); this.isRunning = false; this.pendingWrite = 0; this.executeRequest = asyncToGenerator(function* () { if (!_this.isRunning) return; const range = _this.taskMeta.allocRange(_this.options.windowSize); if (!range) { return _this.emitEnd(); } try { _this.currentPosition = range[0]; _this.currentRequest = yield fetch(_this.taskMeta.url, { method: 'GET', headers: { Range: `bytes=${range[0]}-${range[1] - 1}` } }); if (_this.currentRequest.statusCode >= 400) { throw new Error(Task.ERROR.ERR_NETWORK); } _this.currentRequest.on('end', _this.handleSingleRequestEnd); _this.currentRequest.on('data', _this.handleData); } catch (e) { _this.emitError(e); } }); this.taskMeta = taskMeta; this.options = options; this.handleData = this.handleData.bind(this); this.handleSingleRequestEnd = this.handleSingleRequestEnd.bind(this); } start() { if (this.isRunning) return; this.isRunning = true; this.executeRequest(); } stop() { var _this2 = this; return asyncToGenerator(function* () { if (!_this2.isRunning) return; _this2.isRunning = false; if (_this2.currentRequest) { _this2.currentRequest.destroy(); _this2.currentRequest = null; } if (_this2.pendingWrite > 0) { yield _this2.awaitPendingWrite(); } })(); } awaitPendingWrite() { var _this3 = this; return asyncToGenerator(function* () { return new Promise(function (resolve) { let awaitProgress = function () { if (_this3.pendingWrite === 0) { _this3.off('progress', awaitProgress); resolve(); } }; if (_this3.pendingWrite === 0) { resolve(); } else { _this3.on('progress', awaitProgress); } }); })(); } handleData(data) { var _this4 = this; return asyncToGenerator(function* () { if (!_this4.isRunning) return; const start = _this4.currentPosition; const end = start + data.byteLength; _this4.currentPosition = end; _this4.pendingWrite++; try { yield writeAsync(_this4.options.fd, data, 0, data.byteLength, start); } catch (e) { _this4.pendingWrite--; return _this4.emitError(new Error(Task.ERROR.ERR_DISK_IO_WRITE)); } _this4.pendingWrite--; _this4.taskMeta.completeRange([start, end]); _this4.emit('progress'); })(); } emitError(e) { var _this5 = this; return asyncToGenerator(function* () { yield _this5.stop(); _this5.emit('error', e); })(); } emitEnd() { var _this6 = this; return asyncToGenerator(function* () { yield _this6.stop(); _this6.emit('end'); })(); } handleSingleRequestEnd() { var _this7 = this; return asyncToGenerator(function* () { if (!_this7.isRunning) return; _this7.currentRequest = null; yield _this7.awaitPendingWrite(); _this7.executeRequest(); })(); } off(...arg) { return this.removeListener(...arg); } } const writeFileAsync = promisify(writeFile); const readFileAsync = promisify(readFile); const openAsync = promisify(open); const closeAsync = promisify(close); class Task extends EventEmitter { constructor(...args) { var _temp, _this; return _temp = _this = super(...args), this.metadata = null, this.threads = [], this.isRunning = false, this.emitError = (() => { var _ref = asyncToGenerator(function* (err) { yield _this.stop(); _this.emit('error', err); }); return function (_x) { return _ref.apply(this, arguments); }; })(), _temp; } static create({ url: url$$1, referUrl, downloadDir, downloadPath }) { return asyncToGenerator(function* () { const task = new Task(); const response = yield fetch(url$$1, { method: 'HEAD' }); if (response.statusCode !== 200) { throw new Error(Task.ERROR.NETWORK_ERROR); } const acceptRanges = response.headers['accept-ranges']; const mimeType = response.headers['content-type']; const totalBytes = response.headers['content-length']; if (!downloadPath) { downloadPath = path.join(downloadDir, `${uuid()}.${mimeType.replace(/^.*\//, '')}`); } task.metadata = new TaskMeta({ url: url$$1, referUrl, mimeType, totalBytes: Number(totalBytes), resumable: !!acceptRanges, downloadPath: downloadPath }); return task; })(); } static restoreFromFile(path$$1) { return asyncToGenerator(function* () { const content = yield readFileAsync(path$$1, 'utf-8'); const task = new Task(); task.metadata = new TaskMeta(JSON.parse(content)); return task; })(); } storeToFile(path$$1) { var _this2 = this; return asyncToGenerator(function* () { const content = JSON.stringify(_this2.metadata.toJSONObject()); yield writeFileAsync(path$$1, content, 'utf-8'); })(); } start(numOfThread = 1, windowSize) { var _this3 = this; return asyncToGenerator(function* () { if (!_this3.metadata) { return _this3.emitError(new Error(Task.ERROR.ERR_NO_METADATA)); } if (_this3.isRunning) return; _this3.isRunning = true; if (!windowSize) { windowSize = Math.ceil(_this3.metadata.remainingByteCount / numOfThread); } if (!_this3.metadata.resumable) { // non-resumable resource, use 1 thread to download all the bytes numOfThread = 1; windowSize = _this3.metadata.totalBytes; } yield _this3.openFd(); if (_this3.fd) { let endHandler = _this3.getThreadEndHandler(numOfThread); const options = { windowSize, fd: _this3.fd }; for (; numOfThread > 0; numOfThread--) { const taskThread = new TaskThread(_this3.metadata, options); taskThread.on('progress', function () { return _this3.emit('progress'); }); taskThread.on('end', endHandler); taskThread.on('error', _this3.emitError); taskThread.start(); _this3.threads.push(taskThread); } } })(); } getThreadEndHandler(num) { var _this4 = this; return asyncToGenerator(function* () { if (--num === 0) { _this4.isRunning = false; _this4.threads = []; yield _this4.closeFd(); _this4.emit('end'); } }); } stop() { var _this5 = this; return asyncToGenerator(function* () { if (!_this5.isRunning) return; _this5.isRunning = false; yield Promise.all(_this5.threads.map(function (thread) { return thread.stop(); })); _this5.threads = []; _this5.metadata.resetDownloadingRange(); yield _this5.closeFd(); })(); } openFd(dir) { var _this6 = this; return asyncToGenerator(function* () { _this6.fd = yield openAsync(_this6.metadata.downloadPath, 'a'); })(); } closeFd() { var _this7 = this; return asyncToGenerator(function* () { yield closeAsync(_this7.fd); })(); } off(...arg) { return this.removeListener(...arg); } get progress() { return this.metadata.progress; } } Task.ERROR = { ERR_NO_METADATA: 'ERR_NO_METADATA', ERR_DISK_IO_WRITE: 'ERR_DISK_IO_WRITE', ERR_NETWORK: 'ERR_NETWORK' }; var index = { Task }; export default index;