UNPKG

chronosjs

Version:

JS Channels Mechanism

774 lines (687 loc) 33.5 kB
;(function (root, chronosRoot, factory) { "use strict"; /* istanbul ignore if */ //<amd> if ("function" === typeof define && define.amd) { // AMD. Register as an anonymous module. define("Chronos.PostMessageChannel", ["Chronos.PostMessageUtilities", "Chronos.PostMessageChannelPolyfill"], function (PostMessageUtilities, PostMessageChannelPolyfill) { return factory(root, chronosRoot, PostMessageUtilities, PostMessageChannelPolyfill, true); }); return; } //</amd> /* istanbul ignore next */ if ("object" !== typeof exports) { /** * @depend ./PostMessageUtilities.js * @depend ./PostMessageChannelPolyfill.js */ chronosRoot.Chronos = chronosRoot.Chronos || {}; factory(root, chronosRoot.Chronos, chronosRoot.Chronos.PostMessageUtilities, chronosRoot.Chronos.PostMessageChannelPolyfill); } }(this, typeof ChronosRoot === "undefined" ? this : ChronosRoot, function (root, exports, PostMessageUtilities, PostMessageChannelPolyfill, hide) { "use strict"; /*jshint validthis:true */ var IFRAME_PREFIX = "LPFRM"; var TOKEN_PREFIX = "LPTKN"; var HANSHAKE_PREFIX = "HNDSK"; var DEFAULT_CONCURRENCY = 100; var DEFAULT_HANDSHAKE_RETRY_INTERVAL = 5000; var DEFAULT_HANDSHAKE_RETRY_ATTEMPTS = 3; var DEFAULT_BODY_LOAD_DELAY = 100; /** * PostMessageChannel constructor * @constructor * @param {Object} options the configuration options for the instance * @param {Object} options.target - the target iframe or iframe configuration * @param {String} [options.target.url] - the url to load * @param {Object} [options.target.container] - the container in which the iframe should be created (if not supplied, document.body will be used) * @param {String} [options.target.style] - the CSS style to apply * @param {String} [options.target.style.width] width of iframe * @param {String} [options.target.style.height] height of iframe * ..... * @param {Boolean} [options.target.bust = true] - optional flag to indicate usage of cache buster when loading the iframe (default to true) * @param {Function} [options.target.callback] - a callback to invoke after the iframe had been loaded, * @param {Object} [options.target.context] - optional context for the callback * @param {Function|Object} [options.onready] - optional data for usage when iframe had been loaded { * @param {Function} [options.onready.callback] - a callback to invoke after the iframe had been loaded * @param {Object} [options.onready.context] - optional context for the callback * @param {Boolean} [options.removeDispose] - optional flag for removal of the iframe on dispose * @param {Function} [options.serialize = JSON.stringify] - optional serialization method for post message * @param {Function} [options.deserialize = JSON.parse] - optional deserialization method for post message * @param {String} [options.targetOrigin] optional targetOrigin to be used when posting the message (must be supplied in case of external iframe) * @param {Number} [options.maxConcurrency = 100] - optional maximum concurrency that can be managed by the component before dropping * @param {Number} [options.handshakeInterval = 5000] - optional handshake interval for retries * @param {Number} [options.handshakeAttempts = 3] - optional number of retries handshake attempts * @param {String} [options.hostParam] - optional parameter of the host parameter name (default is lpHost) * @param {Function} onmessage - the handler for incoming messages */ function PostMessageChannel(options, onmessage) { /* istanbul ignore if */ // For forcing new keyword if (false === (this instanceof PostMessageChannel)) { return new PostMessageChannel(options, onmessage); } this.initialize(options, onmessage); } PostMessageChannel.prototype = (function () { /** * Method for initialization * @param {Object} options the configuration options for the instance * @param {Object} options.target - the target iframe or iframe configuration * @param {String} [options.target.url] - the url to load * @param {Object} [options.target.container] - the container in which the iframe should be created (if not supplied, document.body will be used) * @param {String} [options.target.style] - the CSS style to apply * @param {String} [options.target.style.width] width of iframe * @param {String} [options.target.style.height] height of iframe * ..... * @param {Boolean} [options.target.bust = true] - optional flag to indicate usage of cache buster when loading the iframe (default to true) * @param {Function} [options.target.callback] - a callback to invoke after the iframe had been loaded, * @param {Object} [options.target.context] - optional context for the callback * @param {Function|Object} [options.onready] - optional data for usage when iframe had been loaded { * @param {Function} [options.onready.callback] - a callback to invoke after the iframe had been loaded * @param {Object} [options.onready.context] - optional context for the callback * @param {Boolean} [options.removeDispose] - optional flag for removal of the iframe on dispose * @param {Function} [options.serialize = JSON.stringify] - optional serialization method for post message * @param {Function} [options.deserialize = JSON.parse] - optional deserialization method for post message * @param {String} [options.targetOrigin] optional targetOrigin to be used when posting the message (must be supplied in case of external iframe) * @param {Number} [options.maxConcurrency = 100] - optional maximum concurrency that can be managed by the component before dropping * @param {Number} [options.handshakeInterval = 5000] - optional handshake interval for retries * @param {Number} [options.handshakeAttempts = 3] - optional number of retries handshake attempts * @param {String} [options.hostParam] - optional parameter of the host parameter name (default is lpHost) * @param {Function} onmessage - the handler for incoming messages */ function initialize(options, onmessage) { var handleMessage; var handler; if (!this.initialized) { this.hosted = false; this.messageQueue = []; options = options || {}; handler = _initParameters.call(this, options, onmessage); if (!_isNativeMessageChannelSupported.call(this)) { this.receiver = new PostMessageChannelPolyfill(this.target, { serialize: this.serialize, deserialize: this.deserialize }); this.receiver.onmessage = handler; } if (this.hosted || !_isNativeMessageChannelSupported.call(this)) { handleMessage = _getHandleMessage(handler).bind(this); this.removeListener = PostMessageUtilities.addEventListener(root, "message", handleMessage); } else if (_isNativeMessageChannelSupported.call(this)) { this.channelFactory(); } if (this.target && !this.loading && !this.ready) { _kickStartHandshake.call(this, handler, handleMessage); } this.initialized = true; } } /** * Method for removing the handler * @param {String} name - a name of the reference which holds the remove handler on this context, * @param {Boolean} ignore - optional flag to indicate whether to ignore the execution of the remove handler * */ function _removeHandler(name, ignore) { // Remove handler if needed var func = PostMessageUtilities.parseFunction(this[name]); if (func) { if (!ignore) { func.call(this); } this[name] = void 0; delete this[name]; } } /** * Method for removing the timer */ function _removeTimer(ignore) { // Remove timer if needed _removeHandler.call(this, "rmtimer", ignore); } /** * Method for removing the timer */ function _removeLoadedHandler(ignore) { // Remove load handler if needed _removeHandler.call(this, "rmload", ignore); } /** * Method for disposing the object */ function dispose() { if (!this.disposed) { if (this.removeListener) { this.removeListener.call(this); this.removeListener = void 0; } if (this.targetUrl && this.target || this.removeDispose) { try { if (this.targetContainer) { this.targetContainer.removeChild(this.target); } else { document.body.removeChild(this.target); } } catch(ex) { /* istanbul ignore next */ PostMessageUtilities.log("Error while trying to remove the iframe from the container", "ERROR", "PostMessageChannel"); } } // Remove load handler if needed _removeTimer.call(this); // Remove timer if needed _removeLoadedHandler.call(this); this.messageQueue.length = 0; this.messageQueue = void 0; this.channel = void 0; this.onready = void 0; this.disposed = true; } } /** * Method to post the message to the target * @param {Object} message - the message to post * @param {Object} [target] - optional target for post * @param {Boolean} [force = false] - force post even if not ready */ function postMessage(message, target, force) { var consumer; var parsed; if (!this.disposed) { try { if (message) { if (this.ready || force) { // Post the message consumer = target || this.receiver; parsed = _prepareMessage.call(this, message); consumer.postMessage(parsed); return true; } else if (this.maxConcurrency >= this.messageQueue.length) { // Need to delay/queue messages till target is ready this.messageQueue.push(message); return true; } else { return false; } } } catch(ex) { /* istanbul ignore next */ PostMessageUtilities.log("Error while trying to post the message", "ERROR", "PostMessageChannel"); return false; } } } function _kickStartHandshake(handler, handleMessage) { var initiated; try { initiated = _handshake.call(this); } catch (ex) { initiated = false; } if (!initiated) { // Fallback to pure postMessage this.channel = false; this.receiver = new PostMessageChannelPolyfill(this.target, { serialize: this.serialize, deserialize: this.deserialize }); this.receiver.onmessage = handler; if (!this.hosted) { handleMessage = _getHandleMessage(handler).bind(this); this.removeListener = PostMessageUtilities.addEventListener(root, "message", handleMessage); } _handshake.call(this); } this.handshakeAttempts--; PostMessageUtilities.delay(function () { if (!this.disposed && !this.hosted && !this.ready) { this.rmload = _addLoadHandler.call(this, this.target); this.rmtimer = PostMessageUtilities.delay(_handshake.bind(this, this.handshakeInterval), this.handshakeInterval); } }.bind(this)); } function _initParameters(options, onmessage) { var handler; _simpleParametersInit.call(this, options); handler = _wrapMessageHandler(onmessage).bind(this); this.channelFactory = _hookupMessageChannel.call(this, handler); // No Iframe - We are inside it (hosted) initialized by the host/container if (!options.target || (options.target !== root || options.target === root.top) && "undefined" !== typeof Window && options.target instanceof Window) { this.hosted = true; this.target = options.target || root.top; } else if (options.target.contentWindow) { // We've got a reference to an "external" iframe this.target = options.target; } else if (options.target.url) { // We've got the needed configuration for creating an iframe this.targetUrl = options.target.url; this.targetOrigin = this.targetOrigin || PostMessageUtilities.getHost(options.target.url); } if (!this.hosted) { this.token = PostMessageUtilities.createUniqueSequence(TOKEN_PREFIX + PostMessageUtilities.SEQUENCE_FORMAT); } if (this.targetUrl) { // We've got the needed configuration for creating an iframe this.loading = true; this.targetContainer = options.target.container || document.body; this.target = _createIFrame.call(this, options.target, this.targetContainer); } return handler; } function _simpleParametersInit(options) { this.serialize = PostMessageUtilities.parseFunction(options.serialize, PostMessageUtilities.stringify); this.deserialize = PostMessageUtilities.parseFunction(options.deserialize, JSON.parse); this.targetOrigin = options.targetOrigin; this.maxConcurrency = PostMessageUtilities.parseNumber(options.maxConcurrency, DEFAULT_CONCURRENCY); this.handshakeInterval = PostMessageUtilities.parseNumber(options.handshakeInterval, DEFAULT_HANDSHAKE_RETRY_INTERVAL); this.handshakeAttemptsOrig = PostMessageUtilities.parseNumber(options.handshakeAttempts, DEFAULT_HANDSHAKE_RETRY_ATTEMPTS); this.handshakeAttempts = this.handshakeAttemptsOrig; this.hostParam = options.hostParam; this.channel = "undefined" !== typeof options.channel ? options.channel : _getChannelUrlIndicator(); this.useObjects = options.useObjects; this.onready = _wrapReadyCallback(options.onready, options.target).bind(this); this.removeDispose = options.removeDispose; } /** * Method for handling the initial handler binding for needed event listeners * @param {Object} handler - the event object on message */ function _getHandleMessage(handler) { return function(event) { var handshake; var previous; if (event.ports && 0 < event.ports.length) { this.receiver = event.ports[0]; if (_isHandshake.call(this, event)) { if (!this.token) { this.token = event.data; } } this.receiver.start(); // Swap Listeners previous = this.removeListener.bind(this); this.removeListener = PostMessageUtilities.addEventListener(this.receiver, "message", handler); previous(); if (!this.disposed && this.hosted && !this.ready) { handshake = true; } } else { if (_isHandshake.call(this, event)) { if (!this.token) { this.token = event.data; } if (!this.disposed && this.hosted && !this.ready) { handshake = true; } } else if (this.token) { this.receiver.receive.call(this.receiver, event); } } if (handshake) { this.receiver.postMessage(HANSHAKE_PREFIX + this.token); _onReady.call(this); } }; } /** * Method to prepare the message for posting to the target * @param message * @returns {*} * @private */ function _prepareMessage(message) { _tokenize.call(this, message); return this.serialize(message); } /** * Method to get url indication for using message channel or polyfill * @returns {Boolean} indication for message channel usage * @private */ /* istanbul ignore next: it is being covered at the iframe side - cannot add it to coverage matrix */ function _getChannelUrlIndicator() { if ("true" === PostMessageUtilities.getURLParameter("lpPMCPolyfill")) { return false; } } /** * Method to create and hookup message channel factory for further use * @param {Function} onmessage - the message handler to be used with the channel * @private */ function _hookupMessageChannel(onmessage) { return function() { this.channel = new MessageChannel(); this.receiver = this.channel.port1; this.dispatcher = this.channel.port2; this.receiver.onmessage = onmessage; this.neutered = false; }.bind(this); } /** * Method for applying the token if any on the message * @param {Object} message - the message to be tokenize * @private */ function _tokenize(message) { if (this.token) { message.token = this.token; } } /** * Method for applying the token if any on the message * @param {Object} message - the message to be tokenize * @private */ function _validateToken(message) { return (message && message.token === this.token); } /** * Method to validate whether an event is for handshake * @param {Object} event - the event object on message * @private */ function _isHandshake(event) { return (event && event.data && "string" === typeof event.data && (0 === event.data.indexOf(TOKEN_PREFIX) || (HANSHAKE_PREFIX + this.token) === event.data)); } /** * Method for wrapping the callback of iframe ready * @param {Function} [onready] - the handler for iframe ready * @param {Object} [target] - the target iframe configuration * @returns {Function} handler function for messages * @private */ function _wrapReadyCallback(onready, target) { return function(err) { if (target && "function" === typeof target.callback) { target.callback.call(target.context, err, this.target); } if (onready) { if ("function" === typeof onready) { onready(err, this.target); } else if ("function" === typeof onready.callback) { onready.callback.call(onready.context, err, this.target); } } }; } /** * Method for wrapping the handler of the postmessage for parsing * @param {Function} onmessage - the handler for incoming messages to invoke * @returns {Function} handler function for messages * @private */ function _wrapMessageHandler(onmessage) { return function(message) { var msgObject; if (!message.origin || "*" === message.origin || this.targetOrigin === message.origin) { if (_isHandshake.call(this, message) && !this.disposed && !this.hosted && !this.ready) { _onReady.call(this); } else { try { msgObject = this.deserialize(message.data); if (_validateToken.call(this, msgObject)) { return onmessage && onmessage(msgObject); } } catch (ex) { msgObject = message.data || message; PostMessageUtilities.log("Error while trying to handle the message", "ERROR", "PostMessageChannel"); } return msgObject || message; } } }; } /** * Method to check whether the browser supports MessageChannel natively * @returns {Boolean} support flag * @private */ function _isNativeMessageChannelSupported() { return false !== this.channel && "undefined" !== typeof MessageChannel && "undefined" !== typeof MessagePort; } /** * Method to hookup the initial "handshake" between the two parties (window and iframe) So they can start their communication * @param {Number} retry - retry in milliseconds * @returns {Boolean} indication if handshake initiated * @private */ function _handshake(retry) { // Remove load handler if needed _removeTimer.call(this, true); if (!this.disposed && !this.ready) { if (!_isNativeMessageChannelSupported.call(this)) { this.targetOrigin = this.targetOrigin || PostMessageUtilities.resolveOrigin(this.target) || "*"; } if (!this.hosted) { if (_isNativeMessageChannelSupported.call(this)) { try { if (this.neutered) { this.channelFactory(); } this.target.contentWindow.postMessage(this.token, this.targetOrigin, [ this.dispatcher ]); this.neutered = true; } catch(ex) { /* istanbul ignore next */ return false; } } else { this.target.contentWindow.postMessage(this.token, this.targetOrigin); } } } if (!this.disposed && !this.ready && retry) { if (0 < this.handshakeAttempts) { this.handshakeAttempts--; this.rmtimer = PostMessageUtilities.delay(_handshake.bind(this, retry), retry); } else { this.onready(new Error("Loading: Operation Timeout!")); } } return true; } /** * Method to mark ready, and process queued/waiting messages if any * @private */ function _onReady() { if (!this.disposed && !this.ready) { this.ready = true; // Handshake was successful, Channel is ready for messages // Set the counter back to original value for dealing with iframe reloads this.handshakeAttempts = this.handshakeAttemptsOrig; // Process queued messages if any if (this.messageQueue && this.messageQueue.length) { PostMessageUtilities.delay(function() { var message; var parsed; if (!this.disposed && this.ready) { while (this.messageQueue && this.messageQueue.length) { message = this.messageQueue.shift(); try { parsed = _prepareMessage.call(this, message); this.receiver.postMessage(parsed); } catch(ex) { /* istanbul ignore next */ PostMessageUtilities.log("Error while trying to post the message from queue", "ERROR", "PostMessageChannel"); } } // Invoke the callback for ready this.onready(); } }.bind(this)); } else { // Invoke the callback for ready this.onready(); } } } /** * Method to enable running a callback once the document body is ready * @param {Object} [options] Configuration options * @param {Function} options.onready - the callback to run when ready * @param {Object} [options.doc = root.document] - document to refer to * @param {Number} [options.delay = 0] - milliseconds to delay the execution * @private */ function _waitForBody(options) { options = options || {}; var onready = options.onready; var doc = options.doc || root.document; var delay = options.delay; function _ready() { if (doc.body) { onready(); } else { PostMessageUtilities.delay(_ready, delay || DEFAULT_BODY_LOAD_DELAY); } } PostMessageUtilities.delay(_ready, delay || false); } /** * Creates an iFrame in memory and sets the default attributes except the actual URL * Does not attach to DOM at this point * @param {Object} options a passed in configuration options * @param {String} options.url - the url to load, * @param {String} [options.style] - the CSS style to apply * @param {String} [options.style.width] width of iframe * @param {String} [options.style.height] height of iframe * ..... * @param {Boolean} [options.bust = true] - optional flag to indicate usage of cache buster when loading the iframe (default to true), * @param {Function} [options.callback] - a callback to invoke after the iframe had been loaded, * @param {Object} [options.context] - optional context for the callback * @param {Object} [container] - the container in which the iframe should be created (if not supplied, document.body will be used) * @returns {Element} the attached iFrame element * @private */ function _createIFrame(options, container) { var frame = document.createElement("IFRAME"); var name = PostMessageUtilities.createUniqueSequence(IFRAME_PREFIX + PostMessageUtilities.SEQUENCE_FORMAT); var delay = options.delayLoad; var defaultAttributes = { "id": name, "name" :name, "tabindex": "-1", // To prevent it getting focus when tabbing through the page "aria-hidden": "true", // To prevent it being picked up by screen-readers "title": "", // Adding an empty title for accessibility "role": "presentation", // Adding a presentation role http://yahoodevelopers.tumblr.com/post/59489724815/easy-fixes-to-common-accessibility-problems "allowTransparency":"true" }; var defaultStyle = { width :"0px", height : "0px", position :"absolute", top : "-1000px", left : "-1000px" }; options.attributes = options.attributes || defaultAttributes; for (var key in options.attributes){ if (options.attributes.hasOwnProperty(key)) { frame.setAttribute(key, options.attributes[key]); } } options.style = options.style || defaultStyle; if (options.style) { for (var attr in options.style) { if (options.style.hasOwnProperty(attr)) { frame.style[attr] = options.style[attr]; } } } // Append and hookup after body tag opens _waitForBody({ delay: delay, onready: function() { (container || document.body).appendChild(frame); this.rmload = _addLoadHandler.call(this, frame); _setIFrameLocation.call(this, frame, options.url, (false !== options.bust)); }.bind(this) }); return frame; } /** * Add load handler for the iframe to make sure it is loaded * @param {Object} frame - the actual DOM iframe * @returns {Function} the remove handler function * @private */ function _addLoadHandler(frame) { var load = function() { this.loading = false; if (this.handshakeAttempts === this.handshakeAttemptsOrig) { // Probably a first try for handshake or a reload of the iframe, // Either way, we'll need to perform handshake, so ready flag should be set to false (if not already) this.ready = false; } _handshake.call(this, this.handshakeInterval); }.bind(this); PostMessageUtilities.addEventListener(frame, "load", load); return function() { _removeLoadHandler(frame, load); }; } /** * Remove load handler for the iframe * @param {Object} frame - the actual DOM iframe * @param {Function} handler - the actual registered load handler * @private */ function _removeLoadHandler(frame, handler) { PostMessageUtilities.removeEventListener(frame, "load", handler); } /** * Sets the iFrame location using a cache bust mechanism, * making sure the iFrame is actually loaded and not from cache * @param {Object} frame - the iframe DOM object * @param {String} src - the source url for the iframe * @param {Boolean} bust - flag to indicate usage of cache buster when loading the iframe * @private */ function _setIFrameLocation(frame, src, bust){ src += (0 < src.indexOf("?") ? "&" : "?"); if (bust) { src += "bust="; src += (new Date()).getTime() + "&"; } src += ((this.hostParam ? "hostParam=" + this.hostParam + "&" + this.hostParam + "=" : "lpHost=") + encodeURIComponent(PostMessageUtilities.getHost(void 0, frame, true))); if (!_isNativeMessageChannelSupported.call(this)) { src += "&lpPMCPolyfill=true"; } if (false === this.useObjects) { src += "&lpPMDeSerialize=true"; } frame.setAttribute("src", src); } return { initialize: initialize, postMessage: postMessage, dispose: dispose }; }()); // attach properties to the exports object to define // the exported module properties. if (!hide) { exports.PostMessageChannel = PostMessageChannel; } return PostMessageChannel; }));