UNPKG

@ima/plugin-xhr

Version:

Helper plugin simplifying the usage of the XMLHttpRequest API.

422 lines (421 loc) 18.6 kB
import { GenericError, StatusCode, Window } from '@ima/core'; /** * Empty object used as a fallback immutable value where an object is expected. * * @type {{}} */ const EMPTY_OBJECT = {}; /** * Options for a request sent using the HTTP agent. * * @typedef {object} XHRRequestOptions * @property {number} [timeout] Specifies the request timeout in milliseconds. * @property {number} [repeatRequest] Specifies the maximum number of tries to * repeat the request if the request fails. * @property {Object<string, string>} [headers] Sets the additional request * headers (the keys are case-insensitive header names, the values * are header values). * @property {boolean} [withCredentials] Flag that indicates whether the * request should be made using credentials such as cookies or * authorization headers. * @property {function(XHRResponse)} [postProcessor] Response post-processor * applied just before the response is returned. * @property {function(RequestObserver)} [observe] The callback that will * receive an observer object when the request is initiated. */ /** * Request observer and controller object. The state of the request may be * observed by setting its <code>on</code>-prefixed properties (these are * <code>null</code> by default. * * @typedef {object} RequestObserver * @property {number} state The current <code>readyState</code> state of the * observed XHR request. * @property {function()} abort Callback used to abort the observed XHR * request. * @property {?function(Event)} [onstatechange] Callback invoked when the * <code>readystatechange</code> event occurs on the observed * request. * @property {?function(Event)} [onprogress] Callback invoked when the * <code>progress</code> event occurs on the observed request. */ /** * Object representing an HTTP response. This is compatible with the IMA's * HTTP Agent's <code>AgentResponse</code> type. * * @typedef {object} XHRResponse * @property {number} status The HTTP response status code. * @property {*} body The parsed response body, parsed as JSON. * @property {Object<string, string>} headers The response HTTP headers. * @property {XHRRequestParameters} params The parameters that were used to * make the request. * @property {boolean} cached Whether or not the response has been cached - * this is always <code>false</code>. */ /** * @typedef {object} XHRRequestParameters * @property {string} method The HTTP method used to make the request. * @property {string} url The original URL to which the request should have * been made. * @property {string} transformedUrl The final URL after all pre-processors * have been applied. This is the URL to which the request was made. * @property {Object<string, (boolean|number|string)>} data The data sent in * the original request. * @property {XHRRequestOptions} options The complete options used to make the * request, with the defaults filled in for easier debugging. */ /** * The main class of this plugin for making HTTP requests using the * XMLHttpRequest backend, which - at the moment of writing this - offers * progress events and aborting requests, features that are not at the moment * available for the fetch API. * * This class may be used only at the client side. */ export default class XHR { static get $dependencies() { return [ Window ]; } /** * Initializes the XHR request agent. * * @param {Window} window The IMA's Window helper. * @param {XHRRequestOptions=} defaultOptions The default request options. */ constructor(window, defaultOptions = EMPTY_OBJECT){ this._window = window; this._defaultOptions = defaultOptions; this._defaultHeaders = {}; } /** * Sends a GET request to the specified url. * * @param {string} url The URL to which the request should be made. * @param {*=} data The data to send in the request's query string. * @param {XHRRequestOptions=} options The optional request options. * @returns {Promise<XHRResponse>} A promise that will resolve to the * response object. */ get(url, data = EMPTY_OBJECT, options = EMPTY_OBJECT) { return this._prepareAndSendRequest('get', url, data, options); } /** * Sends a POST request to the specified url, carrying the specified * request body. * * @param {string} url The URL to which the request should be made. * @param {*=} data The data to send send in the request's body. * @param {XHRRequestOptions=} options The optional request options. * @returns {Promise<XHRResponse>} A promise that will resolve to the * response object. */ post(url, data = null, options = EMPTY_OBJECT) { return this._prepareAndSendRequest('post', url, data, options); } /** * Sends a PUT request to the specified url, carrying the specified * request body. * * @param {string} url The URL to which the request should be made. * @param {*=} data The data to send send in the request's body. * @param {XHRRequestOptions=} options The optional request options. * @returns {Promise<XHRResponse>} A promise that will resolve to the * response object. */ put(url, data = null, options = EMPTY_OBJECT) { return this._prepareAndSendRequest('put', url, data, options); } /** * Sends a PATCH request to the specified url, carrying the specified * request body. * * @param {string} url The URL to which the request should be made. * @param {*=} data The data to send send in the request's body. * @param {XHRRequestOptions=} options The optional request options. * @returns {Promise<XHRResponse>} A promise that will resolve to the * response object. */ patch(url, data = null, options = EMPTY_OBJECT) { return this._prepareAndSendRequest('patch', url, data, options); } /** * Sends a DELETE request to the specified url, carrying the specified * request body. * * @param {string} url The URL to which the request should be made. * @param {*=} data The data to send send in the request's body. * @param {XHRRequestOptions=} options The optional request options. * @returns {Promise<XHRResponse>} A promise that will resolve to the * response object. */ delete(url, data = null, options = EMPTY_OBJECT) { return this._prepareAndSendRequest('delete', url, data, options); } /** * Sets the specified header's default value. This overrides the header's * value in the default options. * * @param {string} headerName The name of the header to set. * @param {string} value The default value to use when the header is not * set in the request's options. */ setDefaultHeader(headerName, value) { this._defaultHeaders[headerName] = value; } /** * Deletes all currently set default headers. */ clearDefaultHeaders() { this._defaultHeaders = {}; } /** * Creates and sends an HTTP request using the specified HTTP method to the * specified url, carrying the specified data in either the query string * (for GET requests) or request body (otherwise). * * The method will re-attempt the request if it fails and additional tries * have been specified in the <code>repeatRequest</code> option. * * @param {string} method The HTTP method to use. * @param {string} url The URL to which the request should be made. * @param {*} data The data to send in the request. These will be encoded * into the query string for GET requests, and sent as request body * for requests using other HTTP methods. * @param {XHRRequestOptions} options Request options, as provided by the * caller of the public API. * @returns {Promise<XHRResponse>} A promise that will resolve to the * response object. */ _prepareAndSendRequest(method, url, data, options) { if (!this._window.isClient()) { throw new GenericError('The XHR plugin may be used only at the client-side.'); } const completeOptions = this._composeOptions(options); const requestParams = this._composeRequestParameters(method, url, data, completeOptions); const completeUrl = method.toLowerCase() === 'get' ? url + (url.includes('?') ? '&' : '?') + this._encodeQuery(data) : url; const body = method.toLowerCase() === 'get' ? null : this._shouldEncodeRequestBody(data) ? JSON.stringify(data) : data; return this._sendRequest(method, completeUrl, body, completeOptions, requestParams).catch((requestError)=>{ if (completeOptions.repeatRequest > 0) { return this._prepareAndSendRequest(method, url, data, Object.assign({}, completeOptions, { repeatRequest: completeOptions.repeatRequest - 1 })); } else { throw requestError; } }); } /** * Creates and sends an HTTP request using the specified HTTP method to the * specified url, carrying the specified request body. * * @param {string} method The HTTP method to use. * @param {string} url The URL to which the request should be made. * @param {*} body The body to send in the request. * @param {XHRRequestOptions} options Options provided by the caller of the * public API, augmented with the default options and headers. * @param {XHRRequestParameters} requestParams Parameters that were used to * create the request. * @returns {Promise<XHRResponse>} A promise that will resolve to the * response object. */ _sendRequest(method, url, body, options, requestParams) { const xhr = new XMLHttpRequest(); xhr.responseType = 'json'; if (options.timeout) { xhr.timeout = options.timeout; } if (options.withCredentials) { xhr.withCredentials = true; } const observer = { get state () { return xhr.readyState; }, abort () { if (xhr.readyState) { xhr.abort(); } else { xhr.shouldAbort = true; } }, onstatechange: null, onprogress: null }; if (options.observe) { options.observe(observer); } xhr.open(method, url); for (const headerName of Object.keys(options.headers)){ xhr.setRequestHeader(headerName, options.headers[headerName]); } return this._sendXHRRequest(xhr, body, observer, options, requestParams); } /** * Sends the specified HTTP request, with the provided body as the * request's body. * * @param {XMLHttpRequest} xhr The XMLHttpRequest instance to use to send * the request. The instance must already be configured and ready * for sending the request body. * @param {*} body The body to send with the request. * @param {RequestObserver} observer The observer of the request to send. * @param {XHRRequestOptions} options Options provided by the caller of the * public API, augmented with the default options and headers. * @param {XHRRequestParameters} requestParams The parameters that were * used to create the request. * @returns {Promise<XHRResponse>} A promise that will resolve to the * response object. */ _sendXHRRequest(xhr, body, observer, options, requestParams) { return new Promise((resolve, reject)=>{ xhr.addEventListener('readystatechange', (event)=>{ if (observer.onstatechange) { observer.onstatechange(event); } if (xhr.shouldAbort) { xhr.shouldAbort = false; xhr.abort(); } }); xhr.addEventListener('progress', (event)=>{ if (observer.onprogress) { observer.onprogress(event); } }); xhr.addEventListener('load', ()=>{ if (xhr.status >= 200 && xhr.status < 300) { const response = this._composeResponse(xhr, requestParams); if (options.postProcessor) { resolve(options.postProcessor(response)); } else { resolve(response); } } else { reject(new GenericError('The request failed.', { xhr })); } }); xhr.addEventListener('error', (event)=>{ reject(new GenericError('The request failed.', { cause: event })); }); xhr.addEventListener('timeout', (event)=>{ reject(new GenericError('The request timed out.', { cause: event })); }); xhr.send(body); }).catch((requestError)=>{ throw this._composeRequestError(requestError, requestParams); }); } /** * Creates a new error containing all the meta-information related to the * request at hand. The returned error is meant to be used for rejecting * the promise returned by the public API of this class. * * @param {Error} cause The cause of the request's failure. * @param {XHRRequestParameters} requestParams Parameters that were used to * create the request. * @returns {GenericError} The error to reject the request promise with. */ _composeRequestError(cause, requestParams) { const params = cause instanceof GenericError ? cause.getParams() : EMPTY_OBJECT; const status = params.status || params.xhr && params.xhr.status || (params.cause && params.cause.type === 'timeout' ? StatusCode.TIMEOUT : StatusCode.SERVER_ERROR); return new GenericError(cause.message, Object.assign({}, requestParams, { status, body: params.xhr && (params.xhr.response || params.xhr.responseText), cause })); } /** * Composes an object representing a request response. * * @param {XMLHttpRequest} xhr The XMLHttpRequest instance representing the * sent request. * @param {XHRRequestParameters} requestParams Parameters that were used to * create the request. * @returns {XHRResponse} Composed response object. */ _composeResponse(xhr, requestParams) { return { status: xhr.status, body: xhr.response, params: requestParams, headers: this._parseHeaders(xhr.getAllResponseHeaders()), cached: false }; } /** * Parses the provided string containing a set of HTTP headers into a * key-value object. * * @param {string} allHeaders A string containing HTTP headers separated * by the CRLF sequence. * @returns {Object<string, string>} Parsed HTTP headers. */ _parseHeaders(allHeaders) { const parsedHeaders = {}; for (const header of allHeaders.split('\r\n')){ if (!header || !header.includes(':')) { continue; } const [headerName, values] = header.split(/:\s*/, 2); parsedHeaders[headerName] = values; } return parsedHeaders; } /** * Encodes the provided query parameters into a query string. * * @param {Object<string, (boolean|number|string)>} query The query to * encode. * @returns {string} Encoded query string, without the "?" prefix. */ _encodeQuery(query) { // It would be great if we had native support for URLSearchParams, but // IE does not support them return Object.keys(query).map((key)=>[ key, query[key] ].map(encodeURIComponent).join('=')).join('&'); } /** * Composes an object representing the parameters used to create the * request. * * @param {string} method The HTTP method to use to make the request. * @param {string} url The URL to which the request should be made. * @param {Object<string, (boolean|number|string)>} data The data sent with * the request. * @param {XHRRequestOptions} options The options passed by the calling * API. * @returns {XHRRequestParameters} The composed request parameters object. */ _composeRequestParameters(method, url, data, options) { return { method, url, transformedUrl: url, data, options }; } /** * Tests whether the provided request body needs to be manually encoded as * JSON. * * @param {*} requestBody The request body. * @returns {boolean} <code>true</code> if and only if the provided request * body should be manually encoded as JSON, the request body may be * used as-is otherwise. */ _shouldEncodeRequestBody(requestBody) { if (!requestBody || typeof requestBody === 'string') { return false; } const NOOP = function() {}; const window = this._window.getWindow(); const Blob = window.Blob || NOOP; const BufferSource = window.BufferSource || NOOP; const FormData = window.FormData || NOOP; const URLSearchParams = window.URLSearchParams || NOOP; const ReadableStream = window.ReadableStream || NOOP; const isNative = requestBody instanceof Blob || requestBody instanceof BufferSource || requestBody instanceof FormData || requestBody instanceof URLSearchParams || requestBody instanceof ReadableStream; return !isNative; } /** * Composes complete request options by filling in the ones missing in the * provided options using the default options and default headers. * * @param {XHRRequestOptions} options The options, as provided by the * calling code. * @returns {XHRRequestOptions} Composed options, with the default options * and default headers filled in. */ _composeOptions(options) { return Object.assign({}, this._defaultOptions, options, { headers: Object.assign({}, this._defaultOptions.headers, this._defaultHeaders, options.headers) }); } }