@lynker-desktop/electron-sdk 0.0.9-alpha.89 → 0.0.9-alpha.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1270 @@
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
+ 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
+ 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<boolean>}
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<boolean>}
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<boolean>}
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<boolean>}
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
+
349
+ let retryCount = 0;
350
+ let retryTimeout = null;
351
+
352
+ const retry = (err, url) => {
353
+ if (!this.__opts.retry || typeof this.__opts.retry !== 'object') {
354
+ return Promise.reject(err || new Error('wrong retry options'));
355
+ }
356
+
357
+ if (retryTimeout) {
358
+ clearTimeout(retryTimeout);
359
+ retryTimeout = null;
360
+ }
361
+
362
+ const { delay: retryDelay = 0, maxRetries = 999 } = this.__opts.retry;
363
+
364
+ if (retryCount >= maxRetries) {
365
+ return Promise.reject(err || new Error('reached the maximum retries'));
366
+ }
367
+
368
+ retryCount++;
369
+ this.__setState(this.__states.RETRY);
370
+ this.emit('retry', retryCount, this.__opts.retry, err);
371
+
372
+ return new Promise((resolveRetry) => {
373
+ retryTimeout = setTimeout(() => {
374
+ this.__setState(this.__states.IDLE);
375
+ getRequest(url, getReqOptions(url));
376
+ resolveRetry();
377
+ }, retryDelay);
378
+ });
379
+ };
380
+
381
+ const getRequest = (url, options) => {
382
+ if (retryTimeout) {
383
+ clearTimeout(retryTimeout);
384
+ retryTimeout = null;
385
+ }
386
+ const req = this.__protocol.request(options, response => {
387
+ if (this.__isRequireRedirect(response)) {
388
+ const redirectedURL = /^https?:\/\//.test(response.headers.location)
389
+ ? response.headers.location
390
+ : new URL(response.headers.location, url).href;
391
+ this.emit('redirected', redirectedURL, url);
392
+ return getRequest(redirectedURL, getReqOptions(redirectedURL));
393
+ }
394
+ if (response.statusCode < 200 || response.statusCode >= 400) {
395
+ const err = new Error(`Response status was ${response.statusCode}`);
396
+ // 5xx server errors
397
+ if (this.__opts.retry &&
398
+ response.statusCode >= 500 && response.statusCode < 600) {
399
+ return retry(err, url).catch(reject);
400
+ }
401
+ return reject(err);
402
+ }
403
+ resolve({
404
+ name: this.__getFileNameFromHeaders(response.headers, response),
405
+ total: parseInt(response.headers['content-length']) || null
406
+ });
407
+ });
408
+
409
+ req.on('error', (err) => {
410
+ if (this.__opts.retry) {
411
+ return retry(err, url).catch(reject);
412
+ }
413
+ reject(err);
414
+ });
415
+
416
+ req.on('timeout', () => {
417
+ if (this.__opts.retry) {
418
+ return retry(new Error('timeout'), url).catch(reject);
419
+ }
420
+ reject(new Error('timeout'));
421
+ });
422
+
423
+ req.on('uncaughtException', (err) => {
424
+ if (this.__opts.retry) {
425
+ return retry(err, url).catch(reject);
426
+ }
427
+ reject(err);
428
+ });
429
+
430
+ req.end();
431
+ };
432
+ getRequest(this.url, getReqOptions(this.url));
433
+ });
434
+ }
435
+
436
+ /**
437
+ * Get the state required to resume the download after restart. This state
438
+ * can be passed back to `resumeFromFile()` to resume a download
439
+ *
440
+ * @returns {Object} Returns the state required to resume
441
+ * @memberof DownloaderHelper
442
+ */
443
+ getResumeState() {
444
+ return {
445
+ downloaded: this.__downloaded,
446
+ filePath: this.__filePath,
447
+ fileName: this.__fileName,
448
+ total: this.__total,
449
+ };
450
+ }
451
+
452
+ /**
453
+ * Resume the download from a previous file path
454
+ *
455
+ * @param {string} filePath - The path to the file to resume from ex: C:\Users\{user}\Downloads\file.txt
456
+ * @param {Object} state - (optionl) resume download state, if not provided it will try to fetch from the headers and filePath
457
+ *
458
+ * @returns {Promise<boolean>} - Returns the same result as `start()`
459
+ * @memberof DownloaderHelper
460
+ */
461
+ resumeFromFile(filePath, state = {}) {
462
+ this.__opts.override = true;
463
+ this.__filePath = filePath;
464
+ return ((state.total && state.fileName)
465
+ ? Promise.resolve({ name: state.fileName, total: state.total })
466
+ : this.getTotalSize())
467
+ .then(({ name, total }) => {
468
+ this.__total = state.total || total;
469
+ this.__fileName = state.fileName || name;
470
+ this.__downloaded = state.downloaded || this.__getFilesizeInBytes(this.__filePath);
471
+ this.__reqOptions['headers']['range'] = `bytes=${this.__downloaded}-`;
472
+ this.__isResumed = true;
473
+ this.__isResumable = true;
474
+ this.__setState(this.__states.RESUMED);
475
+ this.emit('resume', this.__isResumed);
476
+ return new Promise((resolve, reject) => {
477
+ this.__promise = { resolve, reject };
478
+ this.__start();
479
+ });
480
+ });
481
+ }
482
+
483
+ __start() {
484
+ if (!this.__isRedirected &&
485
+ this.state !== this.__states.RESUMED) {
486
+ this.emit('start');
487
+ this.__setState(this.__states.STARTED);
488
+ this.__initProtocol(this.url);
489
+ }
490
+
491
+ // Start the Download
492
+ this.__response = null;
493
+ this.__isAborted = false;
494
+
495
+ if (this.__request && !this.__request.destroyed) {
496
+ this.__request.destroy();
497
+ }
498
+
499
+ if (this.__retryTimeout) {
500
+ clearTimeout(this.__retryTimeout);
501
+ this.__retryTimeout = null;
502
+ }
503
+
504
+ this.__request = this.__downloadRequest(this.__promise.resolve, this.__promise.reject);
505
+
506
+ // Error Handling
507
+ this.__request.on('error', this.__onError(this.__promise.resolve, this.__promise.reject));
508
+ this.__request.on('timeout', this.__onTimeout(this.__promise.resolve, this.__promise.reject));
509
+ this.__request.on('uncaughtException', this.__onError(this.__promise.resolve, this.__promise.reject, true));
510
+
511
+ if (this.__opts.body) {
512
+ this.__request.write(this.__opts.body);
513
+ }
514
+
515
+ this.__request.end();
516
+ }
517
+
518
+ /**
519
+ * Resolve pending promises from Start method
520
+ *
521
+ * @memberof DownloaderHelper
522
+ */
523
+ __resolvePending() {
524
+ if (!this.__promise) {
525
+ return;
526
+ }
527
+ const { resolve } = this.__promise;
528
+ this.__promise = null;
529
+ return resolve(true);
530
+ }
531
+
532
+ /**
533
+ *
534
+ *
535
+ * @param {Promise.resolve} resolve
536
+ * @param {Promise.reject} reject
537
+ * @returns {http.ClientRequest}
538
+ * @memberof DownloaderHelper
539
+ */
540
+ __downloadRequest(resolve, reject) {
541
+ return this.__protocol.request(this.__reqOptions, response => {
542
+ this.__response = response;
543
+
544
+ //Stats
545
+ if (!this.__isResumed) {
546
+ this.__total = parseInt(response.headers['content-length']) || null;
547
+ this.__resetStats();
548
+ }
549
+
550
+ // Handle Redirects
551
+ if (this.__isRequireRedirect(response)) {
552
+ const redirectedURL = /^https?:\/\//.test(response.headers.location)
553
+ ? response.headers.location
554
+ : new URL(response.headers.location, this.url).href;
555
+ this.__isRedirected = true;
556
+ this.__initProtocol(redirectedURL);
557
+ this.emit('redirected', redirectedURL, this.url);
558
+ return this.__start();
559
+ }
560
+
561
+ // check if response wans't a success
562
+ if (response.statusCode < 200 || response.statusCode >= 400) {
563
+ const err = new Error(`Response status was ${response.statusCode}`);
564
+ err.status = response.statusCode || 0;
565
+ err.body = response.body || '';
566
+
567
+ // 5xx server errors
568
+ if (response.statusCode >= 500 && response.statusCode < 600) {
569
+ return this.__onError(resolve, reject)(err);
570
+ }
571
+ this.__setState(this.__states.FAILED);
572
+ this.emit('error', err);
573
+ return reject(err);
574
+ }
575
+
576
+ if (this.__opts.forceResume) {
577
+ this.__isResumable = true;
578
+ } else if (response.headers.hasOwnProperty('accept-ranges') &&
579
+ response.headers['accept-ranges'] !== 'none') {
580
+ this.__isResumable = true;
581
+ }
582
+
583
+ this.__startDownload(response, resolve, reject);
584
+ });
585
+ }
586
+
587
+ /**
588
+ *
589
+ *
590
+ * @param {http.IncomingMessage} response
591
+ * @param {Promise.resolve} resolve
592
+ * @param {Promise.reject} reject
593
+ * @memberof DownloaderHelper
594
+ */
595
+ __startDownload(response, resolve, reject) {
596
+ let readable = response;
597
+
598
+ if (!this.__isResumed) {
599
+ const _fileName = this.__getFileNameFromHeaders(response.headers);
600
+ this.__filePath = this.__getFilePath(_fileName);
601
+ this.__fileName = this.__filePath.split(path.sep).pop();
602
+ if (fs.existsSync(this.__filePath)) {
603
+ const downloadedSize = this.__getFilesizeInBytes(this.__filePath);
604
+ const totalSize = this.__total ? this.__total : 0;
605
+ if (typeof this.__opts.override === 'object' &&
606
+ this.__opts.override.skip && (
607
+ this.__opts.override.skipSmaller ||
608
+ downloadedSize >= totalSize)) {
609
+ this.emit('skip', {
610
+ totalSize: this.__total,
611
+ fileName: this.__fileName,
612
+ filePath: this.__filePath,
613
+ downloadedSize: downloadedSize
614
+ });
615
+ this.__setState(this.__states.SKIPPED);
616
+ return resolve(true);
617
+ }
618
+ }
619
+ this.__fileStream = fs.createWriteStream(this.__filePath, {});
620
+ } else {
621
+ this.__fileStream = fs.createWriteStream(this.__filePath, { 'flags': 'a' });
622
+ }
623
+
624
+ // Start Downloading
625
+ this.emit('download', {
626
+ fileName: this.__fileName,
627
+ filePath: this.__filePath,
628
+ totalSize: this.__total,
629
+ isResumed: this.__isResumed,
630
+ downloadedSize: this.__downloaded
631
+ });
632
+ this.__retryCount = 0;
633
+ this.__isResumed = false;
634
+ this.__isRedirected = false;
635
+ this.__setState(this.__states.DOWNLOADING);
636
+ this.__statsEstimate.time = new Date();
637
+ this.__statsEstimate.throttleTime = new Date();
638
+
639
+ // Add externals pipe
640
+ readable.on('data', chunk => this.__calculateStats(chunk.length));
641
+ this.__pipes.forEach(pipe => {
642
+ readable.pipe(pipe.stream, pipe.options);
643
+ readable = pipe.stream;
644
+ });
645
+ readable.pipe(this.__fileStream);
646
+ readable.on('error', this.__onError(resolve, reject));
647
+
648
+ this.__fileStream.on('finish', this.__onFinished(resolve, reject));
649
+ this.__fileStream.on('error', this.__onError(resolve, reject));
650
+ }
651
+
652
+
653
+ /**
654
+ *
655
+ *
656
+ * @returns
657
+ * @memberof DownloaderHelper
658
+ */
659
+ __hasFinished() {
660
+ return !this.__isAborted && [
661
+ this.__states.PAUSED,
662
+ this.__states.STOPPED,
663
+ this.__states.RETRY,
664
+ this.__states.FAILED,
665
+ this.__states.RESUMED
666
+ ].indexOf(this.state) === -1;
667
+ }
668
+
669
+
670
+ /**
671
+ *
672
+ *
673
+ * @param {IncomingMessage} response
674
+ * @returns {Boolean}
675
+ * @memberof DownloaderHelper
676
+ */
677
+ __isRequireRedirect(response) {
678
+ return (response.statusCode > 300 &&
679
+ response.statusCode < 400 &&
680
+ response.headers.hasOwnProperty('location') &&
681
+ response.headers.location);
682
+ }
683
+
684
+ /**
685
+ *
686
+ *
687
+ * @param {Promise.resolve} resolve
688
+ * @param {Promise.reject} reject
689
+ * @returns {Function}
690
+ * @memberof DownloaderHelper
691
+ */
692
+ __onFinished(resolve, reject) {
693
+ return () => {
694
+ this.__fileStream.close(_err => {
695
+ if (_err) {
696
+ return reject(_err);
697
+ }
698
+ if (this.__hasFinished()) {
699
+ const isIncomplete = !this.__total ? false : this.__downloaded !== this.__total;
700
+
701
+ if (isIncomplete && this.__isResumable && this.__opts.resumeOnIncomplete &&
702
+ this.__resumeRetryCount <= this.__opts.resumeOnIncompleteMaxRetry) {
703
+ this.__resumeRetryCount++;
704
+ this.emit('warning', new Error('uncomplete download, retrying'));
705
+ return this.resume();
706
+ }
707
+
708
+ this.__setState(this.__states.FINISHED);
709
+ this.__pipes = [];
710
+ this.emit('end', {
711
+ fileName: this.__fileName,
712
+ filePath: this.__filePath,
713
+ totalSize: this.__total,
714
+ incomplete: isIncomplete,
715
+ onDiskSize: this.__getFilesizeInBytes(this.__filePath),
716
+ downloadedSize: this.__downloaded,
717
+ });
718
+ }
719
+ return resolve(this.__downloaded === this.__total);
720
+ });
721
+ };
722
+ }
723
+
724
+ /**
725
+ *
726
+ *
727
+ * @returns
728
+ * @memberof DownloaderHelper
729
+ */
730
+ __closeFileStream() {
731
+ if (!this.__fileStream) {
732
+ return Promise.resolve(true);
733
+ }
734
+ return new Promise((resolve, reject) => {
735
+ this.__fileStream.close(err => {
736
+ if (err) {
737
+ return reject(err);
738
+ }
739
+ return resolve(true);
740
+ });
741
+ });
742
+ }
743
+
744
+ /**
745
+ *
746
+ * @param {Promise.resolve} resolve
747
+ * @param {Promise.reject} reject
748
+ * @param {boolean} abortReq
749
+ * @returns {Function}
750
+ * @memberof DownloaderHelper
751
+ */
752
+ __onError(resolve, reject, abortReq = false) {
753
+ return err => {
754
+ this.__pipes = [];
755
+
756
+ if (abortReq) {
757
+ this.__requestAbort();
758
+ }
759
+
760
+ if (this.state === this.__states.STOPPED ||
761
+ this.state === this.__states.FAILED) {
762
+ return;
763
+ }
764
+ if (!this.__opts.retry) {
765
+ return this.__removeFile().finally(() => {
766
+ this.__setState(this.__states.FAILED);
767
+ this.emit('error', err);
768
+ reject(err);
769
+ });
770
+ }
771
+ return this.__retry(err)
772
+ .catch(_err => {
773
+ this.__removeFile().finally(() => {
774
+ this.__setState(this.__states.FAILED);
775
+ this.emit('error', _err ? _err : err);
776
+ reject(_err ? _err : err);
777
+ });
778
+ });
779
+ };
780
+ }
781
+
782
+ /**
783
+ *
784
+ *
785
+ * @returns {Promise<boolean>}
786
+ * @memberof DownloaderHelper
787
+ */
788
+ __retry(err = null) {
789
+ if (!this.__opts.retry || typeof this.__opts.retry !== 'object') {
790
+ return Promise.reject(err || new Error('wrong retry options'));
791
+ }
792
+
793
+ const { delay: retryDelay = 0, maxRetries = 999 } = this.__opts.retry;
794
+
795
+ // reached the maximum retries
796
+ if (this.__retryCount >= maxRetries) {
797
+ return Promise.reject(err || new Error('reached the maximum retries'));
798
+ }
799
+
800
+ this.__retryCount++;
801
+ this.__setState(this.__states.RETRY);
802
+ this.emit('retry', this.__retryCount, this.__opts.retry, err);
803
+
804
+ if (this.__response) {
805
+ this.__response.unpipe();
806
+ this.__pipes.forEach(pipe => pipe.stream.unpipe());
807
+ }
808
+
809
+ if (this.__fileStream) {
810
+ this.__fileStream.removeAllListeners();
811
+ }
812
+
813
+ this.__requestAbort();
814
+
815
+ return this.__closeFileStream().then(() =>
816
+ new Promise((resolve) =>
817
+ this.__retryTimeout = setTimeout(
818
+ () => resolve(this.__downloaded > 0 ?
819
+ this.resume() :
820
+ this.__start()),
821
+ retryDelay
822
+ )
823
+ )
824
+ );
825
+ }
826
+
827
+ /**
828
+ *
829
+ * @param {Promise.resolve} resolve
830
+ * @param {Promise.reject} reject
831
+ * @returns {Function}
832
+ * @memberof DownloaderHelper
833
+ */
834
+ __onTimeout(resolve, reject) {
835
+ return () => {
836
+ this.__requestAbort();
837
+
838
+ if (!this.__opts.retry) {
839
+ return this.__removeFile().finally(() => {
840
+ this.__setState(this.__states.FAILED);
841
+ this.emit('timeout');
842
+ reject(new Error('timeout'));
843
+ });
844
+ }
845
+
846
+ return this.__retry(new Error('timeout'))
847
+ .catch(_err => {
848
+ this.__removeFile().finally(() => {
849
+ this.__setState(this.__states.FAILED);
850
+ if (_err) {
851
+ reject(_err);
852
+ } else {
853
+ this.emit('timeout');
854
+ reject(new Error('timeout'));
855
+ }
856
+ });
857
+ });
858
+ };
859
+ }
860
+
861
+ /**
862
+ *
863
+ *
864
+ * @memberof DownloaderHelper
865
+ */
866
+ __resetStats() {
867
+ this.__retryCount = 0;
868
+ this.__downloaded = 0;
869
+ this.__progress = 0;
870
+ this.__resumeRetryCount = 0;
871
+ this.__statsEstimate = {
872
+ time: 0,
873
+ bytes: 0,
874
+ prevBytes: 0,
875
+ throttleTime: 0,
876
+ };
877
+ }
878
+
879
+ /**
880
+ *
881
+ *
882
+ * @param {Object} headers
883
+ * @returns {String}
884
+ * @memberof DownloaderHelper
885
+ */
886
+ __getFileNameFromHeaders(headers, response) {
887
+ let fileName = '';
888
+
889
+ const fileNameAndEncodingRegExp = /.*filename\*=.*?'.*?'([^"].+?[^"])(?:(?:;)|$)/i; // match everything after the specified encoding behind a case-insensitive `filename*=`
890
+ const fileNameWithQuotesRegExp = /.*filename="(.*?)";?/i; // match everything inside the quotes behind a case-insensitive `filename=`
891
+ 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
892
+
893
+ const ContentDispositionHeaderExists = headers.hasOwnProperty('content-disposition');
894
+ const fileNameAndEncodingMatch = !ContentDispositionHeaderExists ? null : headers['content-disposition'].match(fileNameAndEncodingRegExp);
895
+ const fileNameWithQuotesMatch = (!ContentDispositionHeaderExists || fileNameAndEncodingMatch) ? null : headers['content-disposition'].match(fileNameWithQuotesRegExp);
896
+ const fileNameWithoutQuotesMatch = (!ContentDispositionHeaderExists || fileNameAndEncodingMatch || fileNameWithQuotesMatch) ? null : headers['content-disposition'].match(fileNameWithoutQuotesRegExp);
897
+
898
+ // Get Filename
899
+ if (ContentDispositionHeaderExists && (fileNameAndEncodingMatch || fileNameWithQuotesMatch || fileNameWithoutQuotesMatch)) {
900
+
901
+ fileName = headers['content-disposition'];
902
+ fileName = fileName.trim();
903
+
904
+ if (fileNameAndEncodingMatch) {
905
+ fileName = fileNameAndEncodingMatch[1];
906
+ } else if (fileNameWithQuotesMatch) {
907
+ fileName = fileNameWithQuotesMatch[1];
908
+ } else if (fileNameWithoutQuotesMatch) {
909
+ fileName = fileNameWithoutQuotesMatch[1];
910
+ }
911
+
912
+ fileName = fileName.replace(/[/\\]/g, '');
913
+
914
+ } else {
915
+
916
+ if (path.basename(new URL(this.requestURL).pathname).length > 0) {
917
+ fileName = path.basename(new URL(this.requestURL).pathname);
918
+ } else {
919
+ fileName = `${new URL(this.requestURL).hostname}.html`;
920
+ }
921
+ }
922
+
923
+ return (
924
+ (this.__opts.fileName)
925
+ ? this.__getFileNameFromOpts(fileName, response)
926
+ : fileName.replace(/\.*$/, '') // remove any potential trailing '.' (just to be sure)
927
+ )
928
+ }
929
+
930
+ /**
931
+ *
932
+ *
933
+ * @param {String} fileName
934
+ * @returns {String}
935
+ * @memberof DownloaderHelper
936
+ */
937
+ __getFilePath(fileName) {
938
+ const currentPath = path.join(this.__destFolder, fileName);
939
+ let filePath = currentPath;
940
+
941
+ if (!this.__opts.override && this.state !== this.__states.RESUMED) {
942
+ filePath = this.__uniqFileNameSync(filePath);
943
+
944
+ if (currentPath !== filePath) {
945
+ this.emit('renamed', {
946
+ 'path': filePath,
947
+ 'fileName': filePath.split(path.sep).pop(),
948
+ 'prevPath': currentPath,
949
+ 'prevFileName': currentPath.split(path.sep).pop()
950
+ });
951
+ }
952
+ }
953
+
954
+ return filePath;
955
+ }
956
+
957
+
958
+ /**
959
+ *
960
+ *
961
+ * @param {String} fileName
962
+ * @returns {String}
963
+ * @memberof DownloaderHelper
964
+ */
965
+ __getFileNameFromOpts(fileName, response) {
966
+
967
+ if (!this.__opts.fileName) {
968
+ return fileName;
969
+ } else if (typeof this.__opts.fileName === 'string') {
970
+ return this.__opts.fileName;
971
+ } else if (typeof this.__opts.fileName === 'function') {
972
+ const currentPath = path.join(this.__destFolder, fileName);
973
+ if ((response && response.headers) || (this.__response && this.__response.headers)) {
974
+ return this.__opts.fileName(fileName, currentPath, (response ? response : this.__response).headers['content-type']);
975
+ } else {
976
+ return this.__opts.fileName(fileName, currentPath);
977
+ }
978
+ } else if (typeof this.__opts.fileName === 'object') {
979
+
980
+ const fileNameOpts = this.__opts.fileName; // { name:string, ext:true|false|string}
981
+ const name = fileNameOpts.name;
982
+ const ext = fileNameOpts.hasOwnProperty('ext')
983
+ ? fileNameOpts.ext : false;
984
+
985
+ if (typeof ext === 'string') {
986
+ return `${name}.${ext}`;
987
+ } else if (typeof ext === 'boolean') {
988
+ // true: use the 'name' as full file name
989
+ // false (default) only replace the name
990
+ if (ext) {
991
+ return name;
992
+ } else {
993
+ const _ext = fileName.includes('.') ? fileName.split('.').pop() : ''; // make sure there is a '.' in the fileName string
994
+ return _ext !== '' ? `${name}.${_ext}` : name; // if there is no extension, replace the whole file name
995
+ }
996
+ }
997
+ }
998
+
999
+ return fileName;
1000
+ }
1001
+
1002
+ /**
1003
+ *
1004
+ *
1005
+ * @param {Number} receivedBytes
1006
+ * @memberof DownloaderHelper
1007
+ */
1008
+ __calculateStats(receivedBytes) {
1009
+ const currentTime = new Date();
1010
+ const elaspsedTime = currentTime - this.__statsEstimate.time;
1011
+ const throttleElapseTime = currentTime - this.__statsEstimate.throttleTime;
1012
+ const total = this.__total || 0;
1013
+
1014
+ if (!receivedBytes) {
1015
+ return;
1016
+ }
1017
+
1018
+ this.__downloaded += receivedBytes;
1019
+ this.__progress = total === 0 ? 0 : (this.__downloaded / total) * 100;
1020
+
1021
+ // Calculate the speed every second or if finished
1022
+ if (this.__downloaded === total || elaspsedTime > 1000) {
1023
+ this.__statsEstimate.time = currentTime;
1024
+ this.__statsEstimate.bytes = this.__downloaded - this.__statsEstimate.prevBytes;
1025
+ this.__statsEstimate.prevBytes = this.__downloaded;
1026
+ }
1027
+
1028
+ if (this.__downloaded === total || throttleElapseTime > this.__opts.progressThrottle) {
1029
+ this.__statsEstimate.throttleTime = currentTime;
1030
+ this.emit('progress.throttled', this.getStats());
1031
+ }
1032
+
1033
+ // emit the progress
1034
+ this.emit('progress', this.getStats());
1035
+ }
1036
+
1037
+ /**
1038
+ *
1039
+ *
1040
+ * @param {String} state
1041
+ * @memberof DownloaderHelper
1042
+ */
1043
+ __setState(state) {
1044
+ this.state = state;
1045
+ this.emit('stateChanged', this.state);
1046
+ }
1047
+
1048
+ /**
1049
+ *
1050
+ *
1051
+ * @param {String} method
1052
+ * @param {String} url
1053
+ * @param {Object} [headers={}]
1054
+ * @returns {Object}
1055
+ * @memberof DownloaderHelper
1056
+ */
1057
+ __getReqOptions(method, url, headers = {}) {
1058
+ const urlParse = new URL(url);
1059
+ const options = {
1060
+ protocol: urlParse.protocol,
1061
+ host: urlParse.hostname,
1062
+ port: urlParse.port,
1063
+ path: urlParse.pathname + urlParse.search,
1064
+ method,
1065
+ };
1066
+
1067
+ if (headers) {
1068
+ options['headers'] = headers;
1069
+ }
1070
+
1071
+ return options;
1072
+ }
1073
+
1074
+ /**
1075
+ *
1076
+ *
1077
+ * @param {String} filePath
1078
+ * @returns {Number}
1079
+ * @memberof DownloaderHelper
1080
+ */
1081
+ __getFilesizeInBytes(filePath) {
1082
+ try {
1083
+ // 'throwIfNoEntry' was implemented on Node.js v14.17.0
1084
+ // so we added try/catch in case is using an older version
1085
+ const stats = fs.statSync(filePath, { throwIfNoEntry: false });
1086
+ const fileSizeInBytes = stats.size || 0;
1087
+ return fileSizeInBytes;
1088
+ } catch (err) {
1089
+ // mostly probably the file doesn't exist
1090
+ this.emit('warning', err);
1091
+ }
1092
+ return 0;
1093
+ }
1094
+
1095
+ /**
1096
+ *
1097
+ *
1098
+ * @param {String} url
1099
+ * @param {String} destFolder
1100
+ * @returns {Boolean|Error}
1101
+ * @memberof DownloaderHelper
1102
+ */
1103
+ __validate(url, destFolder) {
1104
+
1105
+ if (typeof url !== 'string') {
1106
+ throw new Error('URL should be an string');
1107
+ }
1108
+
1109
+ if (url.trim() === '') {
1110
+ throw new Error("URL couldn't be empty");
1111
+ }
1112
+
1113
+ if (typeof destFolder !== 'string') {
1114
+ throw new Error('Destination Folder should be an string');
1115
+ }
1116
+
1117
+ if (destFolder.trim() === '') {
1118
+ throw new Error("Destination Folder couldn't be empty");
1119
+ }
1120
+
1121
+ if (!fs.existsSync(destFolder)) {
1122
+ throw new Error('Destination Folder must exist');
1123
+ }
1124
+ const isMas = process.mas === true;
1125
+ if (isMas) {
1126
+ return true;
1127
+ }
1128
+ const stats = fs.statSync(destFolder);
1129
+ if (!stats.isDirectory()) {
1130
+ throw new Error('Destination Folder must be a directory');
1131
+ }
1132
+
1133
+ try {
1134
+ fs.accessSync(destFolder, fs.constants.W_OK);
1135
+ } catch (e) {
1136
+ throw new Error('Destination Folder must be writable');
1137
+ }
1138
+
1139
+ return true;
1140
+ }
1141
+
1142
+ /**
1143
+ *
1144
+ *
1145
+ * @param {String} url
1146
+ * @memberof DownloaderHelper
1147
+ */
1148
+ __initProtocol(url) {
1149
+ const defaultOpts = this.__getReqOptions(this.__opts.method, url, this.__headers);
1150
+ this.requestURL = url;
1151
+
1152
+ if (url.indexOf('https://') > -1) {
1153
+ this.__protocol = https;
1154
+ defaultOpts.agent = new https.Agent({ keepAlive: false });
1155
+ this.__reqOptions = Object.assign({}, defaultOpts, this.__opts.httpsRequestOptions);
1156
+ } else {
1157
+ this.__protocol = http;
1158
+ defaultOpts.agent = new http.Agent({ keepAlive: false });
1159
+ this.__reqOptions = Object.assign({}, defaultOpts, this.__opts.httpRequestOptions);
1160
+ }
1161
+
1162
+ }
1163
+
1164
+ /**
1165
+ *
1166
+ *
1167
+ * @param {String} path
1168
+ * @returns {String}
1169
+ * @memberof DownloaderHelper
1170
+ */
1171
+ __uniqFileNameSync(path) {
1172
+ if (typeof path !== 'string' || path === '') {
1173
+ return path;
1174
+ }
1175
+
1176
+ try {
1177
+ // if access fail, the file doesnt exist yet
1178
+ fs.accessSync(path, fs.F_OK);
1179
+ const pathInfo = path.match(/(.*)(\([0-9]+\))(\..*)$/);
1180
+ let base = pathInfo ? pathInfo[1].trim() : path;
1181
+ let suffix = pathInfo ? parseInt(pathInfo[2].replace(/\(|\)/, '')) : 0;
1182
+ let ext = path.split('.').pop();
1183
+
1184
+ if (ext !== path && ext.length > 0) {
1185
+ ext = '.' + ext;
1186
+ base = base.replace(ext, '');
1187
+ } else {
1188
+ ext = '';
1189
+ }
1190
+
1191
+ // generate a new path until it doesn't exist
1192
+ return this.__uniqFileNameSync(base + ' (' + (++suffix) + ')' + ext);
1193
+ } catch (err) {
1194
+ return path;
1195
+ }
1196
+ }
1197
+
1198
+ /**
1199
+ *
1200
+ *
1201
+ * @returns {Promise<void>}
1202
+ * @memberof DownloaderHelper
1203
+ */
1204
+ __removeFile() {
1205
+ return new Promise(resolve => {
1206
+ if (!this.__fileStream) {
1207
+ return resolve();
1208
+ }
1209
+ this.__fileStream.close((err) => {
1210
+ if (err) {
1211
+ this.emit('warning', err);
1212
+ }
1213
+ if (this.__opts.removeOnFail) {
1214
+ return fs.access(this.__filePath, _accessErr => {
1215
+ // if can't access, probably is not created yet
1216
+ if (_accessErr) {
1217
+ return resolve();
1218
+ }
1219
+
1220
+ fs.unlink(this.__filePath, (_err) => {
1221
+ if (_err) {
1222
+ this.emit('warning', err);
1223
+ }
1224
+ resolve();
1225
+ });
1226
+ });
1227
+ }
1228
+ resolve();
1229
+ });
1230
+ });
1231
+ }
1232
+
1233
+ /**
1234
+ *
1235
+ *
1236
+ * @memberof DownloaderHelper
1237
+ */
1238
+ __requestAbort() {
1239
+ this.__isAborted = true;
1240
+ if (this.__retryTimeout) {
1241
+ clearTimeout(this.__retryTimeout);
1242
+ this.__retryTimeout = null;
1243
+ }
1244
+
1245
+ if (this.__response) {
1246
+ this.__response.destroy();
1247
+ }
1248
+
1249
+ if (this.__request) {
1250
+ // from node => v13.14.X
1251
+ if (this.__request.destroy) {
1252
+ this.__request.destroy();
1253
+ } else {
1254
+ this.__request.abort();
1255
+ }
1256
+ }
1257
+ }
1258
+
1259
+ /**
1260
+ * @memberof DownloaderHelper
1261
+ */
1262
+ __emitStop() {
1263
+ this.__resolvePending();
1264
+ this.__setState(this.__states.STOPPED);
1265
+ this.emit('stop');
1266
+ }
1267
+ }
1268
+
1269
+ export { DH_STATES, DownloaderHelper };
1270
+ //# sourceMappingURL=index.js.map