-
Notifications
You must be signed in to change notification settings - Fork 1
/
index.js
146 lines (121 loc) · 5.37 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
'use strict';
const fs = require('fs');
const got = require('got');
const ProgressBar = require('progress');
class LargeDownload {
/**
* @typedef {Object} HTTPOptions
* @see https://www.npmjs.com/package/got
* @property {Number} [retries=0] number of retries to establish the connection
* It doesn't mix well with download retries, so default value is set to disable network retries
* All other properties work as expected
*/
/**
* @typedef {Object} LargeDownloadOptions
* @property {String} link download url
* @property {String} destination where to write the result
* @property {Number} [timeout] timeout in milliseconds for the download
* @property {Number} [retries=1] max retries for the whole operation
* @property {HTTPOptions} [httpOptions]
* @property {Function} [onRetry] will be called for each retry occured with an Error as the only argument
* @property {Number} [minSizeToShowProgress=0] minumum file size in bytes to show progress bar
*/
/**
* @param {LargeDownloadOptions} opts
*/
constructor(opts) {
if ( ! opts.link) {
throw new Error('Download link is not provided');
}
if ( ! opts.destination) {
throw new Error('Destination is not provided');
}
this.link = opts.link;
this.destination = opts.destination;
this.timeout = opts.timeout;
this.retries = opts.hasOwnProperty('retries') ? opts.retries : 1;
this.httpOptions = Object.assign({ retries: 0 }, opts.httpOptions);
this.onRetry = opts.onRetry;
this.minSizeToShowProgress = opts.minSizeToShowProgress || 0;
}
/**
* @returns {Promise}
*/
load() {
const _this = this;
return new Promise((resolve, reject) => {
let retriesDone = 0;
function tryDownload() {
let downloadTimer;
let bar;
let declaredSize = 0;
let downloadedSize = 0;
const readable = got.stream(_this.link, _this.httpOptions);
const writable = fs.createWriteStream(_this.destination);
function cleanup() {
// Ensure no more data is written to destination file
readable.unpipe(writable);
writable.removeListener('finish', onFinish);
// Writable stream should be closed manually in case of unpipe.
// It is OK to call `end` several times
writable.end();
downloadTimer && clearTimeout(downloadTimer);
bar && bar.terminate();
}
function onError(err) {
cleanup();
if (++retriesDone <= _this.retries) {
typeof _this.onRetry === 'function' && _this.onRetry(err);
writable.on('close', tryDownload);
} else {
reject(new Error(
`Could not download ${_this.link} in ${retriesDone} attempts:\n${err.message}`));
}
}
function onFinish() {
// It's frequent for a large download to fail due to the server closing connection prematurely
if (declaredSize && declaredSize !== downloadedSize) {
return onError(new Error(
`Downloaded file size (${downloadedSize}) doesn't match "content-length" ` +
`header (${declaredSize}) in the server response`));
}
cleanup();
// Resolve only after file has been closed
writable.on('close', resolve);
}
readable.on('error', e => onError(e));
writable.on('error', e => onError(e));
if (_this.timeout) {
readable.on('request', req => {
downloadTimer = setTimeout(() => {
req.abort();
onError(new Error(`Download timeout (${_this.timeout}) reached`));
}, _this.timeout);
});
}
readable.on('response', res => {
declaredSize = parseInt(res.headers['content-length'], 10);
// "progress" module actually checks for redirected output, but still prints empty newlines
const doShowProgressBar = process.stdout.isTTY && declaredSize > _this.minSizeToShowProgress;
if (doShowProgressBar) {
bar = new ProgressBar('[:bar] :percent :etas', {
complete: '=',
incomplete: ' ',
width: 30,
total: declaredSize,
});
}
res.on('data', chunk => {
const len = chunk.length;
downloadedSize += len;
doShowProgressBar && bar.tick(len);
});
});
writable.on('finish', onFinish);
readable.pipe(writable);
}
tryDownload();
});
}
}
module.exports = LargeDownload;