Comparing version 3.1.2 to 4.0.0
@@ -1,438 +0,417 @@ | ||
// Derived from https://github.com/umdjs/umd/blob/master/templates/amdWebGlobal.js | ||
(function (root, factory) { | ||
var exports = {}; | ||
factory(exports); | ||
var Penpal = (function () { | ||
'use strict'; | ||
if (typeof define === 'function' && define.amd) { | ||
// AMD | ||
define('Penpal', exports.default); | ||
} else { | ||
// Browser global | ||
root.Penpal = exports.default; | ||
} | ||
}(this, function (exports) { | ||
const HANDSHAKE = 'handshake'; | ||
const HANDSHAKE_REPLY = 'handshake-reply'; | ||
const CALL = 'call'; | ||
const REPLY = 'reply'; | ||
const FULFILLED = 'fulfilled'; | ||
const REJECTED = 'rejected'; | ||
const MESSAGE = 'message'; | ||
const DATA_CLONE_ERROR = 'DataCloneError'; | ||
"use strict"; | ||
const ERR_CONNECTION_DESTROYED = 'ConnectionDestroyed'; | ||
const ERR_CONNECTION_TIMEOUT = 'ConnectionTimeout'; | ||
const ERR_NOT_IN_IFRAME = 'NotInIframe'; | ||
const ERR_NO_IFRAME_SRC = 'NoIframeSrc'; | ||
Object.defineProperty(exports, "__esModule", { | ||
value: true | ||
}); | ||
exports.default = exports.ERR_IFRAME_ALREADY_ATTACHED_TO_DOM = exports.ERR_NOT_IN_IFRAME = exports.ERR_CONNECTION_TIMEOUT = exports.ERR_CONNECTION_DESTROYED = void 0; | ||
var HANDSHAKE = 'handshake'; | ||
var HANDSHAKE_REPLY = 'handshake-reply'; | ||
var CALL = 'call'; | ||
var REPLY = 'reply'; | ||
var FULFILLED = 'fulfilled'; | ||
var REJECTED = 'rejected'; | ||
var MESSAGE = 'message'; | ||
var DATA_CLONE_ERROR = 'DataCloneError'; | ||
var ERR_CONNECTION_DESTROYED = 'ConnectionDestroyed'; | ||
exports.ERR_CONNECTION_DESTROYED = ERR_CONNECTION_DESTROYED; | ||
var ERR_CONNECTION_TIMEOUT = 'ConnectionTimeout'; | ||
exports.ERR_CONNECTION_TIMEOUT = ERR_CONNECTION_TIMEOUT; | ||
var ERR_NOT_IN_IFRAME = 'NotInIframe'; | ||
exports.ERR_NOT_IN_IFRAME = ERR_NOT_IN_IFRAME; | ||
var ERR_IFRAME_ALREADY_ATTACHED_TO_DOM = 'IframeAlreadyAttachedToDom'; | ||
exports.ERR_IFRAME_ALREADY_ATTACHED_TO_DOM = ERR_IFRAME_ALREADY_ATTACHED_TO_DOM; | ||
var CHECK_IFRAME_IN_DOC_INTERVAL = 60000; | ||
var DEFAULT_PORTS = { | ||
'http:': '80', | ||
'https:': '443' | ||
}; | ||
var URL_REGEX = /^(https?:|file:)?\/\/([^/:]+)?(:(\d+))?/; | ||
var Penpal = { | ||
ERR_CONNECTION_DESTROYED: ERR_CONNECTION_DESTROYED, | ||
ERR_CONNECTION_TIMEOUT: ERR_CONNECTION_TIMEOUT, | ||
ERR_NOT_IN_IFRAME: ERR_NOT_IN_IFRAME, | ||
ERR_IFRAME_ALREADY_ATTACHED_TO_DOM: ERR_IFRAME_ALREADY_ATTACHED_TO_DOM, | ||
var createDestructor = (() => { | ||
const callbacks = []; | ||
let destroyed = false; | ||
return { | ||
destroy() { | ||
destroyed = true; | ||
callbacks.forEach(callback => { | ||
callback(); | ||
}); | ||
}, | ||
/** | ||
* Promise implementation. | ||
* @type {Constructor} | ||
*/ | ||
Promise: function () { | ||
try { | ||
return window ? window.Promise : null; | ||
} catch (e) { | ||
return null; | ||
} | ||
}(), | ||
onDestroy(callback) { | ||
destroyed ? callback() : callbacks.push(callback); | ||
} | ||
}; | ||
}); | ||
const DEFAULT_PORTS = { | ||
'http:': '80', | ||
'https:': '443' | ||
}; | ||
const URL_REGEX = /^(https?:|file:)?\/\/([^/:]+)?(:(\d+))?/; | ||
const opaqueOriginSchemes = ['file:', 'data:']; | ||
/** | ||
* Whether debug messages should be logged. | ||
* @type {boolean} | ||
* Converts a src value into an origin. | ||
* @param {string} src | ||
* @return {string} The URL's origin | ||
*/ | ||
debug: false | ||
}; | ||
/** | ||
* @return {number} A unique ID (not universally unique) | ||
*/ | ||
var generateId = function () { | ||
var id = 0; | ||
return function () { | ||
return ++id; | ||
}; | ||
}(); | ||
/** | ||
* Logs a message. | ||
* @param {...*} args One or more items to log | ||
*/ | ||
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. | ||
var log = function log() { | ||
if (Penpal.debug) { | ||
var _console; | ||
const location = document.location; | ||
const regexResult = URL_REGEX.exec(src); | ||
let protocol; | ||
let hostname; | ||
let port; | ||
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { | ||
args[_key] = arguments[_key]; | ||
} | ||
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 protocol is file, the origin is "null" | ||
// The origin of a document with file protocol is an opaque origin | ||
// and its serialization "null" [1] | ||
// [1] https://html.spec.whatwg.org/multipage/origin.html#origin | ||
// if (protocol === 'file:') { | ||
// return 'null'; | ||
// } | ||
// 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. | ||
(_console = console).log.apply(_console, ['[Penpal]'].concat(args)); // eslint-disable-line no-console | ||
} | ||
}; | ||
/** | ||
* Converts a URL into an origin. | ||
* @param {string} url | ||
* @return {string} The URL's origin | ||
*/ | ||
const portSuffix = port && port !== DEFAULT_PORTS[protocol] ? `:${port}` : ''; | ||
return `${protocol}//${hostname}${portSuffix}`; | ||
}); | ||
var createLogger = (debug => { | ||
return function () { | ||
if (debug) { | ||
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { | ||
args[_key] = arguments[_key]; | ||
} | ||
var getOriginFromUrl = function getOriginFromUrl(url) { | ||
var location = document.location; | ||
var regexResult = URL_REGEX.exec(url); | ||
var protocol; | ||
var hostname; | ||
var port; | ||
console.log('[Penpal]', ...args); // eslint-disable-line no-console | ||
} | ||
}; | ||
}); | ||
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 protocol is file, the origin is "null" | ||
// The origin of a document with file protocol is an opaque origin | ||
// and its serialization "null" [1] | ||
// [1] https://html.spec.whatwg.org/multipage/origin.html#origin | ||
if (protocol === "file:") { | ||
return "null"; | ||
} // 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. | ||
var portSuffix = port && port !== DEFAULT_PORTS[protocol] ? ":".concat(port) : ''; | ||
return "".concat(protocol, "//").concat(hostname).concat(portSuffix); | ||
}; | ||
/** | ||
* A simplified promise class only used internally for when destroy() is called. This is | ||
* used to destroy connections synchronously while promises typically resolve asynchronously. | ||
* | ||
* @param {Function} executor | ||
* @returns {Object} | ||
* @constructor | ||
*/ | ||
var DestructionPromise = function DestructionPromise(executor) { | ||
var handlers = []; | ||
executor(function () { | ||
handlers.forEach(function (handler) { | ||
handler(); | ||
}); | ||
}); | ||
return { | ||
then: function then(handler) { | ||
handlers.push(handler); | ||
} | ||
/** | ||
* Converts an error object into a plain object. | ||
* @param {Error} Error object. | ||
* @returns {Object} | ||
*/ | ||
const serializeError = (_ref) => { | ||
let name = _ref.name, | ||
message = _ref.message, | ||
stack = _ref.stack; | ||
return { | ||
name, | ||
message, | ||
stack | ||
}; | ||
}; | ||
}; | ||
/** | ||
* Converts an error object into a plain object. | ||
* @param {Error} Error object. | ||
* @returns {Object} | ||
*/ | ||
/** | ||
* Converts a plain object into an error object. | ||
* @param {Object} Object with error properties. | ||
* @returns {Error} | ||
*/ | ||
var serializeError = function serializeError(_ref) { | ||
var name = _ref.name, | ||
message = _ref.message, | ||
stack = _ref.stack; | ||
return { | ||
name: name, | ||
message: message, | ||
stack: stack | ||
const deserializeError = obj => { | ||
const deserializedError = new Error(); | ||
Object.keys(obj).forEach(key => deserializedError[key] = obj[key]); | ||
return deserializedError; | ||
}; | ||
}; | ||
/** | ||
* Converts a plain object into an error object. | ||
* @param {Object} Object with error properties. | ||
* @returns {Error} | ||
*/ | ||
/** | ||
* Listens for "call" messages coming from the remote, executes the corresponding method, and | ||
* responds with the return value. | ||
* @param {Object} info Information about the local and remote windows. | ||
* @param {Object} methods The keys are the names of the methods that can be called by the remote | ||
* while the values are the method functions. | ||
* @param {Promise} destructionPromise A promise resolved when destroy() is called on the penpal | ||
* connection. | ||
* @returns {Function} A function that may be called to disconnect the receiver. | ||
*/ | ||
var deserializeError = function deserializeError(obj) { | ||
var deserializedError = new Error(); | ||
Object.keys(obj).forEach(function (key) { | ||
return deserializedError[key] = obj[key]; | ||
}); | ||
return deserializedError; | ||
}; | ||
/** | ||
* 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 connectCallReceiver = ((info, methods, log) => { | ||
const localName = info.localName, | ||
local = info.local, | ||
remote = info.remote, | ||
originForSending = info.originForSending, | ||
originForReceiving = info.originForReceiving; | ||
let destroyed = false; | ||
log(`${localName}: Connecting call receiver`); | ||
const handleMessageEvent = event => { | ||
if (event.source !== remote || event.data.penpal !== CALL) { | ||
return; | ||
} | ||
var connectCallSender = function connectCallSender(callSender, info, methodNames, destroy, destructionPromise) { | ||
var localName = info.localName, | ||
local = info.local, | ||
remote = info.remote, | ||
remoteOrigin = info.remoteOrigin; | ||
var destroyed = false; | ||
log("".concat(localName, ": Connecting call sender")); | ||
var createMethodProxy = function createMethodProxy(methodName) { | ||
return function () { | ||
for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { | ||
args[_key2] = arguments[_key2]; | ||
if (event.origin !== originForReceiving) { | ||
log(`${localName} received message from origin ${event.origin} which did not match expected origin ${originForReceiving}`); | ||
return; | ||
} | ||
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. | ||
const _event$data = event.data, | ||
methodName = _event$data.methodName, | ||
args = _event$data.args, | ||
id = _event$data.id; | ||
log(`${localName}: Received ${methodName}() call`); | ||
if (remote.closed) { | ||
destroy(); | ||
} | ||
const createPromiseHandler = resolution => { | ||
return returnValue => { | ||
log(`${localName}: Sending ${methodName}() reply`); | ||
if (destroyed) { | ||
var error = new Error("Unable to send ".concat(methodName, "() call due ") + "to destroyed connection"); | ||
error.code = ERR_CONNECTION_DESTROYED; | ||
throw error; | ||
} | ||
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(`${localName}: Unable to send ${methodName}() reply due to destroyed connection`); | ||
return; | ||
} | ||
return new Penpal.Promise(function (resolve, reject) { | ||
var id = generateId(); | ||
const message = { | ||
penpal: REPLY, | ||
id, | ||
resolution, | ||
returnValue | ||
}; | ||
var handleMessageEvent = function handleMessageEvent(event) { | ||
if (event.source === remote && event.origin === remoteOrigin && event.data.penpal === REPLY && event.data.id === id) { | ||
log("".concat(localName, ": Received ").concat(methodName, "() reply")); | ||
local.removeEventListener(MESSAGE, handleMessageEvent); | ||
var returnValue = event.data.returnValue; | ||
if (resolution === REJECTED && returnValue instanceof Error) { | ||
message.returnValue = serializeError(returnValue); | ||
message.returnValueIsError = true; | ||
} | ||
if (event.data.returnValueIsError) { | ||
returnValue = deserializeError(returnValue); | ||
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 === DATA_CLONE_ERROR) { | ||
remote.postMessage({ | ||
penpal: REPLY, | ||
id, | ||
resolution: REJECTED, | ||
returnValue: serializeError(err), | ||
returnValueIsError: true | ||
}, originForSending); | ||
} | ||
(event.data.resolution === FULFILLED ? resolve : reject)(returnValue); | ||
throw err; | ||
} | ||
}; | ||
}; | ||
local.addEventListener(MESSAGE, handleMessageEvent); | ||
remote.postMessage({ | ||
penpal: CALL, | ||
id: id, | ||
methodName: methodName, | ||
args: args | ||
}, remoteOrigin); | ||
}); | ||
new Promise(resolve => resolve(methods[methodName].apply(methods, args))).then(createPromiseHandler(FULFILLED), createPromiseHandler(REJECTED)); | ||
}; | ||
}; | ||
destructionPromise.then(function () { | ||
destroyed = true; | ||
local.addEventListener(MESSAGE, handleMessageEvent); | ||
return () => { | ||
destroyed = true; | ||
local.removeEventListener(MESSAGE, handleMessageEvent); | ||
}; | ||
}); | ||
methodNames.reduce(function (api, methodName) { | ||
api[methodName] = createMethodProxy(methodName); | ||
return api; | ||
}, callSender); | ||
}; | ||
/** | ||
* Listens for "call" messages coming from the remote, executes the corresponding method, and | ||
* responds with the return value. | ||
* @param {Object} info Information about the local and remote windows. | ||
* @param {Object} methods The keys are the names of the methods that can be called by the remote | ||
* while the values are the method functions. | ||
* @param {Promise} destructionPromise A promise resolved when destroy() is called on the penpal | ||
* connection. | ||
* @returns {Function} A function that may be called to disconnect the receiver. | ||
*/ | ||
let id = 0; | ||
/** | ||
* @return {number} A unique ID (not universally unique) | ||
*/ | ||
var connectCallReceiver = function connectCallReceiver(info, methods, destructionPromise) { | ||
var localName = info.localName, | ||
local = info.local, | ||
remote = info.remote, | ||
remoteOrigin = info.remoteOrigin; | ||
var destroyed = false; | ||
log("".concat(localName, ": Connecting call receiver")); | ||
var generateId = (() => ++id); | ||
var handleMessageEvent = function handleMessageEvent(event) { | ||
if (event.source === remote && event.origin === remoteOrigin && event.data.penpal === CALL) { | ||
var _event$data = event.data, | ||
methodName = _event$data.methodName, | ||
args = _event$data.args, | ||
id = _event$data.id; | ||
log("".concat(localName, ": Received ").concat(methodName, "() call")); | ||
/** | ||
* 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. | ||
*/ | ||
if (methodName in methods) { | ||
var createPromiseHandler = function createPromiseHandler(resolution) { | ||
return function (returnValue) { | ||
log("".concat(localName, ": Sending ").concat(methodName, "() reply")); | ||
var connectCallSender = ((callSender, info, methodNames, destroyConnection, log) => { | ||
const localName = info.localName, | ||
local = info.local, | ||
remote = info.remote, | ||
originForSending = info.originForSending, | ||
originForReceiving = info.originForReceiving; | ||
let destroyed = false; | ||
log(`${localName}: Connecting call sender`); | ||
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")); | ||
const createMethodProxy = methodName => { | ||
return function () { | ||
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { | ||
args[_key] = arguments[_key]; | ||
} | ||
log(`${localName}: Sending ${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 ${methodName}() call due ` + `to destroyed connection`); | ||
error.code = ERR_CONNECTION_DESTROYED; | ||
throw error; | ||
} | ||
return new Promise((resolve, reject) => { | ||
const id = generateId(); | ||
const handleMessageEvent = event => { | ||
if (event.source !== remote || event.data.penpal !== REPLY || event.data.id !== id) { | ||
return; | ||
} | ||
var message = { | ||
penpal: REPLY, | ||
id: id, | ||
resolution: resolution, | ||
returnValue: returnValue | ||
}; | ||
if (resolution === REJECTED && returnValue instanceof Error) { | ||
message.returnValue = serializeError(returnValue); | ||
message.returnValueIsError = true; | ||
if (event.origin !== originForReceiving) { | ||
log(`${localName} received message from origin ${event.origin} which did not match expected origin ${originForReceiving}`); | ||
return; | ||
} | ||
try { | ||
remote.postMessage(message, remoteOrigin); | ||
} 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 === DATA_CLONE_ERROR) { | ||
remote.postMessage({ | ||
penpal: REPLY, | ||
id: id, | ||
resolution: REJECTED, | ||
returnValue: serializeError(err), | ||
returnValueIsError: true | ||
}, remoteOrigin); | ||
} | ||
log(`${localName}: Received ${methodName}() reply`); | ||
local.removeEventListener(MESSAGE, handleMessageEvent); | ||
let returnValue = event.data.returnValue; | ||
throw err; | ||
if (event.data.returnValueIsError) { | ||
returnValue = deserializeError(returnValue); | ||
} | ||
(event.data.resolution === FULFILLED ? resolve : reject)(returnValue); | ||
}; | ||
}; | ||
new Penpal.Promise(function (resolve) { | ||
return resolve(methods[methodName].apply(methods, args)); | ||
}).then(createPromiseHandler(FULFILLED), createPromiseHandler(REJECTED)); | ||
} | ||
} | ||
}; | ||
local.addEventListener(MESSAGE, handleMessageEvent); | ||
remote.postMessage({ | ||
penpal: CALL, | ||
id, | ||
methodName, | ||
args | ||
}, originForSending); | ||
}); | ||
}; | ||
}; | ||
local.addEventListener(MESSAGE, handleMessageEvent); | ||
destructionPromise.then(function () { | ||
destroyed = true; | ||
local.removeEventListener(MESSAGE, handleMessageEvent); | ||
methodNames.reduce((api, methodName) => { | ||
api[methodName] = createMethodProxy(methodName); | ||
return api; | ||
}, callSender); | ||
return () => { | ||
destroyed = true; | ||
}; | ||
}); | ||
}; | ||
/** | ||
* @typedef {Object} Child | ||
* @property {Promise} promise A promise which will be resolved once a connection has | ||
* been established. | ||
* @property {HTMLIframeElement} iframe The created iframe element. | ||
* @property {Function} destroy A method that, when called, will remove the iframe element from | ||
* the DOM and clean up event listeners. | ||
*/ | ||
/** | ||
* Creates an iframe, loads a webpage into the URL, and attempts to establish communication with | ||
* the iframe. | ||
* @param {Object} options | ||
* @param {string} options.url The URL of the webpage that should be loaded into the created iframe. | ||
* @param {HTMLElement} [options.appendTo] The container to which the iframe should be appended. | ||
* @param {Object} [options.methods={}] Methods that may be called by the iframe. | ||
* @param {Number} [options.timeout] The amount of time, in milliseconds, Penpal should wait | ||
* for the child to respond before rejecting the connection promise. | ||
* @return {Child} | ||
*/ | ||
const CHECK_IFRAME_IN_DOC_INTERVAL = 60000; | ||
/** | ||
* @typedef {Object} Child | ||
* @property {Promise} promise A promise which will be resolved once a connection has | ||
* been established. | ||
* @property {Function} destroy A method that, when called, will remove the iframe element from | ||
* the DOM and clean up event listeners. | ||
*/ | ||
/** | ||
* Creates an iframe, loads a webpage into the URL, and attempts to establish communication with | ||
* the iframe. | ||
* @param {Object} options | ||
* @param {HTMLIframeElement} options.iframe The iframe to connect to. | ||
* @param {Object} [options.methods={}] Methods that may be called by the iframe. | ||
* @param {Number} [options.timeout] The amount of time, in milliseconds, Penpal should wait | ||
* for the child to respond before rejecting the connection promise. | ||
* @return {Child} | ||
*/ | ||
Penpal.connectToChild = function (_ref2) { | ||
var url = _ref2.url, | ||
appendTo = _ref2.appendTo, | ||
iframe = _ref2.iframe, | ||
_ref2$methods = _ref2.methods, | ||
methods = _ref2$methods === void 0 ? {} : _ref2$methods, | ||
timeout = _ref2.timeout; | ||
var connectToChild = ((_ref) => { | ||
let iframe = _ref.iframe, | ||
_ref$methods = _ref.methods, | ||
methods = _ref$methods === void 0 ? {} : _ref$methods, | ||
timeout = _ref.timeout, | ||
debug = _ref.debug; | ||
const log = createLogger(debug); | ||
const parent = window; | ||
if (iframe && iframe.parentNode) { | ||
var error = new Error('connectToChild() must not be called with an iframe already attached to DOM'); | ||
error.code = ERR_IFRAME_ALREADY_ATTACHED_TO_DOM; | ||
throw error; | ||
} | ||
const _createDestructor = createDestructor(), | ||
destroy = _createDestructor.destroy, | ||
onDestroy = _createDestructor.onDestroy; | ||
var destroy; | ||
var connectionDestructionPromise = new DestructionPromise(function (resolveConnectionDestructionPromise) { | ||
destroy = resolveConnectionDestructionPromise; | ||
}); | ||
var parent = window; | ||
iframe = iframe || document.createElement('iframe'); | ||
iframe.src = url; | ||
var childOrigin = getOriginFromUrl(url); | ||
var promise = new Penpal.Promise(function (resolveConnectionPromise, reject) { | ||
var connectionTimeoutId; | ||
if (!iframe.src && !iframe.srcdoc) { | ||
const error = new Error('Iframe must have src or srcdoc property defined.'); | ||
error.code = ERR_NO_IFRAME_SRC; | ||
throw error; | ||
} | ||
if (timeout !== undefined) { | ||
connectionTimeoutId = setTimeout(function () { | ||
var error = new Error("Connection to child timed out after ".concat(timeout, "ms")); | ||
error.code = ERR_CONNECTION_TIMEOUT; | ||
reject(error); | ||
destroy(); | ||
}, timeout); | ||
} // 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 childOrigin = getOriginFromSrc(iframe.src); // If event.origin is "null", the remote protocol is | ||
// file:, data:, and we must post messages with "*" as targetOrigin | ||
// when sending and allow | ||
// [1] https://developer.mozilla.org/fr/docs/Web/API/Window/postMessage#Utiliser_window.postMessage_dans_les_extensions | ||
const originForSending = childOrigin === 'null' ? '*' : childOrigin; | ||
const promise = new Promise((resolveConnectionPromise, reject) => { | ||
let connectionTimeoutId; | ||
var callSender = {}; | ||
var receiverMethodNames; | ||
var destroyCallReceiver; | ||
if (timeout !== undefined) { | ||
connectionTimeoutId = setTimeout(() => { | ||
const error = new Error(`Connection to child timed out after ${timeout}ms`); | ||
error.code = ERR_CONNECTION_TIMEOUT; | ||
reject(error); | ||
destroy(); | ||
}, timeout); | ||
} // 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. | ||
var handleMessage = function handleMessage(event) { | ||
var child = iframe.contentWindow; | ||
if (event.source === child && event.origin === childOrigin && event.data.penpal === HANDSHAKE) { | ||
log('Parent: Received handshake, sending reply'); // If event.origin is "null", the remote protocol is file: | ||
// and we must post messages with "*" as targetOrigin [1] | ||
// [1] https://developer.mozilla.org/fr/docs/Web/API/Window/postMessage#Utiliser_window.postMessage_dans_les_extensions | ||
const callSender = {}; | ||
let receiverMethodNames; | ||
let destroyCallReceiver; | ||
var remoteOrigin = event.origin === "null" ? "*" : event.origin; | ||
const handleMessage = event => { | ||
const child = iframe.contentWindow; | ||
if (event.source !== child || event.data.penpal !== HANDSHAKE) { | ||
return; | ||
} | ||
if (event.origin !== childOrigin) { | ||
log(`Parent received handshake from origin ${event.origin} which did not match expected origin ${childOrigin}`); | ||
return; | ||
} | ||
log('Parent: Received handshake, sending reply'); | ||
event.source.postMessage({ | ||
penpal: HANDSHAKE_REPLY, | ||
methodNames: Object.keys(methods) | ||
}, remoteOrigin); | ||
var info = { | ||
}, originForSending); | ||
const info = { | ||
localName: 'Parent', | ||
local: parent, | ||
remote: child, | ||
remoteOrigin: remoteOrigin | ||
originForSending: originForSending, | ||
originForReceiving: childOrigin | ||
}; // If the child reconnected, we need to destroy the previous call receiver before setting | ||
@@ -443,15 +422,10 @@ // up a new one. | ||
destroyCallReceiver(); | ||
} // When this promise is resolved, it will destroy the call receiver (stop listening to | ||
// method calls from the child) and delete its methods off the call sender. | ||
} | ||
var callReceiverDestructionPromise = new DestructionPromise(function (resolveCallReceiverDestructionPromise) { | ||
connectionDestructionPromise.then(resolveCallReceiverDestructionPromise); | ||
destroyCallReceiver = resolveCallReceiverDestructionPromise; | ||
}); | ||
connectCallReceiver(info, methods, callReceiverDestructionPromise); // If the child reconnected, we need to remove the methods from the previous call receiver | ||
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(function (receiverMethodName) { | ||
receiverMethodNames.forEach(receiverMethodName => { | ||
delete callSender[receiverMethodName]; | ||
@@ -462,130 +436,158 @@ }); | ||
receiverMethodNames = event.data.methodNames; | ||
connectCallSender(callSender, info, receiverMethodNames, destroy, connectionDestructionPromise); | ||
const destroyCallSender = connectCallSender(callSender, info, receiverMethodNames, destroy, log); | ||
onDestroy(destroyCallSender); | ||
clearTimeout(connectionTimeoutId); | ||
resolveConnectionPromise(callSender); | ||
} | ||
}; | ||
}; | ||
parent.addEventListener(MESSAGE, handleMessage); | ||
log('Parent: Loading iframe'); | ||
(appendTo || document.body).appendChild(iframe); // 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. | ||
parent.addEventListener(MESSAGE, handleMessage); | ||
log('Parent: Awaiting handshake'); // 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 checkIframeInDocIntervalId = setInterval(function () { | ||
if (!document.contains(iframe)) { | ||
var checkIframeInDocIntervalId = setInterval(() => { | ||
if (!document.contains(iframe)) { | ||
clearInterval(checkIframeInDocIntervalId); | ||
destroy(); | ||
} | ||
}, CHECK_IFRAME_IN_DOC_INTERVAL); | ||
onDestroy(() => { | ||
parent.removeEventListener(MESSAGE, handleMessage); | ||
clearInterval(checkIframeInDocIntervalId); | ||
destroy(); | ||
} | ||
}, CHECK_IFRAME_IN_DOC_INTERVAL); | ||
connectionDestructionPromise.then(function () { | ||
if (iframe.parentNode) { | ||
iframe.parentNode.removeChild(iframe); | ||
} | ||
parent.removeEventListener(MESSAGE, handleMessage); | ||
clearInterval(checkIframeInDocIntervalId); | ||
var error = new Error('Connection destroyed'); | ||
error.code = ERR_CONNECTION_DESTROYED; | ||
reject(error); | ||
const error = new Error('Connection destroyed'); | ||
error.code = ERR_CONNECTION_DESTROYED; | ||
reject(error); | ||
}); | ||
}); | ||
return { | ||
promise, | ||
destroy | ||
}; | ||
}); | ||
return { | ||
promise: promise, | ||
iframe: iframe, | ||
destroy: destroy | ||
}; | ||
}; | ||
/** | ||
* @typedef {Object} Parent | ||
* @property {Promise} promise A promise which will be resolved once a connection has | ||
* been established. | ||
*/ | ||
/** | ||
* Attempts to establish communication with the parent window. | ||
* @param {Object} options | ||
* @param {string} [options.parentOrigin=*] Valid parent origin used to restrict communication. | ||
* @param {Object} [options.methods={}] Methods that may be called by the parent window. | ||
* @param {Number} [options.timeout] The amount of time, in milliseconds, Penpal should wait | ||
* for the parent to respond before rejecting the connection promise. | ||
* @return {Parent} | ||
*/ | ||
/** | ||
* @typedef {Object} Parent | ||
* @property {Promise} promise A promise which will be resolved once a connection has | ||
* been established. | ||
*/ | ||
/** | ||
* Attempts to establish communication with the parent window. | ||
* @param {Object} options | ||
* @param {string} [options.parentOrigin=*] Valid parent origin used to restrict communication. | ||
* @param {Object} [options.methods={}] Methods that may be called by the parent window. | ||
* @param {Number} [options.timeout] The amount of time, in milliseconds, Penpal should wait | ||
* for the parent to respond before rejecting the connection promise. | ||
* @return {Parent} | ||
*/ | ||
Penpal.connectToParent = function () { | ||
var _ref3 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, | ||
_ref3$parentOrigin = _ref3.parentOrigin, | ||
parentOrigin = _ref3$parentOrigin === void 0 ? '*' : _ref3$parentOrigin, | ||
_ref3$methods = _ref3.methods, | ||
methods = _ref3$methods === void 0 ? {} : _ref3$methods, | ||
timeout = _ref3.timeout; | ||
var connectToParent = (function () { | ||
let _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, | ||
_ref$parentOrigin = _ref.parentOrigin, | ||
parentOrigin = _ref$parentOrigin === void 0 ? '*' : _ref$parentOrigin, | ||
_ref$methods = _ref.methods, | ||
methods = _ref$methods === void 0 ? {} : _ref$methods, | ||
timeout = _ref.timeout, | ||
debug = _ref.debug; | ||
if (window === window.top) { | ||
var error = new Error('connectToParent() must be called within an iframe'); | ||
error.code = ERR_NOT_IN_IFRAME; | ||
throw error; | ||
} | ||
const log = createLogger(debug); | ||
var destroy; | ||
var connectionDestructionPromise = new DestructionPromise(function (resolveConnectionDestructionPromise) { | ||
destroy = resolveConnectionDestructionPromise; | ||
}); | ||
var child = window; | ||
var parent = child.parent; | ||
var promise = new Penpal.Promise(function (resolveConnectionPromise, reject) { | ||
var connectionTimeoutId; | ||
if (timeout !== undefined) { | ||
connectionTimeoutId = setTimeout(function () { | ||
var error = new Error("Connection to parent timed out after ".concat(timeout, "ms")); | ||
error.code = ERR_CONNECTION_TIMEOUT; | ||
reject(error); | ||
destroy(); | ||
}, timeout); | ||
if (window === window.top) { | ||
const error = new Error('connectToParent() must be called within an iframe'); | ||
error.code = ERR_NOT_IN_IFRAME; | ||
throw error; | ||
} | ||
var handleMessageEvent = function handleMessageEvent(event) { | ||
if ((parentOrigin === '*' || parentOrigin === event.origin) && event.source === parent && event.data.penpal === HANDSHAKE_REPLY) { | ||
const _createDestructor = createDestructor(), | ||
destroy = _createDestructor.destroy, | ||
onDestroy = _createDestructor.onDestroy; | ||
const child = window; | ||
const parent = child.parent; | ||
const promise = new Promise((resolveConnectionPromise, reject) => { | ||
let connectionTimeoutId; | ||
if (timeout !== undefined) { | ||
connectionTimeoutId = setTimeout(() => { | ||
const error = new Error(`Connection to parent timed out after ${timeout}ms`); | ||
error.code = ERR_CONNECTION_TIMEOUT; | ||
reject(error); | ||
destroy(); | ||
}, timeout); | ||
} | ||
const handleMessageEvent = 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. | ||
try { | ||
clearTimeout(); | ||
} catch (e) { | ||
return; | ||
} | ||
if (event.source !== parent || event.data.penpal !== HANDSHAKE_REPLY) { | ||
return; | ||
} | ||
if (parentOrigin !== '*' && parentOrigin !== event.origin) { | ||
log(`Child received handshake reply from origin ${event.origin} which did not match expected origin ${parentOrigin}`); | ||
return; | ||
} | ||
log('Child: Received handshake reply'); | ||
child.removeEventListener(MESSAGE, handleMessageEvent); | ||
var info = { | ||
const info = { | ||
localName: 'Child', | ||
local: child, | ||
remote: parent, | ||
remoteOrigin: event.origin | ||
originForSending: event.origin === 'null' ? '*' : event.origin, | ||
originForReceiving: event.origin | ||
}; | ||
var callSender = {}; | ||
connectCallReceiver(info, methods, connectionDestructionPromise); | ||
connectCallSender(callSender, info, event.data.methodNames, destroy, connectionDestructionPromise); | ||
const callSender = {}; | ||
const destroyCallReceiver = connectCallReceiver(info, methods, log); | ||
onDestroy(destroyCallReceiver); | ||
const destroyCallSender = connectCallSender(callSender, info, event.data.methodNames, destroy, log); | ||
onDestroy(destroyCallSender); | ||
clearTimeout(connectionTimeoutId); | ||
resolveConnectionPromise(callSender); | ||
} | ||
}; | ||
}; | ||
child.addEventListener(MESSAGE, handleMessageEvent); | ||
connectionDestructionPromise.then(function () { | ||
child.removeEventListener(MESSAGE, handleMessageEvent); | ||
var error = new Error('Connection destroyed'); | ||
error.code = ERR_CONNECTION_DESTROYED; | ||
reject(error); | ||
child.addEventListener(MESSAGE, handleMessageEvent); | ||
onDestroy(() => { | ||
child.removeEventListener(MESSAGE, handleMessageEvent); | ||
const error = new Error('Connection destroyed'); | ||
error.code = ERR_CONNECTION_DESTROYED; | ||
reject(error); | ||
}); | ||
log('Child: Sending handshake'); | ||
parent.postMessage({ | ||
penpal: HANDSHAKE, | ||
methodNames: Object.keys(methods) | ||
}, parentOrigin); | ||
}); | ||
log('Child: Sending handshake'); | ||
parent.postMessage({ | ||
penpal: HANDSHAKE, | ||
methodNames: Object.keys(methods) | ||
}, parentOrigin); | ||
return { | ||
promise, | ||
destroy | ||
}; | ||
}); | ||
return { | ||
promise: promise, | ||
destroy: destroy | ||
var index = { | ||
ERR_CONNECTION_DESTROYED, | ||
ERR_CONNECTION_TIMEOUT, | ||
ERR_NOT_IN_IFRAME, | ||
ERR_NO_IFRAME_SRC, | ||
connectToChild, | ||
connectToParent | ||
}; | ||
}; | ||
var _default = Penpal; | ||
exports.default = _default; | ||
return index; | ||
})); | ||
}()); |
@@ -1,1 +0,1 @@ | ||
!function(e,n){var t={};!function(e){"use strict";Object.defineProperty(e,"__esModule",{value:!0}),e.default=e.ERR_IFRAME_ALREADY_ATTACHED_TO_DOM=e.ERR_NOT_IN_IFRAME=e.ERR_CONNECTION_TIMEOUT=e.ERR_CONNECTION_DESTROYED=void 0;e.ERR_CONNECTION_DESTROYED="ConnectionDestroyed";e.ERR_CONNECTION_TIMEOUT="ConnectionTimeout";e.ERR_NOT_IN_IFRAME="NotInIframe";e.ERR_IFRAME_ALREADY_ATTACHED_TO_DOM="IframeAlreadyAttachedToDom";var n={"http:":"80","https:":"443"},t=/^(https?:|file:)?\/\/([^\/:]+)?(:(\d+))?/,o={ERR_CONNECTION_DESTROYED:"ConnectionDestroyed",ERR_CONNECTION_TIMEOUT:"ConnectionTimeout",ERR_NOT_IN_IFRAME:"NotInIframe",ERR_IFRAME_ALREADY_ATTACHED_TO_DOM:"IframeAlreadyAttachedToDom",Promise:function(){try{return window?window.Promise:null}catch(e){return null}}(),debug:!1},r=(l=0,function(){return++l}),a=function(){if(o.debug){for(var e,n=arguments.length,t=new Array(n),r=0;r<n;r++)t[r]=arguments[r];(e=console).log.apply(e,["[Penpal]"].concat(t))}},i=function(e){var n=[];return e(function(){n.forEach(function(e){e()})}),{then:function(e){n.push(e)}}},c=function(e){var n=e.name,t=e.message,o=e.stack;return{name:n,message:t,stack:o}},d=function(e,n,t,i,c){var d=n.localName,s=n.local,l=n.remote,u=n.remoteOrigin,f=!1;a("".concat(d,": Connecting call sender"));var m=function(e){return function(){for(var n=arguments.length,t=new Array(n),c=0;c<n;c++)t[c]=arguments[c];if(a("".concat(d,": Sending ").concat(e,"() call")),l.closed&&i(),f){var m=new Error("Unable to send ".concat(e,"() call due ")+"to destroyed connection");throw m.code="ConnectionDestroyed",m}return new o.Promise(function(n,o){var i=r();s.addEventListener("message",function t(r){if(r.source===l&&r.origin===u&&"reply"===r.data.penpal&&r.data.id===i){a("".concat(d,": Received ").concat(e,"() reply")),s.removeEventListener("message",t);var c=r.data.returnValue;r.data.returnValueIsError&&(f=c,m=new Error,Object.keys(f).forEach(function(e){return m[e]=f[e]}),c=m),("fulfilled"===r.data.resolution?n:o)(c)}var f,m}),l.postMessage({penpal:"call",id:i,methodName:e,args:t},u)})}};c.then(function(){f=!0}),t.reduce(function(e,n){return e[n]=m(n),e},e)},s=function(e,n,t){var r=e.localName,i=e.local,d=e.remote,s=e.remoteOrigin,l=!1;a("".concat(r,": Connecting call receiver"));var u=function(e){if(e.source===d&&e.origin===s&&"call"===e.data.penpal){var t=e.data,i=t.methodName,u=t.args,f=t.id;if(a("".concat(r,": Received ").concat(i,"() call")),i in n){var m=function(e){return function(n){if(a("".concat(r,": Sending ").concat(i,"() reply")),l)a("".concat(r,": Unable to send ").concat(i,"() reply due to destroyed connection"));else{var t={penpal:"reply",id:f,resolution:e,returnValue:n};"rejected"===e&&n instanceof Error&&(t.returnValue=c(n),t.returnValueIsError=!0);try{d.postMessage(t,s)}catch(e){throw"DataCloneError"===e.name&&d.postMessage({penpal:"reply",id:f,resolution:"rejected",returnValue:c(e),returnValueIsError:!0},s),e}}}};new o.Promise(function(e){return e(n[i].apply(n,u))}).then(m("fulfilled"),m("rejected"))}}};i.addEventListener("message",u),t.then(function(){l=!0,i.removeEventListener("message",u)})};var l;o.connectToChild=function(e){var r,c=e.url,l=e.appendTo,u=e.iframe,f=e.methods,m=void 0===f?{}:f,v=e.timeout;if(u&&u.parentNode){var E=new Error("connectToChild() must not be called with an iframe already attached to DOM");throw E.code="IframeAlreadyAttachedToDom",E}var p=new i(function(e){r=e}),h=window;(u=u||document.createElement("iframe")).src=c;var T=function(e){var o,r,a,i=document.location,c=t.exec(e);c?(o=c[1]?c[1]:i.protocol,r=c[2],a=c[4]):(o=i.protocol,r=i.hostname,a=i.port);if("file:"===o)return"null";var d=a&&a!==n[o]?":".concat(a):"";return"".concat(o,"//").concat(r).concat(d)}(c),g=new o.Promise(function(e,n){var t;void 0!==v&&(t=setTimeout(function(){var e=new Error("Connection to child timed out after ".concat(v,"ms"));e.code="ConnectionTimeout",n(e),r()},v));var o,c,f={},E=function(n){var l=u.contentWindow;if(n.source===l&&n.origin===T&&"handshake"===n.data.penpal){a("Parent: Received handshake, sending reply");var v="null"===n.origin?"*":n.origin;n.source.postMessage({penpal:"handshake-reply",methodNames:Object.keys(m)},v);var E={localName:"Parent",local:h,remote:l,remoteOrigin:v};c&&c();var g=new i(function(e){p.then(e),c=e});s(E,m,g),o&&o.forEach(function(e){delete f[e]}),o=n.data.methodNames,d(f,E,o,r,p),clearTimeout(t),e(f)}};h.addEventListener("message",E),a("Parent: Loading iframe"),(l||document.body).appendChild(u);var g=setInterval(function(){document.contains(u)||(clearInterval(g),r())},6e4);p.then(function(){u.parentNode&&u.parentNode.removeChild(u),h.removeEventListener("message",E),clearInterval(g);var e=new Error("Connection destroyed");e.code="ConnectionDestroyed",n(e)})});return{promise:g,iframe:u,destroy:r}},o.connectToParent=function(){var e,n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=n.parentOrigin,r=void 0===t?"*":t,c=n.methods,l=void 0===c?{}:c,u=n.timeout;if(window===window.top){var f=new Error("connectToParent() must be called within an iframe");throw f.code="NotInIframe",f}var m=new i(function(n){e=n}),v=window,E=v.parent,p=new o.Promise(function(n,t){var o;void 0!==u&&(o=setTimeout(function(){var n=new Error("Connection to parent timed out after ".concat(u,"ms"));n.code="ConnectionTimeout",t(n),e()},u));var i=function t(i){if(("*"===r||r===i.origin)&&i.source===E&&"handshake-reply"===i.data.penpal){a("Child: Received handshake reply"),v.removeEventListener("message",t);var c={localName:"Child",local:v,remote:E,remoteOrigin:i.origin},u={};s(c,l,m),d(u,c,i.data.methodNames,e,m),clearTimeout(o),n(u)}};v.addEventListener("message",i),m.then(function(){v.removeEventListener("message",i);var e=new Error("Connection destroyed");e.code="ConnectionDestroyed",t(e)}),a("Child: Sending handshake"),E.postMessage({penpal:"handshake",methodNames:Object.keys(l)},r)});return{promise:p,destroy:e}};var u=o;e.default=u}(t),"function"==typeof define&&define.amd?define("Penpal",t.default):e.Penpal=t.default}(this); | ||
var Penpal=function(){"use strict";const e="handshake";const n="handshake-reply";const t="call";const r="reply";const o="fulfilled";const i="rejected";const c="message";const s="DataCloneError";const a="ConnectionDestroyed";const d="ConnectionTimeout";const l="NotInIframe";const u="NoIframeSrc";var m=()=>{const e=[];let n=false;return{destroy(){n=true;e.forEach(e=>{e()})},onDestroy(t){n?t():e.push(t)}}};const h={"http:":"80","https:":"443"};const f=/^(https?:|file:)?\/\/([^\/:]+)?(:(\d+))?/;const g=["file:","data:"];var p=e=>{if(e&&g.find(n=>e.startsWith(n))){return"null"}const n=document.location;const t=f.exec(e);let r;let o;let i;if(t){r=t[1]?t[1]:n.protocol;o=t[2];i=t[4]}else{r=n.protocol;o=n.hostname;i=n.port}const c=i&&i!==h[r]?`:${i}`:"";return`${r}//${o}${c}`};var v=e=>{return function(){if(e){for(var n=arguments.length,t=new Array(n),r=0;r<n;r++){t[r]=arguments[r]}console.log("[Penpal]",...t)}}};const E=e=>{let n=e.name,t=e.message,r=e.stack;return{name:n,message:t,stack:r}};const w=e=>{const n=new Error;Object.keys(e).forEach(t=>n[t]=e[t]);return n};var y=(e,n,a)=>{const d=e.localName,l=e.local,u=e.remote,m=e.originForSending,h=e.originForReceiving;let f=false;a(`${d}: Connecting call receiver`);const g=e=>{if(e.source!==u||e.data.penpal!==t){return}if(e.origin!==h){a(`${d} received message from origin ${e.origin} which did not match expected origin ${h}`);return}const c=e.data,l=c.methodName,g=c.args,p=c.id;a(`${d}: Received ${l}() call`);const v=e=>{return n=>{a(`${d}: Sending ${l}() reply`);if(f){a(`${d}: Unable to send ${l}() reply due to destroyed connection`);return}const t={penpal:r,id:p,resolution:e,returnValue:n};if(e===i&&n instanceof Error){t.returnValue=E(n);t.returnValueIsError=true}try{u.postMessage(t,m)}catch(e){if(e.name===s){u.postMessage({penpal:r,id:p,resolution:i,returnValue:E(e),returnValueIsError:true},m)}throw e}}};new Promise(e=>e(n[l].apply(n,g))).then(v(o),v(i))};l.addEventListener(c,g);return()=>{f=true;l.removeEventListener(c,g)}};let $=0;var N=()=>++$;var C=(e,n,i,s,d)=>{const l=n.localName,u=n.local,m=n.remote,h=n.originForSending,f=n.originForReceiving;let g=false;d(`${l}: Connecting call sender`);const p=e=>{return function(){for(var n=arguments.length,i=new Array(n),p=0;p<n;p++){i[p]=arguments[p]}d(`${l}: Sending ${e}() call`);let v;try{if(m.closed){v=true}}catch(e){v=true}if(v){s()}if(g){const n=new Error(`Unable to send ${e}() call due `+`to destroyed connection`);n.code=a;throw n}return new Promise((n,s)=>{const a=N();const g=t=>{if(t.source!==m||t.data.penpal!==r||t.data.id!==a){return}if(t.origin!==f){d(`${l} received message from origin ${t.origin} which did not match expected origin ${f}`);return}d(`${l}: Received ${e}() reply`);u.removeEventListener(c,g);let i=t.data.returnValue;if(t.data.returnValueIsError){i=w(i)}(t.data.resolution===o?n:s)(i)};u.addEventListener(c,g);m.postMessage({penpal:t,id:a,methodName:e,args:i},h)})}};i.reduce((e,n)=>{e[n]=p(n);return e},e);return()=>{g=true}};const I=6e4;var T=t=>{let r=t.iframe,o=t.methods,i=o===void 0?{}:o,s=t.timeout,l=t.debug;const h=v(l);const f=window;const g=m(),E=g.destroy,w=g.onDestroy;if(!r.src&&!r.srcdoc){const e=new Error("Iframe must have src or srcdoc property defined.");e.code=u;throw e}const $=p(r.src);const N=$==="null"?"*":$;const T=new Promise((t,o)=>{let l;if(s!==undefined){l=setTimeout(()=>{const e=new Error(`Connection to child timed out after ${s}ms`);e.code=d;o(e);E()},s)}const u={};let m;let g;const p=o=>{const c=r.contentWindow;if(o.source!==c||o.data.penpal!==e){return}if(o.origin!==$){h(`Parent received handshake from origin ${o.origin} which did not match expected origin ${$}`);return}h("Parent: Received handshake, sending reply");o.source.postMessage({penpal:n,methodNames:Object.keys(i)},N);const s={localName:"Parent",local:f,remote:c,originForSending:N,originForReceiving:$};if(g){g()}g=y(s,i,h);w(g);if(m){m.forEach(e=>{delete u[e]})}m=o.data.methodNames;const a=C(u,s,m,E,h);w(a);clearTimeout(l);t(u)};f.addEventListener(c,p);h("Parent: Awaiting handshake");var v=setInterval(()=>{if(!document.contains(r)){clearInterval(v);E()}},I);w(()=>{f.removeEventListener(c,p);clearInterval(v);const e=new Error("Connection destroyed");e.code=a;o(e)})});return{promise:T,destroy:E}};var k=function(){let t=arguments.length>0&&arguments[0]!==undefined?arguments[0]:{},r=t.parentOrigin,o=r===void 0?"*":r,i=t.methods,s=i===void 0?{}:i,u=t.timeout,h=t.debug;const f=v(h);if(window===window.top){const e=new Error("connectToParent() must be called within an iframe");e.code=l;throw e}const g=m(),p=g.destroy,E=g.onDestroy;const w=window;const $=w.parent;const N=new Promise((t,r)=>{let i;if(u!==undefined){i=setTimeout(()=>{const e=new Error(`Connection to parent timed out after ${u}ms`);e.code=d;r(e);p()},u)}const l=e=>{try{clearTimeout()}catch(e){return}if(e.source!==$||e.data.penpal!==n){return}if(o!=="*"&&o!==e.origin){f(`Child received handshake reply from origin ${e.origin} which did not match expected origin ${o}`);return}f("Child: Received handshake reply");w.removeEventListener(c,l);const r={localName:"Child",local:w,remote:$,originForSending:e.origin==="null"?"*":e.origin,originForReceiving:e.origin};const a={};const d=y(r,s,f);E(d);const u=C(a,r,e.data.methodNames,p,f);E(u);clearTimeout(i);t(a)};w.addEventListener(c,l);E(()=>{w.removeEventListener(c,l);const e=new Error("Connection destroyed");e.code=a;r(e)});f("Child: Sending handshake");$.postMessage({penpal:e,methodNames:Object.keys(s)},o)});return{promise:N,destroy:p}};var O={ERR_CONNECTION_DESTROYED:a,ERR_CONNECTION_TIMEOUT:d,ERR_NOT_IN_IFRAME:l,ERR_NO_IFRAME_SRC:u,connectToChild:T,connectToParent:k};return O}(); |
576
lib/index.js
@@ -6,569 +6,21 @@ "use strict"; | ||
}); | ||
exports.default = exports.ERR_IFRAME_ALREADY_ATTACHED_TO_DOM = exports.ERR_NOT_IN_IFRAME = exports.ERR_CONNECTION_TIMEOUT = exports.ERR_CONNECTION_DESTROYED = void 0; | ||
var HANDSHAKE = 'handshake'; | ||
var HANDSHAKE_REPLY = 'handshake-reply'; | ||
var CALL = 'call'; | ||
var REPLY = 'reply'; | ||
var FULFILLED = 'fulfilled'; | ||
var REJECTED = 'rejected'; | ||
var MESSAGE = 'message'; | ||
var DATA_CLONE_ERROR = 'DataCloneError'; | ||
var ERR_CONNECTION_DESTROYED = 'ConnectionDestroyed'; | ||
exports.ERR_CONNECTION_DESTROYED = ERR_CONNECTION_DESTROYED; | ||
var ERR_CONNECTION_TIMEOUT = 'ConnectionTimeout'; | ||
exports.ERR_CONNECTION_TIMEOUT = ERR_CONNECTION_TIMEOUT; | ||
var ERR_NOT_IN_IFRAME = 'NotInIframe'; | ||
exports.ERR_NOT_IN_IFRAME = ERR_NOT_IN_IFRAME; | ||
var ERR_IFRAME_ALREADY_ATTACHED_TO_DOM = 'IframeAlreadyAttachedToDom'; | ||
exports.ERR_IFRAME_ALREADY_ATTACHED_TO_DOM = ERR_IFRAME_ALREADY_ATTACHED_TO_DOM; | ||
var CHECK_IFRAME_IN_DOC_INTERVAL = 60000; | ||
var DEFAULT_PORTS = { | ||
'http:': '80', | ||
'https:': '443' | ||
}; | ||
var URL_REGEX = /^(https?:|file:)?\/\/([^/:]+)?(:(\d+))?/; | ||
var Penpal = { | ||
ERR_CONNECTION_DESTROYED: ERR_CONNECTION_DESTROYED, | ||
ERR_CONNECTION_TIMEOUT: ERR_CONNECTION_TIMEOUT, | ||
ERR_NOT_IN_IFRAME: ERR_NOT_IN_IFRAME, | ||
ERR_IFRAME_ALREADY_ATTACHED_TO_DOM: ERR_IFRAME_ALREADY_ATTACHED_TO_DOM, | ||
exports.default = void 0; | ||
/** | ||
* Promise implementation. | ||
* @type {Constructor} | ||
*/ | ||
Promise: function () { | ||
try { | ||
return window ? window.Promise : null; | ||
} catch (e) { | ||
return null; | ||
} | ||
}(), | ||
var _connectToChild = _interopRequireDefault(require("./connectToChild")); | ||
/** | ||
* Whether debug messages should be logged. | ||
* @type {boolean} | ||
*/ | ||
debug: false | ||
}; | ||
/** | ||
* @return {number} A unique ID (not universally unique) | ||
*/ | ||
var _connectToParent = _interopRequireDefault(require("./connectToParent")); | ||
var generateId = function () { | ||
var id = 0; | ||
return function () { | ||
return ++id; | ||
}; | ||
}(); | ||
/** | ||
* Logs a message. | ||
* @param {...*} args One or more items to log | ||
*/ | ||
var _errorCodes = require("./errorCodes"); | ||
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } | ||
var log = function log() { | ||
if (Penpal.debug) { | ||
var _console; | ||
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { | ||
args[_key] = arguments[_key]; | ||
} | ||
(_console = console).log.apply(_console, ['[Penpal]'].concat(args)); // eslint-disable-line no-console | ||
} | ||
var _default = { | ||
ERR_CONNECTION_DESTROYED: _errorCodes.ERR_CONNECTION_DESTROYED, | ||
ERR_CONNECTION_TIMEOUT: _errorCodes.ERR_CONNECTION_TIMEOUT, | ||
ERR_NOT_IN_IFRAME: _errorCodes.ERR_NOT_IN_IFRAME, | ||
ERR_NO_IFRAME_SRC: _errorCodes.ERR_NO_IFRAME_SRC, | ||
connectToChild: _connectToChild.default, | ||
connectToParent: _connectToParent.default | ||
}; | ||
/** | ||
* Converts a URL into an origin. | ||
* @param {string} url | ||
* @return {string} The URL's origin | ||
*/ | ||
var getOriginFromUrl = function getOriginFromUrl(url) { | ||
var location = document.location; | ||
var regexResult = URL_REGEX.exec(url); | ||
var protocol; | ||
var hostname; | ||
var 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 protocol is file, the origin is "null" | ||
// The origin of a document with file protocol is an opaque origin | ||
// and its serialization "null" [1] | ||
// [1] https://html.spec.whatwg.org/multipage/origin.html#origin | ||
if (protocol === "file:") { | ||
return "null"; | ||
} // 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. | ||
var portSuffix = port && port !== DEFAULT_PORTS[protocol] ? ":".concat(port) : ''; | ||
return "".concat(protocol, "//").concat(hostname).concat(portSuffix); | ||
}; | ||
/** | ||
* A simplified promise class only used internally for when destroy() is called. This is | ||
* used to destroy connections synchronously while promises typically resolve asynchronously. | ||
* | ||
* @param {Function} executor | ||
* @returns {Object} | ||
* @constructor | ||
*/ | ||
var DestructionPromise = function DestructionPromise(executor) { | ||
var handlers = []; | ||
executor(function () { | ||
handlers.forEach(function (handler) { | ||
handler(); | ||
}); | ||
}); | ||
return { | ||
then: function then(handler) { | ||
handlers.push(handler); | ||
} | ||
}; | ||
}; | ||
/** | ||
* Converts an error object into a plain object. | ||
* @param {Error} Error object. | ||
* @returns {Object} | ||
*/ | ||
var serializeError = function serializeError(_ref) { | ||
var name = _ref.name, | ||
message = _ref.message, | ||
stack = _ref.stack; | ||
return { | ||
name: name, | ||
message: message, | ||
stack: stack | ||
}; | ||
}; | ||
/** | ||
* Converts a plain object into an error object. | ||
* @param {Object} Object with error properties. | ||
* @returns {Error} | ||
*/ | ||
var deserializeError = function deserializeError(obj) { | ||
var deserializedError = new Error(); | ||
Object.keys(obj).forEach(function (key) { | ||
return deserializedError[key] = obj[key]; | ||
}); | ||
return deserializedError; | ||
}; | ||
/** | ||
* 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 = function connectCallSender(callSender, info, methodNames, destroy, destructionPromise) { | ||
var localName = info.localName, | ||
local = info.local, | ||
remote = info.remote, | ||
remoteOrigin = info.remoteOrigin; | ||
var destroyed = false; | ||
log("".concat(localName, ": Connecting call sender")); | ||
var createMethodProxy = function createMethodProxy(methodName) { | ||
return function () { | ||
for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { | ||
args[_key2] = arguments[_key2]; | ||
} | ||
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. | ||
if (remote.closed) { | ||
destroy(); | ||
} | ||
if (destroyed) { | ||
var error = new Error("Unable to send ".concat(methodName, "() call due ") + "to destroyed connection"); | ||
error.code = ERR_CONNECTION_DESTROYED; | ||
throw error; | ||
} | ||
return new Penpal.Promise(function (resolve, reject) { | ||
var id = generateId(); | ||
var handleMessageEvent = function handleMessageEvent(event) { | ||
if (event.source === remote && event.origin === remoteOrigin && event.data.penpal === REPLY && event.data.id === id) { | ||
log("".concat(localName, ": Received ").concat(methodName, "() reply")); | ||
local.removeEventListener(MESSAGE, handleMessageEvent); | ||
var returnValue = event.data.returnValue; | ||
if (event.data.returnValueIsError) { | ||
returnValue = deserializeError(returnValue); | ||
} | ||
(event.data.resolution === FULFILLED ? resolve : reject)(returnValue); | ||
} | ||
}; | ||
local.addEventListener(MESSAGE, handleMessageEvent); | ||
remote.postMessage({ | ||
penpal: CALL, | ||
id: id, | ||
methodName: methodName, | ||
args: args | ||
}, remoteOrigin); | ||
}); | ||
}; | ||
}; | ||
destructionPromise.then(function () { | ||
destroyed = true; | ||
}); | ||
methodNames.reduce(function (api, methodName) { | ||
api[methodName] = createMethodProxy(methodName); | ||
return api; | ||
}, callSender); | ||
}; | ||
/** | ||
* Listens for "call" messages coming from the remote, executes the corresponding method, and | ||
* responds with the return value. | ||
* @param {Object} info Information about the local and remote windows. | ||
* @param {Object} methods The keys are the names of the methods that can be called by the remote | ||
* while the values are the method functions. | ||
* @param {Promise} destructionPromise A promise resolved when destroy() is called on the penpal | ||
* connection. | ||
* @returns {Function} A function that may be called to disconnect the receiver. | ||
*/ | ||
var connectCallReceiver = function connectCallReceiver(info, methods, destructionPromise) { | ||
var localName = info.localName, | ||
local = info.local, | ||
remote = info.remote, | ||
remoteOrigin = info.remoteOrigin; | ||
var destroyed = false; | ||
log("".concat(localName, ": Connecting call receiver")); | ||
var handleMessageEvent = function handleMessageEvent(event) { | ||
if (event.source === remote && event.origin === remoteOrigin && event.data.penpal === CALL) { | ||
var _event$data = event.data, | ||
methodName = _event$data.methodName, | ||
args = _event$data.args, | ||
id = _event$data.id; | ||
log("".concat(localName, ": Received ").concat(methodName, "() call")); | ||
if (methodName in methods) { | ||
var createPromiseHandler = function createPromiseHandler(resolution) { | ||
return function (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; | ||
} | ||
var message = { | ||
penpal: REPLY, | ||
id: id, | ||
resolution: resolution, | ||
returnValue: returnValue | ||
}; | ||
if (resolution === REJECTED && returnValue instanceof Error) { | ||
message.returnValue = serializeError(returnValue); | ||
message.returnValueIsError = true; | ||
} | ||
try { | ||
remote.postMessage(message, remoteOrigin); | ||
} 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 === DATA_CLONE_ERROR) { | ||
remote.postMessage({ | ||
penpal: REPLY, | ||
id: id, | ||
resolution: REJECTED, | ||
returnValue: serializeError(err), | ||
returnValueIsError: true | ||
}, remoteOrigin); | ||
} | ||
throw err; | ||
} | ||
}; | ||
}; | ||
new Penpal.Promise(function (resolve) { | ||
return resolve(methods[methodName].apply(methods, args)); | ||
}).then(createPromiseHandler(FULFILLED), createPromiseHandler(REJECTED)); | ||
} | ||
} | ||
}; | ||
local.addEventListener(MESSAGE, handleMessageEvent); | ||
destructionPromise.then(function () { | ||
destroyed = true; | ||
local.removeEventListener(MESSAGE, handleMessageEvent); | ||
}); | ||
}; | ||
/** | ||
* @typedef {Object} Child | ||
* @property {Promise} promise A promise which will be resolved once a connection has | ||
* been established. | ||
* @property {HTMLIframeElement} iframe The created iframe element. | ||
* @property {Function} destroy A method that, when called, will remove the iframe element from | ||
* the DOM and clean up event listeners. | ||
*/ | ||
/** | ||
* Creates an iframe, loads a webpage into the URL, and attempts to establish communication with | ||
* the iframe. | ||
* @param {Object} options | ||
* @param {string} options.url The URL of the webpage that should be loaded into the created iframe. | ||
* @param {HTMLElement} [options.appendTo] The container to which the iframe should be appended. | ||
* @param {Object} [options.methods={}] Methods that may be called by the iframe. | ||
* @param {Number} [options.timeout] The amount of time, in milliseconds, Penpal should wait | ||
* for the child to respond before rejecting the connection promise. | ||
* @return {Child} | ||
*/ | ||
Penpal.connectToChild = function (_ref2) { | ||
var url = _ref2.url, | ||
appendTo = _ref2.appendTo, | ||
iframe = _ref2.iframe, | ||
_ref2$methods = _ref2.methods, | ||
methods = _ref2$methods === void 0 ? {} : _ref2$methods, | ||
timeout = _ref2.timeout; | ||
if (iframe && iframe.parentNode) { | ||
var error = new Error('connectToChild() must not be called with an iframe already attached to DOM'); | ||
error.code = ERR_IFRAME_ALREADY_ATTACHED_TO_DOM; | ||
throw error; | ||
} | ||
var destroy; | ||
var connectionDestructionPromise = new DestructionPromise(function (resolveConnectionDestructionPromise) { | ||
destroy = resolveConnectionDestructionPromise; | ||
}); | ||
var parent = window; | ||
iframe = iframe || document.createElement('iframe'); | ||
iframe.src = url; | ||
var childOrigin = getOriginFromUrl(url); | ||
var promise = new Penpal.Promise(function (resolveConnectionPromise, reject) { | ||
var connectionTimeoutId; | ||
if (timeout !== undefined) { | ||
connectionTimeoutId = setTimeout(function () { | ||
var error = new Error("Connection to child timed out after ".concat(timeout, "ms")); | ||
error.code = ERR_CONNECTION_TIMEOUT; | ||
reject(error); | ||
destroy(); | ||
}, timeout); | ||
} // 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. | ||
var callSender = {}; | ||
var receiverMethodNames; | ||
var destroyCallReceiver; | ||
var handleMessage = function handleMessage(event) { | ||
var child = iframe.contentWindow; | ||
if (event.source === child && event.origin === childOrigin && event.data.penpal === HANDSHAKE) { | ||
log('Parent: Received handshake, sending reply'); // If event.origin is "null", the remote protocol is file: | ||
// and we must post messages with "*" as targetOrigin [1] | ||
// [1] https://developer.mozilla.org/fr/docs/Web/API/Window/postMessage#Utiliser_window.postMessage_dans_les_extensions | ||
var remoteOrigin = event.origin === "null" ? "*" : event.origin; | ||
event.source.postMessage({ | ||
penpal: HANDSHAKE_REPLY, | ||
methodNames: Object.keys(methods) | ||
}, remoteOrigin); | ||
var info = { | ||
localName: 'Parent', | ||
local: parent, | ||
remote: child, | ||
remoteOrigin: remoteOrigin | ||
}; // If the child reconnected, we need to destroy the previous call receiver before setting | ||
// up a new one. | ||
if (destroyCallReceiver) { | ||
destroyCallReceiver(); | ||
} // When this promise is resolved, it will destroy the call receiver (stop listening to | ||
// method calls from the child) and delete its methods off the call sender. | ||
var callReceiverDestructionPromise = new DestructionPromise(function (resolveCallReceiverDestructionPromise) { | ||
connectionDestructionPromise.then(resolveCallReceiverDestructionPromise); | ||
destroyCallReceiver = resolveCallReceiverDestructionPromise; | ||
}); | ||
connectCallReceiver(info, methods, callReceiverDestructionPromise); // If the child reconnected, we need to remove the methods from the previous call receiver | ||
// off the sender. | ||
if (receiverMethodNames) { | ||
receiverMethodNames.forEach(function (receiverMethodName) { | ||
delete callSender[receiverMethodName]; | ||
}); | ||
} | ||
receiverMethodNames = event.data.methodNames; | ||
connectCallSender(callSender, info, receiverMethodNames, destroy, connectionDestructionPromise); | ||
clearTimeout(connectionTimeoutId); | ||
resolveConnectionPromise(callSender); | ||
} | ||
}; | ||
parent.addEventListener(MESSAGE, handleMessage); | ||
log('Parent: Loading iframe'); | ||
(appendTo || document.body).appendChild(iframe); // 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 checkIframeInDocIntervalId = setInterval(function () { | ||
if (!document.contains(iframe)) { | ||
clearInterval(checkIframeInDocIntervalId); | ||
destroy(); | ||
} | ||
}, CHECK_IFRAME_IN_DOC_INTERVAL); | ||
connectionDestructionPromise.then(function () { | ||
if (iframe.parentNode) { | ||
iframe.parentNode.removeChild(iframe); | ||
} | ||
parent.removeEventListener(MESSAGE, handleMessage); | ||
clearInterval(checkIframeInDocIntervalId); | ||
var error = new Error('Connection destroyed'); | ||
error.code = ERR_CONNECTION_DESTROYED; | ||
reject(error); | ||
}); | ||
}); | ||
return { | ||
promise: promise, | ||
iframe: iframe, | ||
destroy: destroy | ||
}; | ||
}; | ||
/** | ||
* @typedef {Object} Parent | ||
* @property {Promise} promise A promise which will be resolved once a connection has | ||
* been established. | ||
*/ | ||
/** | ||
* Attempts to establish communication with the parent window. | ||
* @param {Object} options | ||
* @param {string} [options.parentOrigin=*] Valid parent origin used to restrict communication. | ||
* @param {Object} [options.methods={}] Methods that may be called by the parent window. | ||
* @param {Number} [options.timeout] The amount of time, in milliseconds, Penpal should wait | ||
* for the parent to respond before rejecting the connection promise. | ||
* @return {Parent} | ||
*/ | ||
Penpal.connectToParent = function () { | ||
var _ref3 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, | ||
_ref3$parentOrigin = _ref3.parentOrigin, | ||
parentOrigin = _ref3$parentOrigin === void 0 ? '*' : _ref3$parentOrigin, | ||
_ref3$methods = _ref3.methods, | ||
methods = _ref3$methods === void 0 ? {} : _ref3$methods, | ||
timeout = _ref3.timeout; | ||
if (window === window.top) { | ||
var error = new Error('connectToParent() must be called within an iframe'); | ||
error.code = ERR_NOT_IN_IFRAME; | ||
throw error; | ||
} | ||
var destroy; | ||
var connectionDestructionPromise = new DestructionPromise(function (resolveConnectionDestructionPromise) { | ||
destroy = resolveConnectionDestructionPromise; | ||
}); | ||
var child = window; | ||
var parent = child.parent; | ||
var promise = new Penpal.Promise(function (resolveConnectionPromise, reject) { | ||
var connectionTimeoutId; | ||
if (timeout !== undefined) { | ||
connectionTimeoutId = setTimeout(function () { | ||
var error = new Error("Connection to parent timed out after ".concat(timeout, "ms")); | ||
error.code = ERR_CONNECTION_TIMEOUT; | ||
reject(error); | ||
destroy(); | ||
}, timeout); | ||
} | ||
var handleMessageEvent = function handleMessageEvent(event) { | ||
if ((parentOrigin === '*' || parentOrigin === event.origin) && event.source === parent && event.data.penpal === HANDSHAKE_REPLY) { | ||
log('Child: Received handshake reply'); | ||
child.removeEventListener(MESSAGE, handleMessageEvent); | ||
var info = { | ||
localName: 'Child', | ||
local: child, | ||
remote: parent, | ||
remoteOrigin: event.origin | ||
}; | ||
var callSender = {}; | ||
connectCallReceiver(info, methods, connectionDestructionPromise); | ||
connectCallSender(callSender, info, event.data.methodNames, destroy, connectionDestructionPromise); | ||
clearTimeout(connectionTimeoutId); | ||
resolveConnectionPromise(callSender); | ||
} | ||
}; | ||
child.addEventListener(MESSAGE, handleMessageEvent); | ||
connectionDestructionPromise.then(function () { | ||
child.removeEventListener(MESSAGE, handleMessageEvent); | ||
var error = new Error('Connection destroyed'); | ||
error.code = ERR_CONNECTION_DESTROYED; | ||
reject(error); | ||
}); | ||
log('Child: Sending handshake'); | ||
parent.postMessage({ | ||
penpal: HANDSHAKE, | ||
methodNames: Object.keys(methods) | ||
}, parentOrigin); | ||
}); | ||
return { | ||
promise: promise, | ||
destroy: destroy | ||
}; | ||
}; | ||
var _default = Penpal; | ||
exports.default = _default; | ||
exports.default = _default; | ||
module.exports = exports.default; |
{ | ||
"name": "penpal", | ||
"version": "3.1.2", | ||
"version": "4.0.0", | ||
"description": "A promise-based library for communicating with iframes via postMessage.", | ||
@@ -20,14 +20,27 @@ "author": "Aaron Hardy <aaron@aaronhardy.com>", | ||
"main": "lib/index.js", | ||
"types": "lib/index.d.ts", | ||
"scripts": { | ||
"build": "./scripts/build.js", | ||
"build:watch": "npm run build -- --watch", | ||
"lint": "eslint src/**", | ||
"test": "npm-run-all build run-tests", | ||
"test:watch": "npm-run-all build --parallel 'build:watch -- --skip-initial-run' 'run-tests -- --watch'", | ||
"test:sauce": "npm-run-all build 'run-tests -- --sauce'", | ||
"run-tests": "./scripts/test.js", | ||
"prepublish": "npm run build", | ||
"dtslint": "dtslint types" | ||
"clean": "rm -rf dist lib", | ||
"build:lib": "babel ./src --out-dir ./lib", | ||
"build:dist": "rollup --config rollup.config.js && terser dist/penpal.js -o dist/penpal.min.js -m --source-map", | ||
"build": "npm-run-all clean build:lib build:dist && ls -lh dist/penpal.min.js", | ||
"lint": "eslint .", | ||
"format": "prettier --write \"**/*.{json,js,md,html,babelrc,eslintrc}\"", | ||
"test": "npm-run-all clean runTests", | ||
"test:watch": "npm-run-all clean runTests:watch", | ||
"test:sauce": "npm-run-all build 'runTests -- --sauce'", | ||
"runTests": "node ./scripts/test.js", | ||
"runTests:watch": "npm run runTests -- --watch", | ||
"prepublishOnly": "npm-run-all format lint test build" | ||
}, | ||
"husky": { | ||
"hooks": { | ||
"pre-commit": "npm-run-all format lint test && git add ." | ||
} | ||
}, | ||
"browserslist": [ | ||
"last 2 Chrome version", | ||
"last 2 Firefox version", | ||
"last 2 Safari version", | ||
"last 2 Edge version" | ||
], | ||
"files": [ | ||
@@ -39,10 +52,12 @@ "dist", | ||
"devDependencies": { | ||
"@babel/core": "^7.2.2", | ||
"@babel/preset-env": "^7.2.3", | ||
"async": "^2.6.1", | ||
"@babel/cli": "^7.2.3", | ||
"@babel/core": "^7.3.4", | ||
"@babel/preset-env": "^7.3.4", | ||
"@metahub/karma-rollup-preprocessor": "^4.1.0", | ||
"babel-plugin-add-module-exports": "^1.0.0", | ||
"connect": "^3.6.6", | ||
"dtslint": "^0.4.2", | ||
"eslint": "^5.10.0", | ||
"eslint-config-prettier": "^4.1.0", | ||
"eslint-plugin-import": "^2.14.0", | ||
"husky": "^1.3.1", | ||
"jasmine-core": "^3.3.0", | ||
@@ -55,17 +70,10 @@ "karma": "^3.1.4", | ||
"karma-sauce-launcher": "^2.0.2", | ||
"mkdirp": "^0.5.1", | ||
"npm-run-all": "^4.1.5", | ||
"prettier": "1.15.3", | ||
"rsvp": "^4.8.4", | ||
"rollup": "^1.7.0", | ||
"rollup-plugin-babel": "^4.3.2", | ||
"serve-static": "^1.13.2", | ||
"terser": "^3.13.1", | ||
"yargs": "^12.0.5" | ||
}, | ||
"browserslist": [ | ||
"last 1 IE version", | ||
"last 1 Chrome version", | ||
"last 1 Firefox version", | ||
"last 1 Safari version", | ||
"last 1 Edge version" | ||
] | ||
} | ||
} |
128
README.md
@@ -9,3 +9,3 @@ [![npm version](https://badge.fury.io/js/penpal.svg)](https://badge.fury.io/js/penpal) | ||
The total size of the library is approximately 5 KB minified and 2 KB gzipped. It has no dependencies. | ||
This library has no dependencies. | ||
@@ -16,14 +16,6 @@ ## Installation | ||
Preferably, you'll be able to use Penpal from npm with a bundler like [Webpack](https://webpack.github.io/) or [Parcel](https://parceljs.org/). If you use npm for client package management, you can install Penpal with: | ||
Preferably, you'll be able to use Penpal from npm with a bundler like [Webpack](https://webpack.github.io/), [Rollup](https://rollupjs.org), or [Parcel](https://parceljs.org/). If you use npm for client package management, you can install Penpal with: | ||
`npm install penpal --save` | ||
And import Penpal into your code with something like: | ||
`import Penpal from 'penpal';` | ||
#### TypeScript | ||
Penpal provides a `types` file for usage with TypeScript; importing is simply the same as above. | ||
### Using a CDN | ||
@@ -35,4 +27,15 @@ | ||
Penpal will then be installed on `window.Penpal`. | ||
Penpal will then be installed on `window.Penpal`. `window.Penpal` will contain the following properties: | ||
``` | ||
Penpal.ERR_CONNECTION_DESTROYED | ||
Penpal.ERR_CONNECTION_TIMEOUT | ||
Penpal.ERR_NOT_IN_IFRAME | ||
Penpal.ERR_NO_IFRAME_SRC | ||
Penpal.connectToChild | ||
Penpal.connectToParent | ||
``` | ||
Usage is similar to if you were using a bundler, which is documented below, but instead of importing each module, you would access it on the `Penpal` global instead. | ||
## Usage | ||
@@ -43,10 +46,12 @@ | ||
```javascript | ||
import Penpal from 'penpal'; | ||
import connectToChild from 'penpal/lib/connectToChild'; | ||
const connection = Penpal.connectToChild({ | ||
// URL of page to load into iframe. | ||
url: 'http://example.com/iframe.html', | ||
// Container to which the iframe should be appended. | ||
appendTo: document.getElementById('iframeContainer'), | ||
// Methods parent is exposing to child | ||
const iframe = document.createElement('iframe'); | ||
iframe.src = 'http://example.com/iframe.html'; | ||
document.body.appendChild(iframe); | ||
const connection = connectToChild({ | ||
// The iframe to which a connection should be made | ||
iframe, | ||
// Methods the parent is exposing to the child | ||
methods: { | ||
@@ -68,5 +73,5 @@ add(num1, num2) { | ||
```javascript | ||
import Penpal from 'penpal'; | ||
import connectToParent from 'penpal/lib/connectToParent'; | ||
const connection = Penpal.connectToParent({ | ||
const connection = connectToParent({ | ||
// Methods child is exposing to parent | ||
@@ -99,8 +104,6 @@ methods: { | ||
`options.url` (required) The URL of the webpage that should be loaded into the iframe that Penpal will create. A relative path is also supported. | ||
`options.iframe` (required) The iframe element to which Penpal should connect. You will need to have set either the `src` or `srcdoc` property on the iframe prior to calling `connectToChild`. In addition to regular URLs, [data URIs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) and [file URIs](https://en.wikipedia.org/wiki/File_URI_scheme) are also supported. | ||
`options.appendTo` (optional) The element to which the created iframe should be appended. If not provided, the iframe will be appended to `document.body`. | ||
You need to ensure that `connectToChild` is called before the iframe has called `connectToParent`. As shown in the example above, it is safe to set the `src` or `srcdoc` property of the iframe and append the iframe to the document before calling `connectToChild` as long as they are both done in the same [JavaScript event loop](https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop). Alternatively, you can always append the iframe to the document _after_ calling `connectToChild` instead of _before_. | ||
`options.iframe` (optional) The iframe element that Penpal should use instead of creating an iframe element itself. This iframe element must not be already appended to the DOM; it will be appended by Penpal. This option is useful if you need to set properties on the iframe element before it is appended to the DOM (for example, if you need to set the `sandbox` property). Note that the `src` property of the iframe will be set by Penpal using the `options.url` value, even if `src` has been set previously. | ||
`options.methods` (optional) An object containing methods which should be exposed for the child iframe to call. The keys of the object are the method names and the values are the functions. If a function requires asynchronous processing to determine its return value, make the function immediately return a promise and resolve the promise once the value has been determined. | ||
@@ -110,2 +113,4 @@ | ||
`options.debug` (optional) Enables or disables debug logging. Debug logging is disabled by default. | ||
#### Return value | ||
@@ -119,4 +124,2 @@ | ||
`connection.iframe` The child iframe element. The iframe will have already been appended as a child to the element defined in `options.appendTo`, but a reference to the iframe is provided in case you need to add CSS classes, etc. | ||
### `connectToParent([options:Object]) => Object` | ||
@@ -132,2 +135,4 @@ | ||
`options.debug` (optional) Enables or disables debug logging. Debug logging is disabled by default. | ||
#### Return value | ||
@@ -141,10 +146,2 @@ | ||
### `Promise` | ||
Setting `Penpal.Promise` to a Promise constructor provides Penpal with a promise implementation that it will use. If a promise implementation is not provided by the consumer, Penpal will attempt to use `window.Promise`. | ||
### `debug` | ||
Setting `Penpal.debug` to `true` or `false` enables or disables debug logging. Debug logging is disabled by default. | ||
## Reconnection | ||
@@ -160,13 +157,13 @@ | ||
* `Penpal.ERR_CONNECTION_DESTROYED` | ||
* `connection.promise` will be rejected with this error if the connection is destroyed (by calling `connection.destroy()`) while Penpal is attempting to establish the connection. | ||
* This error will be thrown when attempting to call a method on `child` or `parent` objects and the connection was previously destroyed. | ||
* `Penpal.ERR_CONNECTION_TIMEOUT` | ||
* `connection.promise` will be rejected with this error after the `timeout` duration has elapsed and a connection has not been established. | ||
* `Penpal.ERR_NOT_IN_IFRAME` | ||
* This error will be thrown when attempting to call `Penpal.connectToParent()` from outside of an iframe context. | ||
* `Penpal.ERR_IFRAME_ALREADY_ATTACHED_TO_DOM` | ||
* This error will be thrown when an iframe already attached to the DOM is passed to `Penpal.connectToChild()`. | ||
- `ConnectionDestroyed` | ||
- `connection.promise` will be rejected with this error if the connection is destroyed (by calling `connection.destroy()`) while Penpal is attempting to establish the connection. | ||
- This error will be thrown when attempting to call a method on `child` or `parent` objects and the connection was previously destroyed. | ||
- `ConnectionTimeout` | ||
- `connection.promise` will be rejected with this error after the `timeout` duration has elapsed and a connection has not been established. | ||
- `NotInIframe` | ||
- This error will be thrown when attempting to call `connectToParent()` from outside of an iframe context. | ||
- `NoIframeSrc` | ||
- This error will be thrown when the iframe passed into `connectToChild` does not have `src` or `srcdoc` set. | ||
While these error codes are on the Penpal object itself, they are also named exports. You may import them as follows: | ||
For your convenience, these error codes are exported as constants that can be imported as follows: | ||
@@ -178,42 +175,9 @@ ``` | ||
ERR_NOT_IN_IFRAME, | ||
ERR_IFRAME_ALREADY_ATTACHED_TO_DOM | ||
} from 'penpal'; | ||
ERR_NO_IFRAME_SRC | ||
} from 'penpal/lib/errorCodes'; | ||
``` | ||
This provides an opportunity for build optimization (using tools like Webpack or Rollup) in cases where code only needs access to the error constants and not the rest of Penpal. | ||
## Security Note | ||
Penpal does not set the [`sandbox` property](https://www.html5rocks.com/en/tutorials/security/sandboxed-iframes/) on the iframe element it creates. If you need to sandbox the iframe, you must, in the parent, create the iframe element, set its `sandbox` property, then pass the iframe to the `connectToChild` method. Failing to set the `sandbox` property on the iframe prior to Penpal adding the iframe to the DOM can fail to properly enforce security. The following example demonstrates setting the `sandbox` property on the iframe from the parent window: | ||
```javascript | ||
import Penpal from 'penpal'; | ||
const iframe = document.createElement('iframe'); | ||
iframe.sandbox = 'allow-scripts'; | ||
const connection = Penpal.connectToChild({ | ||
// URL of page to load into iframe. | ||
url: 'http://example.com/iframe.html', | ||
// Container to which the iframe should be appended. | ||
appendTo: document.getElementById('iframeContainer'), | ||
// The iframe element to use | ||
iframe: iframe, | ||
// Methods parent is exposing to child | ||
methods: { | ||
add(num1, num2) { | ||
return num1 + num2; | ||
} | ||
} | ||
}); | ||
connection.promise.then(child => { | ||
child.multiply(2, 6).then(total => console.log(total)); | ||
child.divide(12, 4).then(total => console.log(total)); | ||
}); | ||
``` | ||
## Supported Browsers | ||
Penpal is designed to run successfully on the most recent versions of Internet Explorer, Edge, Chrome, Firefox, and Safari. | ||
Penpal is designed to run successfully on the most recent versions of Chrome, Firefox, Safari, and Edge. If you need to support Internet Explorer 11, feel free to use version 3.x of Penpal. See the [3.x README](https://github.com/Aaronius/penpal/tree/3.x) for documentation. | ||
@@ -224,4 +188,4 @@ ## Inspiration | ||
* [Postmate](https://github.com/dollarshaveclub/postmate) | ||
* [JSChannel](https://github.com/mozilla/jschannel) | ||
- [Postmate](https://github.com/dollarshaveclub/postmate) | ||
- [JSChannel](https://github.com/mozilla/jschannel) | ||
@@ -228,0 +192,0 @@ ## License |
Sorry, the diff of this file is not supported yet
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
18
1118
73061
24
182
1