API Docs for: 0.0.1
Show:

File: src/io/extra/io-filetransfer.js

"use strict";

/**
 * Extends io by adding the method `sendBlob` to it.
 *
 * @example
 * var IO = require("io/extra/io-filetransfer.js")(window);
 *
 *
 * <i>Copyright (c) 2015 ITSA - https://github.com/itsa</i>
 * New BSD License - http://choosealicense.com/licenses/bsd-3-clause/
 *
 * @module io
 * @submodule io-filetransfer
 * @class IO
 * @since 0.0.1
*/

require('js-ext/lib/object.js');
require('js-ext/lib/promise.js');

var NAME = '[io-filetransfer]: ',
    GENERATOR_ID = 'ITSA-FILETRANS',
    MIME_BLOB = 'application/octet-stream',
    CONTENT_TYPE = 'Content-Type',
    createHashMap = require('js-ext/extra/hashmap.js').createMap,
    idGenerator = require('utils').idGenerator,
    SPINNER_ICON = 'spinnercircle-anim',
    DEFAULT_TIMEOUT = 300000, // 5 minutes
    MIN_SHOWUP = 500,
    REVIVER = function(key, value) {
        return ((typeof value==='string') && value.toDate()) || value;
    },
    ABORTED = 'Request aborted',
    KB_25 = 25 * 1024, // 25kb
    KB_100 = 100 * 1024, // 100kb
    KB_256 = 256 * 1024, // 256kb
    MB_1 = 1025 * 1024, // 1Mb
    MB_20 = 20 * MB_1; // 20Mb


