penpal
Version:
A promise-based library for communicating with iframes via postMessage.
774 lines (649 loc) • 24.9 kB
JavaScript
var Penpal = (function () {
'use strict';
var MessageType;
(function (MessageType) {
MessageType["Call"] = "call";
MessageType["Reply"] = "reply";
MessageType["Syn"] = "syn";
MessageType["SynAck"] = "synAck";
MessageType["Ack"] = "ack";
})(MessageType || (MessageType = {}));
var Resolution;
(function (Resolution) {
Resolution["Fulfilled"] = "fulfilled";
Resolution["Rejected"] = "rejected";
})(Resolution || (Resolution = {}));
var ErrorCode;
(function (ErrorCode) {
ErrorCode["ConnectionDestroyed"] = "ConnectionDestroyed";
ErrorCode["ConnectionTimeout"] = "ConnectionTimeout";
ErrorCode["NoIframeSrc"] = "NoIframeSrc";
})(ErrorCode || (ErrorCode = {}));
var NativeErrorName;
(function (NativeErrorName) {
NativeErrorName["DataCloneError"] = "DataCloneError";
})(NativeErrorName || (NativeErrorName = {}));
var NativeEventType;
(function (NativeEventType) {
NativeEventType["Message"] = "message";
})(NativeEventType || (NativeEventType = {}));
var createDestructor = ((localName, log) => {
const callbacks = [];
let destroyed = false;
return {
destroy(error) {
if (!destroyed) {
destroyed = true;
log("".concat(localName, ": Destroying connection"));
callbacks.forEach(callback => {
callback(error);
});
}
},
onDestroy(callback) {
destroyed ? callback() : callbacks.push(callback);
}
};
});
var createLogger = (debug => {
/**
* Logs a message if debug is enabled.
*/
return (...args) => {
if (debug) {
console.log('[Penpal]', ...args); // eslint-disable-line no-console
}
};
});
const DEFAULT_PORT_BY_PROTOCOL = {
'http:': '80',
'https:': '443'
};
const URL_REGEX = /^(https?:)?\/\/([^/:]+)?(:(\d+))?/;
const opaqueOriginSchemes = ['file:', 'data:'];
/**
* Converts a src value into an origin.
*/
var getOriginFromSrc = (src => {
if (src && opaqueOriginSchemes.find(scheme => src.startsWith(scheme))) {
// The origin of the child document is an opaque origin and its
// serialization is "null"
// https://html.spec.whatwg.org/multipage/origin.html#origin
return 'null';
} // Note that if src is undefined, then srcdoc is being used instead of src
// and we can follow this same logic below to get the origin of the parent,
// which is the origin that we will need to use.
const location = document.location;
const regexResult = URL_REGEX.exec(src);
let protocol;
let hostname;
let port;
if (regexResult) {
// It's an absolute URL. Use the parsed info.
// regexResult[1] will be undefined if the URL starts with //
protocol = regexResult[1] ? regexResult[1] : location.protocol;
hostname = regexResult[2];
port = regexResult[4];
} else {
// It's a relative path. Use the current location's info.
protocol = location.protocol;
hostname = location.hostname;
port = location.port;
} // If the port is the default for the protocol, we don't want to add it to the origin string
// or it won't match the message's event.origin.
const portSuffix = port && port !== DEFAULT_PORT_BY_PROTOCOL[protocol] ? ":".concat(port) : '';
return "".concat(protocol, "//").concat(hostname).concat(portSuffix);
});
/**
* Converts an error object into a plain object.
*/
const serializeError = ({
name,
message,
stack
}) => ({
name,
message,
stack
});
/**
* Converts a plain object into an error object.
*/
const deserializeError = obj => {
const deserializedError = new Error(); // @ts-ignore
Object.keys(obj).forEach(key => deserializedError[key] = obj[key]);
return deserializedError;
};
/**
* Listens for "call" messages coming from the remote, executes the corresponding method, and
* responds with the return value.
*/
var connectCallReceiver = ((info, serializedMethods, log) => {
const {
localName,
local,
remote,
originForSending,
originForReceiving
} = info;
let destroyed = false;
const handleMessageEvent = event => {
if (event.source !== remote || event.data.penpal !== MessageType.Call) {
return;
}
if (originForReceiving !== '*' && event.origin !== originForReceiving) {
log("".concat(localName, " received message from origin ").concat(event.origin, " which did not match expected origin ").concat(originForReceiving));
return;
}
const callMessage = event.data;
const {
methodName,
args,
id
} = callMessage;
log("".concat(localName, ": Received ").concat(methodName, "() call"));
const createPromiseHandler = resolution => {
return returnValue => {
log("".concat(localName, ": Sending ").concat(methodName, "() reply"));
if (destroyed) {
// It's possible to throw an error here, but it would need to be thrown asynchronously
// and would only be catchable using window.onerror. This is because the consumer
// is merely returning a value from their method and not calling any function
// that they could wrap in a try-catch. Even if the consumer were to catch the error,
// the value of doing so is questionable. Instead, we'll just log a message.
log("".concat(localName, ": Unable to send ").concat(methodName, "() reply due to destroyed connection"));
return;
}
const message = {
penpal: MessageType.Reply,
id,
resolution,
returnValue
};
if (resolution === Resolution.Rejected && returnValue instanceof Error) {
message.returnValue = serializeError(returnValue);
message.returnValueIsError = true;
}
try {
remote.postMessage(message, originForSending);
} catch (err) {
// If a consumer attempts to send an object that's not cloneable (e.g., window),
// we want to ensure the receiver's promise gets rejected.
if (err.name === NativeErrorName.DataCloneError) {
const errorReplyMessage = {
penpal: MessageType.Reply,
id,
resolution: Resolution.Rejected,
returnValue: serializeError(err),
returnValueIsError: true
};
remote.postMessage(errorReplyMessage, originForSending);
}
throw err;
}
};
};
new Promise(resolve => resolve(serializedMethods[methodName].apply(serializedMethods, args))).then(createPromiseHandler(Resolution.Fulfilled), createPromiseHandler(Resolution.Rejected));
};
local.addEventListener(NativeEventType.Message, handleMessageEvent);
return () => {
destroyed = true;
local.removeEventListener(NativeEventType.Message, handleMessageEvent);
};
});
let id = 0;
/**
* @return {number} A unique ID (not universally unique)
*/
var generateId = (() => ++id);
const KEY_PATH_DELIMITER = '.';
const keyPathToSegments = keyPath => keyPath ? keyPath.split(KEY_PATH_DELIMITER) : [];
const segmentsToKeyPath = segments => segments.join(KEY_PATH_DELIMITER);
const createKeyPath = (key, prefix) => {
const segments = keyPathToSegments(prefix || '');
segments.push(key);
return segmentsToKeyPath(segments);
};
/**
* Given a `keyPath`, set it to be `value` on `subject`, creating any intermediate
* objects along the way.
*
* @param {Object} subject The object on which to set value.
* @param {string} keyPath The key path at which to set value.
* @param {Object} value The value to store at the given key path.
* @returns {Object} Updated subject.
*/
const setAtKeyPath = (subject, keyPath, value) => {
const segments = keyPathToSegments(keyPath);
segments.reduce((prevSubject, key, idx) => {
if (typeof prevSubject[key] === 'undefined') {
prevSubject[key] = {};
}
if (idx === segments.length - 1) {
prevSubject[key] = value;
}
return prevSubject[key];
}, subject);
return subject;
};
/**
* Given a dictionary of (nested) keys to function, flatten them to a map
* from key path to function.
*
* @param {Object} methods The (potentially nested) object to serialize.
* @param {string} prefix A string with which to prefix entries. Typically not intended to be used by consumers.
* @returns {Object} An map from key path in `methods` to functions.
*/
const serializeMethods = (methods, prefix) => {
const flattenedMethods = {};
Object.keys(methods).forEach(key => {
const value = methods[key];
const keyPath = createKeyPath(key, prefix);
if (typeof value === 'object') {
// Recurse into any nested children.
Object.assign(flattenedMethods, serializeMethods(value, keyPath));
}
if (typeof value === 'function') {
// If we've found a method, expose it.
flattenedMethods[keyPath] = value;
}
});
return flattenedMethods;
};
/**
* Given a map of key paths to functions, unpack the key paths to an object.
*
* @param {Object} flattenedMethods A map of key paths to functions to unpack.
* @returns {Object} A (potentially nested) map of functions.
*/
const deserializeMethods = flattenedMethods => {
const methods = {};
for (const keyPath in flattenedMethods) {
setAtKeyPath(methods, keyPath, flattenedMethods[keyPath]);
}
return methods;
};
/**
* Augments an object with methods that match those defined by the remote. When these methods are
* called, a "call" message will be sent to the remote, the remote's corresponding method will be
* executed, and the method's return value will be returned via a message.
* @param {Object} callSender Sender object that should be augmented with methods.
* @param {Object} info Information about the local and remote windows.
* @param {Array} methodKeyPaths Key paths of methods available to be called on the remote.
* @param {Promise} destructionPromise A promise resolved when destroy() is called on the penpal
* connection.
* @returns {Object} The call sender object with methods that may be called.
*/
var connectCallSender = ((callSender, info, methodKeyPaths, destroyConnection, log) => {
const {
localName,
local,
remote,
originForSending,
originForReceiving
} = info;
let destroyed = false;
log("".concat(localName, ": Connecting call sender"));
const createMethodProxy = methodName => {
return (...args) => {
log("".concat(localName, ": Sending ").concat(methodName, "() call")); // This handles the case where the iframe has been removed from the DOM
// (and therefore its window closed), the consumer has not yet
// called destroy(), and the user calls a method exposed by
// the remote. We detect the iframe has been removed and force
// a destroy() immediately so that the consumer sees the error saying
// the connection has been destroyed. We wrap this check in a try catch
// because Edge throws an "Object expected" error when accessing
// contentWindow.closed on a contentWindow from an iframe that's been
// removed from the DOM.
let iframeRemoved;
try {
if (remote.closed) {
iframeRemoved = true;
}
} catch (e) {
iframeRemoved = true;
}
if (iframeRemoved) {
destroyConnection();
}
if (destroyed) {
const error = new Error("Unable to send ".concat(methodName, "() call due ") + "to destroyed connection");
error.code = ErrorCode.ConnectionDestroyed;
throw error;
}
return new Promise((resolve, reject) => {
const id = generateId();
const handleMessageEvent = event => {
if (event.source !== remote || event.data.penpal !== MessageType.Reply || event.data.id !== id) {
return;
}
if (originForReceiving !== '*' && event.origin !== originForReceiving) {
log("".concat(localName, " received message from origin ").concat(event.origin, " which did not match expected origin ").concat(originForReceiving));
return;
}
const replyMessage = event.data;
log("".concat(localName, ": Received ").concat(methodName, "() reply"));
local.removeEventListener(NativeEventType.Message, handleMessageEvent);
let returnValue = replyMessage.returnValue;
if (replyMessage.returnValueIsError) {
returnValue = deserializeError(returnValue);
}
(replyMessage.resolution === Resolution.Fulfilled ? resolve : reject)(returnValue);
};
local.addEventListener(NativeEventType.Message, handleMessageEvent);
const callMessage = {
penpal: MessageType.Call,
id,
methodName,
args
};
remote.postMessage(callMessage, originForSending);
});
};
}; // Wrap each method in a proxy which sends it to the corresponding receiver.
const flattenedMethods = methodKeyPaths.reduce((api, name) => {
api[name] = createMethodProxy(name);
return api;
}, {}); // Unpack the structure of the provided methods object onto the CallSender, exposing
// the methods in the same shape they were provided.
Object.assign(callSender, deserializeMethods(flattenedMethods));
return () => {
destroyed = true;
};
});
/**
* Handles an ACK handshake message.
*/
var handleAckMessageFactory = ((serializedMethods, childOrigin, originForSending, destructor, log) => {
const {
destroy,
onDestroy
} = destructor;
let destroyCallReceiver;
let receiverMethodNames; // We resolve the promise with the call sender. If the child reconnects
// (for example, after refreshing or navigating to another page that
// uses Penpal, we'll update the call sender with methods that match the
// latest provided by the child.
const callSender = {};
return event => {
if (childOrigin !== '*' && event.origin !== childOrigin) {
log("Parent: Handshake - Received ACK message from origin ".concat(event.origin, " which did not match expected origin ").concat(childOrigin));
return;
}
log('Parent: Handshake - Received ACK');
const info = {
localName: 'Parent',
local: window,
remote: event.source,
originForSending: originForSending,
originForReceiving: childOrigin
}; // If the child reconnected, we need to destroy the prior call receiver
// before setting up a new one.
if (destroyCallReceiver) {
destroyCallReceiver();
}
destroyCallReceiver = connectCallReceiver(info, serializedMethods, log);
onDestroy(destroyCallReceiver); // If the child reconnected, we need to remove the methods from the
// previous call receiver off the sender.
if (receiverMethodNames) {
receiverMethodNames.forEach(receiverMethodName => {
delete callSender[receiverMethodName];
});
}
receiverMethodNames = event.data.methodNames;
const destroyCallSender = connectCallSender(callSender, info, receiverMethodNames, destroy, log);
onDestroy(destroyCallSender);
return callSender;
};
});
/**
* Handles a SYN handshake message.
*/
var handleSynMessageFactory = ((log, serializedMethods, childOrigin, originForSending) => {
return event => {
if (childOrigin !== '*' && event.origin !== childOrigin) {
log("Parent: Handshake - Received SYN message from origin ".concat(event.origin, " which did not match expected origin ").concat(childOrigin));
return;
}
log('Parent: Handshake - Received SYN, responding with SYN-ACK');
const synAckMessage = {
penpal: MessageType.SynAck,
methodNames: Object.keys(serializedMethods)
};
event.source.postMessage(synAckMessage, originForSending);
};
});
const CHECK_IFRAME_IN_DOC_INTERVAL = 60000;
/**
* Monitors for iframe removal and destroys connection if iframe
* is found to have been removed from DOM. This is to prevent memory
* leaks when the iframe is removed from the document and the consumer
* hasn't called destroy(). Without this, event listeners attached to
* the window would stick around and since the event handlers have a
* reference to the iframe in their closures, the iframe would stick
* around too.
*/
var monitorIframeRemoval = ((iframe, destructor) => {
const {
destroy,
onDestroy
} = destructor;
const checkIframeInDocIntervalId = setInterval(() => {
if (!iframe.isConnected) {
clearInterval(checkIframeInDocIntervalId);
destroy();
}
}, CHECK_IFRAME_IN_DOC_INTERVAL);
onDestroy(() => {
clearInterval(checkIframeInDocIntervalId);
});
});
/**
* Starts a timeout and calls the callback with an error
* if the timeout completes before the stop function is called.
*/
var startConnectionTimeout = ((timeout, callback) => {
let timeoutId;
if (timeout !== undefined) {
timeoutId = window.setTimeout(() => {
const error = new Error("Connection timed out after ".concat(timeout, "ms"));
error.code = ErrorCode.ConnectionTimeout;
callback(error);
}, timeout);
}
return () => {
clearTimeout(timeoutId);
};
});
var validateIframeHasSrcOrSrcDoc = (iframe => {
if (!iframe.src && !iframe.srcdoc) {
const error = new Error('Iframe must have src or srcdoc property defined.');
error.code = ErrorCode.NoIframeSrc;
throw error;
}
});
/**
* Attempts to establish communication with an iframe.
*/
var connectToChild = (options => {
let {
iframe,
methods = {},
childOrigin,
timeout,
debug = false
} = options;
const log = createLogger(debug);
const destructor = createDestructor('Parent', log);
const {
onDestroy,
destroy
} = destructor;
if (!childOrigin) {
validateIframeHasSrcOrSrcDoc(iframe);
childOrigin = getOriginFromSrc(iframe.src);
} // If event.origin is "null", the remote protocol is file: or data: and we
// must post messages with "*" as targetOrigin when sending messages.
// https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#Using_window.postMessage_in_extensions
const originForSending = childOrigin === 'null' ? '*' : childOrigin;
const serializedMethods = serializeMethods(methods);
const handleSynMessage = handleSynMessageFactory(log, serializedMethods, childOrigin, originForSending);
const handleAckMessage = handleAckMessageFactory(serializedMethods, childOrigin, originForSending, destructor, log);
const promise = new Promise((resolve, reject) => {
const stopConnectionTimeout = startConnectionTimeout(timeout, destroy);
const handleMessage = event => {
if (event.source !== iframe.contentWindow || !event.data) {
return;
}
if (event.data.penpal === MessageType.Syn) {
handleSynMessage(event);
return;
}
if (event.data.penpal === MessageType.Ack) {
const callSender = handleAckMessage(event);
if (callSender) {
stopConnectionTimeout();
resolve(callSender);
}
return;
}
};
window.addEventListener(NativeEventType.Message, handleMessage);
log('Parent: Awaiting handshake');
monitorIframeRemoval(iframe, destructor);
onDestroy(error => {
window.removeEventListener(NativeEventType.Message, handleMessage);
if (error) {
reject(error);
}
});
});
return {
promise,
destroy() {
// Don't allow consumer to pass an error into destroy.
destroy();
}
};
});
/**
* Handles a SYN-ACK handshake message.
*/
var handleSynAckMessageFactory = ((parentOrigin, serializedMethods, destructor, log) => {
const {
destroy,
onDestroy
} = destructor;
return event => {
let originQualifies = parentOrigin instanceof RegExp ? parentOrigin.test(event.origin) : parentOrigin === '*' || parentOrigin === event.origin;
if (!originQualifies) {
log("Child: Handshake - Received SYN-ACK from origin ".concat(event.origin, " which did not match expected origin ").concat(parentOrigin));
return;
}
log('Child: Handshake - Received SYN-ACK, responding with ACK'); // If event.origin is "null", the remote protocol is file: or data: and we
// must post messages with "*" as targetOrigin when sending messages.
// https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#Using_window.postMessage_in_extensions
const originForSending = event.origin === 'null' ? '*' : event.origin;
const ackMessage = {
penpal: MessageType.Ack,
methodNames: Object.keys(serializedMethods)
};
window.parent.postMessage(ackMessage, originForSending);
const info = {
localName: 'Child',
local: window,
remote: window.parent,
originForSending,
originForReceiving: event.origin
};
const destroyCallReceiver = connectCallReceiver(info, serializedMethods, log);
onDestroy(destroyCallReceiver);
const callSender = {};
const destroyCallSender = connectCallSender(callSender, info, event.data.methodNames, destroy, log);
onDestroy(destroyCallSender);
return callSender;
};
});
const areGlobalsAccessible = () => {
try {
clearTimeout();
} catch (e) {
return false;
}
return true;
};
/**
* Attempts to establish communication with the parent window.
*/
var connectToParent = ((options = {}) => {
const {
parentOrigin = '*',
methods = {},
timeout,
debug = false
} = options;
const log = createLogger(debug);
const destructor = createDestructor('Child', log);
const {
destroy,
onDestroy
} = destructor;
const serializedMethods = serializeMethods(methods);
const handleSynAckMessage = handleSynAckMessageFactory(parentOrigin, serializedMethods, destructor, log);
const sendSynMessage = () => {
log('Child: Handshake - Sending SYN');
const synMessage = {
penpal: MessageType.Syn
};
const parentOriginForSyn = parentOrigin instanceof RegExp ? '*' : parentOrigin;
window.parent.postMessage(synMessage, parentOriginForSyn);
};
const promise = new Promise((resolve, reject) => {
const stopConnectionTimeout = startConnectionTimeout(timeout, destroy);
const handleMessage = event => {
// Under niche scenarios, we get into this function after
// the iframe has been removed from the DOM. In Edge, this
// results in "Object expected" errors being thrown when we
// try to access properties on window (global properties).
// For this reason, we try to access a global up front (clearTimeout)
// and if it fails we can assume the iframe has been removed
// and we ignore the message event.
if (!areGlobalsAccessible()) {
return;
}
if (event.source !== parent || !event.data) {
return;
}
if (event.data.penpal === MessageType.SynAck) {
const callSender = handleSynAckMessage(event);
if (callSender) {
window.removeEventListener(NativeEventType.Message, handleMessage);
stopConnectionTimeout();
resolve(callSender);
}
}
};
window.addEventListener(NativeEventType.Message, handleMessage);
sendSynMessage();
onDestroy(error => {
window.removeEventListener(NativeEventType.Message, handleMessage);
if (error) {
reject(error);
}
});
});
return {
promise,
destroy() {
// Don't allow consumer to pass an error into destroy.
destroy();
}
};
});
var indexForBundle = {
connectToChild,
connectToParent,
ErrorCode
};
return indexForBundle;
}());