mirror of
https://we.phorge.it/source/phorge.git
synced 2024-12-18 19:40:55 +01:00
Add JX.Leader: synchronization over localStorage
Summary: Ref T6559. Adds a "JX.Leader" primitive to replace the synchronization over Flash. This does a couple of things: - Offers an "onBecomeLeader" event, which we can use to open the WebSocket. - Offers an "onReceiveMessage" event, which we can use to dispatch notifications and handle requests to play sounds and send desktop notifications. - Offers a "broadcast()" method, which we can use to send desktop notification and sound requests. Test Plan: Added some code like this: ``` if (!statics.leader) { statics.leader = true; JX.Leader.listen('onBecomeLeader', function() { JX.log("This tab is now the leader."); }); JX.Leader.listen('onReceiveBroadcast', function(message, is_leader) { JX.log('[' + (is_leader ? 'As Leader' : 'Not Leader') + '] ' + message); }); JX.Leader.start(); } ``` Then: - Saw first tab open become leader reliably in Safari, Chrome, Firefox. - Saw new tab become leader reliably when the first tab was closed. - Saw broadcast() work as documented and deliver messages with correct leadership-flag and uniqueness. Reviewers: joshuaspence, btrahan Reviewed By: btrahan Subscribers: epriestley Maniphest Tasks: T6559 Differential Revision: https://secure.phabricator.com/D11235
This commit is contained in:
parent
8c21ef2c0b
commit
e0087a9f7a
3 changed files with 329 additions and 13 deletions
|
@ -166,7 +166,7 @@ return array(
|
|||
'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 @@ return array(
|
|||
'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 @@ return array(
|
|||
'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 @@ return array(
|
|||
'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 @@ return array(
|
|||
'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 @@ return array(
|
|||
'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 @@ return array(
|
|||
'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 @@ return array(
|
|||
'javelin-request',
|
||||
'phabricator-shaped-request',
|
||||
),
|
||||
'9f8874bb' => array(
|
||||
'javelin-install',
|
||||
),
|
||||
'a155550f' => array(
|
||||
'javelin-install',
|
||||
'javelin-dom',
|
||||
|
|
3
webroot/rsrc/externals/javelin/core/init.js
vendored
3
webroot/rsrc/externals/javelin/core/init.js
vendored
|
@ -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]);
|
||||
|
|
308
webroot/rsrc/externals/javelin/lib/Leader.js
vendored
Normal file
308
webroot/rsrc/externals/javelin/lib/Leader.js
vendored
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
|
Loading…
Reference in a new issue