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;
}());