flectra/addons/bus/static/src/js/bus.js

412 lines
14 KiB
JavaScript

flectra.define('bus.bus', function (require) {
"use strict";
var local_storage = require('web.local_storage');
var session = require('web.session');
var Widget = require('web.Widget');
var bus = {};
var PARTNERS_PRESENCE_CHECK_PERIOD = 30000; // don't check presence more than once every 30s
var TAB_HEARTBEAT_PERIOD = 10000; // 10 seconds
var MASTER_TAB_HEARTBEAT_PERIOD = 1500; // 1.5 second
bus.ERROR_DELAY = 10000;
bus.Bus = Widget.extend({
init: function(){
var self = this;
this._super();
this.options = {};
this.activated = false;
this.bus_id = _.uniqueId('bus');
this.channels = [];
this.last = 0;
this.stop = false;
this.is_master = true;
// bus presence
this.last_presence = new Date().getTime();
this.last_partners_presence_check = this.last_presence;
this.set("window_focus", true);
this.on("change:window_focus", this, function () {
if (this.get("window_focus")) {
this.trigger('window_focus', this.is_master);
}
});
$(window).on("focus." + this.bus_id, _.bind(this.focus_change, this, true));
$(window).on("blur." + this.bus_id, _.bind(this.focus_change, this, false));
$(window).on("unload." + this.bus_id, _.bind(this.focus_change, this, false));
_.each('click,keydown,keyup'.split(','), function(evtype) {
$(window).on(evtype + "." + self.bus_id, function() {
self.last_presence = new Date().getTime();
});
});
},
destroy: function () {
var self = this;
$(window).off("focus." + this.bus_id);
$(window).off("blur." + this.bus_id);
$(window).off("unload." + this.bus_id);
_.each('click,keydown,keyup'.split(','), function(evtype) {
$(window).off(evtype + "." + self.bus_id);
});
},
start_polling: function(){
if(!this.activated){
this.poll();
this.stop = false;
}
},
stop_polling: function(){
this.activated = false;
this.stop = true;
this.channels = [];
},
poll: function() {
var self = this;
self.activated = true;
var now = new Date().getTime();
var options = _.extend({}, this.options, {
bus_inactivity: now - this.get_last_presence(),
});
if (this.last_partners_presence_check + PARTNERS_PRESENCE_CHECK_PERIOD > now) {
options = _.omit(options, 'bus_presence_partner_ids');
} else {
this.last_partners_presence_check = now;
}
var data = {channels: self.channels, last: self.last, options: options};
// The backend has a maximum cycle time of 50 seconds so give +10 seconds
session.rpc('/longpolling/poll', data, {shadow : true, timeout: 60000}).then(function(result) {
self.on_notification(result);
if(!self.stop){
self.poll();
}
}, function(unused, e) {
// no error popup if request is interrupted or fails for any reason
e.preventDefault();
// random delay to avoid massive longpolling
setTimeout(_.bind(self.poll, self), bus.ERROR_DELAY + (Math.floor((Math.random()*20)+1)*1000));
});
},
on_notification: function(notifications) {
var self = this;
var notifs = _.map(notifications, function (notif) {
if (notif.id > self.last) {
self.last = notif.id;
}
return [notif.channel, notif.message];
});
this.trigger("notification", notifs);
},
add_channel: function(channel){
this.channels.push(channel);
this.channels = _.uniq(this.channels);
},
delete_channel: function(channel){
this.channels = _.without(this.channels, channel);
},
// bus presence : window focus/unfocus
focus_change: function(focus) {
this.set("window_focus", focus);
},
is_flectra_focused: function () {
return this.get("window_focus");
},
get_last_presence: function () {
return this.last_presence;
},
update_option: function(key, value){
this.options[key] = value;
},
delete_option: function(key){
if(_.contains(_.keys(this.options), key)){
delete this.options[key];
}
},
});
/**
* CrossTabBus Widget
*
* Manage the communication before browser tab to allow only one tab polling for the others (performance improvement)
* When a tab is opened, and the start_polling method is called, the tab is signaling through the localStorage to the
* others. When a tab is closed, it signals its removing. If he was the master tab (the polling one), he choose another
* one in the list of open tabs. This one start polling for the other. When a notification is recieved from the poll, it
* is signaling through the localStorage too.
*
* localStorage used keys are:
*
* - bus.channels : shared public channel list to listen during the poll
* - bus.options : shared options
* - bus.notification : the received notifications from the last poll
* - bus.tab_list : list of opened tab ids
* - bus.tab_master : generated id of the master tab
*/
var CrossTabBus = bus.Bus.extend({
init: function(){
this._super.apply(this, arguments);
this.is_master = false;
this.is_registered = false;
if (parseInt(getItem('bus.last_ts', 0)) + 50000 < new Date().getTime()) {
setItem('bus.last', -1);
}
on("storage", this.on_storage.bind(this));
},
start_polling: function(){
var self = this;
if (!this.is_registered) {
this.is_registered = true;
tab_manager.register_tab(function () {
self.is_master = true;
self.start_polling();
}, function () {
self.is_master = false;
self.stop_polling();
}, function () {
// Write last_presence in local storage if it has been updated since last heartbeat
var hb_period = this.is_master ? MASTER_TAB_HEARTBEAT_PERIOD : TAB_HEARTBEAT_PERIOD;
if (self.last_presence + hb_period > new Date().getTime()) {
setItem('bus.last_presence', self.last_presence);
}
});
if (this.is_master) {
setItem('bus.channels', this.channels);
setItem('bus.options', this.options);
} else {
this.channels = getItem('bus.channels', this.channels);
this.options = getItem('bus.options', this.options);
}
return; // start_polling will be called again on tab registration
}
if (this.is_master) {
this._super.apply(this, arguments);
}
},
on_notification: function(notifications){
if(this.is_master) { // broadcast to other tabs
var last = getItem('bus.last', -1);
var max_id = Math.max(last, 0);
var new_notifications = _.filter(notifications, function (notif) {
max_id = Math.max(max_id, notif.id);
return notif.id < 0 || notif.id > last;
});
this.last = max_id;
if (new_notifications.length) {
setItem('bus.last', max_id);
setItem('bus.last_ts', new Date().getTime());
setItem('bus.notification', new_notifications);
this._super(new_notifications);
}
} else {
this._super.apply(this, arguments);
}
},
on_storage: function (e) {
// use the value of event to not read from
// localStorage (avoid race condition)
var value = e.newValue;
// notifications changed
if(e.key === 'bus.notification'){
var notifs = JSON.parse(value);
this.on_notification(notifs);
}
// update channels
if(e.key === 'bus.channels'){
this.channels = JSON.parse(value);
}
// update options
if(e.key === 'bus.options'){
this.options = JSON.parse(value);
}
// update focus
if(e.key === 'bus.focus'){
this.set('window_focus', JSON.parse(value));
}
},
add_channel: function(){
this._super.apply(this, arguments);
setItem('bus.channels', this.channels);
},
delete_channel: function(){
this._super.apply(this, arguments);
setItem('bus.channels', this.channels);
},
get_last_presence: function () {
return getItem('bus.last_presence') || new Date().getTime();
},
update_option: function(){
this._super.apply(this, arguments);
setItem('bus.options', this.options);
},
delete_option: function(){
this._super.apply(this, arguments);
setItem('bus.options', this.options);
},
focus_change: function(focus) {
this._super.apply(this, arguments);
setItem('bus.focus', focus);
},
});
//utility functions
function on(type, listener) {
if (window.addEventListener) {
window.addEventListener(type, listener);
} else { //IE8
window.attachEvent('on' + type, listener);
}
}
function getItem(key, defaultValue) {
var val = local_storage.getItem(key);
return val ? JSON.parse(val) : defaultValue;
}
function setItem(key, value) {
local_storage.setItem(key, JSON.stringify(value));
}
var tab_manager = {
peersKey: 'bus.peers',
masterKey: 'bus.master',
heartbeatKey: 'bus.heartbeat',
isMaster: false,
id: new Date().getTime() + ':' + (Math.random() * 1000000000 | 0),
register_tab: function (is_master_callback, is_no_longer_master, on_heartbeat_callback) {
this.is_master_callback = is_master_callback;
this.is_no_longer_master = is_no_longer_master || function () {};
this.on_heartbeat_callback = on_heartbeat_callback || function () {};
var peers = getItem(tab_manager.peersKey, {});
peers[tab_manager.id] = new Date().getTime();
setItem(tab_manager.peersKey, peers);
on('unload', function () {
// unload peer
var peers = getItem(tab_manager.peersKey, {});
delete peers[tab_manager.id];
setItem(tab_manager.peersKey, peers);
// unload master
if (tab_manager.isMaster) {
local_storage.removeItem(tab_manager.masterKey);
}
});
if (!local_storage.getItem(tab_manager.masterKey)) {
tab_manager.start_election();
}
on('storage', function(e) {
if (!e) { e = window.event;}
if (e.key !== tab_manager.masterKey) {
return;
}
if (e.newValue === null) { //master was unloaded
tab_manager.start_election();
}
});
tab_manager.heartbeat();
},
heartbeat: function () {
var current = new Date().getTime();
var heartbeatValue = local_storage.getItem(tab_manager.heartbeatKey) || 0;
var peers = getItem(tab_manager.peersKey, {});
if ((parseInt(heartbeatValue) + 5000) < current) {
// Heartbeat is out of date. Electing new master
tab_manager.start_election();
}
if (tab_manager.isMaster) {
//walk through all peers and kill old
var cleanedPeers = {};
for (var peerName in peers) {
if (peers[peerName] + 15000 > current) {
cleanedPeers[peerName] = peers[peerName];
}
}
if (!tab_manager.is_last_heartbeat_mine()) {
// someone else is also master...
// it should not happen, except in some race condition situation.
tab_manager.isMaster = false;
tab_manager.last_heartbeat = 0;
peers[tab_manager.id] = current;
setItem(tab_manager.peersKey, peers);
tab_manager.is_no_longer_master();
} else {
tab_manager.last_heartbeat = current;
local_storage.setItem(tab_manager.heartbeatKey, current);
setItem(tab_manager.peersKey, cleanedPeers);
}
} else {
//update own heartbeat
peers[tab_manager.id] = current;
setItem(tab_manager.peersKey, peers);
}
this.on_heartbeat_callback();
setTimeout(function(){
tab_manager.heartbeat();
}, tab_manager.isMaster ? MASTER_TAB_HEARTBEAT_PERIOD : TAB_HEARTBEAT_PERIOD);
},
is_last_heartbeat_mine: function () {
var heartbeatValue = local_storage.getItem(tab_manager.heartbeatKey) || 0;
return (parseInt(heartbeatValue) === tab_manager.last_heartbeat);
},
start_election: function () {
if (tab_manager.isMaster) {
return;
}
//check who's next
var peers = getItem(tab_manager.peersKey, {});
var now = new Date().getTime();
var newMaster;
for (var peerName in peers) {
//check for dead peers
if (peers[peerName] + 15000 < now) {
continue;
}
newMaster = peerName;
break;
}
if (newMaster === tab_manager.id) {
//we're next in queue. Electing as master
setItem(tab_manager.masterKey, tab_manager.id);
tab_manager.last_heartbeat = new Date().getTime();
setItem(tab_manager.heartbeatKey, tab_manager.last_heartbeat);
tab_manager.isMaster = true;
tab_manager.is_master_callback();
//removing master peer from queue
delete peers[newMaster];
setItem(tab_manager.peersKey, peers);
}
},
};
// bus singleton, depending of the browser :
// if supporting LocalStorage, there will be only one tab polling
if(typeof Storage !== "undefined"){
bus.bus = new CrossTabBus();
} else {
bus.bus = new bus.Bus();
}
return bus;
});