Source: net/http.js

Object.assign(pc, function () {
    /**
     * @constructor
     * @name pc.Http
     * @classdesc Used to send and receive HTTP requests.
     * @description Create a new Http instance. By default, a PlayCanvas application creates an instance of this
     * object at `pc.http`.
     */
    var Http = function Http() {
    };

    Http.ContentType = {
        FORM_URLENCODED: "application/x-www-form-urlencoded",
        GIF: "image/gif",
        JPEG: "image/jpeg",
        DDS: "image/dds",
        JSON: "application/json",
        PNG: "image/png",
        TEXT: "text/plain",
        XML: "application/xml",
        WAV: "audio/x-wav",
        OGG: "audio/ogg",
        MP3: "audio/mpeg",
        MP4: "audio/mp4",
        AAC: "audio/aac",
        BIN: "application/octet-stream"
    };

    Http.ResponseType = {
        TEXT: 'text',
        ARRAY_BUFFER: 'arraybuffer',
        BLOB: 'blob',
        DOCUMENT: 'document',
        JSON: 'json'
    };

    Http.binaryExtensions = [
        '.model',
        '.wav',
        '.ogg',
        '.mp3',
        '.mp4',
        '.m4a',
        '.aac',
        '.dds'
    ];

    Http.retryDelay = 100;

    Object.assign(Http.prototype, {

        ContentType: Http.ContentType,
        ResponseType: Http.ResponseType,
        binaryExtensions: Http.binaryExtensions,

        /**
         * @function
         * @name pc.Http#get
         * @description Perform an HTTP GET request to the given url.
         * @param {String} url The URL to make the request to.
         * @param {Object} [options] Additional options
         * @param {Object} [options.headers] HTTP headers to add to the request
         * @param {Boolean} [options.async] Make the request asynchronously. Defaults to true.
         * @param {Object} [options.cache] If false, then add a timestamp to the request to prevent caching
         * @param {Boolean} [options.withCredentials] Send cookies with this request. Defaults to true.
         * @param {String} [options.responseType] Override the response type
         * @param {Document | Object} [options.postdata] Data to send in the body of the request.
         * Some content types are handled automatically. If postdata is an XML Document, it is handled. If
         * the Content-Type header is set to 'application/json' then the postdata is JSON stringified.
         * Otherwise, by default, the data is sent as form-urlencoded.
         * @param {Boolean} [options.retry] If true then if the request fails it will be retried with an exponential backoff.
         * @param {Number} [options.maxRetries] If options.retry is true this specifies the maximum number of retries. Defaults to 5.
         * @param {Number} [options.maxRetryDelay] If options.retry is true this specifies the maximum amount of time to wait between retries in milliseconds. Defaults to 5000.
         * @param {Function} callback The callback used when the response has returned. Passed (err, data)
         * where data is the response (format depends on response type: text, Object, ArrayBuffer, XML) and
         * err is the error code.
         * @example
         * pc.http.get("http://example.com/", function (err, response) {
         *     console.log(response);
         * });
         * @returns {XMLHttpRequest} The request object.
         */
        get: function (url, options, callback) {
            if (typeof options === "function") {
                callback = options;
                options = {};
            }
            return this.request("GET", url, options, callback);
        },

        /**
         * @function
         * @name pc.Http#post
         * @description Perform an HTTP POST request to the given url.
         * @param {String} url The URL to make the request to.
         * @param {Object} data Data to send in the body of the request.
         * Some content types are handled automatically. If postdata is an XML Document, it is handled. If
         * the Content-Type header is set to 'application/json' then the postdata is JSON stringified.
         * Otherwise, by default, the data is sent as form-urlencoded.
         * @param {Object} [options] Additional options
         * @param {Object} [options.headers] HTTP headers to add to the request
         * @param {Boolean} [options.async] Make the request asynchronously. Defaults to true.
         * @param {Object} [options.cache] If false, then add a timestamp to the request to prevent caching
         * @param {Boolean} [options.withCredentials] Send cookies with this request. Defaults to true.
         * @param {String} [options.responseType] Override the response type
         * @param {Boolean} [options.retry] If true then if the request fails it will be retried with an exponential backoff.
         * @param {Number} [options.maxRetries] If options.retry is true this specifies the maximum number of retries. Defaults to 5.
         * @param {Number} [options.maxRetryDelay] If options.retry is true this specifies the maximum amount of time to wait between retries in milliseconds. Defaults to 5000.
         * @param {Function} callback The callback used when the response has returned. Passed (err, data)
         * where data is the response (format depends on response type: text, Object, ArrayBuffer, XML) and
         * err is the error code.
         * @returns {XMLHttpRequest} The request object.
         */
        post: function (url, data, options, callback) {
            if (typeof options === "function") {
                callback = options;
                options = {};
            }
            options.postdata = data;
            return this.request("POST", url, options, callback);
        },

        /**
         * @function
         * @name pc.Http#put
         * @description Perform an HTTP PUT request to the given url.
         * @param {String} url The URL to make the request to.
         * @param {Document | Object} data Data to send in the body of the request.
         * Some content types are handled automatically. If postdata is an XML Document, it is handled. If
         * the Content-Type header is set to 'application/json' then the postdata is JSON stringified.
         * Otherwise, by default, the data is sent as form-urlencoded.
         * @param {Object} [options] Additional options
         * @param {Object} [options.headers] HTTP headers to add to the request
         * @param {Boolean} [options.async] Make the request asynchronously. Defaults to true.
         * @param {Object} [options.cache] If false, then add a timestamp to the request to prevent caching
         * @param {Boolean} [options.withCredentials] Send cookies with this request. Defaults to true.
         * @param {String} [options.responseType] Override the response type
         * @param {Boolean} [options.retry] If true then if the request fails it will be retried with an exponential backoff.
         * @param {Number} [options.maxRetries] If options.retry is true this specifies the maximum number of retries. Defaults to 5.
         * @param {Number} [options.maxRetryDelay] If options.retry is true this specifies the maximum amount of time to wait between retries in milliseconds. Defaults to 5000.
         * @param {Function} callback The callback used when the response has returned. Passed (err, data)
         * where data is the response (format depends on response type: text, Object, ArrayBuffer, XML) and
         * err is the error code.
         * @returns {XMLHttpRequest} The request object.
         */
        put: function (url, data, options, callback) {
            if (typeof options === "function") {
                callback = options;
                options = {};
            }
            options.postdata = data;
            return this.request("PUT", url, options, callback);
        },

        /**
         * @function
         * @name pc.Http#del
         * @description Perform an HTTP DELETE request to the given url
         * @param {Object} url The URL to make the request to
         * @param {Object} [options] Additional options
         * @param {Object} [options.headers] HTTP headers to add to the request
         * @param {Boolean} [options.async] Make the request asynchronously. Defaults to true.
         * @param {Object} [options.cache] If false, then add a timestamp to the request to prevent caching
         * @param {Boolean} [options.withCredentials] Send cookies with this request. Defaults to true.
         * @param {String} [options.responseType] Override the response type
         * @param {Document | Object} [options.postdata] Data to send in the body of the request.
         * Some content types are handled automatically. If postdata is an XML Document, it is handled. If
         * the Content-Type header is set to 'application/json' then the postdata is JSON stringified.
         * Otherwise, by default, the data is sent as form-urlencoded.
         * @param {Boolean} [options.retry] If true then if the request fails it will be retried with an exponential backoff.
         * @param {Number} [options.maxRetries] If options.retry is true this specifies the maximum number of retries. Defaults to 5.
         * @param {Number} [options.maxRetryDelay] If options.retry is true this specifies the maximum amount of time to wait between retries in milliseconds. Defaults to 5000.
         * @param {Function} callback The callback used when the response has returned. Passed (err, data)
         * where data is the response (format depends on response type: text, Object, ArrayBuffer, XML) and
         * err is the error code.
         * @returns {XMLHttpRequest} The request object.
         */
        del: function (url, options, callback) {
            if (typeof options === "function") {
                callback = options;
                options = {};
            }
            return this.request("DELETE", url, options, callback);
        },

        /**
         * @function
         * @name pc.Http#request
         * @description Make a general purpose HTTP request.
         * @param {String} method The HTTP method "GET", "POST", "PUT", "DELETE"
         * @param {String} url The url to make the request to
         * @param {Object} [options] Additional options
         * @param {Object} [options.headers] HTTP headers to add to the request
         * @param {Boolean} [options.async] Make the request asynchronously. Defaults to true.
         * @param {Object} [options.cache] If false, then add a timestamp to the request to prevent caching
         * @param {Boolean} [options.withCredentials] Send cookies with this request. Defaults to true.
         * @param {Boolean} [options.retry] If true then if the request fails it will be retried with an exponential backoff.
         * @param {Number} [options.maxRetries] If options.retry is true this specifies the maximum number of retries. Defaults to 5.
         * @param {Number} [options.maxRetryDelay] If options.retry is true this specifies the maximum amount of time to wait between retries in milliseconds. Defaults to 5000.
         * @param {String} [options.responseType] Override the response type
         * @param {Document|Object} [options.postdata] Data to send in the body of the request.
         * Some content types are handled automatically. If postdata is an XML Document, it is handled. If
         * the Content-Type header is set to 'application/json' then the postdata is JSON stringified.
         * Otherwise, by default, the data is sent as form-urlencoded.
         * @param {Function} callback The callback used when the response has returned. Passed (err, data)
         * where data is the response (format depends on response type: text, Object, ArrayBuffer, XML) and
         * err is the error code.
         * @returns {XMLHttpRequest} The request object.
         */
        request: function (method, url, options, callback) {
            var uri, query, timestamp, postdata, xhr;
            var errored = false;

            if (typeof options === "function") {
                callback = options;
                options = {};
            }

            // if retryable we are going to store new properties
            // in the options so create a new copy to not affect
            // the original
            if (options.retry) {
                options = Object.assign({
                    retries: 0,
                    maxRetries: 5
                }, options);
            }

            // store callback
            options.callback = callback;

            // setup defaults
            if (options.async == null) {
                options.async = true;
            }
            if (options.headers == null) {
                options.headers = {};
            }

            if (options.postdata != null) {
                if (options.postdata instanceof Document) {
                    // It's an XML document, so we can send it directly.
                    // XMLHttpRequest will set the content type correctly.
                    postdata = options.postdata;
                } else if (options.postdata instanceof FormData) {
                    postdata = options.postdata;
                } else if (options.postdata instanceof Object) {
                    // Now to work out how to encode the post data based on the headers
                    var contentType = options.headers["Content-Type"];

                    // If there is no type then default to form-encoded
                    if (contentType === undefined) {
                        options.headers["Content-Type"] = Http.ContentType.FORM_URLENCODED;
                        contentType = options.headers["Content-Type"];
                    }
                    switch (contentType) {
                        case Http.ContentType.FORM_URLENCODED:
                            // Normal URL encoded form data
                            postdata = "";
                            var bFirstItem = true;

                            // Loop round each entry in the map and encode them into the post data
                            for (var key in options.postdata) {
                                if (options.postdata.hasOwnProperty(key)) {
                                    if (bFirstItem) {
                                        bFirstItem = false;
                                    } else {
                                        postdata += "&";
                                    }
                                    postdata += escape(key) + "=" + escape(options.postdata[key]);
                                }
                            }
                            break;
                        default:
                        case Http.ContentType.JSON:
                            if (contentType == null) {
                                options.headers["Content-Type"] = Http.ContentType.JSON;
                            }
                            postdata = JSON.stringify(options.postdata);
                            break;
                    }
                } else {
                    postdata = options.postdata;
                }
            }

            if (options.cache === false) {
                // Add timestamp to url to prevent browser caching file
                timestamp = pc.time.now();

                uri = new pc.URI(url);
                if (!uri.query) {
                    uri.query = "ts=" + timestamp;
                } else {
                    uri.query = uri.query + "&ts=" + timestamp;
                }
                url = uri.toString();
            }

            if (options.query) {
                uri = new pc.URI(url);
                query = pc.extend(uri.getQuery(), options.query);
                uri.setQuery(query);
                url = uri.toString();
            }

            xhr = new XMLHttpRequest();
            xhr.open(method, url, options.async);
            xhr.withCredentials = options.withCredentials !== undefined ? options.withCredentials : false;
            xhr.responseType = options.responseType || this._guessResponseType(url);

            // Set the http headers
            for (var header in options.headers) {
                if (options.headers.hasOwnProperty(header)) {
                    xhr.setRequestHeader(header, options.headers[header]);
                }
            }

            xhr.onreadystatechange = function () {
                this._onReadyStateChange(method, url, options, xhr);
            }.bind(this);

            xhr.onerror = function () {
                this._onError(method, url, options, xhr);
                errored = true;
            }.bind(this);

            try {
                xhr.send(postdata);
            } catch (e) {
                // DWE: Don't callback on exceptions as behaviour is inconsistent, e.g. cross-domain request errors don't throw an exception.
                // Error callback should be called by xhr.onerror() callback instead.
                if (!errored) {
                    options.error(xhr.status, xhr, e);
                }
            }

            // Return the request object as it can be handy for blocking calls
            return xhr;
        },

        _guessResponseType: function (url) {
            var uri = new pc.URI(url);
            var ext = pc.path.getExtension(uri.path);

            if (Http.binaryExtensions.indexOf(ext) >= 0) {
                return Http.ResponseType.ARRAY_BUFFER;
            }

            if (ext === ".xml") {
                return Http.ResponseType.DOCUMENT;
            }

            return Http.ResponseType.TEXT;
        },

        _isBinaryContentType: function (contentType) {
            var binTypes = [Http.ContentType.MP4, Http.ContentType.WAV, Http.ContentType.OGG, Http.ContentType.MP3, Http.ContentType.BIN, Http.ContentType.DDS];
            if (binTypes.indexOf(contentType) >= 0) {
                return true;
            }

            return false;
        },

        _onReadyStateChange: function (method, url, options, xhr) {
            if (xhr.readyState === 4) {
                switch (xhr.status) {
                    case 0: {

                        // If this is a local resource then continue (IOS) otherwise the request
                        // didn't complete, possibly an exception or attempt to do cross-domain request
                        if (url[0] != '/') {
                            this._onSuccess(method, url, options, xhr);
                        } else {
                            this._onError(method, url, options, xhr);
                        }

                        break;
                    }
                    case 200:
                    case 201:
                    case 206:
                    case 304: {
                        this._onSuccess(method, url, options, xhr);
                        break;
                    }
                    default: {
                        this._onError(method, url, options, xhr);
                        break;
                    }
                }
            }
        },

        _onSuccess: function (method, url, options, xhr) {
            var response;
            var header;
            var contentType;
            var parts;
            header = xhr.getResponseHeader("Content-Type");
            if (header) {
                // Split up header into content type and parameter
                parts = header.split(";");
                contentType = parts[0].trim();
            }
            try {
                // Check the content type to see if we want to parse it
                if (contentType === this.ContentType.JSON || url.split('?')[0].endsWith(".json")) {
                    // It's a JSON response
                    response = JSON.parse(xhr.responseText);
                } else if (this._isBinaryContentType(contentType)) {
                    response = xhr.response;
                } else {
                    if (contentType) {
                        logWARNING(pc.string.format('responseType: {0} being served with Content-Type: {1}', xhr.responseType, contentType));
                    }

                    if (xhr.responseType === Http.ResponseType.ARRAY_BUFFER) {
                        response = xhr.response;
                    } else if (xhr.responseType === Http.ResponseType.BLOB || xhr.responseType === Http.ResponseType.JSON) {
                        response = xhr.response;
                    } else {
                        if (xhr.responseType === Http.ResponseType.DOCUMENT || contentType === this.ContentType.XML) {
                            // It's an XML response
                            response = xhr.responseXML;
                        } else {
                            // It's raw data
                            response = xhr.responseText;
                        }
                    }
                }

                options.callback(null, response);
            } catch (err) {
                options.callback(err);
            }
        },

        _onError: function (method, url, options, xhr) {
            if (options.retrying) {
                return;
            }

            // retry if necessary
            if (options.retry && options.retries < options.maxRetries) {
                options.retries++;
                options.retrying = true; // used to stop retrying when both onError and xhr.onerror are called
                var retryDelay = pc.math.clamp(Math.pow(2, options.retries) * Http.retryDelay, 0, options.maxRetryDelay || 5000);
                console.log(method + ': ' + url + ' - Error ' + xhr.status + '. Retrying in ' + retryDelay + ' ms');

                setTimeout(function () {
                    options.retrying = false;
                    this.request(method, url, options, options.callback);
                }.bind(this), retryDelay);
            } else {
                // no more retries or not retry so just fail
                options.callback(xhr.status === 0 ? 'Network error' : xhr.status, null);
            }
        }
    });

    return {
        Http: Http,
        http: new Http()
    };
}());