module.exports = function (window) {

    window._ITSAmodules || Object.protectedProp(window, '_ITSAmodules', createHashMap());

    if (window._ITSAmodules.IO_Filetransfer) {
        return window._ITSAmodules.IO_Filetransfer; // IO_Filetransfer was already created
    }

    var IO = require('../io.js')(window),
        clientIdPromiseOriginal, clientIdPromise, _entendXHR, _progressHandle;

    window._ITSAmodules.IO_Filetransfer = IO;


    /*
     * Adds properties to the xhr-object: in case of streaming,
     * xhr._isStream is set and xhr._isXDR might be set in case of IE<10
     *
     * @method _entendXHR
     * @param xhr {Object} containing the xhr-instance
     * @param props {Object} the propertie-object that is added too xhr and can be expanded
     * @param options {Object} options of the request
     * @private
    */
    _entendXHR = function(xhr, props, options /*, promise */) {
        props._isUpload = (typeof options.progressfn === 'function');
        return xhr;
    },

    /*
     * Adds extra initialisation of the xhr-object: in case of streaming,
     * an `onprogress`-handler is set up
     *
     * @method _progressHandle
     * @param xhr {Object} containing the xhr-instance
     * @param promise {Promise} reference to the Promise created by xhr
     * @private
    */
    _progressHandle = function(xhr, promise /*, headers, method */) {
        if (xhr._isUpload) {
            console.log(NAME, 'progressHandle upload');
            xhr.upload.onprogress = function(e) {
                if (e.lengthComputable) {
                    // promise.callback(data);
                    console.log('file upload-progress: '+e.loaded+'/'+e.total);
                    promise._loaded = e.loaded;
                    promise._notify();
                }
            };
        }
    },

    /**
     * Is send to the server and expects an unique id which can be used for its file-transactions.
     *
     * @method _getClientId
     * @private
     * @param url {String} URL of the resource server
     * @return {Promise}
     * on success:
        * Unique clientId
     * on failure an Error object
        * reason {Error}
    */
    IO._getClientId = function(url) {
        var options;
        // check for existance, but also if it was not rejected --> in that case we need to reactivate
        // (the server might have been coming up again)
        if (clientIdPromiseOriginal && !clientIdPromiseOriginal.isRejected()) {
            return clientIdPromise;
        }
        options = {
            url: url,
            method: 'GET',
            data: {
                ts: Date.now() // prevent caching
            }
        };
        clientIdPromiseOriginal = this.request(options);
        clientIdPromise = clientIdPromiseOriginal.then(function(xhrResponse) {
            return xhrResponse.responseText;
        }).catch(function(err) {
            console.warn(err);
            throw new Error(err);
        });
        return clientIdPromise;
    };

    /**
     * Sends a `blob` by using an AJAX PUT request.
     * Additional parameters can be through the `params` argument.
     *
     * The Promise gets fulfilled if the server responses with `STATUS-CODE` in the 200-range (excluded 204).
     * It will be rejected if a timeout occurs (see `options.timeout`), or if `xhr.abort()` gets invoked.
     *
     * Note: `params` should be a plain object with only primitive types which are transformed into key/value pairs.
     *
     * @method sendBlob
     * @param url {String} URL of the resource server
     * @param blob {blob} blob (data) representing the file to be send. Typically `HTMLInputElement.files[0]`
     * @param [params] {Object} additional parameters. NOTE: this object will be `stringified` set a HEADER: `x-data` on the request!
     *        should be a plain object with only primitive types which are transformed into key/value pairs.
     * @param [options] {Object}
     *    @param [options.sync=false] {boolean} By default, all requests are sent asynchronously. To send synchronous requests, set to true.
     *    @param [options.headers] {Object} HTTP request headers.
     *    @param [options.timeout=300000] {Number} to timeout the request, leading into a rejected Promise. Defaults to 5 minutes
     *    @param [options.progressfn] {Function} callbackfunction in case you want to process upload-status.
     *           Function has 3 parameters: total, loaded and target (io-promise)
     *    @param [options.withCredentials=false] {boolean} Whether or not to send credentials on the request.
     *    @param [options.parseJSONDate=false] {boolean} Whether the server returns JSON-stringified data which has Date-objects.
     *    @param [options.stayActive] {Number} minimal time the request should be pending, even if IO has finished
     * @return {Promise}
     * on success:
        * Object any received data
     * on failure an Error object
        * reason {Error}
    */
    IO.sendBlob = function (url, blob, params, options) {
        console.log(NAME, 'get --> '+url);
        var instance = this,
            start = 0,
            promiseHash = [],
            ioHash = [],
            i = 0,
            chunkSize, end, size, returnPromise, filename, headers, notify,
            ioPromise, partialSize, notifyResolved, hashPromise, responseObject, setXHR;
        if (!IO.supportXHR2) {
            return Promise.reject('This browser does not support fileupload');
        }
        if (!blob instanceof window.Blob) {
            return Promise.reject('No proper fileobject');
        }
        if ((typeof url!=='string') || (url.length===0)) {
            return Promise.reject('No valid url specified');
        }

        // send a notification to `progressfn` whenever a chunk gets intermediate update
        // by `xhr.upload.onprogress`
        notify = function() {
            var len = promiseHash.length,
                totalLoaded = 0,
                i, promise;
            for (i=0; i<len; i++){
                promise = promiseHash[i];
                totalLoaded += promise._loaded || 0;
            }
            returnPromise.callback({
                total: size,
                loaded: totalLoaded,
                target: returnPromise
            });
        };

        // send a notification to `progressfn` whenever a chunk finishes
        notifyResolved = function(promise, loaded) {
            promise.then(function() {
                promise._loaded = loaded;
                promise._notify();
            });
        };

        // sets the `right` xhr in the returned promise. That is: the server-response that holds the final data
        // and not the intermediate response.
        setXHR = function(xhr) {
            var responseText = xhr.responseText,
                response;
            try {
                response = JSON.parse(responseText); // no reviver, we are only interested in the `status` property
            }
            catch(err) {
                response = {};
                console.warn(err);
            }
            response.status && (response.status=response.status.toLowerCase());
            if (response.status!=='busy') {
                if (options.parseJSONDate) {
                    try {
                        responseObject = JSON.parse(responseText, REVIVER);
                    }
                    catch(err) {
                        console.warn(err);
                        responseObject = {};
                    }
                }
                else {
                    responseObject = response;
                }
            }
        };

        options = Object.isObject(options) ? options.deepClone() : {};
        options.timeout || (options.timeout=DEFAULT_TIMEOUT);
        returnPromise = Promise.manage(options.progressfn);

        filename = blob.name;
        size = blob.size;
        if (options.progressfn) {
            // immediately start with processing 0%
            returnPromise.callback({
                total: size,
                loaded: 0,
                target: returnPromise
            });
        }

        instance._getClientId(url).then(function(clientId) {
            options.url = url;
            options.method || (options.method='PUT');
            options.url = url;
            options.data = blob;
            // delete hidden property `responseType`: don't want accedentially to be used
            delete options.responseType;

            // Important: headers need to be deepCloned, otherwise all chuncks share the same headers
            // and we dwant the last chunk to have different headers!
            headers = options.headers ? options.headers.deepClone() : {};
            // options.headers[CONTENT_TYPE] = blob.type || MIME_BLOB;
            headers['X-TransId'] = idGenerator(GENERATOR_ID);
            headers['X-ClientId'] = clientId;
            headers['X-Total-size'] = size;
            headers[CONTENT_TYPE] = MIME_BLOB;

            if (size<=MB_1) {
                chunkSize = KB_25;
            }
            else if (size<=MB_20) {
                chunkSize = KB_100;
            }
            else {
                chunkSize = KB_256;
            }
            end = chunkSize;
            partialSize = chunkSize;
            while (start < size) {
                //push the fragments to an array
                options.data = blob.slice(start, end);

                start = end;
                end = start + chunkSize;
                headers['X-Partial']= ++i;
                options.headers = headers.deepClone();
                if (start>=size) {
                    // set the filename on the last request:
                    options.headers['X-Filename'] = filename || 'blob';
                    Object.isObject(params) && (options.headers['x-data']=JSON.stringify(params));
                    partialSize = (size % chunkSize) || chunkSize;
                }
                //upload the fragment to the server:
                ioPromise = instance.request(options);
                if (options.progressfn) {
                    ioPromise._notify = notify;
                    notifyResolved(ioPromise, partialSize);
                }
                promiseHash.push(ioPromise); // needed to be able to call `abort`
                // we need to inspect the response for status==='busy' to know which promise holds the final
                // value and should be used as the returnvalue. Therefore, create `hashPromise`
                hashPromise = ioPromise.then(setXHR);
                ioHash.push(hashPromise);
            }

            Promise.all(ioHash).then(
                function() {
                    returnPromise.fulfill(responseObject);
                },
                function(e) {
                    returnPromise.reject(e);
                }
            );
        });

        // set `abort` to the thennable-promise:
        returnPromise.abort = function() {
            promiseHash.forEach(function(partialIO) {
                partialIO.abort();
            });
            returnPromise.reject(new Error(ABORTED));
        };
        return returnPromise;
    };

    IO._xhrList.push(_entendXHR);
    IO._xhrInitList.push(_progressHandle);

    return IO;
};