ArticleTemplates/assets/js/modules/messenger.js (123 lines of code) (raw):
import { postMessage } from 'modules/post-message';
let allowedHosts = [
location.protocol + '//' + location.host,
'http://localhost:9000',
'https://api.nextgen.guardianapps.co.uk'
];
let listeners = {};
let registeredListeners = 0;
let error405 = { code: 405, message: 'Service %% not implemented' };
let error500 = { code: 500, message: 'Internal server error\n\n%%' };
function init(modules) {
register('syn', function() {
return 'ack';
});
modules.forEach(function (module) {
module.init(register);
});
}
function register(type, callback, options) {
options || (options = {});
if (registeredListeners === 0) {
on(window);
}
/* Persistent listeners are exclusive */
if (options.persist) {
listeners[type] = callback;
registeredListeners += 1;
} else {
listeners[type] || (listeners[type] = []);
if (!listeners[type].includes(callback)) {
listeners[type].push(callback);
registeredListeners += 1;
}
}
}
function unregister(type, callback) {
if (listeners[type] === undefined) {
throw new Error(formatError(error405, type));
}
if (callback === undefined) {
registeredListeners -= listeners[type].length;
listeners[type].length = 0;
} else {
if (listeners[type] === callback) {
listeners[type] = null;
registeredListeners -= 1;
} else {
let idx = listeners[type].indexOf(callback);
if (idx > -1) {
registeredListeners -= 1;
listeners[type].splice(idx, 1);
}
}
}
if (registeredListeners === 0) {
off(window);
}
}
function on(window) {
window.addEventListener('message', onMessage);
}
function off(window) {
window.removeEventListener('message', onMessage);
}
function onMessage(event) {
// We only allow communication with selected hosts
if (allowedHosts.indexOf(event.origin) < 0) {
return;
}
let data = getData(event.data);
if (!isValidPayload(data)) {
return;
}
if (Array.isArray(listeners[data.type]) && listeners[data.type].length) {
// Because any listener can have side-effects (by unregistering itself),
// we run the promise chain on a copy of the `listeners` array.
// Hat tip @piuccio
let promise = listeners[data.type].slice()
// We offer, but don't impose, the possibility that a listener returns
// a value that must be sent back to the calling frame. To do this,
// we pass the cumulated returned value as a second argument to each
// listener. Notice we don't try some clever way to compose the result
// value ourselves, this would only make the solution more complex.
// That means a listener can ignore the cumulated return value and
// return something else entirely—life is unfair.
// We don't know what each callack will be made of, we don't want to.
// And so we wrap each call in a promise chain, in case one drops the
// occasional fastdom bomb in the middle.
.reduce(function (promise, listener) {
return promise.then(function promiseCallback(ret) {
var iframe = getIframe(data);
if (!iframe) {
throw new Error(formatError(error500, 'iframe element not found'));
}
var thisRet = listener(data.value, ret, iframe);
return thisRet === undefined ? ret : thisRet;
});
}, Promise.resolve(true));
return promise.then(function (response) {
respond(null, response);
}).catch(function (ex) {
respond(formatError(error500, ex), null);
});
} else if (typeof listeners[data.type] === 'function') {
// We found a persistent listener, to which we just delegate
// responsibility to write something. Anything. Really.
listeners[data.type](respond, data.value, getIframe(data));
} else {
// If there is no routine attached to this event type, we just answer
// with an error code
respond(formatError(error405, data.type), null);
}
function respond(error, result) {
postMessage({ id: data.id, error: error, result: result }, event.source, event.origin);
}
}
function getData(data) {
try {
// Even though the postMessage API allows passing objects as-is, the
// serialisation/deserialisation is slower than using JSON
// Source: https://bugs.chromium.org/p/chromium/issues/detail?id=536620#c11
return JSON.parse(data);
} catch( ex ) {
return {};
}
}
// Just some housekeeping to avoid malformed messages from coming through
function isValidPayload(payload) {
return 'type' in payload &&
'value' in payload &&
'id' in payload &&
'iframeId' in payload &&
payload.type in listeners;
}
// Incoming messages contain the ID of the iframe into which the
// source window is embedded.
function getIframe(data) {
return document.getElementById(data.iframeId);
}
// Cheap string formatting function. It accepts as its first argument
// an object `{ code, message }`. `message` is a string where successive
// occurences of %% will be replaced by the following arguments. e.g.
//
// formatError({ message: "%%, you are so %%" }, "Regis", "lovely")
//
// returns `{ message: "Regis, you are so lovely" }`. Oh, thank you!
function formatError() {
let error = arguments[0];
let args = Array.prototype.slice.call(arguments, 1);
return args.reduce(function (e, arg) {
// Keep in mind that when the first argument is a string,
// String.replace only replaces the first occurence
e.message = e.message.replace('%%', arg);
return e;
}, error);
}
export { register, unregister, init };