UNPKG

@tradeshift/io

Version:

ts.io - Tradeshift App Messaging Library

960 lines (857 loc) 22.6 kB
'use strict'; function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } var uuid = _interopDefault(require('uuid')); const colors = [ // Red // 'hsl(0, 57%, 90%)', // 'hsl(0, 59%, 80%)', // 'hsl(0, 59%, 60%)', // 'hsl(0, 100%, 37%)', // 'hsl(0, 100%, 30%)', // 'hsl(0, 100%, 24%)', // Orange 'hsl(32, 100%, 92%)', 'hsl(33, 100%, 84%)', 'hsl(33, 100%, 68%)', 'hsl(33, 100%, 50%)', 'hsl(31, 100%, 47%)', 'hsl(26, 100%, 41%)', // Yellow 'hsl(44, 95%, 92%)', 'hsl(45, 95%, 85%)', 'hsl(44, 96%, 70%)', 'hsl(44, 98%, 53%)', 'hsl(40, 100%, 52%)', 'hsl(34, 100%, 49%)', // Green 'hsl(99, 59%, 90%)', 'hsl(99, 60%, 81%)', 'hsl(99, 61%, 63%)', 'hsl(99, 85%, 42%)', 'hsl(101, 87%, 33%)', 'hsl(103, 91%, 26%)', // Blue 'hsl(199, 100%, 92%)', 'hsl(199, 100%, 84%)', 'hsl(199, 100%, 68%)', 'hsl(199, 100%, 50%)', 'hsl(201, 100%, 40%)', 'hsl(203, 100%, 32%)', // Purple 'hsl(295, 39%, 89%)', 'hsl(295, 40%, 78%)', 'hsl(295, 40%, 57%)', 'hsl(295, 79%, 34%)', 'hsl(294, 82%, 26%)', 'hsl(296, 100%, 19%)', // Pink 'hsl(325, 46%, 89%)', 'hsl(325, 48%, 78%)', 'hsl(325, 48%, 57%)', 'hsl(325, 98%, 33%)', 'hsl(327, 99%, 26%)', 'hsl(329, 100%, 21%)' ]; function debugEnabled(namespace) { let debugExpression; try { if (window.localStorage) { debugExpression = window.localStorage.getItem('debug'); } } catch (error) { if ( error instanceof DOMException && (error.name === 'DataCloneError' || error.code === 25) /* DATA_CLONE_ERR */ ) { console.warn( "ts.io error while setting up debug logging. You should ignore this message or set 'allow-same-origin' while sandboxing your iframe.\n" + JSON.stringify(error, null, 2) ); } else { console.warn( 'ts.io error while setting up debug logging.\n' + JSON.stringify(error, null, 2) ); } } // No expression, no logging. if (!debugExpression) { return false; } const debugExpressionLength = debugExpression.length; // If the namespace is shorter than the expression, it definitely won't match. if (namespace.length < debugExpressionLength) { return false; } // '*' => Log everything. if (debugExpression === '*') { return true; } let shouldEnable = false; for (let i = 0; i < debugExpressionLength; i++) { const debugExpressionChar = debugExpression[i]; const matchNamespace = debugExpressionChar === namespace[i]; const atLastChar = i === debugExpressionLength - 1; if (matchNamespace || (atLastChar && debugExpressionChar === '*')) { shouldEnable = true; continue; } else { shouldEnable = false; break; } } return shouldEnable; } /** * Console debug logger. */ class Log { /** * @constructor * @param {string} namespace Namespace * @param {string} color Color */ constructor(namespace, color) { this.namespace = namespace; this.color = color; this.previousTime = 0; this.noColors = console.log .toString() .toLowerCase() .includes('browserstack'); /** * @param {string=} message Message * @param {any[]} optionalParams Optional Params */ this.log = (message, ...optionalParams) => { const now = window.performance.now(); const deltaTime = now - (this.previousTime || now); this.previousTime = now; if (this.noColors) { console.log( `${this.namespace} - ${message} - ${parseFloat(deltaTime).toFixed( 2 )}ms`, ...optionalParams ); } else { console.log( `%c${this.namespace}%c - ${message} - %c${parseFloat( deltaTime ).toFixed(2)}ms`, `color: ${this.color};`, 'font-weight: normal;', ...optionalParams, `color: ${this.color};` ); } }; } } /** * Generate a debug logger. * @param {string} namespace Namespace * @return {Function} */ function log(namespace) { if (!debugEnabled(namespace)) { return function() {}; } let hash = 0; const namespaceLength = namespace.length; for (let i = 0; i < namespaceLength; i++) { hash = (hash << 5) - hash + namespace.charCodeAt(i); hash |= 0; // Convert to 32bit integer } const color = colors[Math.abs(hash) % colors.length]; const debug = new Log(namespace, color); return debug.log; } /** * The Message. * @typedef {object} Message * @property {string} type The type of the message. (one of ['CONNECT', 'CONNACK', 'EVENT', 'PINGREQ', 'PINGRES']) * @property {string=} topic The topic of the message. (required for 'EVENT' type) * @property {string=} target Target appId. (required for ['EVENT', 'CONNACK', 'PINGREQ'] types) * @property {boolean} viaHub The message was brokered by the Hub. * @property {string=} token Hack-proof session token. (required for all types except 'CONNECT') * @property {*=} data Data to be passed with the message. Can be any type that is compatible with the structured clone algorithm, */ const targetOrigin = '*'; function isWindow(win) { try { return win && win.postMessage; } catch (error) { return false; } } /** * Send message to target window. * @param {Message} message * @param {Window=} targetWindow */ function postMessage(message, targetWindow) { if (!targetWindow) { targetWindow = window.top; } if (!isWindow(targetWindow)) { throw new Error('postMessage called on a non Window object.'); } try { targetWindow.postMessage(message, targetOrigin); } catch (error) { if ( error instanceof DOMException && (error.name === 'DataCloneError' || error.code === 25) /* DATA_CLONE_ERR */ ) { throw new Error( "ts.io method called with { data } argument that can't be cloned using the structural clone algorithm." ); } else { console.warn( 'Something went wrong while sending postMessage.\n' + JSON.stringify(error, null, 2) ); } } } const messageQueue = []; function queueMessage(message, targetWindow) { if (!targetWindow) { targetWindow = window.top; } messageQueue.push({ targetWindow, message }); } function flushQueue(token) { if (messageQueue.length) { messageQueue .reverse() .forEach(queuedMessage => queuedMessage.targetWindow.postMessage( { ...queuedMessage.message, token }, targetOrigin ) ); return messageQueue.length; } return false; } /** * Validate message. * @param {Message} message */ function messageValid(message) { return message && message.type; } /** * Validate message for 'SPAWN', 'SPAWNED', 'EVENT', etc. complex types. * @param {Message} message */ function complexMessageValid(message) { return messageValid(message) && message.target; } /** * Message is sent to an App. * @param {Message} message */ function appMessageValid(message) { return ( messageValid(message) && message.viaHub && ['CONNACK', 'EVENT', 'PING', 'SPAWN-SUCCESS', 'SPAWN-FAIL'].includes( message.type ) ); } /** * Message sent to the Hub. * @param {Message} message */ function hubMessageValid(message) { return ( messageValid(message) && !message.viaHub && [ 'CONNECT', 'EVENT', 'PONG', 'SPAWN', 'SPAWN-SUCCESS', 'SPAWN-FAIL' ].includes(message.type) ); } /** * Does the topic match the expression? * * @todo Support more than '*' or exact match. * * @param {string} topicExpression Topic expression to match. * @param {string} topic Topic to match. */ function matchTopic(topicExpression, topic) { if (topicExpression === '*') { return true; } return topicExpression === topic; } let appInstance; let debug = log('ts:io:sub:NEW'); let appId = ''; let token = ''; let spawnSubmit; /** * Set of `on*()` listeners keyed by `topic`. * @type {Map<topic: string, handlers: Set<Function>>} */ const listeners = new Map(); /** * Set of `define()` handlers keyed by handler name. * @type {Map<method: string, handler: Function>} */ const lifecycle = new Map(); /** * The Message Client AKA The App. */ function app() { if (appInstance) { return appInstance; } appInstance = { /** * Handle messages. * @param {string} topic Specific topic that we will call the handler for. * @param {Function} listener Event listener. * @returns {Function} Deregistrator of listener. */ on(topic, listener) { if (listeners.has(topic)) { listeners.get(topic).add(listener); } else { listeners.set(topic, new Set([listener])); } /** * Return the deregistrator. */ return () => appInstance.off(topic, listener); }, /** * Handle message once. * @param {string} topic Specific topic that we will call the handler for. * @param {Function} handler Event listener. * @returns {Function} Deregistrator of listener. */ once(topic, listener) { const wrappedListener = message => { this.off(topic, wrappedListener); listener(message); }; this.on(topic, wrappedListener); /** * Return the deregistrator. */ return () => this.off(topic, wrappedListener); }, /** * Remove message handler. * @param {string} topic Same as the topic which the handler uses. * @param {Function} listener Reference to the same listener to delete. * @return {boolean} true on success. */ off(topic, listener) { const eventListeners = listeners.get(topic); let deleted; if (eventListeners) { deleted = eventListeners.delete(listener); } debug( '%s handler %o - %O', deleted ? 'Deleted' : "Didn't find", topic, listener ); return deleted; }, /** * Publish message.. * @param {string} target Target appId. - No wildcards supported * @param {string} topic Topic. * @param {*=} data Data. */ emit(topic, ...args) { if (args.length === 0 || args.length > 2) { throw new Error( 'ts.io().emit() called with invalid arguments.', arguments ); } let target, data; if (args.length === 1) { target = args[0]; } else { data = args[0]; target = args[1]; } const message = { type: 'EVENT', token, target, topic, data }; if (token) { debug('%o (%o) to %o - %o', 'EVENT', topic, target, data); postMessage(message); } else { debug('%o (%o) to %o - %o', 'EVENT(queued)', topic, target, data); queueMessage(message); } }, define(handlers) { if (!(typeof handlers === 'object')) { return; } if (typeof handlers.spawn === 'function') { lifecycle.set('spawn', handlers.spawn); } if (typeof handlers.connect === 'function') { lifecycle.set('connect', handlers.connect); } }, /** * Spawn app method * @async * @param {string} target Target appId. - No wildcards supported * @param {*=} data Data. * @returns {Promise} */ async spawn(target, data = {}) { const message = { type: 'SPAWN', token, target, data }; if (token) { debug('%o to %o - %o', 'SPAWN', target, data); postMessage(message); } else { debug('%o to %o - %o', 'SPAWN(queued)', target, data); queueMessage(message); } // Wait for response from the app or some sort of failure return new Promise(resolve => { spawnSubmit = resolve; }); } }; function handleSpawn({ data: message, source: sourceWindow }) { debug('SPAWNED from %o - %O', message.source, message); if (!lifecycle.has('spawn')) { // this app can't be spawned and we should send an error back to the source app postMessage({ type: 'SPAWN-FAIL', target: message.source, topic: message.topic, data: message.target + " doesn't have a 'spawn' handler, it's not compatible with this SPAWN request.", token }); } /** * @TODO Timeout handling! */ new Promise(resolve => { const spawnValue = lifecycle .get('spawn') .apply({}, [message.data, resolve, message.source]); // check for promise if (Promise.resolve(spawnValue) === spawnValue) { resolve(spawnValue); } }).then( data => { postMessage({ type: 'SPAWN-SUCCESS', target: message.source, token, data }); }, err => { postMessage({ type: 'SPAWN-FAIL', target: message.source, topic: message.topic, data: err, token }); } ); } function handleConnack({ data: message, source: sourceWindow }) { appId = message.target || ''; token = message.token || ''; debug = log('ts:io:sub:' + appId); debug('CONNECTED %o', message); const queueLength = flushQueue(message.token); if (queueLength) { debug( 'Publishing %s queued events%s', queueLength, queueLength === 1 ? '' : 's' ); } if (lifecycle.has('connect')) { lifecycle.get('connect')(); } } /** * Handle events this app is listening for. * @param {MessageEvent} event */ const eventHandler = event => { const message = event.data; // Only accept messages from the hub in window.top. if (event.source !== window.top || !appMessageValid(message)) { return; } // The hub.top will get its own messages back, they will be ignored. if (message.target && appId && message.target !== appId) { return; } // Call the matching handlers for the message topic. if (!['PING', 'CONNACK'].includes(message.type)) { debug( 'Received %s %s from %o - %O', message.type, message.topic ? `('${message.topic}')` : '', message.source, message ); } switch (message.type) { case 'CONNACK': handleConnack(event); if (message.source) { handleSpawn(event); } return; case 'EVENT': listeners.forEach( (eventListeners, topic) => matchTopic(topic, message.topic) && eventListeners.forEach(listener => listener(message)) ); break; case 'SPAWN-SUCCESS': return spawnSubmit([null, message.data]); case 'SPAWN-FAIL': return spawnSubmit([message.data, null]); case 'PING': return postMessage({ type: 'PONG', token }); default: break; } }; /** * Start listening to messages from window.top. */ window.addEventListener('message', eventHandler); /** * Send CONNECT to Hub. */ debug('Connecting…'); postMessage({ type: 'CONNECT' }); return appInstance; } /** * Are we in the same frame as the Tradeshift® Chrome™? * @return {boolean} */ function isChromeWindow() { return window.ts && window.ts.chrome !== undefined; } /** * Heartbeat regularity in ms. * @type {number} */ const HEARTBEAT = 3333; /** * Harcoded appId for the Tradeshift® Chrome™ * @type {string} */ const CHROME_APP_ID = 'Tradeshift.Chrome'; let hubInstance; /** * WeakMap of frames with apps. * @type {WeakMap<Window, Object<appId: string, token: string>} */ const appWindows = new WeakMap(); const appTokens = {}; /** * Map of when the last PONG, or any other message was sent from an app. * @type {Map<token: string, Object<lastPong: DOMHighResTimeStamp, timeoutIds: Set<timeoutId: number>} */ const appPongs = new Map(); const appSpawns = []; /** * Special features supplied by the Tradeshift® Chrome™. * @typedef {object} ChromeWindowFeatures * @property {function(Window): string} appByWindow Called to get an appId based on a Window object. * @property {function(string, Window): Window} windowByApp Called to get a window object based on an appId and the requesting app's Window object. */ function invalidFunction(name) { return function() { throw new Error(`Can't initialize ts.io() Hub. ${name}() wasn't passed.`); }; } /** * The Message Broker AKA The Hub. * @param {ChromeWindowFeatures} chrome Special features supplied by the Tradeshift® Chrome™ */ function hub(chrome) { if (hubInstance) { return hubInstance; } const debug = log('ts:io:top'); hubInstance = { HEARTBEAT: HEARTBEAT }; const { appByWindow = invalidFunction('appByWindow'), windowByApp = invalidFunction('windowByApp'), handleAppSpawn = invalidFunction('handleAppSpawn'), handleAppSubmit = invalidFunction('handleAppSubmit'), handleAppTimeout = invalidFunction('handleAppTimeout') } = chrome; /** * Quickly test that appByWindow & windowByApp work for 'Tradeshift.Chrome' */ { const testChromeWindow = windowByApp(CHROME_APP_ID); const testNotWindow = !(testChromeWindow instanceof Window); const testNotAppId = appByWindow(testChromeWindow) !== CHROME_APP_ID; if (testNotWindow) { throw new Error( `Can't initialize ts.io() Hub. Expected windowByApp('${CHROME_APP_ID}') to return a 'Window' object.` ); } else if (testNotAppId) { throw new Error( `Can't initialize ts.io() Hub. Expected appByWindow(windowByApp('${CHROME_APP_ID}')) to return '${CHROME_APP_ID}'.` ); } } function forgetApp(appId, targetWindow) { try { const token = appTokens[appId]; if (appId && token) { debug('Forgetting app %o', appId); appPongs .get(token) .timeoutIds.forEach(timeoutId => clearTimeout(timeoutId)); appPongs.delete(token); delete appTokens[appId]; appWindows.delete(targetWindow); } } catch (error) { debug("App couldn't be forgotten in %o - %o", targetWindow, error); } } /* 1. after sending CONNACK to an app, PING it after HEARTBEAT ms 2. if it replies, wait HEARTBEAT ms and PING again, - repeat forever 3. if it doesn't reply within 4 setTimeouts of HEARTBEAT ms, consider it dead */ function pingApp(opts, { previousPong, attempt = 0 } = {}) { const { appId, token, targetWindow } = opts; const now = window.performance.now(); const appPongInfo = appPongs.get(token); const lastPong = (appPongInfo && appPongInfo.lastPong) || now; const nextAttempt = lastPong !== previousPong ? 0 : attempt + 1; let appAlive = nextAttempt <= 3; if (appAlive) { appPongInfo.timeoutIds.add( setTimeout( () => pingApp(opts, { previousPong: lastPong, attempt: nextAttempt }), hubInstance.HEARTBEAT ) ); try { postMessage( { type: 'PING', viaHub: true, target: appId, token }, targetWindow ); } catch (error) { appAlive = false; } } if (!appAlive) { debug('App timed out, considering it dead! %o', appId); handleAppTimeout(appId, targetWindow); try { forgetApp(appId, targetWindow); } catch (error) { console.warn( "App couldn't be killed.\n" + JSON.stringify(error, null, 2) ); } } } function handleAppConnect({ data: message, source: sourceWindow }) { const appId = appByWindow(sourceWindow); const token = uuid(); const spawnWaiting = appSpawns.findIndex(spawn => spawn.appId === appId); const connackMessage = { type: 'CONNACK', viaHub: true, target: appId, token }; appWindows.set(sourceWindow, { appId, token }); appTokens[appId] = token; debug('CONNECT %o', appId); if (spawnWaiting !== -1) { const { data, source } = appSpawns[spawnWaiting].message; connackMessage.source = source; connackMessage.data = data; appSpawns.splice(spawnWaiting, 1); } postMessage(connackMessage, sourceWindow); let timeoutId; if (appId !== CHROME_APP_ID) { const pingOpts = { targetWindow: sourceWindow, appId, token }; timeoutId = setTimeout(() => pingApp(pingOpts), hubInstance.HEARTBEAT); appPongs.set(token, { lastPong: window.performance.now(), timeoutIds: new Set([timeoutId]) }); } } function handleSpawn({ data: message, source: sourceWindow }) { debug('Spawning %o from %o - %O', message.target, message.source, message); try { const appId = handleAppSpawn(message.target, message.source); appSpawns.push({ appId, message }); } catch (e) { postMessage( { type: 'SPAWN-FAIL', target: message.source, topic: message.topic, data: message.target + " is not activated on the current user's account", viaHub: true }, windowByApp(message.source, CHROME_APP_ID) ); } } function handlePong(event) { const token = event.data.token; appPongs.set(token, { ...appPongs.get(token), lastPong: window.performance.now() }); } function handleEvent({ data: message, source: sourceWindow }) { /** * @TODO Handle the case when the Chrome blocks certain targets for certain sources */ const targetWindow = windowByApp(message.target, sourceWindow); debug( 'Routing %o from %o to %o - %O', message.type, message.source, message.target, message ); postMessage(message, targetWindow); if (message.type.indexOf('SPAWN') === 0) { handleAppSubmit(message.source, message.target, message.data); } } window.addEventListener('message', function(event) { const message = event.data; // Only accept valid messages from apps. if (!hubMessageValid(message)) { return; } const appWindow = appWindows.get(event.source) || {}; message.source = appWindow.appId; message.viaHub = true; // Message from a frame we don't know yet. // The only command should be CONNECT, we fail otherwise. if (!appWindow && message.type !== 'CONNECT') { console.warn( 'Unexpected critical error! App sent message without being connected!\n' + JSON.stringfy(message, null, 2) ); return; } if (message.token !== appWindow.token) { console.warn( 'Token invalid, discarding message!\n' + JSON.stringify(message, null, 2) ); return; } if (message.source && message.source === message.target) { console.warn( 'Source and destination match, discarding message!\n' + JSON.stringify(message, null, 2) ); return; } switch (message.type) { case 'CONNECT': // Message from a frame we don't know yet. if (Object.keys(appWindow).length) { console.warn( 'CONNECT received from known app, discarding message!\n' + JSON.stringify(message, null, 2) ); return; } return handleAppConnect(event); case 'EVENT': case 'SPAWN-SUCCESS': case 'SPAWN-FAIL': if (!complexMessageValid(message)) { console.warn( `Message incomplete for a ${message.type} command!\n` + JSON.stringify(message, null, 2) ); return; } return handleEvent(event); case 'SPAWN': if (!complexMessageValid(message)) { console.warn( 'Message incomplete for a SPAWN command!\n' + JSON.stringify(message, null, 2) ); return; } return handleSpawn(event); case 'PONG': return handlePong(event); default: debug('* %o', event.data); } }); app(); hubInstance = { top: app, forgetApp, HEARTBEAT: hubInstance.HEARTBEAT }; return hubInstance; } const api = isChromeWindow() ? hub : app; module.exports = api; //# sourceMappingURL=ts.io-cjs.js.map