broadcastpubsub.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. // Copyright 2014 The Closure Library Authors. All Rights Reserved.
  2. //
  3. // Licensed under the Apache License, Version 2.0 (the "License");
  4. // you may not use this file except in compliance with the License.
  5. // You may obtain a copy of the License at
  6. //
  7. // http://www.apache.org/licenses/LICENSE-2.0
  8. //
  9. // Unless required by applicable law or agreed to in writing, software
  10. // distributed under the License is distributed on an "AS-IS" BASIS,
  11. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. // See the License for the specific language governing permissions and
  13. // limitations under the License.
  14. goog.provide('goog.labs.pubsub.BroadcastPubSub');
  15. goog.require('goog.Disposable');
  16. goog.require('goog.Timer');
  17. goog.require('goog.array');
  18. goog.require('goog.async.run');
  19. goog.require('goog.events.EventHandler');
  20. goog.require('goog.events.EventType');
  21. goog.require('goog.json');
  22. goog.require('goog.log');
  23. goog.require('goog.math');
  24. goog.require('goog.pubsub.PubSub');
  25. goog.require('goog.storage.Storage');
  26. goog.require('goog.storage.mechanism.HTML5LocalStorage');
  27. goog.require('goog.string');
  28. goog.require('goog.userAgent');
  29. /**
  30. * Topic-based publish/subscribe messaging implementation that provides
  31. * communication between browsing contexts that share the same origin.
  32. *
  33. * Wrapper around PubSub that utilizes localStorage to broadcast publications to
  34. * all browser windows with the same origin as the publishing context. This
  35. * allows for topic-based publish/subscribe implementation of strings shared by
  36. * all browser contexts that share the same origin.
  37. *
  38. * Delivery is guaranteed on all browsers except IE8 where topics expire after a
  39. * timeout. Publishing of a topic within a callback function provides no
  40. * guarantee on ordering in that there is a possibility that separate origin
  41. * contexts may see topics in a different order.
  42. *
  43. * This class is not secure and in certain cases (e.g., a browser crash) data
  44. * that is published can persist in localStorage indefinitely. Do not use this
  45. * class to communicate private or confidential information.
  46. *
  47. * On IE8, localStorage is shared by the http and https origins. An attacker
  48. * could possibly leverage this to publish to the secure origin.
  49. *
  50. * goog.labs.pubsub.BroadcastPubSub wraps an instance of PubSub rather than
  51. * subclassing because the base PubSub class allows publishing of arbitrary
  52. * objects.
  53. *
  54. * Special handling is done for the IE8 browsers. See the IE8_EVENTS_KEY_
  55. * constant and the {@code publish} function for more information.
  56. *
  57. *
  58. * @constructor @struct @extends {goog.Disposable}
  59. */
  60. goog.labs.pubsub.BroadcastPubSub = function() {
  61. goog.labs.pubsub.BroadcastPubSub.base(this, 'constructor');
  62. goog.labs.pubsub.BroadcastPubSub.instances_.push(this);
  63. /** @private @const */
  64. this.pubSub_ = new goog.pubsub.PubSub();
  65. this.registerDisposable(this.pubSub_);
  66. /** @private @const */
  67. this.handler_ = new goog.events.EventHandler(this);
  68. this.registerDisposable(this.handler_);
  69. /** @private @const */
  70. this.logger_ = goog.log.getLogger('goog.labs.pubsub.BroadcastPubSub');
  71. /** @private @const */
  72. this.mechanism_ = new goog.storage.mechanism.HTML5LocalStorage();
  73. /** @private {goog.storage.Storage} */
  74. this.storage_ = null;
  75. /** @private {Object<string, number>} */
  76. this.ie8LastEventTimes_ = null;
  77. /** @private {number} */
  78. this.ie8StartupTimestamp_ = goog.now() - 1;
  79. if (this.mechanism_.isAvailable()) {
  80. this.storage_ = new goog.storage.Storage(this.mechanism_);
  81. var target = window;
  82. if (goog.labs.pubsub.BroadcastPubSub.IS_IE8_) {
  83. this.ie8LastEventTimes_ = {};
  84. target = document;
  85. }
  86. this.handler_.listen(
  87. target, goog.events.EventType.STORAGE, this.handleStorageEvent_);
  88. }
  89. };
  90. goog.inherits(goog.labs.pubsub.BroadcastPubSub, goog.Disposable);
  91. /** @private @const {!Array<!goog.labs.pubsub.BroadcastPubSub>} */
  92. goog.labs.pubsub.BroadcastPubSub.instances_ = [];
  93. /**
  94. * SitePubSub namespace for localStorage.
  95. * @private @const
  96. */
  97. goog.labs.pubsub.BroadcastPubSub.STORAGE_KEY_ = '_closure_bps';
  98. /**
  99. * Handle the storage event and possibly dispatch topics.
  100. * @param {!goog.events.BrowserEvent} e Event object.
  101. * @private
  102. */
  103. goog.labs.pubsub.BroadcastPubSub.prototype.handleStorageEvent_ = function(e) {
  104. if (goog.labs.pubsub.BroadcastPubSub.IS_IE8_) {
  105. // Even though we have the event, IE8 doesn't update our localStorage until
  106. // after we handle the actual event.
  107. goog.async.run(this.handleIe8StorageEvent_, this);
  108. return;
  109. }
  110. var browserEvent = e.getBrowserEvent();
  111. if (browserEvent.key != goog.labs.pubsub.BroadcastPubSub.STORAGE_KEY_) {
  112. return;
  113. }
  114. var data = goog.json.parse(browserEvent.newValue);
  115. var args = goog.isObject(data) && data['args'];
  116. if (goog.isArray(args) && goog.array.every(args, goog.isString)) {
  117. this.dispatch_(args);
  118. } else {
  119. goog.log.warning(this.logger_, 'storage event contained invalid arguments');
  120. }
  121. };
  122. /**
  123. * Dispatches args on the internal pubsub queue.
  124. * @param {!Array<string>} args The arguments to publish.
  125. * @private
  126. */
  127. goog.labs.pubsub.BroadcastPubSub.prototype.dispatch_ = function(args) {
  128. goog.pubsub.PubSub.prototype.publish.apply(this.pubSub_, args);
  129. };
  130. /**
  131. * Publishes a message to a topic. Remote subscriptions in other tabs/windows
  132. * are dispatched via local storage events. Local subscriptions are called
  133. * asynchronously via Timer event in order to simulate remote behavior locally.
  134. * @param {string} topic Topic to publish to.
  135. * @param {...string} var_args String arguments that are applied to each
  136. * subscription function.
  137. */
  138. goog.labs.pubsub.BroadcastPubSub.prototype.publish = function(topic, var_args) {
  139. var args = goog.array.toArray(arguments);
  140. // Dispatch to localStorage.
  141. if (this.storage_) {
  142. // Update topics to use the optional prefix.
  143. var now = goog.now();
  144. var data = {'args': args, 'timestamp': now};
  145. if (!goog.labs.pubsub.BroadcastPubSub.IS_IE8_) {
  146. // Generated events will contain all the data in modern browsers.
  147. this.storage_.set(goog.labs.pubsub.BroadcastPubSub.STORAGE_KEY_, data);
  148. this.storage_.remove(goog.labs.pubsub.BroadcastPubSub.STORAGE_KEY_);
  149. } else {
  150. // With IE8 we need to manage our own events queue.
  151. var events = null;
  152. try {
  153. events =
  154. this.storage_.get(goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_);
  155. } catch (ex) {
  156. goog.log.error(
  157. this.logger_, 'publish encountered invalid event queue at ' +
  158. goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_);
  159. }
  160. if (!goog.isArray(events)) {
  161. events = [];
  162. }
  163. // Avoid a race condition where we're publishing in the same
  164. // millisecond that another event that may be getting
  165. // processed. In short, we try go guarantee that whatever event
  166. // we put on the event queue has a timestamp that is older than
  167. // any other timestamp in the queue.
  168. var lastEvent = events[events.length - 1];
  169. var lastTimestamp =
  170. lastEvent && lastEvent['timestamp'] || this.ie8StartupTimestamp_;
  171. if (lastTimestamp >= now) {
  172. now = lastTimestamp +
  173. goog.labs.pubsub.BroadcastPubSub.IE8_TIMESTAMP_UNIQUE_OFFSET_MS_;
  174. data['timestamp'] = now;
  175. }
  176. events.push(data);
  177. this.storage_.set(
  178. goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_, events);
  179. // Cleanup this event in IE8_EVENT_LIFETIME_MS_ milliseconds.
  180. goog.Timer.callOnce(
  181. goog.bind(this.cleanupIe8StorageEvents_, this, now),
  182. goog.labs.pubsub.BroadcastPubSub.IE8_EVENT_LIFETIME_MS_);
  183. }
  184. }
  185. // W3C spec is to not dispatch the storage event to the same window that
  186. // modified localStorage. For conforming browsers we have to manually dispatch
  187. // the publish event to subscriptions on instances of BroadcastPubSub in the
  188. // current window.
  189. if (!goog.userAgent.IE) {
  190. // Dispatch the publish event to local instances asynchronously to fix some
  191. // quirks with timings. The result is that all subscriptions are dispatched
  192. // before any future publishes are processed. The effect is that
  193. // subscriptions in the same window are dispatched as if they are the result
  194. // of a publish from another tab.
  195. goog.array.forEach(
  196. goog.labs.pubsub.BroadcastPubSub.instances_, function(instance) {
  197. goog.async.run(goog.bind(instance.dispatch_, instance, args));
  198. });
  199. }
  200. };
  201. /**
  202. * Unsubscribes a function from a topic. Only deletes the first match found.
  203. * Returns a Boolean indicating whether a subscription was removed.
  204. * @param {string} topic Topic to unsubscribe from.
  205. * @param {Function} fn Function to unsubscribe.
  206. * @param {Object=} opt_context Object in whose context the function was to be
  207. * called (the global scope if none).
  208. * @return {boolean} Whether a matching subscription was removed.
  209. */
  210. goog.labs.pubsub.BroadcastPubSub.prototype.unsubscribe = function(
  211. topic, fn, opt_context) {
  212. return this.pubSub_.unsubscribe(topic, fn, opt_context);
  213. };
  214. /**
  215. * Removes a subscription based on the key returned by {@link #subscribe}. No-op
  216. * if no matching subscription is found. Returns a Boolean indicating whether a
  217. * subscription was removed.
  218. * @param {number} key Subscription key.
  219. * @return {boolean} Whether a matching subscription was removed.
  220. */
  221. goog.labs.pubsub.BroadcastPubSub.prototype.unsubscribeByKey = function(key) {
  222. return this.pubSub_.unsubscribeByKey(key);
  223. };
  224. /**
  225. * Subscribes a function to a topic. The function is invoked as a method on the
  226. * given {@code opt_context} object, or in the global scope if no context is
  227. * specified. Subscribing the same function to the same topic multiple times
  228. * will result in multiple function invocations while publishing. Returns a
  229. * subscription key that can be used to unsubscribe the function from the topic
  230. * via {@link #unsubscribeByKey}.
  231. * @param {string} topic Topic to subscribe to.
  232. * @param {Function} fn Function to be invoked when a message is published to
  233. * the given topic.
  234. * @param {Object=} opt_context Object in whose context the function is to be
  235. * called (the global scope if none).
  236. * @return {number} Subscription key.
  237. */
  238. goog.labs.pubsub.BroadcastPubSub.prototype.subscribe = function(
  239. topic, fn, opt_context) {
  240. return this.pubSub_.subscribe(topic, fn, opt_context);
  241. };
  242. /**
  243. * Subscribes a single-use function to a topic. The function is invoked as a
  244. * method on the given {@code opt_context} object, or in the global scope if no
  245. * context is specified, and is then unsubscribed. Returns a subscription key
  246. * that can be used to unsubscribe the function from the topic via {@link
  247. * #unsubscribeByKey}.
  248. * @param {string} topic Topic to subscribe to.
  249. * @param {Function} fn Function to be invoked once and then unsubscribed when
  250. * a message is published to the given topic.
  251. * @param {Object=} opt_context Object in whose context the function is to be
  252. * called (the global scope if none).
  253. * @return {number} Subscription key.
  254. */
  255. goog.labs.pubsub.BroadcastPubSub.prototype.subscribeOnce = function(
  256. topic, fn, opt_context) {
  257. return this.pubSub_.subscribeOnce(topic, fn, opt_context);
  258. };
  259. /**
  260. * Returns the number of subscriptions to the given topic (or all topics if
  261. * unspecified). This number will not change while publishing any messages.
  262. * @param {string=} opt_topic The topic (all topics if unspecified).
  263. * @return {number} Number of subscriptions to the topic.
  264. */
  265. goog.labs.pubsub.BroadcastPubSub.prototype.getCount = function(opt_topic) {
  266. return this.pubSub_.getCount(opt_topic);
  267. };
  268. /**
  269. * Clears the subscription list for a topic, or all topics if unspecified.
  270. * @param {string=} opt_topic Topic to clear (all topics if unspecified).
  271. */
  272. goog.labs.pubsub.BroadcastPubSub.prototype.clear = function(opt_topic) {
  273. this.pubSub_.clear(opt_topic);
  274. };
  275. /** @override */
  276. goog.labs.pubsub.BroadcastPubSub.prototype.disposeInternal = function() {
  277. goog.array.remove(goog.labs.pubsub.BroadcastPubSub.instances_, this);
  278. if (goog.labs.pubsub.BroadcastPubSub.IS_IE8_ &&
  279. goog.isDefAndNotNull(this.storage_) &&
  280. goog.labs.pubsub.BroadcastPubSub.instances_.length == 0) {
  281. this.storage_.remove(goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_);
  282. }
  283. goog.labs.pubsub.BroadcastPubSub.base(this, 'disposeInternal');
  284. };
  285. /**
  286. * Prefix for IE8 storage event queue keys.
  287. * @private @const
  288. */
  289. goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_PREFIX_ = '_closure_bps_ie8evt';
  290. /**
  291. * Time (in milliseconds) that IE8 events should live. If they are not
  292. * processed by other windows in this time they will be removed.
  293. * @private @const
  294. */
  295. goog.labs.pubsub.BroadcastPubSub.IE8_EVENT_LIFETIME_MS_ = 1000 * 10;
  296. /**
  297. * Time (in milliseconds) that the IE8 event queue should live.
  298. * @private @const
  299. */
  300. goog.labs.pubsub.BroadcastPubSub.IE8_QUEUE_LIFETIME_MS_ = 1000 * 30;
  301. /**
  302. * Time delta that is used to distinguish between timestamps of events that
  303. * happen in the same millisecond.
  304. * @private @const
  305. */
  306. goog.labs.pubsub.BroadcastPubSub.IE8_TIMESTAMP_UNIQUE_OFFSET_MS_ = .01;
  307. /**
  308. * Name for this window/tab's storage key that stores its IE8 event queue.
  309. *
  310. * The browsers storage events are supposed to track the key which was changed,
  311. * the previous value for that key, and the new value of that key. Our
  312. * implementation is dependent on this information but IE8 doesn't provide it.
  313. * We implement our own event queue using local storage to track this
  314. * information in IE8. Since all instances share the same localStorage context
  315. * in a particular tab, we share the events queue.
  316. *
  317. * This key is a static member shared by all instances of BroadcastPubSub in the
  318. * same Window context. To avoid read-update-write contention, this key is only
  319. * written in a single context in the cleanupIe8StorageEvents_ function. Since
  320. * instances in other contexts will read this key there is code in the {@code
  321. * publish} function to make sure timestamps are unique even within the same
  322. * millisecond.
  323. *
  324. * @private @const {string}
  325. */
  326. goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_ =
  327. goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_PREFIX_ +
  328. goog.math.randomInt(1e9);
  329. /**
  330. * All instances of this object should access elements using strings and not
  331. * attributes. Since we are communicating across browser tabs we could be
  332. * dealing with different versions of javascript and thus may have different
  333. * obfuscation in each tab.
  334. * @private @typedef {{'timestamp': number, 'args': !Array<string>}}
  335. */
  336. goog.labs.pubsub.BroadcastPubSub.Ie8Event_;
  337. /** @private @const */
  338. goog.labs.pubsub.BroadcastPubSub.IS_IE8_ =
  339. goog.userAgent.IE && goog.userAgent.DOCUMENT_MODE == 8;
  340. /**
  341. * Validates an event object.
  342. * @param {!Object} obj The object to validate as an Event.
  343. * @return {?goog.labs.pubsub.BroadcastPubSub.Ie8Event_} A valid
  344. * event object or null if the object is invalid.
  345. * @private
  346. */
  347. goog.labs.pubsub.BroadcastPubSub.validateIe8Event_ = function(obj) {
  348. if (goog.isObject(obj) && goog.isNumber(obj['timestamp']) &&
  349. goog.array.every(obj['args'], goog.isString)) {
  350. return {'timestamp': obj['timestamp'], 'args': obj['args']};
  351. }
  352. return null;
  353. };
  354. /**
  355. * Returns an array of valid IE8 events.
  356. * @param {!Array<!Object>} events Possible IE8 events.
  357. * @return {!Array<!goog.labs.pubsub.BroadcastPubSub.Ie8Event_>}
  358. * Valid IE8 events.
  359. * @private
  360. */
  361. goog.labs.pubsub.BroadcastPubSub.filterValidIe8Events_ = function(events) {
  362. return goog.array.filter(
  363. goog.array.map(
  364. events, goog.labs.pubsub.BroadcastPubSub.validateIe8Event_),
  365. goog.isDefAndNotNull);
  366. };
  367. /**
  368. * Returns the IE8 events that have a timestamp later than the provided
  369. * timestamp.
  370. * @param {number} timestamp Expired timestamp.
  371. * @param {!Array<!goog.labs.pubsub.BroadcastPubSub.Ie8Event_>} events
  372. * Possible IE8 events.
  373. * @return {!Array<!goog.labs.pubsub.BroadcastPubSub.Ie8Event_>}
  374. * Unexpired IE8 events.
  375. * @private
  376. */
  377. goog.labs.pubsub.BroadcastPubSub.filterNewIe8Events_ = function(
  378. timestamp, events) {
  379. return goog.array.filter(
  380. events, function(event) { return event['timestamp'] > timestamp; });
  381. };
  382. /**
  383. * Processes the events array for key if all elements are valid IE8 events.
  384. * @param {string} key The key in localStorage where the event queue is stored.
  385. * @param {!Array<!Object>} events Array of possible events stored at key.
  386. * @return {boolean} Return true if all elements in the array are valid
  387. * events, false otherwise.
  388. * @private
  389. */
  390. goog.labs.pubsub.BroadcastPubSub.prototype.maybeProcessIe8Events_ = function(
  391. key, events) {
  392. if (!events.length) {
  393. return false;
  394. }
  395. var validEvents =
  396. goog.labs.pubsub.BroadcastPubSub.filterValidIe8Events_(events);
  397. if (validEvents.length == events.length) {
  398. var lastTimestamp = goog.array.peek(validEvents)['timestamp'];
  399. var previousTime =
  400. this.ie8LastEventTimes_[key] || this.ie8StartupTimestamp_;
  401. if (lastTimestamp > previousTime -
  402. goog.labs.pubsub.BroadcastPubSub.IE8_QUEUE_LIFETIME_MS_) {
  403. this.ie8LastEventTimes_[key] = lastTimestamp;
  404. validEvents = goog.labs.pubsub.BroadcastPubSub.filterNewIe8Events_(
  405. previousTime, validEvents);
  406. for (var i = 0, event; event = validEvents[i]; i++) {
  407. this.dispatch_(event['args']);
  408. }
  409. return true;
  410. }
  411. } else {
  412. goog.log.warning(this.logger_, 'invalid events found in queue ' + key);
  413. }
  414. return false;
  415. };
  416. /**
  417. * Handle the storage event and possibly dispatch events. Looks through all keys
  418. * in localStorage for valid keys.
  419. * @private
  420. */
  421. goog.labs.pubsub.BroadcastPubSub.prototype.handleIe8StorageEvent_ = function() {
  422. var numKeys = this.mechanism_.getCount();
  423. for (var idx = 0; idx < numKeys; idx++) {
  424. var key = this.mechanism_.key(idx);
  425. // Don't process events we generated. The W3C standard says that storage
  426. // events should be queued by the browser for each window whose document's
  427. // storage object is affected by a change in localStorage. Chrome, Firefox,
  428. // and modern IE don't dispatch the event to the window which made the
  429. // change. This code simulates that behavior in IE8.
  430. if (!(goog.isString(key) &&
  431. goog.string.startsWith(
  432. key, goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_PREFIX_))) {
  433. continue;
  434. }
  435. var events = null;
  436. try {
  437. events = this.storage_.get(key);
  438. } catch (ex) {
  439. goog.log.warning(this.logger_, 'invalid remote event queue ' + key);
  440. }
  441. if (!(goog.isArray(events) && this.maybeProcessIe8Events_(key, events))) {
  442. // Events is not an array, empty, contains invalid events, or expired.
  443. this.storage_.remove(key);
  444. }
  445. }
  446. };
  447. /**
  448. * Cleanup our IE8 event queue by removing any events that come at or before the
  449. * given timestamp.
  450. * @param {number} timestamp Maximum timestamp to remove from the queue.
  451. * @private
  452. */
  453. goog.labs.pubsub.BroadcastPubSub.prototype.cleanupIe8StorageEvents_ = function(
  454. timestamp) {
  455. var events = null;
  456. try {
  457. events =
  458. this.storage_.get(goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_);
  459. } catch (ex) {
  460. goog.log.error(
  461. this.logger_, 'cleanup encountered invalid event queue key ' +
  462. goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_);
  463. }
  464. if (!goog.isArray(events)) {
  465. this.storage_.remove(goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_);
  466. return;
  467. }
  468. events = goog.labs.pubsub.BroadcastPubSub.filterNewIe8Events_(
  469. timestamp,
  470. goog.labs.pubsub.BroadcastPubSub.filterValidIe8Events_(events));
  471. if (events.length > 0) {
  472. this.storage_.set(goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_, events);
  473. } else {
  474. this.storage_.remove(goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_);
  475. }
  476. };