UNPKG

@squirrel-forge/ui-util

Version:

A collection of utilities, classes, functions and abstracts made for the browser and babel compatible.

391 lines (349 loc) 12.1 kB
/** * Requires */ import { EventDispatcher } from '../Events/EventDispatcher.js'; import { Exception } from '../Error/Exception.js'; import { isPojo } from '../Object/isPojo.js'; import { str2node } from '../String/str2node.js'; import { mergeObject } from '../Object/mergeObject.js'; /** * Async request exception * @class * @extends Exception */ class AsyncRequestException extends Exception {} /** * @typedef {Object} AsyncRequestConfig - Async Request Config * @property {string} url * @property {string} user * @property {string} pwd * @property {('head'|'get'|'post'|'put'|'delete'|'patch'|'options')} method * @property {boolean} cache * @property {('auto'|'html','string','json')} type * @property {Array.<number>} successStatus * @property {string} eventPrefix */ /** * Async request * @class * @extends EventDispatcher */ export class AsyncRequest extends EventDispatcher { /** * Request object * @private * @property * @type {null|XMLHttpRequest} */ #request = null; /** * Constructor * @constructor * @param {null|string|AsyncRequestConfig} options - Url or options object * @param {null|EventDispatcher} parent - Parent component * @param {null|console|Object} debug - Debug object */ constructor( options = null, parent = null, debug = null ) { super( null, parent, debug ); // Default properties/options Object.assign( this, { url : '', user : null, pwd : null, // Allowed methods: head, get, post, put, delete, patch, options method : 'get', cache : false, type : 'auto', successStatus : [ 200, 201, 202, 203 ], eventPrefix : '', error : null, status : null, statusText : null, readyState : null, responseText : null, responseType : null, responseParsed : null, responseParsingError : null, } ); // Shorthand only url if ( typeof options === 'string' ) { options = { url : options }; } // Valid url or options if ( isPojo( options ) ) { mergeObject( this, options ); } else { throw new AsyncRequestException( 'Url or options object required' ); } // Create request this.#request = new XMLHttpRequest(); // Request state change this.#request.addEventListener( 'readystatechange', ( event ) => { return this.#event_readystatechange( event ); } ); // On progress handler this.#request.upload.addEventListener( 'progress', ( event ) => { return this.#event_progress( event ); } ); } /** * Get unique request url with time param if cache is disabled * @public * @param {string} url - Url to add cache breaker * @param {boolean} cache - Set to not modify url * @return {string} - Url with optional cache breaker */ static unique_url( url, cache = false ) { if ( !cache ) { const now = new Date().getTime() + '' + performance.now(); url += ( url.indexOf( '?' ) >= 0 ? '&' : '?' ) + now; } return url; } /** * Send request * @public * @param {null|*} data - Data to send * @param {null|Function} modifyProcessed - Callback to modify processed request data * @return {void} */ send( data = null, modifyProcessed = null ) { if ( typeof data === 'function' ) { modifyProcessed = data; data = null; } // Open request this.#request.open( this.method, this.constructor.unique_url( this.url, this.cache ), true, this.user, this.pwd ); const processed = this.#process( data ); // Modify request before actual sending if ( typeof modifyProcessed === 'function' ) { modifyProcessed( processed, this ); } // Set headers this.#set_headers( processed ); // Send request const data_methods = [ 'post', 'put', 'patch' ]; if ( data_methods.includes( this.method ) ) { this.#request.send( processed.body ); } else { this.#request.send(); } } /** * Abort request * @public * @return {void} */ abort() { if ( this.#request ) { this.#request.abort(); } } /** * Ready state change event handler * @private * @param {Event} event - Event readystatechange * @return {void} */ #event_readystatechange( event ) { // Propagate infos Object.assign( this, { status : this.#request.status, statusText : this.#request.statusText, readyState : this.#request.readyState, responseText : this.#request.responseText, } ); // Parse response when completed if ( this.readyState === 4 ) { this.error = !this.successStatus.includes( this.status ); if ( this.responseText ) { const method = '_parse_' + this.type; if ( typeof this[ method ] !== 'function' ) { throw new AsyncRequestException( 'Response type method not defined: ' + method ); } this[ method ]( event ); } } // State change this.dispatchEvent( this.eventPrefix + 'readystatechange', { event } ); // Finished handlers if ( this.readyState === 4 ) { this.dispatchEvent( this.eventPrefix + ( this.error ? 'error' : 'success' ), { event } ); this.dispatchEvent( this.eventPrefix + 'complete', { event } ); } } /** * Progress event handler * @private * @param {Event} event - Event progress * @return {void} */ #event_progress( event ) { let percent = Number.NaN; if ( event && event.lengthComputable ) { percent = event.loaded / event.total * 100; } this.dispatchEvent( this.eventPrefix + 'progress', { percent, event } ); } /** * Set headers * @private * @param {Object} processed - Processed data object * @return {void} */ #set_headers( processed ) { let has_contentType = false; if ( processed.headers && processed.headers.length ) { for ( let i = 0; i < processed.headers.length; i++ ) { const header = processed.headers[ i ]; this.#request.setRequestHeader( ...header ); if ( header[ 0 ] === 'Content-Type' ) { has_contentType = true; } } } if ( !has_contentType && typeof processed.body === 'string' ) { this.#request.setRequestHeader( 'Content-Type', 'text/plain' ); } } /** * Process data * @private * @param {*} data - Data to send * @return {{headers: [], body: null}} - Processed data object */ #process( data ) { const processed = { body : null, headers : [] }; if ( data !== null ) { const to = typeof data; if ( to === 'object' ) { this.#process_object( data, processed ); } else if ( to === 'string' || to === 'number' ) { processed.body = '' + data; processed.headers.push( [ 'Content-Length', processed.body.length ] ); } } return processed; } /** * Process object data * @private * @param {Object} data - Data object * @param {Object} processed - Processed data object * @return {void} */ #process_object( data, processed ) { // Plain json like structures if ( data instanceof Array || isPojo( data ) ) { processed.headers.push( [ 'Content-Type', 'application/json' ] ); try { processed.body = JSON.stringify( data ); } catch ( e ) { throw new AsyncRequestException( 'Failed to convert to json', e ); } processed.headers.push( [ 'Content-Length', processed.body.length ] ); // Form data } else if ( data instanceof FormData ) { processed.body = data; // Object to string conversion } else if ( typeof data.toString === 'function' ) { let converted = null; try { converted = data.toString(); } catch ( e ) { throw new AsyncRequestException( 'Failed to convert to string', 4, e ); } if ( typeof converted !== 'string' ) { throw new AsyncRequestException( 'The toString method did not return a string', 5 ); } processed.body = converted; processed.headers.push( [ 'Content-Length', processed.body.length ] ); // Failed to process } else { throw new AsyncRequestException( 'Unprocessable object', 6 ); } } /** * Detect content type before parsing * @protected * @return {void} */ _parse_auto() { const type = this.#request.getResponseHeader( 'Content-Type' ); this.responseType = type; // application/json > plain text json const to = typeof this.responseText; if ( to === 'string' && this.responseText.trim().length ) { const src = this.responseText.trim(); if ( type === 'application/json' || src[ 0 ] === '[' && src[ src.length - 1 ] === ']' || src[ 0 ] === '{' && src[ src.length - 1 ] === '}' ) { this._parse_json(); } else if ( type === 'image/svg+xml' ) { this._parse_svg(); } else if ( src.substring( 0, 5 ) !== '<?xml' && src.substring( 0, 9 ) !== '<!DOCTYPE' && ( type === 'text/html' || type === 'application/xhtml+xml' || type === 'application/x-httpd-php' || src[ 0 ] === '<' && src[ src.length - 1 ] === '>' ) ) { this._parse_html(); } else { this._parse_string(); } } } /** * Parse response as html element * @protected * @return {void} */ _parse_html() { this.responseType = 'text/html'; try { this.responseParsed = str2node( this.responseText, false ); } catch ( e ) { this.responseType = null; this.responseParsed = null; this.responseParsingError = e; } } /** * Parse response as svg image * @protected * @return {void} */ _parse_svg() { this.responseType = 'image/svg+xml'; try { const result = new DOMParser().parseFromString( this.responseText, 'text/xml' ); this.responseParsed = result.getElementsByTagName( 'svg' )[ 0 ]; if ( !( this.responseParsed instanceof SVGElement ) ) { throw new AsyncRequestException( 'Failed to extract svg image' ); } } catch ( e ) { this.responseType = null; this.responseParsed = null; this.responseParsingError = e; } } /** * Parse response as string * @protected * @return {void} */ _parse_string() { if ( !this.responseType || !this.responseType.length ) { this.responseType = 'text/plain'; } this.responseParsed = this.responseText || ''; } /** * Parse response as json * @protected * @return {void} */ _parse_json() { this.responseType = 'application/json'; try { this.responseParsed = JSON.parse( this.responseText ); } catch ( e ) { this.responseType = null; this.responseParsed = null; this.responseParsingError = e; } } }