src/postmate.js
require('json.date-extensions');
/**
* The type of messages our frames our sending
* @type {String}
*/
const MESSAGE_TYPE = 'application/x-postmate-v1+json';
/**
* hasOwnProperty()
* @type {Function}
* @return {Boolean}
*/
const hasOwnProperty = Object.prototype.hasOwnProperty;
/**
* The maximum number of attempts to send a handshake request to the parent
* @type {Number}
*/
const maxHandshakeRequests = 5;
/**
* A unique message ID that is used to ensure responses are sent to the correct requests
* @type {Number}
*/
let _messageId = 0;
/**
* Increments and returns a message ID
* @return {Number} A unique ID for a message
*/
function messageId() {
return ++_messageId;
}
/**
* Postmate logging function that enables/disables via config
* @param {Object} ...args Rest Arguments
*/
function log(...args) {
// eslint-disable-next-line
if (!Postmate.debug) return;
console.log(...args); // eslint-disable-line no-console
}
/**
* Takes a URL and returns the origin
* @param {String} url The full URL being requested
* @return {String} The URLs origin
*/
function resolveOrigin(url) {
const a = document.createElement('a');
a.href = url;
const protocol = a.protocol.length > 4 ? a.protocol : window.location.protocol;
const host = a.host.length ? ((a.port === '80' || a.port === '443') ? a.hostname : a.host) : window.location.host;
return a.origin || `${protocol}//${host}`;
}
/**
* Ensures that a message is safe to interpret
* @param {Object} message The postmate message being sent
* @param {String} allowedOrigin The whitelisted origin
* @return {Boolean}
*/
function sanitize(message, allowedOrigin, messageType) {
if (message.origin !== allowedOrigin) return false;
if (typeof message.data !== 'object') return false;
if (!('postmate' in message.data)) return false;
if (message.data.type !== messageType) return false;
if (typeof message.data.postmate === 'object') return true;
if (!{
'handshake-reply': 1,
call: 1,
emit: 1,
reply: 1,
request: 1
}[message.data.postmate]) return false;
return true;
}
/**
* Takes a model, and searches for a value by the property
* @param {Object} model The dictionary to search against
* @param {String} property A path within a dictionary (i.e. 'window.location.href')
* @param {Object} data Additional information from the get request that is
* passed to functions in the child model
* @return {Promise}
*/
function resolveValue(model, property) {
const unwrappedContext = typeof model[property] === 'function' ?
model[property]() : model[property];
return Promise.resolve(unwrappedContext);
}
let shouldStringifyMessages = false;
try {
window.postMessage({
toString: () => {
shouldStringifyMessages = true;
}
}, '*');
} catch (e) {}
function postMessage(target, message, origin) {
if (shouldStringifyMessages) {
message = JSON.stringify(message);
}
target.postMessage(message, origin);
}
function listenMessage(target, callback) {
const listener = (e) => {
const message = shouldStringifyMessages ? JSON.parseWithDate(e.data) : e.data;
callback({
data: message,
origin: e.origin,
source: e.source
});
};
callback.listener = listener;
target.addEventListener('message', listener, false);
}
function unlistenMessage(target, callback) {
target.removeEventListener('message', callback.listener || callback);
}
/**
* Composes an API to be used by the parent
* @param {Object} info Information on the consumer
*/
class ParentAPI {
constructor(info) {
this.parent = info.parent;
this.frame = info.frame;
this.child = info.child;
this.childOrigin = info.childOrigin;
this.messageType = info.messageType;
this.getIncomingMessage = info.getIncomingMessage.bind(info);
this.getOutcomingMessage = info.getOutcomingMessage.bind(info);
this.events = {};
log('Parent: Registering API');
log('Parent: Awaiting messages...');
this.listener = (e) => {
if (!sanitize(e, this.childOrigin, this.messageType)) return;
const message = this.getIncomingMessage(e.data);
const { data, name } = (message.value || {});
if (message.postmate === 'emit') {
log(`Parent: Received event emission: ${name}`);
if (name in this.events) {
this.events[name].call(this, data);
}
}
};
listenMessage(this.parent, this.listener);
log('Parent: Awaiting event emissions from Child');
}
get(property) {
return new Promise((resolve) => {
// Extract data from response and kill listeners
const uid = messageId();
const transact = (e) => {
if (!sanitize(e, this.childOrigin, this.messageType)) return;
const message = this.getIncomingMessage(e.data);
if (message.uid === uid && message.postmate === 'reply') {
unlistenMessage(this.parent, transact);
resolve(message.value);
}
};
// Prepare for response from Child...
listenMessage(this.parent, transact);
// Then ask child for information
postMessage(this.child, this.getOutcomingMessage({
postmate: 'request',
property,
uid
}), this.childOrigin);
});
}
call(property, data) {
// Send information to the child
postMessage(this.child, this.getOutcomingMessage({
postmate: 'call',
property,
data
}), this.childOrigin);
}
on(eventName, callback) {
this.events[eventName] = callback;
}
destroy() {
log('Parent: Destroying Postmate instance');
unlistenMessage(window, this.listener);
this.frame.parentNode.removeChild(this.frame);
}
}
/**
* Composes an API to be used by the child
* @param {Object} info Information on the consumer
*/
class ChildAPI {
constructor(info) {
this.model = info.model;
this.parent = info.parent;
this.parentOrigin = info.parentOrigin;
this.child = info.child;
this.messageType = info.messageType;
this.getIncomingMessage = info.getIncomingMessage.bind(info);
this.getOutcomingMessage = info.getOutcomingMessage.bind(info);
log('Child: Registering API');
log('Child: Awaiting messages...');
listenMessage(this.child, (e) => {
if (!sanitize(e, this.parentOrigin, this.messageType)) return;
const message = this.getIncomingMessage(e.data);
log('Child: Received request', message);
const { property, uid, data } = message;
if (message.postmate === 'call') {
if (property in this.model && typeof this.model[property] === 'function') {
this.model[property].call(this, data);
}
return;
}
// Reply to Parent
resolveValue(this.model, property)
.then(value => postMessage(e.source, this.getOutcomingMessage({
property,
postmate: 'reply',
uid,
value
}), e.origin));
});
}
emit(name, data) {
log(`Child: Emitting Event "${name}"`, data);
postMessage(this.parent, this.getOutcomingMessage({
postmate: 'emit',
value: {
name,
data
}
}), this.parentOrigin);
}
}
/**
* The entry point of the Parent.
* @type {Class}
*/
class Postmate {
/**
* Sets options related to the Parent
* @param {Object} userOptions The element to inject the frame into, and the url
* @return {Promise}
*/
constructor({ container, url, model, messageType }) {
this.parent = window;
this.frame = document.createElement('iframe');
(container || document.body).appendChild(this.frame);
this.child = this.frame.contentWindow || this.frame.contentDocument.parentWindow;
this.model = model || {};
this.messageType = messageType || MESSAGE_TYPE;
return this.sendHandshake(url);
}
getIncomingMessage(data) {
return data;
}
getOutcomingMessage(data) {
data.type = this.messageType;
return data;
}
/**
* Begins the handshake strategy
* @param {String} url The URL to send a handshake request to
* @return {Promise} Promise that resolves when the handshake is complete
*/
sendHandshake(url) {
const childOrigin = resolveOrigin(url);
const messageType = this.messageType;
let attempt = 0;
let responseInterval;
return new Promise((resolve, reject) => {
const reply = (e) => {
if (!sanitize(e, childOrigin, messageType)) return false;
const message = this.getIncomingMessage(e.data);
if (message.postmate === 'handshake-reply') {
clearInterval(responseInterval);
log('Parent: Received handshake reply from Child');
unlistenMessage(this.parent, reply);
this.childOrigin = e.origin;
log('Parent: Saving Child origin', this.childOrigin);
this.handleHandshakeData(e.data);
return resolve(new ParentAPI(this));
}
// Might need to remove since parent might be receiving different messages
// from different hosts
log('Parent: Invalid handshake reply');
return reject('Failed handshake');
};
listenMessage(this.parent, reply);
const request = this.getHandshakeRequest();
const doSend = () => {
attempt++;
log(`Parent: Sending handshake attempt ${attempt}, ${childOrigin}`);
postMessage(this.child, request, childOrigin);
if (attempt === maxHandshakeRequests) {
clearInterval(responseInterval);
}
};
const loaded = () => {
doSend();
responseInterval = setInterval(doSend, 500);
};
if (this.frame.attachEvent) {
this.frame.attachEvent('onload', loaded);
} else {
this.frame.onload = loaded;
}
log('Parent: Loading frame', { url });
this.frame.src = url;
});
}
getHandshakeRequest() {
return this.getOutcomingMessage({
postmate: 'handshake',
model: this.model
});
}
handleHandshakeData(data) {}
}
Postmate.debug = false;
/**
* The entry point of the Child
* @type {Class}
*/
Postmate.Model = class Model {
/**
* Initializes the child, model, parent, and responds to the Parents handshake
* @param {Object} model Hash of values, functions, or promises
* @return {Promise} The Promise that resolves when the handshake has been received
*/
constructor(model, messageType) {
this.child = window;
this.model = model;
this.parent = this.child.parent;
this.messageType = messageType || MESSAGE_TYPE;
return this.sendHandshakeReply();
}
/**
* Responds to a handshake initiated by the Parent
* @return {Promise} Resolves an object that exposes an API for the Child
*/
sendHandshakeReply() {
return new Promise((resolve, reject) => {
const shake = (e) => {
if (!e.data.postmate) {
return;
}
if (e.data.postmate === 'handshake') {
log('Child: Received handshake from Parent');
unlistenMessage(this.child, shake);
log('Child: Inherited and extended model from Parent');
this.handleHandshakeData(e.data);
log('Child: Saving Parent origin', e.origin);
this.parentOrigin = e.origin;
log('Child: Sending handshake reply to Parent');
postMessage(e.source, this.getOutcomingMessage(this.getHandshakeResponse()), this.parentOrigin);
resolve(new ChildAPI(this));
} else {
reject('Handshake Reply Failed');
}
};
log('Child: Waiting for handshake from Parent');
listenMessage(this.child, shake);
});
}
handleHandshakeData(data) {
// Extend model with the one provided by the parent
const defaults = data.model;
if (defaults) {
const keys = Object.keys(defaults);
for (let i = 0; i < keys.length; i++) {
if (hasOwnProperty.call(defaults, keys[i])) {
this.model[keys[i]] = defaults[keys[i]];
}
}
}
}
getHandshakeResponse() {
return {
postmate: 'handshake-reply'
};
}
getIncomingMessage(data) {
return data;
}
getOutcomingMessage(data) {
data.type = this.messageType;
return data;
}
};
// Export
module.exports = Postmate;