Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F15407230
D11235.id.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
13 KB
Referenced Files
None
Subscribers
None
D11235.id.diff
View Options
diff --git a/resources/celerity/map.php b/resources/celerity/map.php
--- a/resources/celerity/map.php
+++ b/resources/celerity/map.php
@@ -166,7 +166,7 @@
'rsrc/externals/javelin/core/__tests__/install.js' => 'c432ee85',
'rsrc/externals/javelin/core/__tests__/stratcom.js' => 'da194d4b',
'rsrc/externals/javelin/core/__tests__/util.js' => 'd3b157a9',
- 'rsrc/externals/javelin/core/init.js' => 'b88ab49e',
+ 'rsrc/externals/javelin/core/init.js' => '76e1fd61',
'rsrc/externals/javelin/core/init_node.js' => 'd7dde471',
'rsrc/externals/javelin/core/install.js' => '1ffb3a9c',
'rsrc/externals/javelin/core/util.js' => '90e3fde9',
@@ -193,6 +193,7 @@
'rsrc/externals/javelin/lib/DOM.js' => 'c4569c05',
'rsrc/externals/javelin/lib/History.js' => 'c60f4327',
'rsrc/externals/javelin/lib/JSON.js' => '69adf288',
+ 'rsrc/externals/javelin/lib/Leader.js' => '9f8874bb',
'rsrc/externals/javelin/lib/Mask.js' => '8a41885b',
'rsrc/externals/javelin/lib/Request.js' => '97258e55',
'rsrc/externals/javelin/lib/Resource.js' => '0f81f8df',
@@ -405,7 +406,7 @@
'rsrc/js/application/policy/behavior-policy-rule-editor.js' => 'fe9a552f',
'rsrc/js/application/ponder/behavior-votebox.js' => '4e9b766b',
'rsrc/js/application/projects/behavior-boards-dropdown.js' => '0ec56e1d',
- 'rsrc/js/application/projects/behavior-project-boards.js' => '8c2ab1e0',
+ 'rsrc/js/application/projects/behavior-project-boards.js' => '87cb6b51',
'rsrc/js/application/projects/behavior-project-create.js' => '065227cc',
'rsrc/js/application/projects/behavior-reorder-columns.js' => 'e1d25dfb',
'rsrc/js/application/releeph/releeph-preview-branch.js' => 'b2b4fbaf',
@@ -631,7 +632,7 @@
'javelin-behavior-policy-control' => 'f3fef818',
'javelin-behavior-policy-rule-editor' => 'fe9a552f',
'javelin-behavior-ponder-votebox' => '4e9b766b',
- 'javelin-behavior-project-boards' => '8c2ab1e0',
+ 'javelin-behavior-project-boards' => '87cb6b51',
'javelin-behavior-project-create' => '065227cc',
'javelin-behavior-refresh-csrf' => '7814b593',
'javelin-behavior-releeph-preview-branch' => 'b2b4fbaf',
@@ -659,7 +660,8 @@
'javelin-history' => 'c60f4327',
'javelin-install' => '1ffb3a9c',
'javelin-json' => '69adf288',
- 'javelin-magical-init' => 'b88ab49e',
+ 'javelin-leader' => '9f8874bb',
+ 'javelin-magical-init' => '76e1fd61',
'javelin-mask' => '8a41885b',
'javelin-reactor' => '77b1cf6f',
'javelin-reactor-dom' => 'b6d401d6',
@@ -1350,6 +1352,15 @@
'85ea0626' => array(
'javelin-install',
),
+ '87cb6b51' => array(
+ 'javelin-behavior',
+ 'javelin-dom',
+ 'javelin-util',
+ 'javelin-vector',
+ 'javelin-stratcom',
+ 'javelin-workflow',
+ 'phabricator-draggable-list',
+ ),
'88236f00' => array(
'javelin-behavior',
'phabricator-keyboard-shortcut',
@@ -1381,15 +1392,6 @@
'javelin-request',
'javelin-typeahead-source',
),
- '8c2ab1e0' => array(
- 'javelin-behavior',
- 'javelin-dom',
- 'javelin-util',
- 'javelin-vector',
- 'javelin-stratcom',
- 'javelin-workflow',
- 'phabricator-draggable-list',
- ),
'8c49f386' => array(
'javelin-install',
'javelin-util',
@@ -1468,6 +1470,9 @@
'javelin-request',
'phabricator-shaped-request',
),
+ '9f8874bb' => array(
+ 'javelin-install',
+ ),
'a155550f' => array(
'javelin-install',
'javelin-dom',
diff --git a/webroot/rsrc/externals/javelin/core/init.js b/webroot/rsrc/externals/javelin/core/init.js
--- a/webroot/rsrc/externals/javelin/core/init.js
+++ b/webroot/rsrc/externals/javelin/core/init.js
@@ -184,6 +184,9 @@
'hashchange'
];
+ if (window.localStorage) {
+ window_events.push('storage');
+ }
for (ii = 0; ii < window_events.length; ++ii) {
JX.enableDispatch(window, window_events[ii]);
diff --git a/webroot/rsrc/externals/javelin/lib/Leader.js b/webroot/rsrc/externals/javelin/lib/Leader.js
new file mode 100644
--- /dev/null
+++ b/webroot/rsrc/externals/javelin/lib/Leader.js
@@ -0,0 +1,308 @@
+/**
+ * @requires javelin-install
+ * @provides javelin-leader
+ * @javelin
+ */
+
+/**
+ * Synchronize multiple tabs over LocalStorage.
+ *
+ * This class elects one tab as the "Leader". It remains the leader until it
+ * is closed.
+ *
+ * Tabs can conditionally call a function if they are the leader using
+ * @{method:callIfLeader}. This will trigger leader election, and call the
+ * function if the current tab is elected. This can be used to keep one
+ * websocket open across a group of tabs, or play a sound only once in response
+ * to a server state change.
+ *
+ * Tabs can broadcast messages to other tabs using @{method:broadcast}. Each
+ * message has an optional ID. When a tab receives multiple copies of a message
+ * with the same ID, copies after the first copy are discarded. This can be
+ * used in conjunction with @{method:callIfLeader} to allow multiple event
+ * responders to trigger a reaction to an event (like a sound) and ensure that
+ * it is played only once (not once for each notification), and by only one
+ * tab (not once for each tab).
+ *
+ * Finally, tabs can register a callback which will run if they become the
+ * leading tab, by listening for `onBecomeLeader`.
+ */
+
+JX.install('Leader', {
+
+ events: ['onBecomeLeader', 'onReceiveBroadcast'],
+
+ statics: {
+ _interval: null,
+ _broadcastKey: 'JX.Leader.broadcast',
+ _leaderKey: 'JX.Leader.id',
+
+
+ /**
+ * Tracks leadership state. Since leadership election is asynchronous,
+ * we can't expose this directly without inconsistent behavior.
+ */
+ _isLeader: false,
+
+
+ /**
+ * Keeps track of message IDs we've seen, so we send each message only
+ * once.
+ */
+ _seen: {},
+
+
+ /**
+ * Helps keep the list of seen message IDs from growing without bound.
+ */
+ _seenList: [],
+
+
+ /**
+ * Elect a leader, triggering leadership callbacks if they are registered.
+ */
+ start: function() {
+ var self = JX.Leader;
+ self.callIfLeader(JX.bag);
+ },
+
+ /**
+ * Call a method if this tab is the leader.
+ *
+ * This is asynchronous because leadership election is asynchronous. If
+ * the current tab is not the leader after any election takes place, the
+ * callback will not be invoked.
+ */
+ callIfLeader: function(callback) {
+ JX.Leader._callIf(callback, JX.bag);
+ },
+
+
+ /**
+ * Call a method after leader election.
+ *
+ * This is asynchronous because leadership election is asynchronous. The
+ * callback will be invoked after election takes place.
+ *
+ * This method is useful if you want to invoke a callback no matter what,
+ * but the callback behavior depends on whether this is the leader or
+ * not.
+ */
+ call: function(callback) {
+ JX.Leader._callIf(callback, callback);
+ },
+
+ /**
+ * Elect a leader, then invoke either a leader callback or a follower
+ * callback.
+ */
+ _callIf: function(leader_callback, follower_callback) {
+
+ if (!window.localStorage) {
+ // If we don't have localStorage, pretend we're the only tab.
+ self._becomeLeader();
+ leader_callback();
+ return;
+ }
+
+ var self = JX.Leader;
+
+ // If we don't have an ID for this tab yet, generate one and register
+ // event listeners.
+ if (!self._id) {
+ self._id = 1 + parseInt(Math.random() * 1000000000, 10);
+ JX.Stratcom.listen('pagehide', null, self._pagehide);
+ JX.Stratcom.listen('storage', null, self._storage);
+ }
+
+ // Read the current leadership lease.
+ var lease = self._read();
+
+ // If the lease is good, we're all set.
+ var now = +new Date();
+ if (lease.until > now) {
+ if (lease.id === self._id) {
+
+ // If we haven't installed an update timer yet, do so now. This will
+ // renew our lease every 5 seconds, making sure we hold it until the
+ // tab is closed.
+ if (!self._interval && lease.until > now + 10000) {
+ self._interval = window.setInterval(self._write, 5000);
+ }
+
+ self._becomeLeader();
+ leader_callback();
+ } else {
+ follower_callback();
+ }
+ return;
+ }
+
+ // If the lease isn't good, try to become the leader. We don't have
+ // proper locking primitives for this, but can do a relatively good
+ // job. The algorithm here is:
+ //
+ // - Write our ID, trying to acquire the lease.
+ // - Delay for much longer than a write "could possibly" take.
+ // - Read the key back.
+ // - If nothing else overwrote the key, we become the leader.
+ //
+ // This avoids a race where our reads and writes could otherwise
+ // interleave with another tab's reads and writes, electing both or
+ // neither as the leader.
+ //
+ // This approximately follows an algorithm attributed to Fischer in
+ // "A Fast Mutual Exclusion Algorithm" (Leslie Lamport, 1985). That
+ // paper also describes a faster (but more complex) algorithm, but
+ // it's not problematic to add a significant delay here because
+ // leader election is not especially performance-sensitive.
+
+ self._write();
+
+ window.setTimeout(
+ JX.bind(null, self._callIf, leader_callback, follower_callback),
+ 50);
+ },
+
+
+ /**
+ * Send a message to all open tabs.
+ *
+ * Tabs can receive messages by listening to `onReceiveBroadcast`.
+ *
+ * @param string|null Message ID. If provided, subsequent messages with
+ * the same ID will be discarded.
+ * @param wild The message to send.
+ */
+ broadcast: function(id, message) {
+ var self = JX.Leader;
+ if (id !== null) {
+ if (id in self._seen) {
+ return;
+ }
+ self._markSeen(id);
+ }
+
+ if (window.localStorage) {
+ var json = JX.JSON.stringify(
+ {
+ id: id,
+ message: message,
+
+ // LocalStorage only emits events if the value changes. Include
+ // a random component to make sure that broadcasts are never
+ // eaten. Although this is probably not often useful in a
+ // production system, it makes testing easier and more predictable.
+ uniq: parseInt(Math.random() * 1000000, 10)
+ });
+ window.localStorage.setItem(self._broadcastKey, json);
+ }
+
+ self._receiveBroadcast(message);
+ },
+
+
+ /**
+ * Write a lease which names us as the leader.
+ */
+ _write: function() {
+ var self = JX.Leader;
+
+ var str = [self._id, ((+new Date()) + 16000)].join(':');
+ window.localStorage.setItem(self._leaderKey, str);
+ },
+
+
+ /**
+ * Read the current lease.
+ */
+ _read: function() {
+ var self = JX.Leader;
+
+ leader = window.localStorage.getItem(self._leaderKey) || '0:0';
+ leader = leader.split(':');
+
+ return {
+ id: parseInt(leader[0], 10),
+ until: parseInt(leader[1], 10)
+ };
+ },
+
+
+ /**
+ * When the tab is closed, if we're the leader, release leadership.
+ *
+ * This will trigger a new election if there are other tabs open.
+ */
+ _pagehide: function() {
+ var self = JX.Leader;
+ if (self._read().id === self._id) {
+ window.localStorage.removeItem(self._leaderKey);
+ }
+ },
+
+
+ /**
+ * React to a storage update.
+ */
+ _storage: function(e) {
+ var self = JX.Leader;
+
+ var key = e.getRawEvent().key;
+ var new_value = e.getRawEvent().newValue;
+
+ switch (key) {
+ case self._broadcastKey:
+ new_value = JX.JSON.parse(new_value);
+ if (new_value.id !== null) {
+ if (new_value.id in self._seen) {
+ return;
+ }
+ self._markSeen(new_value.id);
+ }
+ self._receiveBroadcast(new_value.message);
+ break;
+ case self._leaderKey:
+ // If the leader tab closed, elect a new leader.
+ if (new_value === null) {
+ self.callIfLeader(JX.bag);
+ }
+ break;
+ }
+ },
+
+ _receiveBroadcast: function(message) {
+ var self = JX.Leader;
+ new JX.Leader().invoke('onReceiveBroadcast', message, self._isLeader);
+ },
+
+ _becomeLeader: function() {
+ var self = JX.Leader;
+ if (self._isLeader) {
+ return;
+ }
+
+ self._isLeader = true;
+ new JX.Leader().invoke('onBecomeLeader');
+ },
+
+ /**
+ * Mark a message as seen.
+ *
+ * We keep a fixed-sized list of recent messages, and let old ones fall
+ * off the end after a while.
+ */
+ _markSeen: function(id) {
+ var self = JX.Leader;
+
+ self._seen[id] = true;
+ self._seenList.push(id);
+ while (self._seenList.length > 128) {
+ delete self._seen[self._seenList[0]];
+ self._seenList.splice(0, 1);
+ }
+ }
+
+ }
+});
+
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Mar 19 2025, 4:27 PM (4 w, 6 d ago)
Storage Engine
blob
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
7601860
Default Alt Text
D11235.id.diff (13 KB)
Attached To
Mode
D11235: Add JX.Leader: synchronization over localStorage
Attached
Detach File
Event Timeline
Log In to Comment