├── .babelrc ├── .eslintrc ├── .github ├── FUNDING.yml └── workflows │ ├── npmpublish.yml │ └── test.yml ├── .gitignore ├── .gitlab-ci.yml ├── .npmignore ├── .travis.yml ├── CHANGELOG ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── appveyor.yml ├── bin ├── helpers.js └── ndh ├── example └── index.js ├── jest.config.js ├── package.json ├── src └── index.js ├── test └── index.spec.js └── types └── index.d.ts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "comments": false, 3 | "presets": [ 4 | "env" 5 | ] 6 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "sourceType": "module", 4 | "ecmaFeatures": { 5 | "globalReturn": true 6 | } 7 | }, 8 | "env": { 9 | "node": true, 10 | "es6": true 11 | }, 12 | "rules": { 13 | "quotes": [2,"single",{"avoidEscape": true}], 14 | "strict": [2,"never"] 15 | }, 16 | "extends": [ 17 | "eslint:recommended" 18 | ] 19 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: hgouveia 2 | custom: ["https://www.paypal.me/JoseDeGouveia", "https://www.buymeacoffee.com/hgouveia"] 3 | -------------------------------------------------------------------------------- /.github/workflows/npmpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: 18 18 | - run: npm install --no-optional 19 | - run: npm test 20 | 21 | publish-npm: 22 | needs: build 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: actions/setup-node@v1 27 | with: 28 | node-version: 18 29 | registry-url: https://registry.npmjs.org/ 30 | - run: npm install --no-optional 31 | - run: npm run build 32 | - run: npm publish 33 | env: 34 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [main, dev] 6 | pull_request: 7 | branches: [main, dev] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [14.x, 16.x, 19.x] 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm install --no-optional 22 | - run: npm test 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules 3 | package-lock.json 4 | *.log* 5 | example/* 6 | !example/*.js 7 | dist 8 | *sandbox.js -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: node:18 2 | 3 | cache: 4 | paths: 5 | - node_modules/ 6 | 7 | before_script: 8 | - npm install --no-optional 9 | 10 | build: 11 | script: 12 | - npm run build 13 | - npm test 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | node_modules/ 3 | example/ 4 | src/ 5 | test/ 6 | .babelrc 7 | .eslintrc 8 | .gitignore 9 | .gitlab-ci.yml 10 | .travis.yml 11 | appveyor.yml 12 | CHANGELOG 13 | CONTRIBUTING.md 14 | jest.config.js 15 | sandbox.js -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "18.12.1" -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hgouveia/node-downloader-helper/e5cc3a5884fb4f90f86b2dae1b918450b6d24d42/CHANGELOG -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | * If you find solution to an [issue/improvements](https://github.com/hgouveia/node-downloader-helper/issues) would be helpful to everyone, feel free to send us a pull request. 4 | * The ideal approach to create a fix would be to fork the repository, create a branch in your repository, and make a pull request out of it. Preferable into **Dev** branch 5 | * It is desirable if there is enough comments/documentation and Tests included in the pull request. 6 | * For general idea of contribution, please follow the guidelines mentioned [here](https://guides.github.com/activities/contributing-to-open-source/). -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jose De Gouveia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-downloader-helper 2 | 3 | [![NPM Version](https://img.shields.io/npm/v/node-downloader-helper.svg?style=flat-square "npm version")](https://www.npmjs.com/package/node-downloader-helper) 4 | ![npm](https://img.shields.io/npm/dw/node-downloader-helper?style=flat-square "npm download") 5 | ![GitHub Actions Build](https://github.com/hgouveia/node-downloader-helper/actions/workflows/test.yml/badge.svg "GitHub Actions Build") 6 | [![Windows Build Status](https://img.shields.io/appveyor/ci/hgouveia/node-downloader-helper/master.svg?label=windows&style=flat-square "Windows Build Status")](https://ci.appveyor.com/project/hgouveia/node-downloader-helper) [![Join the chat at https://gitter.im/node-downloader-helper/Lobby](https://badges.gitter.im/node-downloader-helper/Lobby.svg)](https://gitter.im/node-downloader-helper/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 7 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fhgouveia%2Fnode-downloader-helper.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Fhgouveia%2Fnode-downloader-helper?ref=badge_shield) 8 | 9 | 10 | A simple http file downloader for node.js 11 | 12 | Features: 13 | - No thirdparty dependecies 14 | - Pause/Resume 15 | - Retry on fail 16 | - Supports http/https 17 | - Supports http redirects 18 | - Supports pipes 19 | - Custom native http request options 20 | - Usable on vanilla nodejs, electron, nwjs 21 | - Progress stats 22 | 23 | ## Install 24 | 25 | ``` 26 | $ npm install --save node-downloader-helper 27 | ``` 28 | 29 | ## Usage 30 | 31 | For a more complete example check [example](example/) folder 32 | 33 | ```javascript 34 | const { DownloaderHelper } = require('node-downloader-helper'); 35 | const dl = new DownloaderHelper('https://proof.ovh.net/files/1Gb.dat', __dirname); 36 | 37 | dl.on('end', () => console.log('Download Completed')); 38 | dl.on('error', (err) => console.log('Download Failed', err)); 39 | dl.start().catch(err => console.error(err)); 40 | ``` 41 | 42 | **IMPORTANT NOTE:** I highly recommend to use both `.on('error')` and `.start().catch`, although they do the same thing, if `on('error')` is not defined, an error will be thrown when the `error` event is emitted and not listing, this is because EventEmitter is designed to throw an `unhandled error event` error if not been listened and is too late to change it now. 43 | 44 | ### CLI 45 | 46 | This can be used as standalone CLI downloader 47 | 48 | Install `npm i -g node-downloader-helper` 49 | 50 | Usage: `ndh [folder] [url]` 51 | 52 | ```bash 53 | $ ndh ./folder http://url 54 | ``` 55 | 56 | ## Options 57 | 58 | Download Helper constructor also allow a 3rd parameter to set some options `constructor(url, destinationFolder, options)`, 59 | these are the default values 60 | 61 | ```javascript 62 | { 63 | body: null, // Request body, can be any, string, object, etc. 64 | method: 'GET', // Request Method Verb 65 | headers: {}, // Custom HTTP Header ex: Authorization, User-Agent 66 | timeout: -1, // Request timeout in milliseconds (-1 use default), is the equivalent of 'httpRequestOptions: { timeout: value }' (also applied to https) 67 | metadata: {}, // custom metadata for the user retrieve later (default:null) 68 | resumeOnIncomplete: true, // Resume download if the file is incomplete (set false if using any pipe that modifies the file) 69 | resumeOnIncompleteMaxRetry: 5, // Max retry when resumeOnIncomplete is true 70 | resumeIfFileExists: false, // it will resume if a file already exists and is not completed, you might want to set removeOnStop and removeOnFail to false. If you used pipe for compression it will produce corrupted files 71 | fileName: string|cb(fileName, filePath, contentType)|{name, ext}, // Custom filename when saved 72 | retry: false, // { maxRetries: number, delay: number in ms } or false to disable (default) 73 | forceResume: false, // If the server does not return the "accept-ranges" header, can be force if it does support it 74 | removeOnStop: true, // remove the file when is stopped (default:true) 75 | removeOnFail: true, // remove the file when fail (default:true) 76 | progressThrottle: 1000, // interval time of the 'progress.throttled' event will be emitted 77 | override: boolean|{skip, skipSmaller}, // Behavior when local file already exists 78 | httpRequestOptions: {}, // Override the http request options 79 | httpsRequestOptions: {}, // Override the https request options, ex: to add SSL Certs 80 | } 81 | ``` 82 | for `body` you can provide any parameter accepted by http.request write function `req.write(body)` https://nodejs.org/api/http.html, when using this, you might need to add the `content-length` and `content-type` header in addition with the http method `POST` or `PUT` 83 | 84 | ex: 85 | ```javascript 86 | const data = JSON.stringify({ 87 | todo: 'Buy the milk' 88 | }); 89 | const dl = new DownloaderHelper('my_url', __dirname, { 90 | method: 'POST', 91 | body: data, 92 | headers: { 93 | 'Content-Type': 'application/json', 94 | 'Content-Length': data.length 95 | } } ); 96 | ``` 97 | 98 | for `fileName` you can provide 3 types of parameter 99 | - **string**: will use the full string as the filename including extension 100 | - **callback(fileName, filePath, contentType)**: must return an string, only sync function are supported ex: `(fileName) => 'PREFIX_' + fileName;`, **contentType** will be provided if available 101 | - **object**: this object must contain a `name` attribute and an optional `ext` attribute, the `ext` attribute can be an string without dot(`.`) or a boolean where `true` use the `name` as full file name (same as just giving an string to the `fileName` parameter) or false *(default)* will only replace the name and keep the original extension, for example if the original name is `myfile.zip` and the option is `{name: 'somename'}` the output will be `somename.zip` 102 | 103 | for `override` you can provide 2 types of parameter 104 | - **boolean**: `true` to override existing local file, `false` to append '(number)' to new file name 105 | - **object**: object with properties `skip` (boolean): whether to skip download if file exists, and `skipSmaller` (boolean): whether to skip download if file exists but is smaller. Both default to `false`, for the equivalent of `override: true`. 106 | 107 | for `httpRequestOptions` the available options are detailed in here https://nodejs.org/api/http.html#http_http_request_options_callback 108 | 109 | for `httpsRequestOptions` the available options are detailed in here https://nodejs.org/api/https.html#https_https_request_options_callback 110 | 111 | 112 | ## Methods 113 | 114 | | Name | Description | 115 | |---------- |--------------------------------------------------------------------------- | 116 | | start | starts the downloading | 117 | | pause | pause the downloading | 118 | | resume | resume the downloading if supported, if not it will start from the beginning | 119 | | stop | stop the downloading and remove the file | 120 | | pipe | `readable.pipe(stream.Readable, options) : stream.Readable` | 121 | | unpipe | `(stream)` if not stream is not specified, then all pipes are detached. | 122 | | updateOptions | `(options, url)` updates the options, can be use on pause/resume events | 123 | | getStats | returns `stats` from the current download, these are the same `stats` sent via progress event | 124 | | getTotalSize | gets the total file size from the server | 125 | | getDownloadPath | gets the full path where the file will be downloaded (available after the start phase) | 126 | | isResumable | return true/false if the download can be resumable (available after the start phase) | 127 | | getResumeState | Get the state required to resume the download after restart. This state can be passed back to `resumeFromFile()` to resume a download | 128 | | resumeFromFile | `resumeFromFile(filePath?: string, state?: IResumeState)` Resume the download from a previous file path, if the state is not provided it will try to fetch from the information the headers and filePath, @see `resumeIfFileExists` option | 129 | 130 | usage of `resumeFromFile` 131 | 132 | ```javascript 133 | const downloadDir = 'D:/TEMP'; 134 | const { DownloaderHelper } = require('node-downloader-helper'); 135 | const dl = new DownloaderHelper('https://proof.ovh.net/files/1Gb.dat', downloadDir); 136 | dl.on('end', () => console.log('Download Completed')); 137 | dl.on('error', (err) => console.log('Download Failed', err)); 138 | 139 | // option 1 140 | const prevFilePath = `${downloadDir}/1Gb.dat`; 141 | dl.resumeFromFile(prevFilePath).catch(err => console.error(err)); 142 | 143 | // option 2 144 | const prevState = dl.getResumeState(); // this should be stored in a file, localStorage, db, etc in a previous process for example on 'stop' 145 | dl.resumeFromFile(prevState.filePath, prevState).catch(err => console.error(err)); 146 | ``` 147 | 148 | ## Events 149 | 150 | | Name | Description | 151 | |-------------- |----------------------------------------------------------------------------------- | 152 | | start | Emitted when the .start method is called | 153 | | skip | Emitted when the download is skipped because the file already exists | 154 | | download | Emitted when the download starts `callback(downloadInfo)` | 155 | | progress | Emitted every time gets data from the server `callback(stats)` | 156 | | progress.throttled| The same as `progress` but emits every 1 second while is downloading `callback(stats)` | 157 | | retry | Emitted when the download fails and retry is enabled `callback(attempt, retryOpts, err)` | 158 | | end | Emitted when the downloading has finished `callback(downloadInfo)` | 159 | | error | Emitted when there is any error `callback(error)` | 160 | | timeout | Emitted when the underlying socket times out from inactivity. | 161 | | pause | Emitted when the .pause method is called | 162 | | stop | Emitted when the .stop method is called | 163 | | resume | Emitted when the .resume method is called `callback(isResume)` | 164 | | renamed | Emitted when '(number)' is appended to the end of file, this requires `override:false` opt, `callback(filePaths)` | 165 | | redirected | Emitted when an url redirect happened `callback(newUrl, oldUrl)` NOTE: this will be triggered during getTotalSize() as well | 166 | | stateChanged | Emitted when the state changes `callback(state)` | 167 | | warning | Emitted when an error occurs that was not thrown intentionally `callback(err: Error)` | 168 | 169 | event **skip** `skipInfo` object 170 | ```javascript 171 | { 172 | totalSize:, // total file size got from the server (will be set as 'null' if content-length header is not available) 173 | fileName:, // original file name 174 | filePath:, // original path name 175 | downloadedSize:, // the downloaded amount 176 | } 177 | ``` 178 | 179 | event **download** `downloadInfo` object 180 | ```javascript 181 | { 182 | totalSize:, // total file size got from the server (will be set as 'null' if content-length header is not available) 183 | fileName:, // assigned name 184 | filePath:, // download path 185 | isResumed:, // if the download is a resume, 186 | downloadedSize:, // the downloaded amount (only if is resumed otherwise always 0) 187 | } 188 | ``` 189 | 190 | event **progress** or **progress.throttled** `stats` object 191 | ```javascript 192 | { 193 | name:, // file name 194 | total:, // total size that needs to be downloaded in bytes, (will be set as 'null' if content-length header is not available) 195 | downloaded:, // downloaded size in bytes 196 | progress:, // progress porcentage 0-100%, (will be set as 0 if total is null) 197 | speed: // download speed in bytes 198 | } 199 | ``` 200 | 201 | event **end** `downloadInfo` object 202 | ```javascript 203 | { 204 | fileName:, 205 | filePath:, 206 | totalSize:, // total file size got from the server, (will be set as 'null' if content-length header is not available) 207 | incomplete:, // true/false if the download endend but still incomplete, set as 'false' if totalSize is null 208 | onDiskSize, // total size of file on the disk 209 | downloadedSize:, // the total size downloaded 210 | } 211 | ``` 212 | 213 | event **renamed** `filePaths` object 214 | ```javascript 215 | { 216 | path:, // modified path name 217 | fileName:, // modified file name 218 | prevPath:, // original path name 219 | prevFileName:, // original file name 220 | } 221 | ``` 222 | 223 | event **error** `error` object 224 | ```javascript 225 | { 226 | message:, // Error message 227 | status:, // Http status response if available 228 | body:, // Http body response if available 229 | } 230 | ``` 231 | 232 | ## States 233 | 234 | | Name | Value | 235 | |-------------- |---------------------------------- | 236 | | IDLE | 'IDLE' | 237 | | SKIPPED | 'SKIPPED' | 238 | | STARTED | 'STARTED' | 239 | | DOWNLOADING | 'DOWNLOADING' | 240 | | PAUSED | 'PAUSED' | 241 | | RESUMED | 'RESUMED' | 242 | | STOPPED | 'STOPPED' | 243 | | FINISHED | 'FINISHED' | 244 | | FAILED | 'FAILED' | 245 | | RETRY | 'RETRY' | 246 | 247 | ## Test 248 | 249 | ``` 250 | $ npm test 251 | ``` 252 | 253 | ## License 254 | 255 | Read [License](LICENSE) for more licensing information. 256 | 257 | 258 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fhgouveia%2Fnode-downloader-helper.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fhgouveia%2Fnode-downloader-helper?ref=badge_large) 259 | 260 | ## Contributing 261 | 262 | Read [here](CONTRIBUTING.md) for more information. 263 | 264 | ## TODO 265 | - Better code testing 266 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | nodejs_version: "14" 3 | 4 | # Install scripts. (runs after repo cloning) 5 | install: 6 | # Get the latest stable version of Node.js or io.js 7 | - ps: Install-Product node $env:nodejs_version 8 | # install modules 9 | - npm install 10 | 11 | # Post-install test scripts. 12 | test_script: 13 | # Output useful info for debugging. 14 | - node --version 15 | - npm --version 16 | # run tests 17 | - npm run build 18 | - npm test 19 | 20 | # Don't actually build. 21 | build: off -------------------------------------------------------------------------------- /bin/helpers.js: -------------------------------------------------------------------------------- 1 | /*eslint no-console: ["error", { allow: ["log", "warn", "error"] }] */ 2 | const { URL } = require('url'); 3 | const { existsSync } = require('fs'); 4 | 5 | // Console colors 6 | module.exports.COLOR_NC = '\x1b[0m'; // No Color \e 7 | module.exports.COLOR_RED = '\x1b[0;31m'; 8 | module.exports.COLOR_GREEN = '\x1b[0;32m'; 9 | module.exports.COLOR_YELLOW = '\x1b[0;33m'; 10 | module.exports.COLOR_BLUE = '\x1b[0;34m'; 11 | module.exports.COLOR_MAGENTA = '\x1b[0;35m'; 12 | module.exports.COLOR_CYAN = '\x1b[0;36m'; 13 | 14 | // https://gist.github.com/thomseddon/3511330 15 | module.exports.byteHelper = function (value) { 16 | if (value === 0) { 17 | return '0 b'; 18 | } 19 | const units = ['b', 'kB', 'MB', 'GB', 'TB']; 20 | const number = Math.floor(Math.log(value) / Math.log(1024)); 21 | return (value / Math.pow(1024, Math.floor(number))).toFixed(1) + ' ' + 22 | units[number]; 23 | }; 24 | 25 | module.exports.color = function (color, text) { 26 | return `${color}${text}${module.exports.COLOR_NC}`; 27 | }; 28 | 29 | module.exports.inlineLog = function (msg) { 30 | process.stdout.clearLine(); 31 | process.stdout.cursorTo(0); 32 | process.stdout.write(msg); 33 | }; 34 | 35 | module.exports.isValidUrl = function (url) { 36 | try { 37 | new URL(url); 38 | return true; 39 | } catch (_) { 40 | return false; 41 | } 42 | } 43 | 44 | module.exports.isValidPath = function (path) { 45 | try { 46 | return existsSync(path); 47 | } catch (_) { 48 | return false; 49 | } 50 | }; -------------------------------------------------------------------------------- /bin/ndh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /*eslint no-console: ["error", { allow: ["log", "warn", "error"] }] */ 3 | const args = process.argv.slice(2); 4 | const { DownloaderHelper } = require('../dist'); 5 | const { byteHelper, color, COLOR_RED, COLOR_GREEN, 6 | COLOR_YELLOW, COLOR_CYAN, COLOR_MAGENTA, 7 | isValidPath, isValidUrl, inlineLog 8 | } = require('./helpers'); 9 | const pkg = require('../package.json'); 10 | 11 | // Arguments 12 | if (!args.length || args.length < 2) { 13 | return console.log('USAGE: ndh [destination] [url]'); 14 | } 15 | 16 | const dest = args[0]; 17 | const url = args[1]; 18 | 19 | if (!isValidPath(dest)) { 20 | return console.error(color(COLOR_RED, 'Please use an existing folder or valid path')); 21 | } 22 | 23 | if (!isValidUrl(url)) { 24 | return console.error(color(COLOR_RED, 'Please use a valid URL')); 25 | } 26 | 27 | let progressLog = ''; 28 | const dl = new DownloaderHelper(url, dest, { 29 | headers: { 30 | 'user-agent': pkg.name + '@' + pkg.version 31 | }, 32 | }); 33 | 34 | dl 35 | .on('end', _ => 36 | inlineLog(progressLog + ' - ' + color(COLOR_GREEN, 'Download Completed')) 37 | ) 38 | .on('retry', (attempt, opts) => { 39 | let count = Math.floor(opts.delay / 1000); 40 | const retryLog = () => { 41 | inlineLog(color(COLOR_YELLOW, `Retry Attempt: ${attempt}/${opts.maxRetries} | Starts on: ${count} secs`)); 42 | if (count > 0) { 43 | setTimeout(() => retryLog(), 1000); 44 | } 45 | count--; 46 | }; 47 | retryLog(); 48 | }) 49 | .on('resume', isResumed => { 50 | if (!isResumed) { 51 | console.warn(color(COLOR_YELLOW, "\nThis URL doesn't support resume, it will start from the beginning")); 52 | } 53 | }) 54 | .on('progress.throttled', stats => { 55 | const progress = stats.progress.toFixed(1); 56 | const speed = byteHelper(stats.speed); 57 | const downloaded = byteHelper(stats.downloaded); 58 | const total = byteHelper(stats.total); 59 | 60 | const cName = color(COLOR_YELLOW, stats.name); 61 | const cSpeed = color(COLOR_MAGENTA, speed + '/s'); 62 | const cProgress = color(COLOR_MAGENTA, progress + '%'); 63 | const cSize = color(COLOR_CYAN, `[${downloaded}/${total}]`); 64 | progressLog = `${cName}: ${cSpeed} - ${cProgress} ${cSize}`; 65 | inlineLog(progressLog); 66 | }); 67 | 68 | dl.start().catch(err => console.error(color(COLOR_RED, 'Something happend'), err)); 69 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | /*eslint no-console: ["error", { allow: ["log", "warn", "error"] }] */ 2 | const { byteHelper } = require('../bin/helpers'); 3 | const { DownloaderHelper, DH_STATES } = require('../dist'); 4 | const url = 'https://proof.ovh.net/files/1Gb.dat'; // https://proof.ovh.net/files/ 5 | const pkg = require('../package.json'); 6 | const zlib = require('zlib'); 7 | 8 | const pauseResumeTimer = (_dl, wait) => { 9 | setTimeout(() => { 10 | if (_dl.state === DH_STATES.FINISHED || 11 | _dl.state === DH_STATES.FAILED) { 12 | return; 13 | } 14 | 15 | _dl.pause() 16 | .then(() => console.log(`Paused for ${wait / 1000} seconds`)) 17 | .then(() => setTimeout(() => _dl.resume(), wait)); 18 | 19 | }, wait); 20 | }; 21 | 22 | const options = { 23 | method: 'GET', // Request Method Verb 24 | // Custom HTTP Header ex: Authorization, User-Agent 25 | headers: { 26 | 'user-agent': pkg.name + '/' + pkg.version 27 | }, 28 | retry: { maxRetries: 3, delay: 3000 }, // { maxRetries: number, delay: number in ms } or false to disable (default) 29 | fileName: filename => `${filename}.gz`, // Custom filename when saved 30 | /* override 31 | object: { skip: skip if already exists, skipSmaller: skip if smaller } 32 | boolean: true to override file, false to append '(number)' to new file name 33 | */ 34 | metadata: { name: 'download-1' }, 35 | override: { skip: true, skipSmaller: true }, 36 | forceResume: false, // If the server does not return the "accept-ranges" header but it does support it 37 | removeOnStop: true, // remove the file when is stopped (default:true) 38 | removeOnFail: true, // remove the file when fail (default:true) 39 | httpRequestOptions: {}, // Override the http request options 40 | httpsRequestOptions: {} // Override the https request options, ex: to add SSL Certs 41 | }; 42 | 43 | let startTime = new Date(); 44 | const dl = new DownloaderHelper(url, __dirname, options); 45 | 46 | dl 47 | .once('download', () => pauseResumeTimer(dl, 5000)) 48 | .on('download', downloadInfo => console.log('Download Begins: ', 49 | { 50 | name: downloadInfo.fileName, 51 | total: downloadInfo.totalSize 52 | })) 53 | .on('end', downloadInfo => console.log('Download Completed: ', downloadInfo)) 54 | .on('skip', skipInfo => 55 | console.log('Download skipped. File already exists: ', skipInfo)) 56 | .on('error', err => console.error('Something happened', err)) 57 | .on('retry', (attempt, opts, err) => { 58 | console.log({ 59 | RetryAttempt: `${attempt}/${opts.maxRetries}`, 60 | StartsOn: `${opts.delay / 1000} secs`, 61 | Reason: err ? err.message : 'unknown' 62 | }); 63 | }) 64 | .on('resume', isResumed => { 65 | // is resume is not supported, 66 | // a new pipe instance needs to be attached 67 | if (!isResumed) { 68 | dl.unpipe(); 69 | dl.pipe(zlib.createGzip()); 70 | console.warn("This URL doesn't support resume, it will start from the beginning"); 71 | } 72 | }) 73 | .on('stateChanged', state => console.log('State: ', state)) 74 | .on('renamed', filePaths => console.log('File Renamed to: ', filePaths.fileName)) 75 | .on('redirected', (newUrl, oldUrl) => console.log(`Redirect from '${newUrl}' => '${oldUrl}'`)) 76 | .on('progress', stats => { 77 | const progress = stats.progress.toFixed(1); 78 | const speed = byteHelper(stats.speed); 79 | const downloaded = byteHelper(stats.downloaded); 80 | const total = byteHelper(stats.total); 81 | 82 | // print every one second (`progress.throttled` can be used instead) 83 | const currentTime = new Date(); 84 | const elaspsedTime = currentTime - startTime; 85 | if (elaspsedTime > 1000) { 86 | startTime = currentTime; 87 | console.log(`${speed}/s - ${progress}% [${downloaded}/${total}]`); 88 | } 89 | }); 90 | 91 | console.log(`Downloading [${dl.getMetadata().name}]: ${url}`); 92 | dl.pipe(zlib.createGzip()); // Adding example of pipe to compress the file while downloading 93 | dl.start().catch(err => { /* already listening on 'error' event but catch can be used too */ }); 94 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // Automatically clear mock calls and instances between every test 6 | clearMocks: true, 7 | 8 | // The directory where Jest should output its coverage files 9 | coverageDirectory: "coverage", 10 | 11 | // The test environment that will be used for testing 12 | testEnvironment: "node", 13 | 14 | // A map from regular expressions to paths to transformers 15 | // transform: undefined, 16 | transform : { 17 | '^.+\\.[jt]sx?$': require.resolve('babel-jest') 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-downloader-helper", 3 | "version": "2.1.9", 4 | "description": "A simple http/s file downloader for node.js", 5 | "main": "./dist/index.js", 6 | "types": "./types/index.d.ts", 7 | "scripts": { 8 | "start": "npm run build && node ./example/index.js", 9 | "lint": "npx eslint src", 10 | "build": "npx babel --presets minify -d ./dist ./src", 11 | "watch": "npx babel --watch ./src/index.js -o ./dist/index.js", 12 | "test": "npm run lint && jest" 13 | }, 14 | "pre-commit": [ 15 | "test" 16 | ], 17 | "keywords": [ 18 | "nwjs", 19 | "node", 20 | "nodejs", 21 | "node.js", 22 | "electron", 23 | "download", 24 | "resumable", 25 | "resume", 26 | "http" 27 | ], 28 | "author": "Jose De Gouveia", 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/hgouveia/node-downloader-helper" 32 | }, 33 | "bugs": { 34 | "url": "https://github.com/hgouveia/node-downloader-helper/issues" 35 | }, 36 | "homepage": "https://github.com/hgouveia/node-downloader-helper", 37 | "license": "MIT", 38 | "bin": { 39 | "ndh": "bin/ndh" 40 | }, 41 | "engines": { 42 | "node": ">=14.18" 43 | }, 44 | "devDependencies": { 45 | "@types/node": "^14.14.31", 46 | "babel-cli": "^6.26.0", 47 | "babel-jest": "^23.6.0", 48 | "babel-preset-env": "^1.6.1", 49 | "babel-preset-minify": "^0.5.0", 50 | "chai": "^4.1.2", 51 | "eslint": "^4.19.1", 52 | "jest": "^24.9.0", 53 | "pre-commit": "^1.2.2" 54 | } 55 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { URL } from 'url'; 3 | import * as path from 'path'; 4 | import * as http from 'http'; 5 | import * as https from 'https'; 6 | import { EventEmitter } from 'events'; 7 | 8 | export const DH_STATES = { 9 | IDLE: 'IDLE', 10 | SKIPPED: 'SKIPPED', 11 | STARTED: 'STARTED', 12 | DOWNLOADING: 'DOWNLOADING', 13 | RETRY: 'RETRY', 14 | PAUSED: 'PAUSED', 15 | RESUMED: 'RESUMED', 16 | STOPPED: 'STOPPED', 17 | FINISHED: 'FINISHED', 18 | FAILED: 'FAILED' 19 | }; 20 | 21 | export class DownloaderHelper extends EventEmitter { 22 | 23 | /** 24 | * Creates an instance of DownloaderHelper. 25 | * @param {String} url 26 | * @param {String} destFolder 27 | * @param {Object} [options={}] 28 | * @memberof DownloaderHelper 29 | */ 30 | constructor(url, destFolder, options = {}) { 31 | super({ captureRejections: true }); 32 | 33 | if (!this.__validate(url, destFolder)) { 34 | return; 35 | } 36 | 37 | this.url = this.requestURL = url.trim(); 38 | this.state = DH_STATES.IDLE; 39 | this.__defaultOpts = { 40 | body: null, 41 | retry: false, // { maxRetries: 3, delay: 3000 } 42 | method: 'GET', 43 | headers: {}, 44 | fileName: '', 45 | timeout: -1, // -1 use default 46 | metadata: null, 47 | override: false, // { skip: false, skipSmaller: false } 48 | forceResume: false, 49 | removeOnStop: true, 50 | removeOnFail: true, 51 | progressThrottle: 1000, 52 | httpRequestOptions: {}, 53 | httpsRequestOptions: {}, 54 | resumeOnIncomplete: true, 55 | resumeIfFileExists: false, 56 | resumeOnIncompleteMaxRetry: 5, 57 | }; 58 | this.__opts = Object.assign({}, this.__defaultOpts); 59 | this.__pipes = []; 60 | this.__total = 0; 61 | this.__downloaded = 0; 62 | this.__progress = 0; 63 | this.__retryCount = 0; 64 | this.__retryTimeout = null; 65 | this.__resumeRetryCount = 0; 66 | this.__states = DH_STATES; 67 | this.__promise = null; 68 | this.__request = null; 69 | this.__response = null; 70 | this.__isAborted = false; 71 | this.__isResumed = false; 72 | this.__isResumable = false; 73 | this.__isRedirected = false; 74 | this.__destFolder = destFolder; 75 | this.__statsEstimate = { 76 | time: 0, 77 | bytes: 0, 78 | prevBytes: 0, 79 | throttleTime: 0, 80 | }; 81 | this.__fileName = ''; 82 | this.__filePath = ''; 83 | this.updateOptions(options); 84 | } 85 | 86 | /** 87 | * 88 | * 89 | * @returns {Promise} 90 | * @memberof DownloaderHelper 91 | */ 92 | start() { 93 | const startPromise = () => new Promise((resolve, reject) => { 94 | this.__promise = { resolve, reject }; 95 | this.__start(); 96 | }); 97 | 98 | // this will determine the file path from the headers 99 | // and attempt to get the file size and resume if possible 100 | if (this.__opts.resumeIfFileExists && this.state !== this.__states.RESUMED) { 101 | return this.getTotalSize().then(({ name, total }) => { 102 | const override = this.__opts.override; 103 | this.__opts.override = true; 104 | this.__filePath = this.__getFilePath(name); 105 | this.__opts.override = override; 106 | if (this.__filePath && fs.existsSync(this.__filePath)) { 107 | const fileSize = this.__getFilesizeInBytes(this.__filePath); 108 | return fileSize !== total 109 | ? this.resumeFromFile(this.__filePath, { total, fileName: name }) 110 | : startPromise(); 111 | } 112 | return startPromise(); 113 | }); 114 | } 115 | return startPromise(); 116 | } 117 | 118 | /** 119 | * 120 | * 121 | * @returns {Promise} 122 | * @memberof DownloaderHelper 123 | */ 124 | pause() { 125 | if (this.state === this.__states.STOPPED) { 126 | return Promise.resolve(true); 127 | } 128 | 129 | if (this.__response) { 130 | this.__response.unpipe(); 131 | this.__pipes.forEach(pipe => pipe.stream.unpipe()); 132 | } 133 | 134 | if (this.__fileStream) { 135 | this.__fileStream.removeAllListeners(); 136 | } 137 | 138 | this.__requestAbort(); 139 | 140 | return this.__closeFileStream().then(() => { 141 | this.__setState(this.__states.PAUSED); 142 | this.emit('pause'); 143 | return true; 144 | }); 145 | } 146 | 147 | /** 148 | * 149 | * 150 | * @returns {Promise} 151 | * @memberof DownloaderHelper 152 | */ 153 | resume() { 154 | // if the promise is null, the download was started using resume instead of start 155 | if (!this.__promise) { 156 | return this.start(); 157 | } 158 | 159 | if (this.state === this.__states.STOPPED) { 160 | return Promise.resolve(false); 161 | } 162 | 163 | this.__setState(this.__states.RESUMED); 164 | if (this.__isResumable) { 165 | this.__isResumed = true; 166 | this.__reqOptions['headers']['range'] = `bytes=${this.__downloaded}-`; 167 | } 168 | this.emit('resume', this.__isResumed); 169 | return this.__start(); 170 | } 171 | 172 | /** 173 | * 174 | * 175 | * @returns {Promise} 176 | * @memberof DownloaderHelper 177 | */ 178 | stop() { 179 | if (this.state === this.__states.STOPPED) { 180 | return Promise.resolve(true); 181 | } 182 | const removeFile = () => new Promise((resolve, reject) => { 183 | fs.access(this.__filePath, _accessErr => { 184 | // if can't access, probably is not created yet 185 | if (_accessErr) { 186 | this.__emitStop(); 187 | return resolve(true); 188 | } 189 | 190 | fs.unlink(this.__filePath, _err => { 191 | if (_err) { 192 | this.__setState(this.__states.FAILED); 193 | this.emit('error', _err); 194 | return reject(_err); 195 | } 196 | this.__emitStop(); 197 | resolve(true); 198 | }); 199 | }); 200 | }); 201 | 202 | this.__requestAbort(); 203 | 204 | return this.__closeFileStream().then(() => { 205 | if (this.__opts.removeOnStop) { 206 | return removeFile(); 207 | } 208 | this.__emitStop(); 209 | return Promise.resolve(true); 210 | }); 211 | } 212 | 213 | /** 214 | * Add pipes to the pipe list that will be applied later when the download starts 215 | * @url https://nodejs.org/api/stream.html#stream_readable_pipe_destination_options 216 | * @param {stream.Readable} stream https://nodejs.org/api/stream.html#stream_class_stream_readable 217 | * @param {Object} [options=null] 218 | * @returns {stream.Readable} 219 | * @memberof DownloaderHelper 220 | */ 221 | pipe(stream, options = null) { 222 | this.__pipes.push({ stream, options }); 223 | return stream; 224 | } 225 | 226 | /** 227 | * Unpipe an stream , if a stream is not specified, then all pipes are detached. 228 | * 229 | * @url https://nodejs.org/api/stream.html#stream_readable_unpipe_destination 230 | * @param {stream.Readable} [stream=null] 231 | * @returns 232 | * @memberof DownloaderHelper 233 | */ 234 | unpipe(stream = null) { 235 | const unpipeStream = _stream => (this.__response) 236 | ? this.__response.unpipe(_stream) 237 | : _stream.unpipe(); 238 | 239 | 240 | if (stream) { 241 | const pipe = this.__pipes.find(p => p.stream === stream); 242 | if (pipe) { 243 | unpipeStream(stream); 244 | this.__pipes = this.__pipes.filter(p => p.stream !== stream); 245 | } 246 | return; 247 | } 248 | 249 | this.__pipes.forEach(p => unpipeStream(p.stream)); 250 | this.__pipes = []; 251 | } 252 | 253 | /** 254 | * Where the download will be saved 255 | * 256 | * @returns {String} 257 | * @memberof DownloaderHelper 258 | */ 259 | getDownloadPath() { 260 | return this.__filePath; 261 | } 262 | 263 | /** 264 | * Indicates if the download can be resumable (available after the start phase) 265 | * 266 | * @returns {Boolean} 267 | * @memberof DownloaderHelper 268 | */ 269 | isResumable() { 270 | return this.__isResumable; 271 | } 272 | 273 | /** 274 | * Updates the options, can be use on pause/resume events 275 | * 276 | * @param {Object} [options={}] 277 | * @param {String} [url=''] 278 | * @memberof DownloaderHelper 279 | */ 280 | updateOptions(options, url = '') { 281 | this.__opts = Object.assign({}, this.__opts, options); 282 | this.__headers = this.__opts.headers; 283 | 284 | if (this.__opts.timeout > -1) { 285 | this.__opts.httpRequestOptions.timeout = this.__opts.timeout; 286 | this.__opts.httpsRequestOptions.timeout = this.__opts.timeout; 287 | } 288 | 289 | // validate the progressThrottle, if invalid, use the default 290 | if (typeof this.__opts.progressThrottle !== 'number' || this.__opts.progressThrottle < 0) { 291 | this.__opts.progressThrottle = this.__defaultOpts.progressThrottle; 292 | } 293 | 294 | this.url = url || this.url; 295 | this.__reqOptions = this.__getReqOptions(this.__opts.method, this.url, this.__opts.headers); 296 | this.__initProtocol(this.url); 297 | } 298 | 299 | /** 300 | * 301 | * @returns {Object} 302 | */ 303 | getOptions() { 304 | return this.__opts; 305 | } 306 | 307 | /** 308 | * 309 | * @returns {Object| null} 310 | */ 311 | getMetadata() { 312 | return this.__opts.metadata; 313 | } 314 | 315 | /** 316 | * Current download progress stats 317 | * 318 | * @returns {Stats} 319 | * @memberof DownloaderHelper 320 | */ 321 | getStats() { 322 | return { 323 | total: this.__total, 324 | name: this.__fileName, 325 | downloaded: this.__downloaded, 326 | progress: this.__progress, 327 | speed: this.__statsEstimate.bytes 328 | }; 329 | } 330 | 331 | /** 332 | * Gets the total file size from the server 333 | * 334 | * @returns {Promise<{name:string, total:number|null}>} 335 | * @memberof DownloaderHelper 336 | */ 337 | getTotalSize() { 338 | return new Promise((resolve, reject) => { 339 | const getReqOptions = (url) => { 340 | this.__initProtocol(url); 341 | const headers = Object.assign({}, this.__headers); 342 | if (headers.hasOwnProperty('range')) { 343 | delete headers['range']; 344 | } 345 | const reqOptions = this.__getReqOptions('HEAD', url, headers); 346 | return Object.assign({}, this.__reqOptions, reqOptions); 347 | }; 348 | const getRequest = (url, options) => { 349 | const req = this.__protocol.request(options, response => { 350 | if (this.__isRequireRedirect(response)) { 351 | const redirectedURL = /^https?:\/\//.test(response.headers.location) 352 | ? response.headers.location 353 | : new URL(response.headers.location, url).href; 354 | this.emit('redirected', redirectedURL, url); 355 | return getRequest(redirectedURL, getReqOptions(redirectedURL)); 356 | } 357 | if (response.statusCode !== 200) { 358 | return reject(new Error(`Response status was ${response.statusCode}`)); 359 | } 360 | resolve({ 361 | name: this.__getFileNameFromHeaders(response.headers, response), 362 | total: parseInt(response.headers['content-length']) || null 363 | }); 364 | }); 365 | req.on('error', (err) => reject(err)); 366 | req.on('timeout', () => reject(new Error('timeout'))); 367 | req.on('uncaughtException', (err) => reject(err)); 368 | req.end(); 369 | }; 370 | getRequest(this.url, getReqOptions(this.url)); 371 | }); 372 | } 373 | 374 | /** 375 | * Get the state required to resume the download after restart. This state 376 | * can be passed back to `resumeFromFile()` to resume a download 377 | * 378 | * @returns {Object} Returns the state required to resume 379 | * @memberof DownloaderHelper 380 | */ 381 | getResumeState() { 382 | return { 383 | downloaded: this.__downloaded, 384 | filePath: this.__filePath, 385 | fileName: this.__fileName, 386 | total: this.__total, 387 | }; 388 | } 389 | 390 | /** 391 | * Resume the download from a previous file path 392 | * 393 | * @param {string} filePath - The path to the file to resume from ex: C:\Users\{user}\Downloads\file.txt 394 | * @param {Object} state - (optionl) resume download state, if not provided it will try to fetch from the headers and filePath 395 | * 396 | * @returns {Promise} - Returns the same result as `start()` 397 | * @memberof DownloaderHelper 398 | */ 399 | resumeFromFile(filePath, state = {}) { 400 | this.__opts.override = true; 401 | this.__filePath = filePath; 402 | return ((state.total && state.fileName) 403 | ? Promise.resolve({ name: state.fileName, total: state.total }) 404 | : this.getTotalSize()) 405 | .then(({ name, total }) => { 406 | this.__total = state.total || total; 407 | this.__fileName = state.fileName || name; 408 | this.__downloaded = state.downloaded || this.__getFilesizeInBytes(this.__filePath); 409 | this.__reqOptions['headers']['range'] = `bytes=${this.__downloaded}-`; 410 | this.__isResumed = true; 411 | this.__isResumable = true; 412 | this.__setState(this.__states.RESUMED); 413 | this.emit('resume', this.__isResumed); 414 | return new Promise((resolve, reject) => { 415 | this.__promise = { resolve, reject }; 416 | this.__start(); 417 | }); 418 | }); 419 | } 420 | 421 | __start() { 422 | if (!this.__isRedirected && 423 | this.state !== this.__states.RESUMED) { 424 | this.emit('start'); 425 | this.__setState(this.__states.STARTED); 426 | this.__initProtocol(this.url); 427 | } 428 | 429 | // Start the Download 430 | this.__response = null; 431 | this.__isAborted = false; 432 | 433 | if (this.__request && !this.__request.destroyed) { 434 | this.__request.destroy() 435 | } 436 | 437 | if (this.__retryTimeout) { 438 | clearTimeout(this.__retryTimeout); 439 | this.__retryTimeout = null; 440 | } 441 | 442 | this.__request = this.__downloadRequest(this.__promise.resolve, this.__promise.reject); 443 | 444 | // Error Handling 445 | this.__request.on('error', this.__onError(this.__promise.resolve, this.__promise.reject)); 446 | this.__request.on('timeout', this.__onTimeout(this.__promise.resolve, this.__promise.reject)); 447 | this.__request.on('uncaughtException', this.__onError(this.__promise.resolve, this.__promise.reject, true)); 448 | 449 | if (this.__opts.body) { 450 | this.__request.write(this.__opts.body); 451 | } 452 | 453 | this.__request.end(); 454 | } 455 | 456 | /** 457 | * Resolve pending promises from Start method 458 | * 459 | * @memberof DownloaderHelper 460 | */ 461 | __resolvePending() { 462 | if (!this.__promise) { 463 | return; 464 | } 465 | const { resolve } = this.__promise; 466 | this.__promise = null; 467 | return resolve(true); 468 | } 469 | 470 | /** 471 | * 472 | * 473 | * @param {Promise.resolve} resolve 474 | * @param {Promise.reject} reject 475 | * @returns {http.ClientRequest} 476 | * @memberof DownloaderHelper 477 | */ 478 | __downloadRequest(resolve, reject) { 479 | return this.__protocol.request(this.__reqOptions, response => { 480 | this.__response = response; 481 | 482 | //Stats 483 | if (!this.__isResumed) { 484 | this.__total = parseInt(response.headers['content-length']) || null; 485 | this.__resetStats(); 486 | } 487 | 488 | // Handle Redirects 489 | if (this.__isRequireRedirect(response)) { 490 | const redirectedURL = /^https?:\/\//.test(response.headers.location) 491 | ? response.headers.location 492 | : new URL(response.headers.location, this.url).href; 493 | this.__isRedirected = true; 494 | this.__initProtocol(redirectedURL); 495 | this.emit('redirected', redirectedURL, this.url); 496 | return this.__start(); 497 | } 498 | 499 | // check if response wans't a success 500 | if (response.statusCode !== 200 && response.statusCode !== 206) { 501 | const err = new Error(`Response status was ${response.statusCode}`); 502 | err.status = response.statusCode || 0; 503 | err.body = response.body || ''; 504 | this.__setState(this.__states.FAILED); 505 | this.emit('error', err); 506 | return reject(err); 507 | } 508 | 509 | if (this.__opts.forceResume) { 510 | this.__isResumable = true; 511 | } else if (response.headers.hasOwnProperty('accept-ranges') && 512 | response.headers['accept-ranges'] !== 'none') { 513 | this.__isResumable = true; 514 | } 515 | 516 | this.__startDownload(response, resolve, reject); 517 | }); 518 | } 519 | 520 | /** 521 | * 522 | * 523 | * @param {http.IncomingMessage} response 524 | * @param {Promise.resolve} resolve 525 | * @param {Promise.reject} reject 526 | * @memberof DownloaderHelper 527 | */ 528 | __startDownload(response, resolve, reject) { 529 | let readable = response; 530 | 531 | if (!this.__isResumed) { 532 | const _fileName = this.__getFileNameFromHeaders(response.headers); 533 | this.__filePath = this.__getFilePath(_fileName); 534 | this.__fileName = this.__filePath.split(path.sep).pop(); 535 | if (fs.existsSync(this.__filePath)) { 536 | const downloadedSize = this.__getFilesizeInBytes(this.__filePath); 537 | const totalSize = this.__total ? this.__total : 0; 538 | if (typeof this.__opts.override === 'object' && 539 | this.__opts.override.skip && ( 540 | this.__opts.override.skipSmaller || 541 | downloadedSize >= totalSize)) { 542 | this.emit('skip', { 543 | totalSize: this.__total, 544 | fileName: this.__fileName, 545 | filePath: this.__filePath, 546 | downloadedSize: downloadedSize 547 | }); 548 | this.__setState(this.__states.SKIPPED); 549 | return resolve(true); 550 | } 551 | } 552 | this.__fileStream = fs.createWriteStream(this.__filePath, {}); 553 | } else { 554 | this.__fileStream = fs.createWriteStream(this.__filePath, { 'flags': 'a' }); 555 | } 556 | 557 | // Start Downloading 558 | this.emit('download', { 559 | fileName: this.__fileName, 560 | filePath: this.__filePath, 561 | totalSize: this.__total, 562 | isResumed: this.__isResumed, 563 | downloadedSize: this.__downloaded 564 | }); 565 | this.__retryCount = 0; 566 | this.__isResumed = false; 567 | this.__isRedirected = false; 568 | this.__setState(this.__states.DOWNLOADING); 569 | this.__statsEstimate.time = new Date(); 570 | this.__statsEstimate.throttleTime = new Date(); 571 | 572 | // Add externals pipe 573 | readable.on('data', chunk => this.__calculateStats(chunk.length)); 574 | this.__pipes.forEach(pipe => { 575 | readable.pipe(pipe.stream, pipe.options); 576 | readable = pipe.stream; 577 | }); 578 | readable.pipe(this.__fileStream); 579 | readable.on('error', this.__onError(resolve, reject)); 580 | 581 | this.__fileStream.on('finish', this.__onFinished(resolve, reject)); 582 | this.__fileStream.on('error', this.__onError(resolve, reject)); 583 | } 584 | 585 | 586 | /** 587 | * 588 | * 589 | * @returns 590 | * @memberof DownloaderHelper 591 | */ 592 | __hasFinished() { 593 | return !this.__isAborted && [ 594 | this.__states.PAUSED, 595 | this.__states.STOPPED, 596 | this.__states.RETRY, 597 | this.__states.FAILED, 598 | this.__states.RESUMED 599 | ].indexOf(this.state) === -1; 600 | } 601 | 602 | 603 | /** 604 | * 605 | * 606 | * @param {IncomingMessage} response 607 | * @returns {Boolean} 608 | * @memberof DownloaderHelper 609 | */ 610 | __isRequireRedirect(response) { 611 | return (response.statusCode > 300 && 612 | response.statusCode < 400 && 613 | response.headers.hasOwnProperty('location') && 614 | response.headers.location); 615 | } 616 | 617 | /** 618 | * 619 | * 620 | * @param {Promise.resolve} resolve 621 | * @param {Promise.reject} reject 622 | * @returns {Function} 623 | * @memberof DownloaderHelper 624 | */ 625 | __onFinished(resolve, reject) { 626 | return () => { 627 | this.__fileStream.close(_err => { 628 | if (_err) { 629 | return reject(_err); 630 | } 631 | if (this.__hasFinished()) { 632 | const isIncomplete = !this.__total ? false : this.__downloaded !== this.__total; 633 | 634 | if (isIncomplete && this.__isResumable && this.__opts.resumeOnIncomplete && 635 | this.__resumeRetryCount <= this.__opts.resumeOnIncompleteMaxRetry) { 636 | this.__resumeRetryCount++; 637 | this.emit('warning', new Error('uncomplete download, retrying')); 638 | return this.resume(); 639 | } 640 | 641 | this.__setState(this.__states.FINISHED); 642 | this.__pipes = []; 643 | this.emit('end', { 644 | fileName: this.__fileName, 645 | filePath: this.__filePath, 646 | totalSize: this.__total, 647 | incomplete: isIncomplete, 648 | onDiskSize: this.__getFilesizeInBytes(this.__filePath), 649 | downloadedSize: this.__downloaded, 650 | }); 651 | } 652 | return resolve(this.__downloaded === this.__total); 653 | }); 654 | }; 655 | } 656 | 657 | /** 658 | * 659 | * 660 | * @returns 661 | * @memberof DownloaderHelper 662 | */ 663 | __closeFileStream() { 664 | if (!this.__fileStream) { 665 | return Promise.resolve(true); 666 | } 667 | return new Promise((resolve, reject) => { 668 | this.__fileStream.close(err => { 669 | if (err) { 670 | return reject(err); 671 | } 672 | return resolve(true); 673 | }); 674 | }); 675 | } 676 | 677 | /** 678 | * 679 | * @param {Promise.resolve} resolve 680 | * @param {Promise.reject} reject 681 | * @param {boolean} abortReq 682 | * @returns {Function} 683 | * @memberof DownloaderHelper 684 | */ 685 | __onError(resolve, reject, abortReq = false) { 686 | return err => { 687 | this.__pipes = []; 688 | 689 | if (abortReq) { 690 | this.__requestAbort(); 691 | } 692 | 693 | if (this.state === this.__states.STOPPED || 694 | this.state === this.__states.FAILED) { 695 | return; 696 | } 697 | if (!this.__opts.retry) { 698 | return this.__removeFile().finally(() => { 699 | this.__setState(this.__states.FAILED); 700 | this.emit('error', err); 701 | reject(err); 702 | }); 703 | } 704 | return this.__retry(err) 705 | .catch(_err => { 706 | this.__removeFile().finally(() => { 707 | this.__setState(this.__states.FAILED); 708 | this.emit('error', _err ? _err : err); 709 | reject(_err ? _err : err); 710 | }); 711 | }); 712 | }; 713 | } 714 | 715 | /** 716 | * 717 | * 718 | * @returns {Promise} 719 | * @memberof DownloaderHelper 720 | */ 721 | __retry(err = null) { 722 | if (!this.__opts.retry || typeof this.__opts.retry !== 'object') { 723 | return Promise.reject(err || new Error('wrong retry options')); 724 | } 725 | 726 | const { delay: retryDelay = 0, maxRetries = 999 } = this.__opts.retry; 727 | 728 | // reached the maximum retries 729 | if (this.__retryCount >= maxRetries) { 730 | return Promise.reject(err || new Error('reached the maximum retries')); 731 | } 732 | 733 | this.__retryCount++; 734 | this.__setState(this.__states.RETRY); 735 | this.emit('retry', this.__retryCount, this.__opts.retry, err); 736 | 737 | if (this.__response) { 738 | this.__response.unpipe(); 739 | this.__pipes.forEach(pipe => pipe.stream.unpipe()); 740 | } 741 | 742 | if (this.__fileStream) { 743 | this.__fileStream.removeAllListeners(); 744 | } 745 | 746 | this.__requestAbort(); 747 | 748 | return this.__closeFileStream().then(() => 749 | new Promise((resolve) => 750 | this.__retryTimeout = setTimeout( 751 | () => resolve(this.__downloaded > 0 ? 752 | this.resume() : 753 | this.__start()), 754 | retryDelay 755 | ) 756 | ) 757 | ); 758 | } 759 | 760 | /** 761 | * 762 | * @param {Promise.resolve} resolve 763 | * @param {Promise.reject} reject 764 | * @returns {Function} 765 | * @memberof DownloaderHelper 766 | */ 767 | __onTimeout(resolve, reject) { 768 | return () => { 769 | this.__requestAbort(); 770 | 771 | if (!this.__opts.retry) { 772 | return this.__removeFile().finally(() => { 773 | this.__setState(this.__states.FAILED); 774 | this.emit('timeout'); 775 | reject(new Error('timeout')); 776 | }); 777 | } 778 | 779 | return this.__retry(new Error('timeout')) 780 | .catch(_err => { 781 | this.__removeFile().finally(() => { 782 | this.__setState(this.__states.FAILED); 783 | if (_err) { 784 | reject(_err); 785 | } else { 786 | this.emit('timeout'); 787 | reject(new Error('timeout')); 788 | } 789 | }); 790 | }); 791 | }; 792 | } 793 | 794 | /** 795 | * 796 | * 797 | * @memberof DownloaderHelper 798 | */ 799 | __resetStats() { 800 | this.__retryCount = 0; 801 | this.__downloaded = 0; 802 | this.__progress = 0; 803 | this.__resumeRetryCount = 0; 804 | this.__statsEstimate = { 805 | time: 0, 806 | bytes: 0, 807 | prevBytes: 0, 808 | throttleTime: 0, 809 | }; 810 | } 811 | 812 | /** 813 | * 814 | * 815 | * @param {Object} headers 816 | * @returns {String} 817 | * @memberof DownloaderHelper 818 | */ 819 | __getFileNameFromHeaders(headers, response) { 820 | let fileName = ''; 821 | 822 | const fileNameAndEncodingRegExp = /.*filename\*=.*?'.*?'([^"].+?[^"])(?:(?:;)|$)/i // match everything after the specified encoding behind a case-insensitive `filename*=` 823 | const fileNameWithQuotesRegExp = /.*filename="(.*?)";?/i // match everything inside the quotes behind a case-insensitive `filename=` 824 | const fileNameWithoutQuotesRegExp = /.*filename=([^"].+?[^"])(?:(?:;)|$)/i // match everything immediately after `filename=` that isn't surrounded by quotes and is followed by either a `;` or the end of the string 825 | 826 | const ContentDispositionHeaderExists = headers.hasOwnProperty('content-disposition') 827 | const fileNameAndEncodingMatch = !ContentDispositionHeaderExists ? null : headers['content-disposition'].match(fileNameAndEncodingRegExp) 828 | const fileNameWithQuotesMatch = (!ContentDispositionHeaderExists || fileNameAndEncodingMatch) ? null : headers['content-disposition'].match(fileNameWithQuotesRegExp) 829 | const fileNameWithoutQuotesMatch = (!ContentDispositionHeaderExists || fileNameAndEncodingMatch || fileNameWithQuotesMatch) ? null : headers['content-disposition'].match(fileNameWithoutQuotesRegExp) 830 | 831 | // Get Filename 832 | if (ContentDispositionHeaderExists && (fileNameAndEncodingMatch || fileNameWithQuotesMatch || fileNameWithoutQuotesMatch)) { 833 | 834 | fileName = headers['content-disposition']; 835 | fileName = fileName.trim(); 836 | 837 | if (fileNameAndEncodingMatch) { 838 | fileName = fileNameAndEncodingMatch[1]; 839 | } else if (fileNameWithQuotesMatch) { 840 | fileName = fileNameWithQuotesMatch[1]; 841 | } else if (fileNameWithoutQuotesMatch) { 842 | fileName = fileNameWithoutQuotesMatch[1]; 843 | } 844 | 845 | fileName = fileName.replace(/[/\\]/g, ''); 846 | 847 | } else { 848 | 849 | if (path.basename(new URL(this.requestURL).pathname).length > 0) { 850 | fileName = path.basename(new URL(this.requestURL).pathname); 851 | } else { 852 | fileName = `${new URL(this.requestURL).hostname}.html`; 853 | } 854 | } 855 | 856 | return ( 857 | (this.__opts.fileName) 858 | ? this.__getFileNameFromOpts(fileName, response) 859 | : fileName.replace(/\.*$/, '') // remove any potential trailing '.' (just to be sure) 860 | ) 861 | } 862 | 863 | /** 864 | * 865 | * 866 | * @param {String} fileName 867 | * @returns {String} 868 | * @memberof DownloaderHelper 869 | */ 870 | __getFilePath(fileName) { 871 | const currentPath = path.join(this.__destFolder, fileName); 872 | let filePath = currentPath; 873 | 874 | if (!this.__opts.override && this.state !== this.__states.RESUMED) { 875 | filePath = this.__uniqFileNameSync(filePath); 876 | 877 | if (currentPath !== filePath) { 878 | this.emit('renamed', { 879 | 'path': filePath, 880 | 'fileName': filePath.split(path.sep).pop(), 881 | 'prevPath': currentPath, 882 | 'prevFileName': currentPath.split(path.sep).pop() 883 | }); 884 | } 885 | } 886 | 887 | return filePath; 888 | } 889 | 890 | 891 | /** 892 | * 893 | * 894 | * @param {String} fileName 895 | * @returns {String} 896 | * @memberof DownloaderHelper 897 | */ 898 | __getFileNameFromOpts(fileName, response) { 899 | 900 | if (!this.__opts.fileName) { 901 | return fileName; 902 | } else if (typeof this.__opts.fileName === 'string') { 903 | return this.__opts.fileName; 904 | } else if (typeof this.__opts.fileName === 'function') { 905 | const currentPath = path.join(this.__destFolder, fileName); 906 | if ((response && response.headers) || (this.__response && this.__response.headers)) { 907 | return this.__opts.fileName(fileName, currentPath, (response ? response : this.__response).headers['content-type']); 908 | } else { 909 | return this.__opts.fileName(fileName, currentPath); 910 | } 911 | } else if (typeof this.__opts.fileName === 'object') { 912 | 913 | const fileNameOpts = this.__opts.fileName; // { name:string, ext:true|false|string} 914 | const name = fileNameOpts.name; 915 | const ext = fileNameOpts.hasOwnProperty('ext') 916 | ? fileNameOpts.ext : false; 917 | 918 | if (typeof ext === 'string') { 919 | return `${name}.${ext}`; 920 | } else if (typeof ext === 'boolean') { 921 | // true: use the 'name' as full file name 922 | // false (default) only replace the name 923 | if (ext) { 924 | return name; 925 | } else { 926 | const _ext = fileName.includes('.') ? fileName.split('.').pop() : ''; // make sure there is a '.' in the fileName string 927 | return _ext !== '' ? `${name}.${_ext}` : name; // if there is no extension, replace the whole file name 928 | } 929 | } 930 | } 931 | 932 | return fileName; 933 | } 934 | 935 | /** 936 | * 937 | * 938 | * @param {Number} receivedBytes 939 | * @memberof DownloaderHelper 940 | */ 941 | __calculateStats(receivedBytes) { 942 | const currentTime = new Date(); 943 | const elaspsedTime = currentTime - this.__statsEstimate.time; 944 | const throttleElapseTime = currentTime - this.__statsEstimate.throttleTime; 945 | const total = this.__total || 0; 946 | 947 | if (!receivedBytes) { 948 | return; 949 | } 950 | 951 | this.__downloaded += receivedBytes; 952 | this.__progress = total === 0 ? 0 : (this.__downloaded / total) * 100; 953 | 954 | // Calculate the speed every second or if finished 955 | if (this.__downloaded === total || elaspsedTime > 1000) { 956 | this.__statsEstimate.time = currentTime; 957 | this.__statsEstimate.bytes = this.__downloaded - this.__statsEstimate.prevBytes; 958 | this.__statsEstimate.prevBytes = this.__downloaded; 959 | } 960 | 961 | if (this.__downloaded === total || throttleElapseTime > this.__opts.progressThrottle) { 962 | this.__statsEstimate.throttleTime = currentTime; 963 | this.emit('progress.throttled', this.getStats()); 964 | } 965 | 966 | // emit the progress 967 | this.emit('progress', this.getStats()); 968 | } 969 | 970 | /** 971 | * 972 | * 973 | * @param {String} state 974 | * @memberof DownloaderHelper 975 | */ 976 | __setState(state) { 977 | this.state = state; 978 | this.emit('stateChanged', this.state); 979 | } 980 | 981 | /** 982 | * 983 | * 984 | * @param {String} method 985 | * @param {String} url 986 | * @param {Object} [headers={}] 987 | * @returns {Object} 988 | * @memberof DownloaderHelper 989 | */ 990 | __getReqOptions(method, url, headers = {}) { 991 | const urlParse = new URL(url); 992 | const options = { 993 | protocol: urlParse.protocol, 994 | host: urlParse.hostname, 995 | port: urlParse.port, 996 | path: urlParse.pathname + urlParse.search, 997 | method, 998 | }; 999 | 1000 | if (headers) { 1001 | options['headers'] = headers; 1002 | } 1003 | 1004 | return options; 1005 | } 1006 | 1007 | /** 1008 | * 1009 | * 1010 | * @param {String} filePath 1011 | * @returns {Number} 1012 | * @memberof DownloaderHelper 1013 | */ 1014 | __getFilesizeInBytes(filePath) { 1015 | try { 1016 | // 'throwIfNoEntry' was implemented on Node.js v14.17.0 1017 | // so we added try/catch in case is using an older version 1018 | const stats = fs.statSync(filePath, { throwIfNoEntry: false }); 1019 | const fileSizeInBytes = stats.size || 0; 1020 | return fileSizeInBytes; 1021 | } catch (err) { 1022 | // mostly probably the file doesn't exist 1023 | this.emit('warning', err); 1024 | } 1025 | return 0; 1026 | } 1027 | 1028 | /** 1029 | * 1030 | * 1031 | * @param {String} url 1032 | * @param {String} destFolder 1033 | * @returns {Boolean|Error} 1034 | * @memberof DownloaderHelper 1035 | */ 1036 | __validate(url, destFolder) { 1037 | 1038 | if (typeof url !== 'string') { 1039 | throw new Error('URL should be an string'); 1040 | } 1041 | 1042 | if (url.trim() === '') { 1043 | throw new Error("URL couldn't be empty"); 1044 | } 1045 | 1046 | if (typeof destFolder !== 'string') { 1047 | throw new Error('Destination Folder should be an string'); 1048 | } 1049 | 1050 | if (destFolder.trim() === '') { 1051 | throw new Error("Destination Folder couldn't be empty"); 1052 | } 1053 | 1054 | if (!fs.existsSync(destFolder)) { 1055 | throw new Error('Destination Folder must exist'); 1056 | } 1057 | 1058 | const stats = fs.statSync(destFolder); 1059 | if (!stats.isDirectory()) { 1060 | throw new Error('Destination Folder must be a directory'); 1061 | } 1062 | 1063 | try { 1064 | fs.accessSync(destFolder, fs.constants.W_OK); 1065 | } catch (e) { 1066 | throw new Error('Destination Folder must be writable'); 1067 | } 1068 | 1069 | return true; 1070 | } 1071 | 1072 | /** 1073 | * 1074 | * 1075 | * @param {String} url 1076 | * @memberof DownloaderHelper 1077 | */ 1078 | __initProtocol(url) { 1079 | const defaultOpts = this.__getReqOptions(this.__opts.method, url, this.__headers); 1080 | this.requestURL = url; 1081 | 1082 | if (url.indexOf('https://') > -1) { 1083 | this.__protocol = https; 1084 | defaultOpts.agent = new https.Agent({ keepAlive: false }); 1085 | this.__reqOptions = Object.assign({}, defaultOpts, this.__opts.httpsRequestOptions); 1086 | } else { 1087 | this.__protocol = http; 1088 | defaultOpts.agent = new http.Agent({ keepAlive: false }); 1089 | this.__reqOptions = Object.assign({}, defaultOpts, this.__opts.httpRequestOptions); 1090 | } 1091 | 1092 | } 1093 | 1094 | /** 1095 | * 1096 | * 1097 | * @param {String} path 1098 | * @returns {String} 1099 | * @memberof DownloaderHelper 1100 | */ 1101 | __uniqFileNameSync(path) { 1102 | if (typeof path !== 'string' || path === '') { 1103 | return path; 1104 | } 1105 | 1106 | try { 1107 | // if access fail, the file doesnt exist yet 1108 | fs.accessSync(path, fs.F_OK); 1109 | const pathInfo = path.match(/(.*)(\([0-9]+\))(\..*)$/); 1110 | let base = pathInfo ? pathInfo[1].trim() : path; 1111 | let suffix = pathInfo ? parseInt(pathInfo[2].replace(/\(|\)/, '')) : 0; 1112 | let ext = path.split('.').pop(); 1113 | 1114 | if (ext !== path && ext.length > 0) { 1115 | ext = '.' + ext; 1116 | base = base.replace(ext, ''); 1117 | } else { 1118 | ext = ''; 1119 | } 1120 | 1121 | // generate a new path until it doesn't exist 1122 | return this.__uniqFileNameSync(base + ' (' + (++suffix) + ')' + ext); 1123 | } catch (err) { 1124 | return path; 1125 | } 1126 | } 1127 | 1128 | /** 1129 | * 1130 | * 1131 | * @returns {Promise} 1132 | * @memberof DownloaderHelper 1133 | */ 1134 | __removeFile() { 1135 | return new Promise(resolve => { 1136 | if (!this.__fileStream) { 1137 | return resolve(); 1138 | } 1139 | this.__fileStream.close((err) => { 1140 | if (err) { 1141 | this.emit('warning', err); 1142 | } 1143 | if (this.__opts.removeOnFail) { 1144 | return fs.access(this.__filePath, _accessErr => { 1145 | // if can't access, probably is not created yet 1146 | if (_accessErr) { 1147 | return resolve(); 1148 | } 1149 | 1150 | fs.unlink(this.__filePath, (_err) => { 1151 | if (_err) { 1152 | this.emit('warning', err); 1153 | } 1154 | resolve(); 1155 | }); 1156 | }); 1157 | } 1158 | resolve(); 1159 | }); 1160 | }); 1161 | } 1162 | 1163 | /** 1164 | * 1165 | * 1166 | * @memberof DownloaderHelper 1167 | */ 1168 | __requestAbort() { 1169 | this.__isAborted = true; 1170 | if (this.__retryTimeout) { 1171 | clearTimeout(this.__retryTimeout); 1172 | this.__retryTimeout = null; 1173 | } 1174 | 1175 | if (this.__response) { 1176 | this.__response.destroy(); 1177 | } 1178 | 1179 | if (this.__request) { 1180 | // from node => v13.14.X 1181 | if (this.__request.destroy) { 1182 | this.__request.destroy(); 1183 | } else { 1184 | this.__request.abort(); 1185 | } 1186 | } 1187 | } 1188 | 1189 | /** 1190 | * @memberof DownloaderHelper 1191 | */ 1192 | __emitStop() { 1193 | this.__resolvePending(); 1194 | this.__setState(this.__states.STOPPED); 1195 | this.emit('stop'); 1196 | } 1197 | } 1198 | -------------------------------------------------------------------------------- /test/index.spec.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const https = require('https'); 3 | const { join } = require('path'); 4 | const { homedir } = require('os'); 5 | const { expect } = require('chai'); 6 | const { DownloaderHelper } = require('../src'); 7 | 8 | jest.mock('fs'); 9 | jest.mock('http'); 10 | jest.mock('https'); 11 | 12 | // http/https request object 13 | function getRequestFn(requestOptions) { 14 | return (opts, callback) => { 15 | callback({ 16 | body: requestOptions.body || '', 17 | on: jest.fn(), 18 | pipe: jest.fn(), 19 | statusCode: requestOptions.statusCode || 200, 20 | headers: requestOptions.headers || {}, 21 | unpipe: jest.fn(), 22 | }); 23 | return { 24 | on: jest.fn(), 25 | end: jest.fn(), 26 | abort: jest.fn(), 27 | write: jest.fn(), 28 | }; 29 | }; 30 | } 31 | 32 | const downloadURL = 'https://proof.ovh.net/files/1Gb.dat'; // https://proof.ovh.net/files/ 33 | describe('DownloaderHelper', function () { 34 | 35 | describe('constructor', function () { 36 | afterEach(() => { 37 | jest.resetAllMocks(); 38 | }); 39 | 40 | it('should create a instance', function () { 41 | fs.existsSync.mockReturnValue(true); 42 | fs.statSync.mockReturnValue({ isDirectory: () => true }); 43 | 44 | expect(function () { 45 | const dl = new DownloaderHelper(downloadURL, __dirname); 46 | }).to.not.throw(); 47 | }); 48 | 49 | it('should fail if url is not an string', function () { 50 | expect(function () { 51 | const dl = new DownloaderHelper(1234, __dirname); 52 | }).to.throw('URL should be an string'); 53 | }); 54 | 55 | it('should fail if url is empty', function () { 56 | expect(function () { 57 | const dl = new DownloaderHelper('', __dirname); 58 | }).to.throw("URL couldn't be empty"); 59 | }); 60 | 61 | it('should fail if destination folder is not an string', function () { 62 | expect(function () { 63 | const dl = new DownloaderHelper(downloadURL, {}); 64 | }).to.throw('Destination Folder should be an string'); 65 | }); 66 | 67 | it('should fail if destination folder is empty', function () { 68 | expect(function () { 69 | const dl = new DownloaderHelper(downloadURL, ''); 70 | }).to.throw("Destination Folder couldn't be empty"); 71 | }); 72 | 73 | it("should fail if destination folder doesn' exist", function () { 74 | expect(function () { 75 | const home = homedir(); 76 | const nonExistingPath = home + '/dh_' + new Date().getTime(); 77 | const dl = new DownloaderHelper(downloadURL, nonExistingPath); 78 | }).to.throw('Destination Folder must exist'); 79 | }); 80 | 81 | it("should fail if destination folder is not a directory", function () { 82 | fs.existsSync.mockReturnValue(true); 83 | fs.statSync.mockReturnValue({ isDirectory: () => false }); 84 | 85 | expect(function () { 86 | const dl = new DownloaderHelper(downloadURL, __dirname); 87 | }).to.throw('Destination Folder must be a directory'); 88 | }); 89 | 90 | it("should fail if destination folder is not writable", function () { 91 | fs.existsSync.mockReturnValue(true); 92 | fs.statSync.mockReturnValue({ isDirectory: () => true }); 93 | fs.accessSync.mockImplementation(() => { 94 | throw new Error(); 95 | }); 96 | 97 | expect(function () { 98 | const dl = new DownloaderHelper(downloadURL, __dirname); 99 | }).to.throw('Destination Folder must be writable'); 100 | }); 101 | 102 | }); 103 | 104 | describe('__getFileNameFromOpts', function () { 105 | let fileName, fileNameExt; 106 | 107 | beforeEach(function () { 108 | fs.existsSync.mockReturnValue(true); 109 | fs.statSync.mockReturnValue({ isDirectory: () => true }); 110 | fileName = 'myfilename.zip'; 111 | fileNameExt = 'zip'; 112 | }); 113 | 114 | 115 | it("should return the same file name when an empty string is passed in the 'fileName' opts", function () { 116 | const dl = new DownloaderHelper(downloadURL, __dirname, { 117 | fileName: '' 118 | }); 119 | const result = dl.__getFileNameFromOpts(fileName); 120 | expect(result).to.be.equal(fileName); 121 | }); 122 | 123 | 124 | it("should rename the file name when string is passed in the 'fileName' opts", function () { 125 | const newFileName = 'mynewname.7z'; 126 | const dl = new DownloaderHelper(downloadURL, __dirname, { 127 | fileName: newFileName 128 | }); 129 | const result = dl.__getFileNameFromOpts(fileName); 130 | expect(result).to.be.equal(newFileName); 131 | }); 132 | 133 | 134 | it("should rename the file name when callback is passed in the 'fileName' opts", function () { 135 | const PREFIX = 'MY_PREFIX_'; 136 | const cb = function (_fileName, _filePath) { 137 | return PREFIX + _fileName; 138 | }; 139 | const dl = new DownloaderHelper(downloadURL, __dirname, { 140 | fileName: cb 141 | }); 142 | const result = dl.__getFileNameFromOpts(fileName); 143 | expect(result).to.be.equal(PREFIX + fileName); 144 | }); 145 | 146 | it("callback should return fileName and filePath", function (done) { 147 | const fullPath = join(__dirname, fileName); 148 | const dl = new DownloaderHelper(downloadURL, __dirname, { 149 | fileName: function (_fileName, _filePath) { 150 | expect(_fileName).to.be.equal(fileName); 151 | expect(_filePath).to.be.equal(fullPath); 152 | done(); 153 | } 154 | }); 155 | dl.__getFileNameFromOpts(fileName); 156 | }); 157 | 158 | it("callback should return fileName, filePath and contentType if a response is provided", function (done) { 159 | const fileNameFromURL = downloadURL.split('/').pop(); 160 | const fullPath = join(__dirname, fileNameFromURL); 161 | const contentType = 'application/zip'; 162 | 163 | fs.createWriteStream.mockReturnValue({ on: jest.fn() }); 164 | https.request.mockImplementation(getRequestFn({ 165 | statusCode: 200, 166 | headers: { 167 | 'content-type': contentType, 168 | } 169 | })); 170 | 171 | const dl = new DownloaderHelper(downloadURL, __dirname, { 172 | fileName: function (_fileName, _filePath, _contentType) { 173 | expect(_fileName).to.be.equal(fileNameFromURL); 174 | expect(_filePath).to.be.equal(fullPath); 175 | expect(_contentType).to.be.equal(contentType); 176 | done(); 177 | return fileNameFromURL; 178 | } 179 | }); 180 | dl.start(); 181 | }); 182 | 183 | it("should rename only the file name and not the extension when a object is passed in the 'fileName' opts with only 'name' attr", function () { 184 | const newFileName = 'mynewname'; 185 | const dl = new DownloaderHelper(downloadURL, __dirname, { 186 | fileName: { name: newFileName } 187 | }); 188 | const result = dl.__getFileNameFromOpts(fileName); 189 | expect(result).to.be.equal(newFileName + '.' + fileNameExt); 190 | }); 191 | 192 | it("should rename only the file name and not the extension when a object is passed in the 'fileName' opts with 'name' and false 'ext' attr", function () { 193 | const newFileName = 'mynewname'; 194 | const dl = new DownloaderHelper(downloadURL, __dirname, { 195 | fileName: { name: newFileName, ext: false } 196 | }); 197 | const result = dl.__getFileNameFromOpts(fileName); 198 | expect(result).to.be.equal(newFileName + '.' + fileNameExt); 199 | }); 200 | 201 | it("should rename the file name and custom extension when a object is passed in the 'fileName' opts with 'name' and string 'ext' attr", function () { 202 | const newFileName = 'mynewname'; 203 | const newFilenameExt = '7z'; 204 | const dl = new DownloaderHelper(downloadURL, __dirname, { 205 | fileName: { name: newFileName, ext: newFilenameExt } 206 | }); 207 | const result = dl.__getFileNameFromOpts(fileName); 208 | expect(result).to.be.equal(newFileName + '.' + newFilenameExt); 209 | }); 210 | 211 | it("should rename the file name and custom extension when a object is passed in the 'fileName' opts with 'name' and string 'ext' attr", function () { 212 | const newFileName = 'mynewname'; 213 | const newFilenameExt = '7z'; 214 | const dl = new DownloaderHelper(downloadURL, __dirname, { 215 | fileName: { name: newFileName, ext: newFilenameExt } 216 | }); 217 | const result = dl.__getFileNameFromOpts(fileName); 218 | expect(result).to.be.equal(newFileName + '.' + newFilenameExt); 219 | }); 220 | 221 | it("should rename the full file name when a object is passed in the 'fileName' opts with 'name' and true in 'ext' attr", function () { 222 | const newFileName = 'mynewname.7z'; 223 | const dl = new DownloaderHelper(downloadURL, __dirname, { 224 | fileName: { name: newFileName, ext: true } 225 | }); 226 | const result = dl.__getFileNameFromOpts(fileName); 227 | expect(result).to.be.equal(newFileName); 228 | }); 229 | 230 | }); 231 | 232 | describe('__getFileNameFromHeaders', function () { 233 | beforeEach(function () { 234 | fs.existsSync.mockReturnValue(true); 235 | fs.statSync.mockReturnValue({ isDirectory: () => true }); 236 | }); 237 | 238 | it("should append '.html' to a file if there is no 'content-disposition' header and no 'path'", function () { 239 | const newFileName = 'google.html'; 240 | const dl = new DownloaderHelper('https://google.com/', __dirname, { 241 | fileName: { name: newFileName, ext: true } 242 | }); 243 | const result = dl.__getFileNameFromHeaders({}); 244 | expect(result).to.be.equal(newFileName); 245 | }); 246 | 247 | it("should *not* append '.html' to a file if there *is* 'content-disposition' header but no 'path'", function () { 248 | const newFileName = 'filename.jpg'; 249 | const dl = new DownloaderHelper('https://google.com/', __dirname, { 250 | fileName: { name: newFileName, ext: true } 251 | }); 252 | const result = dl.__getFileNameFromHeaders({ 253 | 'content-disposition': 'Content-Disposition: attachment; filename="' + newFileName + '"', 254 | }); 255 | expect(result).to.be.equal(newFileName); 256 | }); 257 | 258 | it("should keep leading dots but remove trailing dots for auto-generated file names", function () { 259 | const newFileName = '.gitignore.'; 260 | const expectedFileName = '.gitignore'; 261 | const dl = new DownloaderHelper('https://google.com/', __dirname, { 262 | // fileName: { name: newFileName, ext: true } 263 | }); 264 | const result = dl.__getFileNameFromHeaders({ 265 | 'content-disposition': 'Content-Disposition: attachment; filename="' + newFileName + '"', 266 | }); 267 | expect(result).to.be.equal(expectedFileName); 268 | }); 269 | 270 | it("should not modify the filename when providing a callback", function () { 271 | const newFileName = '.gitignore.'; 272 | const expectedFileName = newFileName 273 | const dl = new DownloaderHelper('https://google.com/', __dirname, { 274 | fileName: () => '.gitignore.' 275 | }); 276 | const result = dl.__getFileNameFromHeaders({ 277 | 'content-disposition': 'Content-Disposition: attachment; filename="' + newFileName + '"', 278 | }); 279 | expect(result).to.be.equal(expectedFileName); 280 | }); 281 | 282 | it("should parse all 'content-disposition' headers", function () { 283 | const dl = new DownloaderHelper('https://google.com/', __dirname); 284 | 285 | const tests = [ 286 | // eslint-disable-next-line quotes 287 | { header: `attachment; filename="Setup64.exe"; filename*=UTF-8''Setup64.exe`, fileName: 'Setup64.exe' }, 288 | // eslint-disable-next-line quotes 289 | { header: `attachment; filename*=UTF-8''Setup64.exe`, fileName: 'Setup64.exe' }, 290 | // eslint-disable-next-line quotes 291 | { header: `attachment;filename="EURO rates";filename*=utf-8''%e2%82%ac%20rates`, fileName: '%e2%82%ac%20rates' }, 292 | // eslint-disable-next-line quotes 293 | { header: `attachment;filename=EURO rates`, fileName: 'EURO rates' }, 294 | // eslint-disable-next-line quotes 295 | { header: `attachment;filename=EURO rates; filename*=utf-8''%e2%82%ac%20rates`, fileName: '%e2%82%ac%20rates' }, 296 | // eslint-disable-next-line quotes 297 | { header: `attachment; filename*= UTF-8''%e2%82%ac%20rates`, fileName: '%e2%82%ac%20rates' }, 298 | // eslint-disable-next-line quotes 299 | { header: `attachment;filename*=utf-8''%e2%82%ac%20rates;filename="EURO rates"`, fileName: '%e2%82%ac%20rates' }, 300 | // eslint-disable-next-line quotes 301 | { header: `attachment;filename*=utf-8''%e2%82%ac%20rates;filename=EURO rates`, fileName: '%e2%82%ac%20rates' }, 302 | // eslint-disable-next-line quotes 303 | { header: `attachment;filename*=utf-8''%e2%82%ac% 20rates;filename=EURO rates `, fileName: '%e2%82%ac% 20rates' }, 304 | // eslint-disable-next-line quotes 305 | { header: `attachment;fIlEnAmE=EURO rates`, fileName: 'EURO rates' }, 306 | // eslint-disable-next-line quotes 307 | { header: `attachment; filename*=UTF-8'en'Setup64.exe`, fileName: 'Setup64.exe' }, 308 | ] 309 | 310 | tests.forEach(x => { 311 | expect(dl.__getFileNameFromHeaders({ 'content-disposition': x.header }, null)).to.be.equal(x.fileName) 312 | }) 313 | 314 | }); 315 | 316 | describe('__getReqOptions', () => { 317 | it("it should return the correct parsed options", function () { 318 | const dl = new DownloaderHelper('https://www.google.com/search?q=javascript', __dirname, { 319 | headers: { 'user-agent': 'my-user-agent' } 320 | }); 321 | const options = dl.__getReqOptions(dl.__opts.method, dl.url, dl.__opts.headers); 322 | expect(options.protocol).to.be.equal('https:'); 323 | expect(options.host).to.be.equal('www.google.com'); 324 | expect(options.port).to.be.equal(''); 325 | expect(options.method).to.be.equal('GET'); 326 | expect(options.path).to.be.equal('/search?q=javascript'); 327 | expect(options.headers['user-agent']).to.be.equal('my-user-agent'); 328 | }); 329 | 330 | }); 331 | }) 332 | 333 | describe('__initProtocol', function () { 334 | it("protocol should be http", function () { 335 | const dl = new DownloaderHelper(downloadURL.replace('https:', 'http:'), __dirname); 336 | expect(dl.__protocol.STATUS_CODES).to.be.not.undefined; 337 | }); 338 | 339 | it("protocol should be https", function () { 340 | // NOTE: STATUS_CODES property seems to be available only in http module 341 | const dl = new DownloaderHelper(downloadURL, __dirname); 342 | expect(dl.__protocol.STATUS_CODES).to.be.undefined; 343 | }); 344 | 345 | it("protocol should has https Agent", function () { 346 | const dl = new DownloaderHelper(downloadURL, __dirname); 347 | expect(dl.__reqOptions.agent).to.be.not.undefined; 348 | }); 349 | 350 | it("protocol should has http Agent", function () { 351 | const dl = new DownloaderHelper(downloadURL.replace('https:', 'http:'), __dirname); 352 | expect(dl.__reqOptions.agent).to.be.not.undefined; 353 | }); 354 | 355 | it("protocol should has custom http Agent", function () { 356 | const dl = new DownloaderHelper(downloadURL, __dirname, { 357 | httpsRequestOptions: { agent: 'myCustomAgent' } 358 | }); 359 | expect(dl.__reqOptions.agent).to.be.equal('myCustomAgent'); 360 | }); 361 | 362 | it("protocol should has custom https Agent", function () { 363 | const dl = new DownloaderHelper(downloadURL.replace('https:', 'http:'), __dirname, { 364 | httpRequestOptions: { agent: 'myCustomAgent' } 365 | }); 366 | expect(dl.__reqOptions.agent).to.be.equal('myCustomAgent'); 367 | }); 368 | }); 369 | 370 | describe('download', function () { 371 | it("if the content-length is not present when the download starts, it should return null as totalSize", function (done) { 372 | fs.createWriteStream.mockReturnValue({ on: jest.fn() }); 373 | https.request.mockImplementation(getRequestFn({ 374 | statusCode: 200, 375 | headers: { 376 | 'content-type': 'application/zip', 377 | } 378 | })); 379 | const dl = new DownloaderHelper(downloadURL, __dirname); 380 | dl.on('download', downloadInfo => { 381 | expect(downloadInfo.totalSize).to.be.null; 382 | done(); 383 | }); 384 | dl.start(); 385 | }); 386 | 387 | it("if the content-length is not present when .getTotalSize() is triggered, should return null as total", function (done) { 388 | fs.createWriteStream.mockReturnValue({ on: jest.fn() }); 389 | https.request.mockImplementation(getRequestFn({ 390 | statusCode: 200, 391 | headers: { 392 | 'content-type': 'application/zip', 393 | } 394 | })); 395 | const dl = new DownloaderHelper(downloadURL, __dirname); 396 | dl.getTotalSize().then(info => { 397 | expect(info.total).to.be.null; 398 | done(); 399 | }); 400 | }); 401 | }); 402 | }); 403 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import stream from 'stream'; 3 | 4 | type Stats = { 5 | total: number; 6 | name: string; 7 | downloaded: number; 8 | progress: number; 9 | speed: number; 10 | } 11 | 12 | export enum DH_STATES { 13 | IDLE = 'IDLE', 14 | SKIPPED = 'SKIPPED', 15 | STARTED = 'STARTED', 16 | DOWNLOADING = 'DOWNLOADING', 17 | RETRY = 'RETRY', 18 | PAUSED = 'PAUSED', 19 | RESUMED = 'RESUMED', 20 | STOPPED = 'STOPPED', 21 | FINISHED = 'FINISHED', 22 | FAILED = 'FAILED', 23 | } 24 | 25 | interface BaseStats { 26 | /** total file size got from the server */ 27 | totalSize: number | null; 28 | /** original file name */ 29 | fileName: string; 30 | /** original path name */ 31 | filePath: string; 32 | /** the downloaded amount */ 33 | downloadedSize: number; 34 | } 35 | 36 | interface DownloadInfoStats extends BaseStats { 37 | /** if the download is a resume */ 38 | isResumed: boolean; 39 | } 40 | 41 | interface DownloadEndedStats extends BaseStats { 42 | /** total size of file on the disk */ 43 | onDiskSize: number; 44 | /** true/false if the download endend but still incomplete */ 45 | incomplete: boolean; 46 | } 47 | 48 | interface FileRenamedStats { 49 | /** modified path name */ 50 | path: string; 51 | /** modified file name */ 52 | fileName: string; 53 | /** original path name */ 54 | prevPath: string; 55 | /** original file name */ 56 | prevFileName: string; 57 | } 58 | 59 | interface IResumeState { 60 | downloaded?: number; 61 | filePath?: string; 62 | fileName?: string; 63 | total?: number; 64 | } 65 | 66 | interface ErrorStats { 67 | /** Error message */ 68 | message: string; 69 | /** Http status response if available */ 70 | status?: number; 71 | /** Http body response if available */ 72 | body?: string; 73 | } 74 | interface DownloadEvents { 75 | /** Emitted when the .start method is called */ 76 | start: () => any; 77 | /** Emitted when the download is skipped because the file already exists */ 78 | skip: (stats: BaseStats) => any; 79 | /** Emitted when the download starts */ 80 | download: (stats: DownloadInfoStats) => any; 81 | /** Emitted every time gets data from the server */ 82 | progress: (stats: Stats) => any; 83 | /** The same as progress but emits every 1 second while is downloading */ 84 | "progress.throttled": (stats: Stats) => any; 85 | /** Emitted when the download fails and retry is enabled */ 86 | retry: (attempt: any, retryOptions: RetryOptions, error: Error | null) => any; 87 | /** Emitted when the downloading has finished */ 88 | end: (stats: DownloadEndedStats) => any; 89 | /** Emitted when there is any error */ 90 | error: (stats: ErrorStats) => any; 91 | /** Emitted when the underlying socket times out from inactivity. */ 92 | timeout: () => any; 93 | /** Emitted when the .pause method is called */ 94 | pause: () => any; 95 | /** Emitted when the .resume method is called */ 96 | resume: (isResume: boolean) => any; 97 | /** Emitted when the .stop method is called */ 98 | stop: () => any; 99 | /** Emitted when '(number)' is appended to the end of file, this requires override:false opt, callback(filePaths) */ 100 | renamed: (stats: FileRenamedStats) => any; 101 | /** Emitted when an url redirect happened `callback(newUrl, oldUrl)` NOTE: this will be triggered during getTotalSize() as well */ 102 | redirected: (newUrl: string, oldUrl: string) => any; 103 | /** Emitted when the state changes */ 104 | stateChanged: (state: DH_STATES) => any; 105 | /** Emitted when an error occurs that was not thrown intentionally */ 106 | warning: (error: Error) => any; 107 | } 108 | type FilenameCallback = (fileName: string, filePath: string) => string; 109 | interface FilenameDefinition { 110 | name: string; 111 | /** The extension of the file. It may be a boolean: `true` will use the `name` property as the full file name (including the extension), 112 | `false` will keep the extension of the downloaded file. 113 | 114 | (default:false) */ 115 | ext?: string | boolean; 116 | } 117 | interface RetryOptions { 118 | maxRetries: number; 119 | /** in milliseconds */ 120 | delay: number; 121 | } 122 | interface OverrideOptions { 123 | skip?: boolean; 124 | skipSmaller?: boolean; 125 | } 126 | interface DownloaderHelperOptions { 127 | /** parameter accepted by http.request write function req.write(body) (default(null)) */ 128 | body?: any; 129 | /** Request Method Verb */ 130 | method?: "GET" | "PUT" | "POST" | "DELETE" | "OPTIONS", 131 | /** Custom HTTP Header ex: Authorization, User-Agent */ 132 | headers?: object; 133 | /** Custom filename when saved */ 134 | fileName?: string | FilenameCallback | FilenameDefinition; 135 | retry?: boolean | RetryOptions; 136 | /* Request timeout in milliseconds (-1 use default), is the equivalent of 'httpRequestOptions: { timeout: value }' (also applied to https) */ 137 | timeout?: number; 138 | /* custom metadata for the user retrieve later */ 139 | metadata?: object | null; 140 | /** it will resume if a file already exists and is not completed, you might want to set removeOnStop and removeOnFail to false. If you used pipe for compression it will produce corrupted files */ 141 | resumeIfFileExists?: boolean; 142 | /** If the server does not return the "accept-ranges" header, can be force if it does support it */ 143 | forceResume?: boolean; 144 | /** remove the file when is stopped (default:true) */ 145 | removeOnStop?: boolean; 146 | /** remove the file when fail (default:true) */ 147 | removeOnFail?: boolean; 148 | /** Behavior when local file already exists (default:false)*/ 149 | override?: boolean | OverrideOptions; 150 | /** interval time of the 'progress.throttled' event will be emitted (default:1000) */ 151 | progressThrottle?: number; 152 | /** Override the http request options */ 153 | httpRequestOptions?: object; 154 | /** Override the https request options, ex: to add SSL Certs */ 155 | httpsRequestOptions?: object; 156 | /** Resume download if the file is incomplete */ 157 | resumeOnIncomplete?: boolean; 158 | /** Max retry when resumeOnIncomplete is true */ 159 | resumeOnIncompleteMaxRetry?: number; 160 | } 161 | export class DownloaderHelper extends EventEmitter { 162 | /** 163 | * Creates an instance of DownloaderHelper. 164 | * @param {String} url 165 | * @param {String} destFolder 166 | * @param {Object} [options={}] 167 | * @memberof DownloaderHelper 168 | */ 169 | constructor(url: string, destFolder: string, options?: DownloaderHelperOptions); 170 | 171 | /** 172 | * 173 | * 174 | * @returns {Promise} 175 | * @memberof DownloaderHelper 176 | */ 177 | start(): Promise; 178 | 179 | /** 180 | * 181 | * 182 | * @returns {Promise} 183 | * @memberof DownloaderHelper 184 | */ 185 | pause(): Promise; 186 | 187 | /** 188 | * 189 | * 190 | * @returns {Promise} 191 | * @memberof DownloaderHelper 192 | */ 193 | resume(): Promise; 194 | 195 | /** 196 | * 197 | * 198 | * @returns {Promise} 199 | * @memberof DownloaderHelper 200 | */ 201 | stop(): Promise; 202 | 203 | /** 204 | * Add pipes to the pipe list that will be applied later when the download starts 205 | * @url https://nodejs.org/api/stream.html#stream_readable_pipe_destination_options 206 | * @param {stream.Readable} stream https://nodejs.org/api/stream.html#stream_class_stream_readable 207 | * @param {Object} [options=null] 208 | * @returns {stream.Readable} 209 | * @memberof DownloaderHelper 210 | */ 211 | pipe(stream: stream.Readable, options?: object): stream.Readable; 212 | 213 | /** 214 | * Unpipe an stream , if a stream is not specified, then all pipes are detached. 215 | * 216 | * @url https://nodejs.org/api/stream.html#stream_readable_unpipe_destination 217 | * @param {stream.Readable} [stream=null] 218 | * @returns 219 | * @memberof DownloaderHelper 220 | */ 221 | unpipe(stream?: stream.Readable): void; 222 | 223 | /** 224 | * Where the download will be saved 225 | * 226 | * @returns {String} 227 | * @memberof DownloaderHelper 228 | */ 229 | getDownloadPath(): string; 230 | 231 | /** 232 | * Indicates if the download can be resumable (available after the start phase) 233 | * 234 | * @returns {Boolean} 235 | * @memberof DownloaderHelper 236 | */ 237 | isResumable(): boolean; 238 | 239 | /** 240 | * Updates the options, can be use on pause/resume events 241 | * 242 | * @param {Object} [options={}] 243 | * @param {String} [url=''] 244 | * @memberof DownloaderHelper 245 | */ 246 | updateOptions(options?: object, url?: string): void; 247 | 248 | getOptions(): object; 249 | getMetadata(): object | null; 250 | 251 | /** 252 | * Current download progress stats 253 | * 254 | * @returns {Stats} 255 | * @memberof DownloaderHelper 256 | */ 257 | getStats(): Stats; 258 | 259 | /** 260 | * Gets the total file size from the server 261 | * 262 | * @returns {Promise<{name:string, total:number|null}>} 263 | * @memberof DownloaderHelper 264 | */ 265 | getTotalSize(): Promise<{ name: string; total: number | null }>; 266 | 267 | /** 268 | * Subscribes to events 269 | * 270 | * @memberof EventEmitter 271 | */ 272 | on(event: E, callback: DownloadEvents[E]): any; 273 | 274 | /** 275 | * Get the state required to resume the download after restart. This state 276 | * can be passed back to `resumeFromFile()` to resume a download 277 | * 278 | * @returns {IResumeState} Returns the state required to resume 279 | * @memberof DownloaderHelper 280 | */ 281 | getResumeState(): IResumeState; 282 | 283 | /** 284 | * 285 | * @param {string} filePath - The path to the file to resume from ex: C:\Users\{user}\Downloads\file.txt 286 | * @param {IResumeState} state - (optionl) resume download state, if not provided it will try to fetch from the headers and filePath 287 | * 288 | * @returns {Promise} - Returns the same result as `start()` 289 | * @memberof DownloaderHelper 290 | */ 291 | resumeFromFile(filePath: string, state?: IResumeState): Promise; 292 | } 293 | --------------------------------------------------------------------------------