diff --git a/addons/bus/static/src/js/bus.js b/addons/bus/static/src/js/bus.js deleted file mode 100644 index bf5bb5277be..00000000000 --- a/addons/bus/static/src/js/bus.js +++ /dev/null @@ -1,485 +0,0 @@ -odoo.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({ - /** - * @override - * @param {Object} params - * @param {Object} params.session the session to use to perform the poll RPC - * @param {string} params.pollRoute the longpolling route to use - */ - init: function (params) { - var self = this; - this._super(); - this.pollRoute = params.pollRoute; - this.session = params.session; - 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; - this.stop_polling(); - $(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 = []; - if (this._pollRpc) { - this._pollRpc.abort(); - } - }, - 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 - this._pollRpc = this.session.rpc(this.pollRoute, data, {shadow : true, timeout: 60000}); - this._pollRpc.then(function (result) { - self._pollRpc = false; - self.on_notification(result); - if (!self.stop && self.activated) { - self.poll(); - } - }, function (error, event) { - self._pollRpc = false; - // no error popup if request is interrupted or fails for any reason - event.preventDefault(); - if (error && error.message === "XmlHttpRequestError abort") { - if (!self.stop && self.activated) { - self.poll(); - } - } else { - // 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) { - if (this.channels.indexOf(channel) === -1) { - this.channels.push(channel); - if (this._pollRpc) { - this._pollRpc.abort(); - } - } - }, - delete_channel: function (channel) { - var index = this.channels.indexOf(channel); - if (index !== -1) { - this.channels.splice(index, 1); - if (this._pollRpc) { - this._pollRpc.abort(); - } - } - }, - // bus presence : window focus/unfocus - focus_change: function (focus) { - this.set("window_focus", focus); - }, - is_odoo_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 - */ -bus.CrossTabBus = bus.Bus.extend({ - init: function () { - this._super.apply(this, arguments); - - // used to prefix localStorage keys - this.sanitizedOrigin = this.session.origin.replace(/:\/{0,2}/g, '_'); - - this.is_master = false; - this.is_registered = false; - if (parseInt(this._getItem('last_ts', 0)) + 50000 < new Date().getTime()) { - this._setItem('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()) { - self._setItem('last_presence', self.last_presence); - } - }, this._generateKey.bind(this)); - if (this.is_master) { - this._setItem('channels', this.channels); - this._setItem('options', this.options); - } else { - this.channels = this._getItem('channels', this.channels); - this.options = this._getItem('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 = this._getItem('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) { - this._setItem('last', max_id); - this._setItem('last_ts', new Date().getTime()); - this._setItem('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 === this._generateKey('notification')) { - var notifs = JSON.parse(value); - this.on_notification(notifs); - } - // update channels - if (e.key === this._generateKey('channels')) { - this.channels = JSON.parse(value); - } - // update options - if (e.key === this._generateKey('options')) { - this.options = JSON.parse(value); - } - // update focus - if (e.key === this._generateKey('focus')) { - this.set('window_focus', JSON.parse(value)); - } - }, - add_channel: function () { - this._super.apply(this, arguments); - this._setItem('channels', this.channels); - }, - delete_channel: function () { - this._super.apply(this, arguments); - this._setItem('channels', this.channels); - }, - get_last_presence: function () { - return this._getItem('last_presence') || new Date().getTime(); - }, - update_option: function () { - this._super.apply(this, arguments); - this._setItem('options', this.options); - }, - delete_option: function () { - this._super.apply(this, arguments); - this._setItem('options', this.options); - }, - focus_change: function (focus) { - this._super.apply(this, arguments); - this._setItem('focus', focus); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Generates localStorage keys prefixed by bus. (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 'bus.' + this.sanitizedOrigin + '.' + key; - }, - /** - * @private - * @param {string} key - * @param {*} defaultValue - */ - _getItem: function (key, defaultValue) { - return getItem(this._generateKey(key), defaultValue); - }, - /** - * @private - * @param {string} key - * @param {*} value - */ - _setItem: function (key, value) { - setItem(this._generateKey(key), value); - }, -}); - - -//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 = { - isMaster: false, - id: new Date().getTime() + ':' + (Math.random() * 1000000000 | 0), - - register_tab: function (is_master_callback, is_no_longer_master, on_heartbeat_callback, generateKey) { - this.heartbeatKey = generateKey('heartbeat'); - this.masterKey = generateKey('master'); - this.peersKey = generateKey('peers'); - - 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 -var params = { - pollRoute: '/longpolling/poll', - session: session, -}; -if (typeof Storage !== "undefined") { - bus.bus = new bus.CrossTabBus(params); -} else { - bus.bus = new bus.Bus(params); -} - -return bus; - - -}); - diff --git a/addons/bus/static/src/js/crosstab_bus.js b/addons/bus/static/src/js/crosstab_bus.js new file mode 100644 index 00000000000..c0402dcac10 --- /dev/null +++ b/addons/bus/static/src/js/crosstab_bus.js @@ -0,0 +1,355 @@ +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(); + var session = this.getSession(); + // 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', -1); + 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); + }, + /** + * 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); + + $(window).on('unload.' + this._id, this._onUnload.bind(this)); + + if (!this._callLocalStorage('getItem', 'peers')) { + 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, hbPeriod); + }, + /** + * 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._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('peers') && !value) { + //master was unloaded + this._startElection(); + } + + // last notification id changed + if (key === this._generateKey('last')) { + this._lastNotificationID = value; + } + // notifications changed + + if (key === this._generateKey('notification')) { + if (!this._isMasterTab) { + this.trigger("notification", value); + } + return; + } + // update channels + if (key === this._generateKey('channels')) { + var channels = value; + _.each(_.difference(this._channels, channels), this.deleteChannel.bind(this)); + _.each(_.difference(channels, this._channels), this.addChannel.bind(this)); + } + // update options + if (key === this._generateKey('options')) { + this._options = value; + } + // update focus + 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', 'peers'); + } + }, +}); + +return CrossTabBus; + +}); + diff --git a/addons/bus/static/src/js/longpolling_bus.js b/addons/bus/static/src/js/longpolling_bus.js new file mode 100644 index 00000000000..4764e2e52b0 --- /dev/null +++ b/addons/bus/static/src/js/longpolling_bus.js @@ -0,0 +1,255 @@ +odoo.define('bus.Longpolling', function (require) { +"use strict"; + +var Bus = require('web.Bus'); +var ServicesMixin = require('web.ServicesMixin'); + + +/** + * Event Longpolling bus used to bind events on the server long polling return + * + * trigger: + * - window_focus : when the window focus change (true for focused, false for blur) + * - notification : when a notification is receive from the long polling + * + * @class Longpolling + */ +var LongpollingBus = Bus.extend(ServicesMixin, { + // constants + PARTNERS_PRESENCE_CHECK_PERIOD: 30000, // don't check presence more than once every 30s + ERROR_RETRY_DELAY: 10000, // 10 seconds + POLL_ROUTE: '/longpolling/poll', + + // properties + _isActive: null, + _lastNotificationID: 0, + _isOdooFocused: true, + _pollRetryTimeout: null, + + /** + * @override + */ + init: function (parent, params) { + this._super.apply(this, arguments); + this._id = _.uniqueId('bus'); + this._options = {}; + this._channels = []; + + // bus presence + this._lastPresenceTime = new Date().getTime(); + this._lastPartnersPresenceCheck = this._lastPresenceTime; + $(window).on("focus." + this._id, this._onFocusChange.bind(this, {focus: true})); + $(window).on("blur." + this._id, this._onFocusChange.bind(this, {focus: false})); + $(window).on("unload." + this._id, this._onFocusChange.bind(this, {focus: false})); + + $(window).on("click." + this._id, this._onPresence.bind(this)); + $(window).on("keydown." + this._id, this._onPresence.bind(this)); + $(window).on("keyup." + this._id, this._onPresence.bind(this)); + }, + /** + * @override + */ + destroy: function () { + this.stopPolling(); + $(window).off("focus." + this._id); + $(window).off("blur." + this._id); + $(window).off("unload." + this._id); + $(window).off("click." + this._id); + $(window).off("keydown." + this._id); + $(window).off("keyup." + this._id); + }, + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + /** + * Register a new channel to listen on the longpoll (ignore if already + * listening on this channel). + * Aborts a pending longpoll, in order to re-start another longpoll, so + * that we can immediately get notifications on newly registered channel. + * + * @param {string} channel + */ + addChannel: function (channel) { + if (this._channels.indexOf(channel) === -1) { + this._channels.push(channel); + if (this._pollRpc) { + this._pollRpc.abort(); + } else { + this.startPolling(); + } + } + }, + /** + * Unregister a channel from listening on the longpoll. + * + * Aborts a pending longpoll, in order to re-start another longpoll, so + * that we immediately remove ourselves from listening on notifications + * on this channel. + * + * @param {string} channel + */ + deleteChannel: function (channel) { + var index = this._channels.indexOf(channel); + if (index !== -1) { + this._channels.splice(index, 1); + if (this._pollRpc) { + this._pollRpc.abort(); + } + } + }, + /** + * Tell whether odoo is focused or not + * + * @returns {boolean} + */ + isOdooFocused: function () { + return this._isOdooFocused; + }, + /** + * Start a long polling, i.e. it continually opens a long poll + * connection as long as it is not stopped (@see `stopPolling`) + */ + startPolling: function () { + if (this._isActive === null) { + this._poll = this._poll.bind(this); + } + if (!this._isActive) { + this._isActive = true; + this._poll(); + } + }, + /** + * Stops any started long polling + * + * Aborts a pending longpoll so that we immediately remove ourselves + * from listening on notifications on this channel. + */ + stopPolling: function () { + this._isActive = false; + this._channels = []; + clearTimeout(this._pollRetryTimeout); + if (this._pollRpc) { + this._pollRpc.abort(); + } + }, + /** + * Add or update an option on the longpoll bus. + * Stored options are sent to the server whenever a poll is started. + * + * @param {string} key + * @param {any} value + */ + updateOption: function (key, value) { + this._options[key] = value; + }, + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + /** + * returns the last recorded presence + * + * @private + * @returns {integer} number of milliseconds since 1 January 1970 00:00:00 + */ + _getLastPresence: function () { + return this._lastPresenceTime; + }, + /** + * Continually start a poll: + * + * A poll is a connection that is kept open for a relatively long period + * (up to 1 minute). Local bus data are sent to the server each time a poll + * is initiated, and the server may return some "real-time" notifications + * about registered channels. + * + * A poll ends on timeout, on abort, on receiving some notifications, or on + * receiving an error. Another poll usually starts afterward, except if the + * poll is aborted or stopped (@see stopPolling). + * + * @private + */ + _poll: function () { + var self = this; + if (!this._isActive) { + return; + } + var now = new Date().getTime(); + var options = _.extend({}, this._options, { + bus_inactivity: now - this._getLastPresence(), + }); + if (this._lastPartnersPresenceCheck + this.PARTNERS_PRESENCE_CHECK_PERIOD > now) { + options = _.omit(options, 'bus_presence_partner_ids'); + } else { + this._lastPartnersPresenceCheck = now; + } + var data = {channels: this._channels, last: this._lastNotificationID, options: options}; + // The backend has a maximum cycle time of 50 seconds so give +10 seconds + this._pollRpc = this._rpc({route: this.POLL_ROUTE, params: data}, {shadow : true, timeout: 60000}); + this._pollRpc.then(function (result) { + self._pollRpc = false; + self._onPoll(result); + self._poll(); + }, function (error, ev) { + self._pollRpc = false; + // no error popup if request is interrupted or fails for any reason + ev.preventDefault(); + if (error && error.message === "XmlHttpRequestError abort") { + self._poll(); + } else { + // random delay to avoid massive longpolling + self._pollRetryTimeout = setTimeout(self._poll, self.ERROR_RETRY_DELAY + (Math.floor((Math.random()*20)+1)*1000)); + } + }); + }, + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + /** + * Handler when the focus of the window change. + * Trigger the 'window_focus' event. + * + * @private + * @param {Object} params + * @param {Boolean} params.focus + */ + _onFocusChange: function (params) { + this._isOdooFocused = params.focus; + if (params.focus) { + this._lastPresenceTime = new Date().getTime(); + this.trigger('window_focus', this._isOdooFocused); + } + }, + /** + * Handler when the long polling receive the new notifications + * Update the last notification id received. + * Triggered the 'notification' event with a list [channel, message] from notifications. + * + * @private + * @param {Object[]} notifications, Input notifications have an id, channel, message + * @returns {Array[]} Output arrays have notification's channel and message + */ + _onPoll: function (notifications) { + var self = this; + var notifs = _.map(notifications, function (notif) { + if (notif.id > self._lastNotificationID) { + self._lastNotificationID = notif.id; + } + return [notif.channel, notif.message]; + }); + this.trigger("notification", notifs); + return notifs; + }, + /** + * Handler when they are an activity on the window (click, keydown, keyup) + * Update the last presence date. + * + * @private + */ + _onPresence: function () { + this._lastPresenceTime = new Date().getTime(); + }, +}); + +return LongpollingBus; + +}); diff --git a/addons/bus/static/src/js/services/bus_service.js b/addons/bus/static/src/js/services/bus_service.js index e0bf8b42636..8e367b7f93a 100644 --- a/addons/bus/static/src/js/services/bus_service.js +++ b/addons/bus/static/src/js/services/bus_service.js @@ -1,34 +1,27 @@ odoo.define('bus.BusService', function (require) { "use strict"; -var bus = require('bus.bus').bus; - -var AbstractService = require('web.AbstractService'); +var CrossTab = require('bus.CrossTab'); var core = require('web.core'); +var ServicesMixin = require('web.ServicesMixin'); + +var BusService = CrossTab.extend(ServicesMixin, { + dependencies : ['local_storage'], + + // properties + _audio: null, -var BusService = AbstractService.extend({ /** - * @override + * This method is necessary in order for this Class to be used to instantiate services + * + * @abstract */ - start: function () { - this._super.apply(this, arguments); - this.bus = bus; - this._audio = null; - }, + start: function () {}, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- - /** - * Get the bus - * - * @return {web.Bus} the longpoll bus - */ - getBus: function () { - return this.bus; - }, - /** * Send a notification, and notify once per browser's tab * @@ -37,16 +30,25 @@ var BusService = AbstractService.extend({ */ sendNotification: function (title, content) { if (window.Notification && Notification.permission === "granted") { - if (this.bus.is_master) { + if (this.isMasterTab()) { this._sendNativeNotification(title, content); } } else { this.do_notify(title, content); - if (this.bus.is_master) { + if (this.isMasterTab()) { this._beep(); } } }, + /** + * Register listeners on notifications received on this bus service + * + * @param {Object} receiver + * @param {function} func + */ + onNotification: function () { + this.on.apply(this, ["notification"].concat(Array.prototype.slice.call(arguments))); + }, //-------------------------------------------------------------------------- // Private diff --git a/addons/bus/static/tests/bus_tests.js b/addons/bus/static/tests/bus_tests.js new file mode 100644 index 00000000000..33255f8d8de --- /dev/null +++ b/addons/bus/static/tests/bus_tests.js @@ -0,0 +1,163 @@ +odoo.define('web.bus_tests', function (require) { +"use strict"; + +var BusService = require('bus.BusService'); +var AbstractStorageService = require('web.AbstractStorageService'); +var RamStorage = require('web.RamStorage'); +var testUtils = require('web.test_utils'); +var Widget = require('web.Widget'); + + +var LocalStorageServiceMock; + +BusService = BusService.extend({ + TAB_HEARTBEAT_PERIOD: 10, + MASTER_TAB_HEARTBEAT_PERIOD: 1, +}); + + +QUnit.module('Bus', { + beforeEach: function () { + LocalStorageServiceMock = AbstractStorageService.extend({storage: new RamStorage()}); + }, +}, function () { + QUnit.test('notifications received from the longpolling channel', function (assert) { + assert.expect(6); + + var pollDeferred = $.Deferred(); + + var parent = new Widget(); + testUtils.addMockEnvironment(parent, { + data: {}, + services: { + bus_service: BusService, + local_storage: LocalStorageServiceMock, + }, + mockRPC: function (route, args) { + if (route === '/longpolling/poll') { + assert.step([route, args.channels.join(',')]); + + pollDeferred = $.Deferred(); + pollDeferred.abort = (function () { + this.reject({message: "XmlHttpRequestError abort"}, $.Event()); + }).bind(pollDeferred); + return pollDeferred; + } + return this._super.apply(this, arguments); + } + }); + + var widget = new Widget(parent); + widget.appendTo($('#qunit-fixture')); + + widget.call('bus_service', 'onNotification', this, function (notifications) { + assert.step(['notification', notifications]); + }); + widget.call('bus_service', 'addChannel', 'lambda'); + + pollDeferred.resolve([{ + id: 1, + channel: 'lambda', + message: 'beta', + }]); + pollDeferred.resolve([{ + id: 2, + channel: 'lambda', + message: 'epsilon', + }]); + + assert.verifySteps([ + ["/longpolling/poll", "lambda"], + ["notification", [["lambda","beta"]]], + ["/longpolling/poll", "lambda"], + ["notification", [["lambda","epsilon"]]], + ["/longpolling/poll", "lambda"] + ]); + + parent.destroy(); + }); + + QUnit.test('cross tab bus share message from a channel', function (assert) { + var done = assert.async(); + assert.expect(5); + + // master + + var pollDeferredMaster = $.Deferred(); + + var parentMaster = new Widget(); + testUtils.addMockEnvironment(parentMaster, { + data: {}, + services: { + bus_service: BusService, + local_storage: LocalStorageServiceMock, + }, + mockRPC: function (route, args) { + if (route === '/longpolling/poll') { + assert.step(['master', route, args.channels.join(',')]); + + pollDeferredMaster = $.Deferred(); + pollDeferredMaster.abort = (function () { + this.reject({message: "XmlHttpRequestError abort"}, $.Event()); + }).bind(pollDeferredMaster); + return pollDeferredMaster; + } + return this._super.apply(this, arguments); + } + }); + + var master = new Widget(parentMaster); + master.appendTo($('#qunit-fixture')); + + master.call('bus_service', 'onNotification', master, function (notifications) { + assert.step(['master', 'notification', notifications]); + }); + master.call('bus_service', 'addChannel', 'lambda'); + + // slave + + setTimeout(function () { + var parentSlave = new Widget(); + testUtils.addMockEnvironment(parentSlave, { + data: {}, + services: { + bus_service: BusService, + local_storage: LocalStorageServiceMock, + }, + mockRPC: function (route, args) { + if (route === '/longpolling/poll') { + throw new Error("Can not use the longpolling of the slave client"); + } + return this._super.apply(this, arguments); + } + }); + + var slave = new Widget(parentSlave); + slave.appendTo($('#qunit-fixture')); + + slave.call('bus_service', 'onNotification', slave, function (notifications) { + assert.step(['slave', 'notification', notifications]); + }); + slave.call('bus_service', 'addChannel', 'lambda'); + + pollDeferredMaster.resolve([{ + id: 1, + channel: 'lambda', + message: 'beta', + }]); + + assert.verifySteps([ + ["master", "/longpolling/poll", "lambda"], + ["master", "notification", [["lambda", "beta"]]], + ["slave", "notification", [["lambda", "beta"]]], + ["master", "/longpolling/poll", "lambda"], + ]); + + parentMaster.destroy(); + parentSlave.destroy(); + + done(); + + }, 3); + }); +});}); diff --git a/addons/bus/views/bus.xml b/addons/bus/views/bus.xml index 914393eefb6..4d8f7bc36de 100644 --- a/addons/bus/views/bus.xml +++ b/addons/bus/views/bus.xml @@ -1,11 +1,16 @@ - - - + + +