/** * @author [Tristan Valcke]{@link https://github.com/Itee} */ import { DefaultLogger } from 'itee-core' import { toArray } from 'itee-utils' import { isDefined, isEmptyArray, isNotDefined, isNotNumber, isNull, isNumberNegative, isObject, isString, isUndefined, isZero } from 'itee-validators' import { WebAPIMessageData } from './messages/WebAPIMessageData' import { WebAPIMessageError } from './messages/WebAPIMessageError' import { WebAPIMessageReady } from './messages/WebAPIMessageReady' import { WebAPIMessageRequest } from './messages/WebAPIMessageRequest' import { WebAPIMessageResponse } from './messages/WebAPIMessageResponse' import { WebAPIOrigin } from './WebAPIOrigin' /** * A POJO object containg datas about a distant source to allow * @typedef {Object} AllowedOrigin * @property {string} id - The id to reference this origin as a human readable string * @property {string} uri - The uri of the origin to allow * @property {Array<String>} methods - An array of methods names that are allowed for this origins. To allow all methods use '*', in case no methods string were provide the origin won't be able to do * anything. */ /** * @class * @classdesc The abstract class to use standardized webapi. * @abstract */ class WebAPI { /** * @constructor * @param {Object} parameters - An object containing all parameters to pass through the inheritance chain to initialize this instance * @param {Boolean} [parameters.allowAnyOrigins=false] - A boolean to allow or not any origins calls * @param {Array<AllowedOrigin>} [parameters.allowedOrigins=[]] - An array containing configured allowed origins * @param {Number} [parameters.requestTimeout=2000] - The request timeout before throw an error */ constructor ( parameters = {} ) { const _parameters = { ...{ logger: DefaultLogger, allowedOrigins: [], requestTimeout: 2000, methods: this, broadcastReadyOnInit: true }, ...parameters } // Private stuff this._localOriginUri = window.location.origin this._awaitingRequest = new Map() // Listen message from Window window.addEventListener( 'message', this._onMessage.bind( this ), false ) // Public stuff this.logger = _parameters.logger this.allowedOrigins = _parameters.allowedOrigins this.requestTimeout = _parameters.requestTimeout this.methods = _parameters.methods // Initiate connection to all origins if ( _parameters.broadcastReadyOnInit ) { this._broadcastReadyMessage() } } /** * * @returns {TLogger} */ get logger () { return this._logger } /** * * @param value {TLogger} */ set logger ( value ) { if ( isNull( value ) ) { throw new ReferenceError( `[${ this._localOriginUri }]: The logger cannot be null, expect a TLogger.` )} if ( isUndefined( value ) ) { throw new ReferenceError( `[${ this._localOriginUri }]: The logger cannot be undefined, expect a TLogger.` )} if ( !value.isLogger ) { throw new ReferenceError( `[${ this._localOriginUri }]: The logger cannot be undefined, expect a TLogger.` )} this._logger = value } /** * * @returns {Array<WebAPIOrigin>} */ get allowedOrigins () { return this._allowedOrigins } /** * * @param value {Array<WebAPIOrigin>} */ set allowedOrigins ( value ) { this._allowedOrigins = [] const _allowedOrigins = toArray( value ) // Special case for any origin if ( _allowedOrigins.includes( '*' ) ) { this.logger.warn( `[${ this._localOriginUri }]: This webApi is allowed for all origin and could lead to security concerne !` ) this._allowedOrigins.push( '*' ) return } // Create WebApiOrigin based on provided settings for ( let allowedOrigin of _allowedOrigins ) { const origin = new WebAPIOrigin( { id: allowedOrigin.id, uri: allowedOrigin.uri, methods: allowedOrigin.methods, window: this._getOriginWindow( allowedOrigin.uri ) } ) this._allowedOrigins.push( origin ) } } /** * * @returns {Number} */ get requestTimeout () { return this._requestTimeout } /** * * @param value {Number} */ set requestTimeout ( value ) { if ( isNull( value ) ) { throw new ReferenceError( `[${ this._localOriginUri }]: The request timeout cannot be null, expect to be 0 or a positive number.` )} if ( isUndefined( value ) ) { throw new ReferenceError( `[${ this._localOriginUri }]: The request timeout cannot be undefined, expect to be 0 or a positive number.` )} if ( isNotNumber( value ) ) { throw new ReferenceError( `[${ this._localOriginUri }]: The request timeout expect to be 0 or a positive number.` )} if ( isNumberNegative( value ) && !isZero( value ) ) { throw new ReferenceError( `[${ this._localOriginUri }]: The request timeout expect to be 0 or a positive number.` )} this._requestTimeout = value } /** * * @returns {Array<Function>} */ get methods () { return this._methods } /** * * @param value Array<Function> */ set methods ( value ) { if ( isNull( value ) ) { throw new ReferenceError( `[${ this._localOriginUri }]: The methods cannot be null, expect any keyed collection of function.` )} if ( isUndefined( value ) ) { throw new ReferenceError( `[${ this._localOriginUri }]: The methods cannot be undefined, expect any keyed collection of function.` )} // Todo: isNotObject && isNotMap && isNotSet && isNotApi this._methods = value } /** * * @param value {TLogger} * @returns {AbstractWebAPI} */ setLogger ( value ) { this.logger = value return this } /** * * @param value {Array<WebAPIOrigin>} * @returns {AbstractWebAPI} */ setAllowedOrigins ( value ) { this.allowedOrigins = value return this } /** * * @param value {Number} * @returns {AbstractWebAPI} */ setRequestTimeout ( value ) { this.requestTimeout = value return this } /** * * @param value Array<Function> * @returns {AbstractWebAPI} */ setMethods ( value ) { this.methods = value return this } // Validators /** * * @returns {boolean} * @private */ _isInIframe () { try { return window.self !== window.top } catch ( e ) { return true } } /** * * @returns {boolean} * @private */ _isNotAllowedForAllOrigins () { return !this._allowedOrigins.includes( '*' ) // return !this.allowAnyOrigins } /** * * @param originURI * @returns {boolean} * @private */ _isNotAllowedOrigin ( originURI ) { return !this._allowedOrigins .filter( origin => origin !== '*' ) .map( origin => origin.uri ) .includes( originURI ) } /** * * @param originURI * @returns {boolean} * @private */ _isSameOrigin ( originURI ) { return this._localOriginUri === originURI } /** * * @param origin {WebAPIOrigin} * @returns {boolean} * @private */ _isNotAllowedForAllMethods ( origin ) { return ( origin.allowedMethods.indexOf( '*' ) === -1 ) } /** * * @param origin {WebAPIOrigin} * @param methodName {string} * @returns {boolean} * @private */ _isNotAllowedMethod ( origin, methodName ) { return ( origin.allowedMethods.indexOf( methodName ) === -1 ) } /** * * @param methodName * @returns {boolean} * @private */ _methodNotExist ( methodName ) { return isNotDefined( this.methods[ methodName ] ) } // Utils /** * * @param propertyName * @param value * @returns {WebAPIOrigin} * @private */ _getAllowedOriginBy ( propertyName, value ) { return this.allowedOrigins.find( origin => origin[ propertyName ] === value ) } /** * * @param originURI * @returns {Window} * @private */ _getOriginWindow ( originURI ) { let originWindow if ( this._isInIframe() ) { originWindow = window.parent } else { const frames = document.getElementsByTagName( 'iframe' ) const frame = Array.from( frames ).find( iframe => iframe.src.includes( originURI ) ) if ( isNotDefined( frame ) ) { this.logger.warn( `[${ this._localOriginUri }]: Unable to find iframe element for [${ originURI }] URI !` ) originWindow = null } else { originWindow = frame.contentWindow } } return originWindow } /** * * @param origin {WebAPIOrigin} * @private */ _processMessageQueueOf ( origin ) { const messageQueue = origin.messageQueue for ( let messageIndex = messageQueue.length - 1 ; messageIndex >= 0 ; messageIndex-- ) { this.postMessageTo( origin.id, messageQueue.shift() ) } } /** * * @private */ _broadcastReadyMessage () { const ready = new WebAPIMessageReady() let checkInterval = 250 const broadcast = () => { const unreadyOrigins = this.allowedOrigins.filter( origin => !origin.isReady && origin.isReachable ) if ( isEmptyArray( unreadyOrigins ) ) { return } for ( let unreadyOrigin of unreadyOrigins ) { this.postReadyTo( unreadyOrigin.id, ready ) } checkInterval += checkInterval setTimeout( broadcast, checkInterval ) } broadcast() } // Messaging /** * * @param event * @returns {Promise<void>} * @private */ async _onMessage ( event ) { // Is allowed origin if ( this._isNotAllowedForAllOrigins() && this._isNotAllowedOrigin( event.origin ) ) { this.logger.warn( `[${ this._localOriginUri }]: An unallowed origin [${ event.origin }] try to access the web api.` ) return } // Is self ? if ( this._isSameOrigin( event.origin ) ) { this.logger.warn( `[${ this._localOriginUri }]: A local origin try to access the web api... or... Am i talking to myself ? Said i (${ isString( event.data ) ? event.data : JSON.stringify( event.data ) }) ? Hummm... Ehhooo ! Who's there ? ` ) return } // In case we are not in embbeded iframe or the origin is not an iframe set the origin window as the source event let origin = this._getAllowedOriginBy( 'uri', event.origin ) if ( isNotDefined( origin ) ) { // If we are here, we are called by an unknown origin but we are allowed for all. So create a new one origin = new WebAPIOrigin( { uri: event.origin, window: event.source } ) this.allowedOrigins.push( origin ) } else if ( isNull( origin.window ) ) { origin.window = event.source } const eventData = event.data const message = isObject( eventData ) ? eventData : JSON.parse( eventData ) if ( isNotDefined( message ) ) { this.logger.error( `[${ this._localOriginUri }]: Recieve null or undefined message from [${ origin.uri }] ! Expect a json object.` ) return } await this._dispatchMessageFrom( origin, message ) } /** * * @param origin * @param message * @private */ async _dispatchMessageFrom ( origin, message ) { this.logger.log( `[${ this._localOriginUri }]: Recieve message of type '${ message.type }' from [${ origin.uri }].` ) switch ( message.type ) { case '_ready': this._onReadyFrom( origin, message ) break case '_request': await this._onRequestFrom( origin, message ) break case '_response': this._onResponseFrom( origin, message ) break case '_data': this.onDataFrom( origin, message ) break case '_error': this.onErrorFrom( origin, message ) break default: this.onMessageFrom( origin, message ) } } /** * * @param origin * @param message */ _onReadyFrom ( origin, message ) { if ( !origin.isReady ) { origin.isReady = true // Avoid some ping-pong ready message if ( !message.isBind ) { const ready = new WebAPIMessageReady( { isBind: true } ) this.postMessageTo( origin.id, ready, true ) } } this._processMessageQueueOf( origin ) } /** * * @param origin * @param request */ async _onRequestFrom ( origin, request ) { let message const methodName = request.method const parameters = request.parameters if ( this._isNotAllowedForAllMethods( origin ) && this._isNotAllowedMethod( origin, methodName ) ) { this.logger.error( `[${ this._localOriginUri }]: Origin [${ origin.uri }] try to access an unallowed method named '${ methodName }'.` ) message = new WebAPIMessageError( new RangeError( `Trying to access an unallowed method named '${ methodName }'.` ) ) } else if ( this._methodNotExist( methodName ) ) { this.logger.error( `[${ this._localOriginUri }]: Origin [${ origin.uri }] try to access an unexisting method named '${ methodName }'.` ) message = new WebAPIMessageError( new RangeError( `Trying to access an unexisting method named '${ methodName }'.` ) ) } else { try { const result = await this.methods[ methodName ]( ...parameters ) message = new WebAPIMessageData( result ) } catch ( error ) { message = new WebAPIMessageError( error ) } } // To avoid unnecessary client timeout we need to respond with error or data in any case this.postResponseTo( origin.id, request, message ) } /** * * @param origin * @param response */ _onResponseFrom ( origin, response ) { const requestId = response.request.id if ( !this._awaitingRequest.has( requestId ) ) { return } const request = this._awaitingRequest.get( requestId ) this._awaitingRequest.delete( requestId ) clearTimeout( request.timeoutId ) const result = response.result if ( isDefined( result ) ) { if ( result.type === '_error' ) { request.reject( result.error ) } else if ( result.type === '_data' ) { request.resolve( result.data ) } else { request.resolve( result ) } } else { request.resolve() } } /** * * @param origin * @param message * @private */ // eslint-disable-next-line no-unused-vars onErrorFrom ( origin, message ) { // Need to be reimplemented if needed this.logger.error( `[${ this._localOriginUri }]: the origin [${ origin.uri }] send error => ${ JSON.stringify( message.error, null, 4 ) }. Need you to reimplement this method ?` ) } /** * * @param origin * @param message */ // eslint-disable-next-line no-unused-vars onDataFrom ( origin, message ) { // Need to be reimplemented if needed this.logger.log( `[${ this._localOriginUri }]: the origin [${ origin.uri }] send data => ${ JSON.stringify( message.data, null, 4 ) }. Need you to reimplement this method ?` ) } /** * * @param origin * @param message */ // eslint-disable-next-line no-unused-vars onMessageFrom ( origin, message ) { // Need to be reimplemented if needed this.logger.log( `[${ this._localOriginUri }]: the origin [${ origin.uri }] send custom message => ${ JSON.stringify( message, null, 4 ) }. Need you to reimplement this method ?` ) } // Send /** * * @param originId * @param ready */ postReadyTo ( originId, ready ) { const _ready = ( ready && ready.constructor.isWebAPIMessageReady ) ? ready : new WebAPIMessageReady() this.postMessageTo( originId, _ready, true ) } /** * * @param originId * @param request * @param params * @returns {Promise<unknown>} */ postRequestTo ( originId, request, ...params ) { const _request = ( request && request.constructor.isWebAPIMessageRequest ) ? request : new WebAPIMessageRequest( request, params ) return new Promise( ( resolve, reject ) => { try { this._awaitingRequest.set( _request.id, { request: _request, resolve: resolve, reject: reject, timeoutId: setTimeout( () => { this._awaitingRequest.delete( _request.id ) reject( new Error( `Request timeout for ${ JSON.stringify( _request, null, 4 ) }` ) ) //Todo send abort to avoid future return that won't be processed }, this.requestTimeout ) } ) this.postMessageTo( originId, _request ) } catch ( error ) { reject( error ) } } ) } /** * * @param originId * @param request * @param reponse */ postResponseTo ( originId, request, reponse ) { const _response = ( reponse && reponse.constructor.isWebAPIMessageResponse ) ? reponse : new WebAPIMessageResponse( request, reponse ) this.postMessageTo( originId, _response ) } /** * * @param originId * @param error {WebAPIMessageError|String} */ postErrorTo ( originId, error ) { const _error = ( error && error.constructor.isWebAPIMessageError ) ? error : new WebAPIMessageError( error ) this.postMessageTo( originId, _error ) } /** * * @param originId * @param data */ postDataTo ( originId, data ) { const _data = ( data && data.constructor.isWebAPIMessageData ) ? data : new WebAPIMessageData( data ) this.postMessageTo( originId, _data ) } /** * * @param originId * @param message * @param force */ postMessageTo ( originId, message, force = false ) { if ( isNotDefined( originId ) ) { throw new ReferenceError( `[${ this._localOriginUri }]: Unable to post message to null or undefined origin id !` ) } if ( isNotDefined( message ) ) { throw new ReferenceError( `[${ this._localOriginUri }]: Unable to post null or undefined message !` ) } const origin = this._getAllowedOriginBy( 'id', originId ) if ( isNotDefined( origin ) ) { throw new ReferenceError( `[${ this._localOriginUri }]: Unable to retrieved origin with id: ${ originId }` ) } try { if ( !force && !origin.isReady ) { this.logger.warn( `[${ this._localOriginUri }]: Origin [${ origin.uri }] is not ready yet !` ) origin.messageQueue.push( message ) } else if ( force && !origin.window ) { this.logger.error( `[${ this._localOriginUri }]: Origin [${ origin.uri }] is unreachable !` ) // origin.isUnreachable = true origin.messageQueue.push( message ) } else { this.logger.log( `[${ this._localOriginUri }]: Send message of type '${ message.type }' to [${ origin.uri }]` ) origin.window.postMessage( JSON.stringify( message ), origin.uri ) } } catch ( error ) { this.logger.error( error ) } } } export { WebAPI }