download.js 3.93 KB
var progress = require('request-progress');
var request = require('request');
var Q = require('q');
var mout = require('mout');
var retry = require('retry');
var createError = require('./createError');
var createWriteStream = require('fs-write-stream-atomic');
var destroy = require('destroy');

var errorCodes = [
    'EADDRINFO',
    'ETIMEDOUT',
    'ECONNRESET',
    'ESOCKETTIMEDOUT',
    'ENOTFOUND'
];

function download(url, file, options) {
    var operation;
    var deferred = Q.defer();
    var progressDelay = 8000;

    options = mout.object.mixIn({
        retries: 5,
        factor: 2,
        minTimeout: 1000,
        maxTimeout: 35000,
        randomize: true,
        progressDelay: progressDelay,
        gzip: true
    }, options || {});

    // Retry on network errors
    operation = retry.operation(options);

    operation.attempt(function () {
        Q.fcall(fetch, url, file, options)
        .then(function (response) {
            deferred.resolve(response);
        })
        .progress(function (status) {
            deferred.notify(status);
        })
        .fail(function (error) {
            // Save timeout before retrying to report
            var timeout = operation._timeouts[0];

            // Reject if error is not a network error
            if (errorCodes.indexOf(error.code) === -1) {
                return deferred.reject(error);
            }

            // Next attempt will start reporting download progress immediately
            progressDelay = 0;

            // This will schedule next retry or return false
            if (operation.retry(error)) {
                deferred.notify({
                    retry: true,
                    delay: timeout,
                    error: error
                });
            } else {
                deferred.reject(error);
            }
        });
    });

    return deferred.promise;
}

function fetch(url, file, options) {
    var deferred = Q.defer();

    var contentLength;
    var bytesDownloaded = 0;

    var reject = function (error) {
        deferred.reject(error);
    };

    var req = progress(request(url, options), {
        delay: options.progressDelay
    })
    .on('response', function (response) {
        contentLength = Number(response.headers['content-length']);

        var status = response.statusCode;

        if (status < 200 || status >= 300) {
            return deferred.reject(createError('Status code of ' + status, 'EHTTP'));
        }

        var writeStream = createWriteStream(file);
        var errored = false;

        // Change error listener so it cleans up writeStream before exiting
        req.removeListener('error', reject);
        req.on('error', function (error) {
            errored = true;
            destroy(req);
            destroy(writeStream);

            // Wait for writeStream to cleanup after itself...
            // TODO: Maybe there's a better way?
            setTimeout(function () {
                deferred.reject(error);
            }, 50);
        });

        writeStream.on('finish', function () {
            if (!errored) {
                destroy(req);
                deferred.resolve(response);
            }
        });

        req.pipe(writeStream);
    })
    .on('data', function (data) {
        bytesDownloaded += data.length;
    })
    .on('progress', function (state) {
        deferred.notify(state);
    })
    .on('error', reject)
    .on('end', function () {
        // Check if the whole file was downloaded
        // In some unstable connections the ACK/FIN packet might be sent in the
        // middle of the download
        // See: https://github.com/joyent/node/issues/6143
        if (contentLength && bytesDownloaded < contentLength) {
            req.emit('error', createError(
                'Transfer closed with ' + (contentLength - bytesDownloaded) + ' bytes remaining to read',
                'EINCOMPLETE'
            ));
        }
    });

    return deferred.promise;
}

module.exports = download;