UNPKG

penpal

Version:

A promise-based library for communicating with iframes via postMessage.

700 lines (588 loc) 22 kB
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["NotInIframe"] = "NotInIframe"; 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 = (() => { const callbacks = []; let destroyed = false; return { destroy(error) { destroyed = true; 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, methods, 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 (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(methods[methodName].apply(methods, 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); /** * 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} methodNames Names 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, methodNames, 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 (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); }); }; }; methodNames.reduce((api, methodName) => { api[methodName] = createMethodProxy(methodName); return api; }, callSender); return () => { destroyed = true; }; }); /** * Handles an ACK handshake message. */ var handleAckMessageFactory = ((methods, 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 (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, methods, 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, methods, childOrigin, originForSending) => { return event => { if (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(methods) }; 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(); 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 handleSynMessage = handleSynMessageFactory(log, methods, childOrigin, originForSending); const handleAckMessage = handleAckMessageFactory(methods, 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) { error = new Error('Connection destroyed'); error.code = ErrorCode.ConnectionDestroyed; } reject(error); }); }); return { promise, destroy() { // Don't allow consumer to pass an error into destroy. destroy(); } }; }); var validateWindowIsIframe = (() => { if (window === window.top) { const error = new Error('connectToParent() must be called within an iframe'); error.code = ErrorCode.NotInIframe; throw error; } }); /** * Handles a SYN-ACK handshake message. */ var handleSynAckMessageFactory = ((parentOrigin, methods, 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(methods) }; window.parent.postMessage(ackMessage, originForSending); const info = { localName: 'Child', local: window, remote: window.parent, originForSending, originForReceiving: event.origin }; const destroyCallReceiver = connectCallReceiver(info, methods, 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(); const { destroy, onDestroy } = destructor; validateWindowIsIframe(); const handleSynAckMessage = handleSynAckMessageFactory(parentOrigin, methods, 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) { error = new Error('Connection destroyed'); error.code = ErrorCode.ConnectionDestroyed; } reject(error); }); }); return { promise, destroy() { // Don't allow consumer to pass an error into destroy. destroy(); } }; }); var indexForBundle = { connectToChild, connectToParent, ErrorCode }; return indexForBundle; }());