/**
* @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 { WebAPIMessageEvent } from './messages/WebAPIMessageEvent'
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()
this._eventListeners = {} //"eventName" : [ callback1, ... ]
// 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
// Todo: should log error in production and cancel '*'
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
}
addEventListener ( eventName, listener ) {
if ( isNotDefined( this._eventListeners[ eventName ] ) ) {
this._eventListeners[ eventName ] = []
}
this._eventListeners[ eventName ].push( listener )
}
removeListener ( eventName, listener ) {
if ( isNotDefined( this._eventListeners[ eventName ] ) ) {
return
}
const index = this._eventListeners[ eventName ].indexOf( listener )
if ( index > -1 ) {
this._eventListeners[ eventName ].splice( index, 1 )
}
}
// 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 '_event':
this.onEventFrom( 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 ?` )
}
onEventFrom ( origin, event ) {
const listeners = this._eventListeners[ event.name ]
for ( const listener of listeners ) {
listener( event.data )
}
}
// 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 )
}
}
postEvent ( name = 'DefaultEventName', data ) {
const _data = ( data && data.constructor.isWebAPIMessageEvent ) ? data : new WebAPIMessageEvent( name, data )
// Broadcast to all potential listener
const allowedOrigins = this._allowedOrigins.filter( origin => origin !== '*' )
for ( let i = 0 ; i < allowedOrigins.length ; i++ ) {
const allowedOrigin = allowedOrigins[ i ]
const originId = allowedOrigin.id
this.postMessageTo( originId, _data )
}
}
}
export { WebAPI }