@tradeshift/io
Version:
ts.io - Tradeshift App Messaging Library
956 lines (855 loc) • 22.4 kB
JavaScript
import uuid from '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;
export default api;
//# sourceMappingURL=ts.io-esm.js.map