// Copyright 2014 The Closure Library Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS-IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. goog.provide('goog.labs.pubsub.BroadcastPubSub'); goog.require('goog.Disposable'); goog.require('goog.Timer'); goog.require('goog.array'); goog.require('goog.async.run'); goog.require('goog.events.EventHandler'); goog.require('goog.events.EventType'); goog.require('goog.json'); goog.require('goog.log'); goog.require('goog.math'); goog.require('goog.pubsub.PubSub'); goog.require('goog.storage.Storage'); goog.require('goog.storage.mechanism.HTML5LocalStorage'); goog.require('goog.string'); goog.require('goog.userAgent'); /** * Topic-based publish/subscribe messaging implementation that provides * communication between browsing contexts that share the same origin. * * Wrapper around PubSub that utilizes localStorage to broadcast publications to * all browser windows with the same origin as the publishing context. This * allows for topic-based publish/subscribe implementation of strings shared by * all browser contexts that share the same origin. * * Delivery is guaranteed on all browsers except IE8 where topics expire after a * timeout. Publishing of a topic within a callback function provides no * guarantee on ordering in that there is a possibility that separate origin * contexts may see topics in a different order. * * This class is not secure and in certain cases (e.g., a browser crash) data * that is published can persist in localStorage indefinitely. Do not use this * class to communicate private or confidential information. * * On IE8, localStorage is shared by the http and https origins. An attacker * could possibly leverage this to publish to the secure origin. * * goog.labs.pubsub.BroadcastPubSub wraps an instance of PubSub rather than * subclassing because the base PubSub class allows publishing of arbitrary * objects. * * Special handling is done for the IE8 browsers. See the IE8_EVENTS_KEY_ * constant and the {@code publish} function for more information. * * * @constructor @struct @extends {goog.Disposable} */ goog.labs.pubsub.BroadcastPubSub = function() { goog.labs.pubsub.BroadcastPubSub.base(this, 'constructor'); goog.labs.pubsub.BroadcastPubSub.instances_.push(this); /** @private @const */ this.pubSub_ = new goog.pubsub.PubSub(); this.registerDisposable(this.pubSub_); /** @private @const */ this.handler_ = new goog.events.EventHandler(this); this.registerDisposable(this.handler_); /** @private @const */ this.logger_ = goog.log.getLogger('goog.labs.pubsub.BroadcastPubSub'); /** @private @const */ this.mechanism_ = new goog.storage.mechanism.HTML5LocalStorage(); /** @private {goog.storage.Storage} */ this.storage_ = null; /** @private {Object} */ this.ie8LastEventTimes_ = null; /** @private {number} */ this.ie8StartupTimestamp_ = goog.now() - 1; if (this.mechanism_.isAvailable()) { this.storage_ = new goog.storage.Storage(this.mechanism_); var target = window; if (goog.labs.pubsub.BroadcastPubSub.IS_IE8_) { this.ie8LastEventTimes_ = {}; target = document; } this.handler_.listen( target, goog.events.EventType.STORAGE, this.handleStorageEvent_); } }; goog.inherits(goog.labs.pubsub.BroadcastPubSub, goog.Disposable); /** @private @const {!Array} */ goog.labs.pubsub.BroadcastPubSub.instances_ = []; /** * SitePubSub namespace for localStorage. * @private @const */ goog.labs.pubsub.BroadcastPubSub.STORAGE_KEY_ = '_closure_bps'; /** * Handle the storage event and possibly dispatch topics. * @param {!goog.events.BrowserEvent} e Event object. * @private */ goog.labs.pubsub.BroadcastPubSub.prototype.handleStorageEvent_ = function(e) { if (goog.labs.pubsub.BroadcastPubSub.IS_IE8_) { // Even though we have the event, IE8 doesn't update our localStorage until // after we handle the actual event. goog.async.run(this.handleIe8StorageEvent_, this); return; } var browserEvent = e.getBrowserEvent(); if (browserEvent.key != goog.labs.pubsub.BroadcastPubSub.STORAGE_KEY_) { return; } var data = goog.json.parse(browserEvent.newValue); var args = goog.isObject(data) && data['args']; if (goog.isArray(args) && goog.array.every(args, goog.isString)) { this.dispatch_(args); } else { goog.log.warning(this.logger_, 'storage event contained invalid arguments'); } }; /** * Dispatches args on the internal pubsub queue. * @param {!Array} args The arguments to publish. * @private */ goog.labs.pubsub.BroadcastPubSub.prototype.dispatch_ = function(args) { goog.pubsub.PubSub.prototype.publish.apply(this.pubSub_, args); }; /** * Publishes a message to a topic. Remote subscriptions in other tabs/windows * are dispatched via local storage events. Local subscriptions are called * asynchronously via Timer event in order to simulate remote behavior locally. * @param {string} topic Topic to publish to. * @param {...string} var_args String arguments that are applied to each * subscription function. */ goog.labs.pubsub.BroadcastPubSub.prototype.publish = function(topic, var_args) { var args = goog.array.toArray(arguments); // Dispatch to localStorage. if (this.storage_) { // Update topics to use the optional prefix. var now = goog.now(); var data = {'args': args, 'timestamp': now}; if (!goog.labs.pubsub.BroadcastPubSub.IS_IE8_) { // Generated events will contain all the data in modern browsers. this.storage_.set(goog.labs.pubsub.BroadcastPubSub.STORAGE_KEY_, data); this.storage_.remove(goog.labs.pubsub.BroadcastPubSub.STORAGE_KEY_); } else { // With IE8 we need to manage our own events queue. var events = null; try { events = this.storage_.get(goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_); } catch (ex) { goog.log.error( this.logger_, 'publish encountered invalid event queue at ' + goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_); } if (!goog.isArray(events)) { events = []; } // Avoid a race condition where we're publishing in the same // millisecond that another event that may be getting // processed. In short, we try go guarantee that whatever event // we put on the event queue has a timestamp that is older than // any other timestamp in the queue. var lastEvent = events[events.length - 1]; var lastTimestamp = lastEvent && lastEvent['timestamp'] || this.ie8StartupTimestamp_; if (lastTimestamp >= now) { now = lastTimestamp + goog.labs.pubsub.BroadcastPubSub.IE8_TIMESTAMP_UNIQUE_OFFSET_MS_; data['timestamp'] = now; } events.push(data); this.storage_.set( goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_, events); // Cleanup this event in IE8_EVENT_LIFETIME_MS_ milliseconds. goog.Timer.callOnce( goog.bind(this.cleanupIe8StorageEvents_, this, now), goog.labs.pubsub.BroadcastPubSub.IE8_EVENT_LIFETIME_MS_); } } // W3C spec is to not dispatch the storage event to the same window that // modified localStorage. For conforming browsers we have to manually dispatch // the publish event to subscriptions on instances of BroadcastPubSub in the // current window. if (!goog.userAgent.IE) { // Dispatch the publish event to local instances asynchronously to fix some // quirks with timings. The result is that all subscriptions are dispatched // before any future publishes are processed. The effect is that // subscriptions in the same window are dispatched as if they are the result // of a publish from another tab. goog.array.forEach( goog.labs.pubsub.BroadcastPubSub.instances_, function(instance) { goog.async.run(goog.bind(instance.dispatch_, instance, args)); }); } }; /** * Unsubscribes a function from a topic. Only deletes the first match found. * Returns a Boolean indicating whether a subscription was removed. * @param {string} topic Topic to unsubscribe from. * @param {Function} fn Function to unsubscribe. * @param {Object=} opt_context Object in whose context the function was to be * called (the global scope if none). * @return {boolean} Whether a matching subscription was removed. */ goog.labs.pubsub.BroadcastPubSub.prototype.unsubscribe = function( topic, fn, opt_context) { return this.pubSub_.unsubscribe(topic, fn, opt_context); }; /** * Removes a subscription based on the key returned by {@link #subscribe}. No-op * if no matching subscription is found. Returns a Boolean indicating whether a * subscription was removed. * @param {number} key Subscription key. * @return {boolean} Whether a matching subscription was removed. */ goog.labs.pubsub.BroadcastPubSub.prototype.unsubscribeByKey = function(key) { return this.pubSub_.unsubscribeByKey(key); }; /** * Subscribes a function to a topic. The function is invoked as a method on the * given {@code opt_context} object, or in the global scope if no context is * specified. Subscribing the same function to the same topic multiple times * will result in multiple function invocations while publishing. Returns a * subscription key that can be used to unsubscribe the function from the topic * via {@link #unsubscribeByKey}. * @param {string} topic Topic to subscribe to. * @param {Function} fn Function to be invoked when a message is published to * the given topic. * @param {Object=} opt_context Object in whose context the function is to be * called (the global scope if none). * @return {number} Subscription key. */ goog.labs.pubsub.BroadcastPubSub.prototype.subscribe = function( topic, fn, opt_context) { return this.pubSub_.subscribe(topic, fn, opt_context); }; /** * Subscribes a single-use function to a topic. The function is invoked as a * method on the given {@code opt_context} object, or in the global scope if no * context is specified, and is then unsubscribed. Returns a subscription key * that can be used to unsubscribe the function from the topic via {@link * #unsubscribeByKey}. * @param {string} topic Topic to subscribe to. * @param {Function} fn Function to be invoked once and then unsubscribed when * a message is published to the given topic. * @param {Object=} opt_context Object in whose context the function is to be * called (the global scope if none). * @return {number} Subscription key. */ goog.labs.pubsub.BroadcastPubSub.prototype.subscribeOnce = function( topic, fn, opt_context) { return this.pubSub_.subscribeOnce(topic, fn, opt_context); }; /** * Returns the number of subscriptions to the given topic (or all topics if * unspecified). This number will not change while publishing any messages. * @param {string=} opt_topic The topic (all topics if unspecified). * @return {number} Number of subscriptions to the topic. */ goog.labs.pubsub.BroadcastPubSub.prototype.getCount = function(opt_topic) { return this.pubSub_.getCount(opt_topic); }; /** * Clears the subscription list for a topic, or all topics if unspecified. * @param {string=} opt_topic Topic to clear (all topics if unspecified). */ goog.labs.pubsub.BroadcastPubSub.prototype.clear = function(opt_topic) { this.pubSub_.clear(opt_topic); }; /** @override */ goog.labs.pubsub.BroadcastPubSub.prototype.disposeInternal = function() { goog.array.remove(goog.labs.pubsub.BroadcastPubSub.instances_, this); if (goog.labs.pubsub.BroadcastPubSub.IS_IE8_ && goog.isDefAndNotNull(this.storage_) && goog.labs.pubsub.BroadcastPubSub.instances_.length == 0) { this.storage_.remove(goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_); } goog.labs.pubsub.BroadcastPubSub.base(this, 'disposeInternal'); }; /** * Prefix for IE8 storage event queue keys. * @private @const */ goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_PREFIX_ = '_closure_bps_ie8evt'; /** * Time (in milliseconds) that IE8 events should live. If they are not * processed by other windows in this time they will be removed. * @private @const */ goog.labs.pubsub.BroadcastPubSub.IE8_EVENT_LIFETIME_MS_ = 1000 * 10; /** * Time (in milliseconds) that the IE8 event queue should live. * @private @const */ goog.labs.pubsub.BroadcastPubSub.IE8_QUEUE_LIFETIME_MS_ = 1000 * 30; /** * Time delta that is used to distinguish between timestamps of events that * happen in the same millisecond. * @private @const */ goog.labs.pubsub.BroadcastPubSub.IE8_TIMESTAMP_UNIQUE_OFFSET_MS_ = .01; /** * Name for this window/tab's storage key that stores its IE8 event queue. * * The browsers storage events are supposed to track the key which was changed, * the previous value for that key, and the new value of that key. Our * implementation is dependent on this information but IE8 doesn't provide it. * We implement our own event queue using local storage to track this * information in IE8. Since all instances share the same localStorage context * in a particular tab, we share the events queue. * * This key is a static member shared by all instances of BroadcastPubSub in the * same Window context. To avoid read-update-write contention, this key is only * written in a single context in the cleanupIe8StorageEvents_ function. Since * instances in other contexts will read this key there is code in the {@code * publish} function to make sure timestamps are unique even within the same * millisecond. * * @private @const {string} */ goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_ = goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_PREFIX_ + goog.math.randomInt(1e9); /** * All instances of this object should access elements using strings and not * attributes. Since we are communicating across browser tabs we could be * dealing with different versions of javascript and thus may have different * obfuscation in each tab. * @private @typedef {{'timestamp': number, 'args': !Array}} */ goog.labs.pubsub.BroadcastPubSub.Ie8Event_; /** @private @const */ goog.labs.pubsub.BroadcastPubSub.IS_IE8_ = goog.userAgent.IE && goog.userAgent.DOCUMENT_MODE == 8; /** * Validates an event object. * @param {!Object} obj The object to validate as an Event. * @return {?goog.labs.pubsub.BroadcastPubSub.Ie8Event_} A valid * event object or null if the object is invalid. * @private */ goog.labs.pubsub.BroadcastPubSub.validateIe8Event_ = function(obj) { if (goog.isObject(obj) && goog.isNumber(obj['timestamp']) && goog.array.every(obj['args'], goog.isString)) { return {'timestamp': obj['timestamp'], 'args': obj['args']}; } return null; }; /** * Returns an array of valid IE8 events. * @param {!Array} events Possible IE8 events. * @return {!Array} * Valid IE8 events. * @private */ goog.labs.pubsub.BroadcastPubSub.filterValidIe8Events_ = function(events) { return goog.array.filter( goog.array.map( events, goog.labs.pubsub.BroadcastPubSub.validateIe8Event_), goog.isDefAndNotNull); }; /** * Returns the IE8 events that have a timestamp later than the provided * timestamp. * @param {number} timestamp Expired timestamp. * @param {!Array} events * Possible IE8 events. * @return {!Array} * Unexpired IE8 events. * @private */ goog.labs.pubsub.BroadcastPubSub.filterNewIe8Events_ = function( timestamp, events) { return goog.array.filter( events, function(event) { return event['timestamp'] > timestamp; }); }; /** * Processes the events array for key if all elements are valid IE8 events. * @param {string} key The key in localStorage where the event queue is stored. * @param {!Array} events Array of possible events stored at key. * @return {boolean} Return true if all elements in the array are valid * events, false otherwise. * @private */ goog.labs.pubsub.BroadcastPubSub.prototype.maybeProcessIe8Events_ = function( key, events) { if (!events.length) { return false; } var validEvents = goog.labs.pubsub.BroadcastPubSub.filterValidIe8Events_(events); if (validEvents.length == events.length) { var lastTimestamp = goog.array.peek(validEvents)['timestamp']; var previousTime = this.ie8LastEventTimes_[key] || this.ie8StartupTimestamp_; if (lastTimestamp > previousTime - goog.labs.pubsub.BroadcastPubSub.IE8_QUEUE_LIFETIME_MS_) { this.ie8LastEventTimes_[key] = lastTimestamp; validEvents = goog.labs.pubsub.BroadcastPubSub.filterNewIe8Events_( previousTime, validEvents); for (var i = 0, event; event = validEvents[i]; i++) { this.dispatch_(event['args']); } return true; } } else { goog.log.warning(this.logger_, 'invalid events found in queue ' + key); } return false; }; /** * Handle the storage event and possibly dispatch events. Looks through all keys * in localStorage for valid keys. * @private */ goog.labs.pubsub.BroadcastPubSub.prototype.handleIe8StorageEvent_ = function() { var numKeys = this.mechanism_.getCount(); for (var idx = 0; idx < numKeys; idx++) { var key = this.mechanism_.key(idx); // Don't process events we generated. The W3C standard says that storage // events should be queued by the browser for each window whose document's // storage object is affected by a change in localStorage. Chrome, Firefox, // and modern IE don't dispatch the event to the window which made the // change. This code simulates that behavior in IE8. if (!(goog.isString(key) && goog.string.startsWith( key, goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_PREFIX_))) { continue; } var events = null; try { events = this.storage_.get(key); } catch (ex) { goog.log.warning(this.logger_, 'invalid remote event queue ' + key); } if (!(goog.isArray(events) && this.maybeProcessIe8Events_(key, events))) { // Events is not an array, empty, contains invalid events, or expired. this.storage_.remove(key); } } }; /** * Cleanup our IE8 event queue by removing any events that come at or before the * given timestamp. * @param {number} timestamp Maximum timestamp to remove from the queue. * @private */ goog.labs.pubsub.BroadcastPubSub.prototype.cleanupIe8StorageEvents_ = function( timestamp) { var events = null; try { events = this.storage_.get(goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_); } catch (ex) { goog.log.error( this.logger_, 'cleanup encountered invalid event queue key ' + goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_); } if (!goog.isArray(events)) { this.storage_.remove(goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_); return; } events = goog.labs.pubsub.BroadcastPubSub.filterNewIe8Events_( timestamp, goog.labs.pubsub.BroadcastPubSub.filterValidIe8Events_(events)); if (events.length > 0) { this.storage_.set(goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_, events); } else { this.storage_.remove(goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_); } };