1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
|
odoo.define('bus.CrossTab', function (require) {
"use strict";
var Longpolling = require('bus.Longpolling');
var session = require('web.session');
/**
* CrossTab
*
* This is an extension of the longpolling bus with browser cross-tab synchronization.
* It uses a Master/Slaves with Leader Election architecture:
* - a single tab handles longpolling.
* - tabs are synchronized by means of the local storage.
*
* localStorage used keys are:
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.channels : shared public channel list to listen during the poll
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.options : shared options
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.notification : the received notifications from the last poll
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.tab_list : list of opened tab ids
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.tab_master : generated id of the master tab
*
* trigger:
* - window_focus : when the window is focused
* - notification : when a notification is receive from the long polling
* - become_master : when this tab became the master
* - no_longer_master : when this tab is not longer the master (the user swith tab)
*/
var CrossTabBus = Longpolling.extend({
// constants
TAB_HEARTBEAT_PERIOD: 10000, // 10 seconds
MASTER_TAB_HEARTBEAT_PERIOD: 1500, // 1.5 seconds
HEARTBEAT_OUT_OF_DATE_PERIOD: 5000, // 5 seconds
HEARTBEAT_KILL_OLD_PERIOD: 15000, // 15 seconds
LOCAL_STORAGE_PREFIX: 'bus',
// properties
_isMasterTab: false,
_isRegistered: false,
/**
* @override
*/
init: function () {
this._super.apply(this, arguments);
var now = new Date().getTime();
// used to prefix localStorage keys
this._sanitizedOrigin = session.origin.replace(/:\/{0,2}/g, '_');
// prevents collisions between different tabs and in tests
this._id = _.uniqueId(this.LOCAL_STORAGE_PREFIX) + ':' + now;
if (this._callLocalStorage('getItem', 'last_ts', 0) + 50000 < now) {
this._callLocalStorage('removeItem', 'last');
}
this._lastNotificationID = this._callLocalStorage('getItem', 'last', 0);
this.call('local_storage', 'onStorage', this, this._onStorage);
},
destroy: function () {
this._super();
clearTimeout(this._heartbeatTimeout);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Share the bus channels with the others tab by the local storage
*
* @override
*/
addChannel: function () {
this._super.apply(this, arguments);
this._callLocalStorage('setItem', 'channels', this._channels);
},
/**
* Share the bus channels with the others tab by the local storage
*
* @override
*/
deleteChannel: function () {
this._super.apply(this, arguments);
this._callLocalStorage('setItem', 'channels', this._channels);
},
/**
* @return {string}
*/
getTabId: function () {
return this._id;
},
/**
* Tells whether this bus is related to the master tab.
*
* @returns {boolean}
*/
isMasterTab: function () {
return this._isMasterTab;
},
/**
* Use the local storage to share the long polling from the master tab.
*
* @override
*/
startPolling: function () {
if (this._isActive === null) {
this._heartbeat = this._heartbeat.bind(this);
}
if (!this._isRegistered) {
this._isRegistered = true;
var peers = this._callLocalStorage('getItem', 'peers', {});
peers[this._id] = new Date().getTime();
this._callLocalStorage('setItem', 'peers', peers);
this._registerWindowUnload();
if (!this._callLocalStorage('getItem', 'master')) {
this._startElection();
}
this._heartbeat();
if (this._isMasterTab) {
this._callLocalStorage('setItem', 'channels', this._channels);
this._callLocalStorage('setItem', 'options', this._options);
} else {
this._channels = this._callLocalStorage('getItem', 'channels', this._channels);
this._options = this._callLocalStorage('getItem', 'options', this._options);
}
return; // startPolling will be called again on tab registration
}
if (this._isMasterTab) {
this._super.apply(this, arguments);
}
},
/**
* Share the option with the local storage
*
* @override
*/
updateOption: function () {
this._super.apply(this, arguments);
this._callLocalStorage('setItem', 'options', this._options);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Call local_storage service
*
* @private
* @param {string} method (getItem, setItem, removeItem, on)
* @param {string} key
* @param {any} param
* @returns service information
*/
_callLocalStorage: function (method, key, param) {
return this.call('local_storage', method, this._generateKey(key), param);
},
/**
* Generates localStorage keys prefixed by bus. (LOCAL_STORAGE_PREFIX = the name
* of this addon), and the sanitized origin, to prevent keys from
* conflicting when several bus instances (polling different origins)
* co-exist.
*
* @private
* @param {string} key
* @returns key prefixed with the origin
*/
_generateKey: function (key) {
return this.LOCAL_STORAGE_PREFIX + '.' + this._sanitizedOrigin + '.' + key;
},
/**
* @override
* @returns {integer} number of milliseconds since 1 January 1970 00:00:00
*/
_getLastPresence: function () {
return this._callLocalStorage('getItem', 'lastPresence') || this._super();
},
/**
* Check all the time (according to the constants) if the tab is the master tab and
* check if it is active. Use the local storage for this checks.
*
* @private
* @see _startElection method
*/
_heartbeat: function () {
var now = new Date().getTime();
var heartbeatValue = parseInt(this._callLocalStorage('getItem', 'heartbeat', 0));
var peers = this._callLocalStorage('getItem', 'peers', {});
if ((heartbeatValue + this.HEARTBEAT_OUT_OF_DATE_PERIOD) < now) {
// Heartbeat is out of date. Electing new master
this._startElection();
heartbeatValue = parseInt(this._callLocalStorage('getItem', 'heartbeat', 0));
}
if (this._isMasterTab) {
//walk through all peers and kill old
var cleanedPeers = {};
for (var peerName in peers) {
if (peers[peerName] + this.HEARTBEAT_KILL_OLD_PERIOD > now) {
cleanedPeers[peerName] = peers[peerName];
}
}
if (heartbeatValue !== this.lastHeartbeat) {
// someone else is also master...
// it should not happen, except in some race condition situation.
this._isMasterTab = false;
this.lastHeartbeat = 0;
peers[this._id] = now;
this._callLocalStorage('setItem', 'peers', peers);
this.stopPolling();
this.trigger('no_longer_master');
} else {
this.lastHeartbeat = now;
this._callLocalStorage('setItem', 'heartbeat', now);
this._callLocalStorage('setItem', 'peers', cleanedPeers);
}
} else {
//update own heartbeat
peers[this._id] = now;
this._callLocalStorage('setItem', 'peers', peers);
}
// Write lastPresence in local storage if it has been updated since last heartbeat
var hbPeriod = this._isMasterTab ? this.MASTER_TAB_HEARTBEAT_PERIOD : this.TAB_HEARTBEAT_PERIOD;
if (this._lastPresenceTime + hbPeriod > now) {
this._callLocalStorage('setItem', 'lastPresence', this._lastPresenceTime);
}
this._heartbeatTimeout = setTimeout(this._heartbeat.bind(this), hbPeriod);
},
/**
* @private
*/
_registerWindowUnload: function () {
$(window).on('unload.' + this._id, this._onUnload.bind(this));
},
/**
* Check with the local storage if the current tab is the master tab.
* If this tab became the master, trigger 'become_master' event
*
* @private
*/
_startElection: function () {
if (this._isMasterTab) {
return;
}
//check who's next
var now = new Date().getTime();
var peers = this._callLocalStorage('getItem', 'peers', {});
var heartbeatKillOld = now - this.HEARTBEAT_KILL_OLD_PERIOD;
var newMaster;
for (var peerName in peers) {
//check for dead peers
if (peers[peerName] < heartbeatKillOld) {
continue;
}
newMaster = peerName;
break;
}
if (newMaster === this._id) {
//we're next in queue. Electing as master
this.lastHeartbeat = now;
this._callLocalStorage('setItem', 'heartbeat', this.lastHeartbeat);
this._callLocalStorage('setItem', 'master', true);
this._isMasterTab = true;
this.startPolling();
this.trigger('become_master');
//removing master peer from queue
delete peers[newMaster];
this._callLocalStorage('setItem', 'peers', peers);
}
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @override
*/
_onFocusChange: function (params) {
this._super.apply(this, arguments);
this._callLocalStorage('setItem', 'focus', params.focus);
},
/**
* If it's the master tab, the notifications ares broadcasted to other tabs by the
* local storage.
*
* @override
*/
_onPoll: function (notifications) {
var notifs = this._super(notifications);
if (this._isMasterTab && notifs.length) {
this._callLocalStorage('setItem', 'last', this._lastNotificationID);
this._callLocalStorage('setItem', 'last_ts', new Date().getTime());
this._callLocalStorage('setItem', 'notification', notifs);
}
},
/**
* Handler when the local storage is updated
*
* @private
* @param {OdooEvent} event
* @param {string} event.key
* @param {string} event.newValue
*/
_onStorage: function (e) {
var value = JSON.parse(e.newValue);
var key = e.key;
if (this._isRegistered && key === this._generateKey('master') && !value) {
//master was unloaded
this._startElection();
}
// last notification id changed
if (key === this._generateKey('last')) {
this._lastNotificationID = value || 0;
}
// notifications changed
else if (key === this._generateKey('notification')) {
if (!this._isMasterTab) {
this.trigger("notification", value);
}
}
// update channels
else if (key === this._generateKey('channels')) {
this._channels = value;
}
// update options
else if (key === this._generateKey('options')) {
this._options = value;
}
// update focus
else if (key === this._generateKey('focus')) {
this._isOdooFocused = value;
this.trigger('window_focus', this._isOdooFocused);
}
},
/**
* Handler when unload the window
*
* @private
*/
_onUnload: function () {
// unload peer
var peers = this._callLocalStorage('getItem', 'peers') || {};
delete peers[this._id];
this._callLocalStorage('setItem', 'peers', peers);
// unload master
if (this._isMasterTab) {
this._callLocalStorage('removeItem', 'master');
}
},
});
return CrossTabBus;
});
|