| // 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 possiblilty 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} |
| * @suppress {checkStructDictInheritance} |
| */ |
| 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<string, number>} */ |
| 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>} */ |
| 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.Event} 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<string>} 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; |
| /** @preserveTry */ |
| 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). |
| * @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 |
| */ |
| 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<string>}} |
| */ |
| 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<!Object>} events Possible IE8 events. |
| * @return {!Array<!goog.labs.pubsub.BroadcastPubSub.Ie8Event_>} |
| * 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<!goog.labs.pubsub.BroadcastPubSub.Ie8Event_>} events |
| * Possible IE8 events. |
| * @return {!Array<!goog.labs.pubsub.BroadcastPubSub.Ie8Event_>} |
| * 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<!Object>} 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; |
| /** @preserveTry */ |
| 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; |
| /** @preserveTry */ |
| 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_); |
| } |
| }; |