UNPKG

playcanvas

Version:

PlayCanvas WebGL game engine

515 lines (512 loc) 24 kB
import { extend } from '../../core/core.js'; import { now } from '../../core/time.js'; import { path } from '../../core/path.js'; import { URI } from '../../core/uri.js'; import { math } from '../../core/math/math.js'; /** * @import { EventHandler } from '../../core/event-handler.js'; */ /** * @callback HttpResponseCallback * Callback used by {@link Http#get}, {@link Http#post}, {@link Http#put}, {@link Http#del}, and * {@link Http#request}. * @param {number|string|Error|null} err - The error code, message, or exception in the case where * the request fails. * @param {any} [response] - The response data if no errors were encountered. Format depends on * response type: text, Object, ArrayBuffer, XML. * @returns {void} */ /** * Used to send and receive HTTP requests. */ class Http { static{ this.ContentType = { AAC: 'audio/aac', BASIS: 'image/basis', BIN: 'application/octet-stream', DDS: 'image/dds', FORM_URLENCODED: 'application/x-www-form-urlencoded', GIF: 'image/gif', GLB: 'model/gltf-binary', JPEG: 'image/jpeg', JSON: 'application/json', MP3: 'audio/mpeg', MP4: 'audio/mp4', OGG: 'audio/ogg', OPUS: 'audio/ogg; codecs="opus"', PNG: 'image/png', TEXT: 'text/plain', WAV: 'audio/x-wav', XML: 'application/xml' }; } static{ this.ResponseType = { TEXT: 'text', ARRAY_BUFFER: 'arraybuffer', BLOB: 'blob', DOCUMENT: 'document', JSON: 'json' }; } static{ this.binaryExtensions = [ '.model', '.wav', '.ogg', '.mp3', '.mp4', '.m4a', '.aac', '.dds', '.basis', '.glb', '.opus' ]; } static{ this.retryDelay = 100; } /** * Perform an HTTP GET request to the given url with additional options such as headers, * retries, credentials, etc. * * @param {string} url - The URL to make the request to. * @param {object} options - Additional options. * @param {Object<string, string>} [options.headers] - HTTP headers to add to the request. * @param {boolean} [options.async] - Make the request asynchronously. Defaults to true. * @param {boolean} [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 false. * @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 {EventHandler} [options.progress] - Object to use for firing progress events. * @param {HttpResponseCallback} 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/", { * "retry": true, * "maxRetries": 5 * }, (err, response) => { * console.log(response); * }); * @returns {XMLHttpRequest} The request object. */ get(url, options, callback) { if (typeof options === 'function') { callback = options; options = {}; } const result = this.request('GET', url, options, callback); const { progress } = options; if (progress) { const handler = (event)=>{ if (event.lengthComputable) { progress.fire('progress', event.loaded, event.total); } }; const endHandler = (event)=>{ handler(event); result.removeEventListener('loadstart', handler); result.removeEventListener('progress', handler); result.removeEventListener('loadend', endHandler); }; result.addEventListener('loadstart', handler); result.addEventListener('progress', handler); result.addEventListener('loadend', endHandler); } return result; } /** * Perform an HTTP POST request to the given url with additional options such as headers, * retries, credentials, etc. * * @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<string, string>} [options.headers] - HTTP headers to add to the request. * @param {boolean} [options.async] - Make the request asynchronously. Defaults to true. * @param {boolean} [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 false. * @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 {HttpResponseCallback} 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.post("http://example.com/", { * "name": "Alex" * }, { * "retry": true, * "maxRetries": 5 * }, (err, response) => { * console.log(response); * }); * @returns {XMLHttpRequest} The request object. */ post(url, data, options, callback) { if (typeof options === 'function') { callback = options; options = {}; } options.postdata = data; return this.request('POST', url, options, callback); } /** * Perform an HTTP PUT request to the given url with additional options such as headers, * retries, credentials, etc. * * @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<string, string>} [options.headers] - HTTP headers to add to the request. * @param {boolean} [options.async] - Make the request asynchronously. Defaults to true. * @param {boolean} [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 false. * @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 {HttpResponseCallback} 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.put("http://example.com/", { * "name": "Alex" * }, { * "retry": true, * "maxRetries": 5 * }, (err, response) => { * console.log(response); * }); * @returns {XMLHttpRequest} The request object. */ put(url, data, options, callback) { if (typeof options === 'function') { callback = options; options = {}; } options.postdata = data; return this.request('PUT', url, options, callback); } /** * Perform an HTTP DELETE request to the given url with additional options such as headers, * retries, credentials, etc. * * @param {string} url - The URL to make the request to. * @param {object} options - Additional options. * @param {Object<string, string>} [options.headers] - HTTP headers to add to the request. * @param {boolean} [options.async] - Make the request asynchronously. Defaults to true. * @param {boolean} [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 false. * @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 {HttpResponseCallback} 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.del("http://example.com/", { * "retry": true, * "maxRetries": 5 * }, (err, response) => { * console.log(response); * }); * @returns {XMLHttpRequest} The request object. */ del(url, options, callback) { if (typeof options === 'function') { callback = options; options = {}; } return this.request('DELETE', url, options, callback); } /** * Make a general purpose HTTP request with additional options such as headers, retries, * credentials, etc. * * @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<string, string>} [options.headers] - HTTP headers to add to the request. * @param {boolean} [options.async] - Make the request asynchronously. Defaults to true. * @param {boolean} [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 false. * @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 {HttpResponseCallback} 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.request("get", "http://example.com/", { * "retry": true, * "maxRetries": 5 * }, (err, response) => { * console.log(response); * }); * @returns {XMLHttpRequest} The request object. */ request(method, url, options, callback) { let uri, query, postdata; let 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 let 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 = ''; let bFirstItem = true; // Loop round each entry in the map and encode them into the post data for(const key in options.postdata){ if (options.postdata.hasOwnProperty(key)) { if (bFirstItem) { bFirstItem = false; } else { postdata += '&'; } const encodedKey = encodeURIComponent(key); const encodedValue = encodeURIComponent(options.postdata[key]); postdata += `${encodedKey}=${encodedValue}`; } } 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 const timestamp = now(); uri = new URI(url); if (!uri.query) { uri.query = `ts=${timestamp}`; } else { uri.query = `${uri.query}&ts=${timestamp}`; } url = uri.toString(); } if (options.query) { uri = new URI(url); query = extend(uri.getQuery(), options.query); uri.setQuery(query); url = uri.toString(); } const 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(const header in options.headers){ if (options.headers.hasOwnProperty(header)) { xhr.setRequestHeader(header, options.headers[header]); } } xhr.onreadystatechange = ()=>{ this._onReadyStateChange(method, url, options, xhr); }; xhr.onerror = ()=>{ this._onError(method, url, options, xhr); errored = true; }; try { xhr.send(postdata); } catch (e) { // DWE: Don't callback on exceptions as behavior 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(url) { const uri = new URI(url); const ext = path.getExtension(uri.path).toLowerCase(); if (Http.binaryExtensions.indexOf(ext) >= 0) { return Http.ResponseType.ARRAY_BUFFER; } else if (ext === '.json') { return Http.ResponseType.JSON; } else if (ext === '.xml') { return Http.ResponseType.DOCUMENT; } return Http.ResponseType.TEXT; } _isBinaryContentType(contentType) { const binTypes = [ Http.ContentType.BASIS, Http.ContentType.BIN, Http.ContentType.DDS, Http.ContentType.GLB, Http.ContentType.MP3, Http.ContentType.MP4, Http.ContentType.OGG, Http.ContentType.OPUS, Http.ContentType.WAV ]; if (binTypes.indexOf(contentType) >= 0) { return true; } return false; } _isBinaryResponseType(responseType) { return responseType === Http.ResponseType.ARRAY_BUFFER || responseType === Http.ResponseType.BLOB || responseType === Http.ResponseType.JSON; } _onReadyStateChange(method, url, options, xhr) { if (xhr.readyState === 4) { switch(xhr.status){ case 0: { // If status code 0, it is assumed that the browser has cancelled the request // Add support for running Chrome browsers in 'allow-file-access-from-file' // This is to allow for specialized programs and libraries such as CefSharp // which embed Chromium in the native app. if (xhr.responseURL && xhr.responseURL.startsWith('file:///')) { // Assume that any file loaded from disk is fine 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(method, url, options, xhr) { let response; let contentType; const header = xhr.getResponseHeader('Content-Type'); if (header) { // Split up header into content type and parameter const parts = header.split(';'); contentType = parts[0].trim(); } try { // Check the content type to see if we want to parse it if (this._isBinaryContentType(contentType) || this._isBinaryResponseType(xhr.responseType)) { // It's a binary response response = xhr.response; } else if (contentType === Http.ContentType.JSON || url.split('?')[0].endsWith('.json')) { // It's a JSON response response = JSON.parse(xhr.responseText); } else if (xhr.responseType === Http.ResponseType.DOCUMENT || contentType === Http.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(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 const retryDelay = 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(()=>{ options.retrying = false; this.request(method, url, options, options.callback); }, retryDelay); } else { // no more retries or not retry so just fail options.callback(xhr.status === 0 ? 'Network error' : xhr.status, null); } } } const http = new Http(); export { Http, http };