diff --git a/addons/bus/__manifest__.py b/addons/bus/__manifest__.py index 12d8939e5e5..76e06236939 100644 --- a/addons/bus/__manifest__.py +++ b/addons/bus/__manifest__.py @@ -9,11 +9,11 @@ ], 'installable': True, 'assets': { - 'web.assets_backend': [ - 'bus/static/src/**/*', - ], - 'web.assets_frontend': [ - 'bus/static/src/**/*.js', + 'web.assets_common': [ + 'bus/static/src/*.js', + 'bus/static/src/services/**/*.js', + 'bus/static/src/workers/websocket_worker.js', + 'bus/static/src/workers/websocket_worker_utils.js', ], 'web.qunit_suite_tests': [ 'bus/static/tests/**/*.js', @@ -21,6 +21,11 @@ 'web.qunit_mobile_suite_tests': [ 'bus/static/tests/helpers/*.js', ], + 'bus.websocket_worker_assets': [ + 'web/static/src/legacy/js/promise_extension.js', + 'web/static/src/boot.js', + 'bus/static/src/workers/*', + ], }, 'license': 'LGPL-3', } diff --git a/addons/bus/controllers/websocket.py b/addons/bus/controllers/websocket.py index 78e23141bb5..97d192419c4 100644 --- a/addons/bus/controllers/websocket.py +++ b/addons/bus/controllers/websocket.py @@ -3,6 +3,7 @@ import json from odoo.http import Controller, request, route +from odoo.addons.base.models.assetsbundle import AssetsBundle from ..models.bus import channel_with_db from ..websocket import WebsocketConnectionHandler @@ -37,3 +38,11 @@ class WebsocketController(Controller): @route('/websocket/update_bus_presence', type='http', auth='public', cors='*') def update_bus_presence(self, inactivity_period): request.env['ir.websocket']._update_bus_presence(inactivity_period) + + @route('/bus/websocket_worker_bundle', type='http', auth='public', cors='*') + def get_websocket_worker_bundle(self): + bundle = 'bus.websocket_worker_assets' + files, _ = request.env["ir.qweb"]._get_asset_content(bundle) + asset = AssetsBundle(bundle, files) + stream = request.env['ir.binary']._get_stream_from(asset.js()) + return stream.get_response() diff --git a/addons/bus/static/src/crosstab_bus.js b/addons/bus/static/src/crosstab_bus.js deleted file mode 100644 index 6f530e832d4..00000000000 --- a/addons/bus/static/src/crosstab_bus.js +++ /dev/null @@ -1,213 +0,0 @@ -/** @odoo-module **/ - -import { browser } from '@web/core/browser/browser'; - -import { Longpolling } from '@bus/longpolling_bus'; - -/** - * CrossTab - * - * This is an extension of the longpolling bus with browser cross-tab synchronization. - * It uses the multi_tab service. - * - only the main tab handles longpolling. - * - tabs are synchronized by means of the multi_tab service. - * - * multi_tab shared values are: - * - channels : shared public channel list to listen during the poll - * - options : shared options - * - notification : the received notifications from the last poll - * - * trigger: - * - notification : when a notification is receive from the long polling - */ -export class CrossTab extends Longpolling { - constructor(env, services) { - super(env, services); - - // properties - this._isRegistered = false; - - var now = new Date().getTime(); - this._currentTabChannels = new Set(); - if (this.env.services['multi_tab'].getSharedValue('last_ts', 0) + 50000 < now) { - this.env.services['multi_tab'].removeSharedValue('last'); - } - this._lastNotificationID = this.env.services['multi_tab'].getSharedValue('last', 0); - - const onSharedValueUpdated = (event) => this._onSharedValueUpdated(event); - const stopPolling = () => this.stopPolling(); - const startPolling = () => this.startPolling(); - services['multi_tab'].bus.addEventListener('shared_value_updated', onSharedValueUpdated); - services['multi_tab'].bus.addEventListener('no_longer_main_tab', stopPolling); - services['multi_tab'].bus.addEventListener('become_main_tab', startPolling); - - browser.addEventListener('unload', () => { - services['multi_tab'].bus.removeEventListener('shared_value_updated', onSharedValueUpdated); - services['multi_tab'].bus.removeEventListener('no_longer_main_tab', stopPolling); - services['multi_tab'].bus.removeEventListener('become_main_tab', startPolling); - this._currentTabChannels.clear(); - this._updateChannels(); - }); - } - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Share the bus channels with the others tab by the local storage - * - * @override - */ - addChannel(channel) { - this._currentTabChannels.add(channel); - super.addChannel(channel); - this._updateChannels(); - } - - /** - * Share the bus channels with the others tab by the local storage - * - * @override - */ - deleteChannel(channel) { - this._currentTabChannels.delete(channel); - super.deleteChannel(channel); - this._updateChannels(); - } - - /** - * Use the local storage to share the long polling from the master tab. - * - * @override - */ - startPolling() { - if (!this._isRegistered) { - this._isRegistered = true; - - if (this.env.services['multi_tab'].isOnMainTab()) { - this.env.services['multi_tab'].setSharedValue('options', this._options); - } else { - this._options = this.env.services['multi_tab'].getSharedValue('options', this._options); - } - this._updateChannels(); - } - - if (this.env.services['multi_tab'].isOnMainTab()) { - super.startPolling(); - } - } - - /** - * Share the option with the local storage - * - * @override - */ - updateOption(key, value) { - super.updateOption(key, value); - this.env.services['multi_tab'].setSharedValue('options', this._options); - } - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Update localstorage channels of with the channels of this tab. - * - * @private - * @return {boolean} true if the aggregated channels has changed. - */ - _updateChannels() { - const currentPeerIds = new Set(Object.keys(this.env.services['multi_tab'].getSharedValue('lastPresenceByTab', {}))); - const peerChannels = this.env.services['multi_tab'].getSharedValue('channels', {}); - const peerChannelsBefore = JSON.stringify(peerChannels); - peerChannels[this._id] = Array.from(this._currentTabChannels); - - // Clean outdated channels. - for (const channelPeerId of Object.keys(peerChannels)) { - if (!currentPeerIds.has(channelPeerId)) { - delete peerChannels[channelPeerId]; - } - } - - const peerChannelsAfter = JSON.stringify(peerChannels); - if (peerChannelsBefore !== peerChannelsAfter) { - this.env.services['multi_tab'].setSharedValue('channels', peerChannels); - } - - const allChannels = new Set(); - for (const channels of Object.values(peerChannels)) { - for (const channel of channels) { - allChannels.add(channel); - } - } - // Insure the current tab channels are always in the aggregated channels - // in case this tab is not in the currentPeerIds nor peerChannels. - for (const channel of this._currentTabChannels) { - allChannels.add(channel); - } - const allChannelsSorted = Array.from(allChannels).sort(); - if (JSON.stringify(allChannelsSorted) === JSON.stringify(this._channels.sort())) { - return false; - } else { - this._channels = allChannelsSorted; - return true; - } - } - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * If it's the master tab, the notifications ares broadcasted to other tabs by the - * local storage. - * - * @override - */ - _onPoll(notifications) { - var notifs = super._onPoll(notifications); - if (this.env.services['multi_tab'].isOnMainTab() && notifs.length) { - this.env.services['multi_tab'].setSharedValue('last', this._lastNotificationID); - this.env.services['multi_tab'].setSharedValue('last_ts', new Date().getTime()); - this.env.services['multi_tab'].setSharedValue('notification', notifs); - } - } - - /** - * Handler when a shared value is updated. - * - * @private - * @param {CustomEvent} ev - * @param {object} [ev.detail] - * @param {string} [ev.detail.key] Key of the value that have changed. - * @param {any} [ev.detail.newValue] New value associated to the key. - */ - _onSharedValueUpdated({ detail }) { - const { key, newValue } = detail; - const value = JSON.parse(newValue); - - // last notification id changed - if (key === 'last') { - this._lastNotificationID = value || 0; - } - // notifications changed - else if (key === 'notification') { - if (!this.env.services['multi_tab'].isOnMainTab()) { - this.trigger("notification", value); - } - } - // update channels - else if (key === 'channels') { - if (this._updateChannels()) { - this._restartPolling(); - }; - } - // update options - else if (key === 'options') { - this._options = value; - } - } - -} diff --git a/addons/bus/static/src/im_status_service.js b/addons/bus/static/src/im_status_service.js new file mode 100644 index 00000000000..a9e712cf052 --- /dev/null +++ b/addons/bus/static/src/im_status_service.js @@ -0,0 +1,47 @@ +/** @odoo-module **/ + +import { registry } from '@web/core/registry'; + +/** + * This service updates periodically the user presence in order for the + * im_status to be up to date. + */ +export const imStatusService = { + dependencies: ['bus_service', 'multi_tab', 'presence'], + + start(env, { bus_service, multi_tab, presence }) { + const UPDATE_BUS_PRESENCE_DELAY = 30000; + let updateBusPresenceInterval; + + function updateBusPresence() { + const now = new Date().getTime(); + bus_service.send("update_presence", now - presence.getLastPresence()); + } + + function stopUpdatingBusPresence() { + clearInterval(updateBusPresenceInterval); + updateBusPresenceInterval = null; + } + + function startUpdatingBusPresence() { + if (updateBusPresenceInterval || !multi_tab.isOnMainTab()) { + return; + } + updateBusPresence(); + updateBusPresenceInterval = setInterval( + updateBusPresence, + UPDATE_BUS_PRESENCE_DELAY, + ); + } + + if (multi_tab.isOnMainTab()) { + startUpdatingBusPresence(); + } + env.bus.addEventListener('become_main_tab', startUpdatingBusPresence); + bus_service.addEventListener('reconnect', startUpdatingBusPresence); + env.bus.addEventListener('no_longer_main_tab', stopUpdatingBusPresence); + bus_service.addEventListener('disconnect', stopUpdatingBusPresence); + }, +}; + +registry.category('services').add('im_status', imStatusService); diff --git a/addons/bus/static/src/longpolling_bus.js b/addons/bus/static/src/longpolling_bus.js deleted file mode 100644 index 7294aa683b0..00000000000 --- a/addons/bus/static/src/longpolling_bus.js +++ /dev/null @@ -1,202 +0,0 @@ -/** @odoo-module **/ - -const { EventBus } = owl; - -/** - * Event Longpolling bus used to bind events on the server long polling return - * - * trigger: - * - notification : when a notification is receive from the long polling - */ -export class Longpolling extends EventBus { - constructor(env, { multi_tab }) { - super(); - this.env = env; - - // CONSTANTS - this.PARTNERS_PRESENCE_CHECK_PERIOD = 30000; // don't check presence more than once every 30s - this.ERROR_RETRY_DELAY = 10000; // 10 seconds - this.POLL_ROUTE = '/longpolling/poll'; - - // PROPERTIES - this._isActive = null; - this._id = multi_tab.currentTabId; - this._lastNotificationID = 0; - this._pollRetryTimeout = null; - - // the _id is modified by crosstab_bus, so we can't use it to unbind the events in the destroy. - this._longPollingBusId = this._id; - this._options = {}; - this._channels = []; - } - - //-------------------------------------------------------------------------- - // 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(channel) { - if (this._channels.indexOf(channel) === -1) { - this._channels.push(channel); - this._restartPolling(); - } - } - - /** - * 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(channel) { - var index = this._channels.indexOf(channel); - if (index !== -1) { - this._channels.splice(index, 1); - if (this._pollRpc) { - this._pollRpc.abort(); - } - } - } - - /** - * Start a long polling, i.e. it continually opens a long poll - * connection as long as it is not stopped (@see `stopPolling`) - */ - startPolling() { - 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() { - 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(key, value) { - this._options[key] = value; - } - - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * 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() { - var self = this; - if (!this._isActive) { - return; - } - var now = new Date().getTime(); - var options = _.extend({}, this._options, { - bus_inactivity: now - this.env.services['presence'].getLastPresence(), - }); - 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._makePoll(data); - this._pollRpc.then(function (result) { - self._pollRpc = false; - self._onPoll(result); - self._poll(); - }).catch(function (result) { - self._pollRpc = false; - if (result.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)); - } - }); - } - - /** - * @private - * @param data: object with poll parameters - */ - _makePoll(params) { - return this.env.services.rpc(this.POLL_ROUTE, params, { silent: true, timeout: 60000 }); - } - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * 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(notifications) { - var self = this; - var notifs = _.map(notifications, function (notif) { - if (notif.id > self._lastNotificationID) { - self._lastNotificationID = notif.id; - } - return notif.message; - }); - this.trigger("notification", notifs); - return notifs; - } - - /** - * Restart polling. - * - * @private - */ - _restartPolling() { - if (this._pollRpc) { - this._pollRpc.abort(); - } else { - this.startPolling(); - } - } -} diff --git a/addons/bus/static/src/services/assets_watchdog_service.js b/addons/bus/static/src/services/assets_watchdog_service.js index eba79b9dd99..ad826122c51 100644 --- a/addons/bus/static/src/services/assets_watchdog_service.js +++ b/addons/bus/static/src/services/assets_watchdog_service.js @@ -12,7 +12,6 @@ export const assetsWatchdogService = { let bundleNotifTimerID = null; bus_service.addEventListener('notification', onNotification.bind(this)); - bus_service.startPolling(); /** * Displays one notification on user's screen when assets have changed diff --git a/addons/bus/static/src/services/bus_service.js b/addons/bus/static/src/services/bus_service.js index 0761161ec09..33e25320de2 100644 --- a/addons/bus/static/src/services/bus_service.js +++ b/addons/bus/static/src/services/bus_service.js @@ -1,13 +1,93 @@ /** @odoo-module **/ -import { CrossTab } from '@bus/crosstab_bus'; +import { WEBSOCKET_CLOSE_CODES } from "@bus/workers/websocket_worker"; +import { browser } from "@web/core/browser/browser"; import { registry } from '@web/core/registry'; +const { EventBus } = owl; +const NO_POPUP_CLOSE_CODES = [WEBSOCKET_CLOSE_CODES.SESSION_EXPIRED, WEBSOCKET_CLOSE_CODES.KEEP_ALIVE_TIMEOUT]; + +/** + * Communicate with a SharedWorker in order to provide a single websocket + * connection shared across multiple tabs. + * + * @emits connect + * @emits disconnect + * @emits reconnect + * @emits reconnecting + * @emits notification + */ export const busService = { - dependencies: ['notification', 'presence', 'rpc', 'multi_tab'], - start(env, services) { - return new CrossTab(env, services); + dependencies: ['localization', 'notification'], + + start(env) { + const bus = new EventBus(); + const workerClass = 'SharedWorker' in window ? browser.SharedWorker : browser.Worker; + const worker = new workerClass('/bus/websocket_worker_bundle', { + name: 'SharedWorker' in window ? 'odoo:websocket_shared_worker' : 'odoo:websocket_worker', + }); + let removeConnectionLostNotification; + + /** + * Send a message to the worker. + * + * @param {WorkerAction} action Action to be + * executed by the worker. + * @param {Object|undefined} data Data required for the action to be + * executed. + */ + function send(action, data) { + const message = { action, data }; + if ('SharedWorker' in window) { + worker.port.postMessage(message); + } else { + worker.postMessage(message); + } + } + + /** + * Handle messages received from the shared worker and fires an + * event according to the message type. + * + * @param {MessageEvent} messageEv + * @param {{type: WorkerEvent, data: any}[]} messageEv.data + */ + function handleMessage(messageEv) { + const { type, data } = messageEv.data; + // Do not trigger the connection lost pop up if the reconnecting + // event is caused by a session expired/keep_alive_timeout. + if (type === 'reconnecting' && !NO_POPUP_CLOSE_CODES.includes(data.closeCode)) { + removeConnectionLostNotification = env.services.notification.add( + env._t("Websocket connection lost. Trying to reconnect..."), + { sticky: true }, + ); + } else if (type === 'reconnect' && removeConnectionLostNotification) { + removeConnectionLostNotification(); + removeConnectionLostNotification = null; + } + bus.trigger(type, data); + } + + if ('SharedWorker' in window) { + worker.port.start(); + worker.port.addEventListener('message', handleMessage); + } else { + worker.addEventListener('message', handleMessage); + } + browser.addEventListener('unload', () => send('leave')); + + + return { + addEventListener: bus.addEventListener.bind(bus), + addChannel: channel => send('add_channel', channel), + deleteChannel: channel => send('delete_channel', channel), + forceUpdateChannels: () => send('force_update_channels'), + trigger: bus.trigger.bind(bus), + removeEventListener: bus.removeEventListener.bind(bus), + send: (eventName, data) => send('send', { event_name: eventName, data }), + stop: () => send('leave'), + }; }, }; registry.category('services').add('bus_service', busService); diff --git a/addons/bus/static/src/workers/websocket_worker.js b/addons/bus/static/src/workers/websocket_worker.js new file mode 100644 index 00000000000..a2fc412c0ad --- /dev/null +++ b/addons/bus/static/src/workers/websocket_worker.js @@ -0,0 +1,301 @@ +/** @odoo-module **/ + +import { debounce } from '@bus/workers/websocket_worker_utils'; + +/** + * Type of events that can be sent from the worker to its clients. + * + * @typedef { 'connect' | 'reconnect' | 'disconnect' | 'reconnecting' | 'notification' } WorkerEvent + */ + +/** + * Type of action that can be sent from the client to the worker. + * + * @typedef {'add_channel' | 'delete_channel' | 'force_update_channels' | 'send' | 'leave' } WorkerAction + */ + +export const WEBSOCKET_CLOSE_CODES = Object.freeze({ + CLEAN: 1000, + GOING_AWAY: 1001, + PROTOCOL_ERROR: 1002, + INCORRECT_DATA: 1003, + ABNORMAL_CLOSURE: 1006, + INCONSISTENT_DATA: 1007, + MESSAGE_VIOLATING_POLICY: 1008, + MESSAGE_TOO_BIG: 1009, + EXTENSION_NEGOTIATION_FAILED: 1010, + SERVER_ERROR: 1011, + RESTART: 1012, + TRY_LATER: 1013, + BAD_GATEWAY: 1014, + SESSION_EXPIRED: 4001, + KEEP_ALIVE_TIMEOUT: 4002, +}); + +/** + * This class regroups the logic necessary in order for the + * SharedWorker/Worker to work. Indeed, Safari and some minor browsers + * do not support SharedWorker. In order to solve this issue, a Worker + * is used in this case. The logic is almost the same than the one used + * for SharedWorker and this class implements it. + */ +export class WebsocketWorker { + constructor(websocketURL) { + this.websocketURL = websocketURL; + this.channelsByClient = new Map(); + this.connectRetryDelay = 0; + this.connectTimeout = null; + this.isReconnecting = false; + this.lastChannelSubscription = null; + this.lastNotificationId = 0; + this.messageWaitQueue = []; + this._forceUpdateChannels = debounce(this._forceUpdateChannels, 300, true); + this._start(); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Send the message to all the clients that are connected to the + * worker. + * + * @param {WorkerEvent} type Event to broadcast to connected + * clients. + * @param {Object} data + */ + broadcast(type, data) { + for (const client of this.channelsByClient.keys()) { + client.postMessage({ type, data }); + } + } + + /** + * Register a client handled by this worker. + * + * @param {MessagePort} messagePort + */ + registerClient(messagePort) { + messagePort.onmessage = ev => { + this._onClientMessage(messagePort, ev.data); + }; + this.channelsByClient.set(messagePort, []); + this._updateChannels(); + } + + /** + * Send message to the given client. + * + * @param {number} client + * @param {WorkerEvent} type + * @param {Object} data + */ + sendToClient(client, type, data) { + client.postMessage({ type, data }); + } + + //-------------------------------------------------------------------------- + // PRIVATE + //-------------------------------------------------------------------------- + + /** + * Called when a message is posted to the worker by a client (i.e. a + * MessagePort connected to this worker). + * + * @param {MessagePort} client + * @param {Object} message + * @param {WorkerAction} [message.action] + * Action to execute. + * @param {Object|undefined} [message.data] Data required by the + * action. + */ + _onClientMessage(client, { action, data }) { + switch (action) { + case 'send': + return this._sendToServer(data); + case 'leave': + return this._unregisterClient(client); + case 'add_channel': + return this._addChannel(client, data); + case 'delete_channel': + return this._deleteChannel(client, data); + case 'force_update_channels': + return this._forceUpdateChannels(); + } + } + + /** + * Add a channel for the given client. If this channel is not yet + * known, update the subscription on the server. + * + * @param {MessagePort} client + * @param {string} channel + */ + _addChannel(client, channel) { + const clientChannels = this.channelsByClient.get(client); + if (!clientChannels.includes(channel)) { + clientChannels.push(channel); + this.channelsByClient.set(client, clientChannels); + this._updateChannels(); + } + } + + /** + * Remove a channel for the given client. If this channel is not + * used anymore, update the subscription on the server. + * + * @param {MessagePort} client + * @param {string} channel + */ + _deleteChannel(client, channel) { + const clientChannels = this.channelsByClient.get(client); + if (!clientChannels) { + return; + } + const channelIndex = clientChannels.indexOf(channel); + if (channelIndex !== -1) { + clientChannels.splice(channelIndex, 1); + this._updateChannels(); + } + } + + /** + * Update the channels on the server side even if the channels on + * the client side are the same than the last time we subscribed. + */ + _forceUpdateChannels() { + this._updateChannels({ force: true }); + } + + /** + * Remove the given client from this worker client list as well as + * its channels. If some of its channels are not used anymore, + * update the subscription on the server. + * + * @param {MessagePort} client + */ + _unregisterClient(client) { + this.channelsByClient.delete(client); + this._updateChannels(); + } + + /** + * Determine whether or not the websocket associated to this worker + * is connected. + * + * @returns {boolean} + */ + _isWebsocketConnected() { + return this.websocket && this.websocket.readyState === 1; + } + + /** + * Triggered when a connection is closed. If closure was not clean , + * try to reconnect after indicating to the clients that the + * connection was closed. + * + * @param {CloseEvent} ev + * @param {number} code close code indicating why the connection + * was closed. + * @param {string} reason reason indicating why the connection was + * closed. + */ + _onWebsocketClose({ code, reason }) { + if (this.isReconnecting) { + // Connection was not established but the close event was + // triggered anyway. Let the onWebsocketError method handle + // this case. + return; + } + this.broadcast('disconnect', { code, reason }); + if (code === WEBSOCKET_CLOSE_CODES.CLEAN) { + // WebSocket was closed on purpose, do not try to reconnect. + return; + } + // WebSocket was not closed cleanly, let's try to reconnect. + this.broadcast('reconnecting', { closeCode: code }); + this.isReconnecting = true; + this._onWebsocketError(); + } + + /** + * Triggered when a connection failed or failed to established. + * Apply an exponential back off to the reconnect attempts. + */ + _onWebsocketError() { + this.connectRetryDelay = this.connectRetryDelay * 1.5 + 500 * Math.random(); + this.connectTimeout = setTimeout(this._start.bind(this), this.connectRetryDelay); + } + + /** + * Handle data received from the bus. + * + * @param {MessageEvent} messageEv + */ + _onWebsocketMessage(messageEv) { + const notifications = JSON.parse(messageEv.data); + this.lastNotificationId = notifications[notifications.length - 1].id; + this.broadcast('notification', notifications.map(notification => notification.message)); + } + + /** + * Triggered on websocket open. Send message that were waiting for + * the connection to open. + */ + _onWebsocketOpen() { + this.messageWaitQueue.forEach(msg => this.websocket.send(msg)); + this.messageWaitQueue = []; + this.broadcast(this.isReconnecting ? 'reconnect' : 'connect'); + if (this.isReconnecting) { + this._forceUpdateChannels(); + } + this.connectRetryDelay = 0; + this.connectTimeout = null; + this.isReconnecting = false; + } + + /** + * Send a message to the server through the websocket connection. + * If the websocket is not open, enqueue the message and send it + * upon the next reconnection. + * + * @param {any} message Message to send to the server. + */ + _sendToServer(message) { + const payload = JSON.stringify(message); + if (!this._isWebsocketConnected()) { + this.messageWaitQueue.push(payload); + } else { + this.websocket.send(payload); + } + } + + /** + * Start the worker by opening a websocket connection. + */ + _start() { + this.websocket = new WebSocket(this.websocketURL); + this.websocket.addEventListener('open', this._onWebsocketOpen.bind(this)); + this.websocket.addEventListener('error', this._onWebsocketError.bind(this)); + this.websocket.addEventListener('message', this._onWebsocketMessage.bind(this)); + this.websocket.addEventListener('close', this._onWebsocketClose.bind(this)); + } + + /** + * Update the channel subscription on the server. Ignore if the channels + * did not change since the last subscription. + * + * @param {boolean} force Whether or not we should update the subscription + * event if the channels haven't change since last subscription. + */ + _updateChannels({ force = false } = {}) { + const allTabsChannels = [...new Set([].concat.apply([], [...this.channelsByClient.values()]))].sort(); + const allTabsChannelsString = JSON.stringify(allTabsChannels); + const shouldUpdateChannelSubscription = allTabsChannelsString !== this.lastChannelSubscription; + if (force || shouldUpdateChannelSubscription) { + this.lastChannelSubscription = allTabsChannelsString; + this._sendToServer({ event_name: 'subscribe', data: { channels: allTabsChannels, last: this.lastNotificationId } }); + } + } +} diff --git a/addons/bus/static/src/workers/websocket_worker_script.js b/addons/bus/static/src/workers/websocket_worker_script.js new file mode 100644 index 00000000000..58076b78972 --- /dev/null +++ b/addons/bus/static/src/workers/websocket_worker_script.js @@ -0,0 +1,25 @@ +/** @odoo-module **/ +/* eslint-env worker */ +/* eslint-disable no-restricted-globals */ + +import { WebsocketWorker } from "./websocket_worker"; + +(function () { + const websocketWorker = new WebsocketWorker( + `${self.location.protocol === 'https:' ? 'wss' : 'ws'}://${self.location.host}/websocket` + ); + + if (self.name.includes('shared')) { + // The script is running in a shared worker: let's register every + // tab connection to the worker in order to relay notifications + // coming from the websocket. + onconnect = function (ev) { + const currentClient = ev.ports[0]; + websocketWorker.registerClient(currentClient); + }; + } else { + // The script is running in a simple web worker. + websocketWorker.registerClient(self); + } +})(); + diff --git a/addons/bus/static/src/workers/websocket_worker_utils.js b/addons/bus/static/src/workers/websocket_worker_utils.js new file mode 100644 index 00000000000..aebbca81c4d --- /dev/null +++ b/addons/bus/static/src/workers/websocket_worker_utils.js @@ -0,0 +1,29 @@ +/** @odoo-module **/ + +/** + * Returns a function, that, as long as it continues to be invoked, will not + * be triggered. The function will be called after it stops being called for + * N milliseconds. If `immediate` is passed, trigger the function on the + * leading edge, instead of the trailing. + * + * Inspired by https://davidwalsh.name/javascript-debounce-function + */ + export function debounce(func, wait, immediate) { + let timeout; + return function () { + const context = this; + const args = arguments; + function later() { + timeout = null; + if (!immediate) { + func.apply(context, args); + } + } + const callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) { + func.apply(context, args); + } + }; +} diff --git a/addons/bus/static/tests/bus_tests.js b/addons/bus/static/tests/bus_tests.js index 9abd7b8341a..d4827196a30 100644 --- a/addons/bus/static/tests/bus_tests.js +++ b/addons/bus/static/tests/bus_tests.js @@ -2,16 +2,18 @@ odoo.define('web.bus_tests', function (require) { "use strict"; var { busService } = require('@bus/services/bus_service'); -const { startServer } = require('@bus/../tests/helpers/mock_python_environment'); const { presenceService } = require('@bus/services/presence_service'); const { multiTabService } = require('@bus/multi_tab_service'); +const { WEBSOCKET_CLOSE_CODES } = require("@bus/workers/websocket_worker"); +const { startServer } = require('@bus/../tests/helpers/mock_python_environment'); +const { patchWebsocketWorkerWithCleanup } = require("@bus/../tests/helpers/mock_websocket"); -var { CrossTab } = require('@bus/crosstab_bus'); var testUtils = require('web.test_utils'); const { browser } = require("@web/core/browser/browser"); const { registry } = require("@web/core/registry"); const { patchWithCleanup, nextTick } = require("@web/../tests/helpers/utils"); const { makeTestEnv } = require('@web/../tests/helpers/mock_env'); +const { createWebClient } = require("@web/../tests/webclient/helpers"); QUnit.module('Bus', { beforeEach: function () { @@ -29,7 +31,7 @@ QUnit.module('Bus', { registry.category('services').add('multi_tab', customMultiTabService); }, }, function () { - QUnit.test('notifications received from the longpolling channel', async function (assert) { + QUnit.test('notifications received from the channel', async function (assert) { assert.expect(3); const pyEnv = await startServer(); @@ -50,6 +52,37 @@ QUnit.module('Bus', { ]); }); + QUnit.test('notifications not received after stoping the service', async function (assert) { + assert.expect(4); + + const pyEnv = await startServer(); + const firstTabEnv = await makeTestEnv({ activateMockServer: true }); + const secondTabEnv = await makeTestEnv({ activateMockServer: true }); + + firstTabEnv.services['bus_service'].addEventListener('notification', ({ detail: notifications }) => { + assert.step('1 - notification - ' + notifications.map(notif => notif.payload).toString()); + }); + firstTabEnv.services['bus_service'].addChannel('lambda'); + secondTabEnv.services['bus_service'].addEventListener('notification', ({ detail: notifications }) => { + assert.step('2 - notification - ' + notifications.map(notif => notif.payload).toString()); + }); + // both tabs should receive the notification + pyEnv['bus.bus']._sendone('lambda', 'notifType', 'beta'); + await nextTick(); + secondTabEnv.services['bus_service'].stop(); + await nextTick(); + // only first tab should receive the notification since the + // second tab has called the stop method. + pyEnv['bus.bus']._sendone('lambda', 'notifType', 'epsilon'); + await nextTick(); + + assert.verifySteps([ + '1 - notification - beta', + '2 - notification - beta', + '1 - notification - epsilon', + ]); + }); + QUnit.test('notifications still received after disconnect/reconnect', async function (assert) { assert.expect(3); @@ -69,9 +102,12 @@ QUnit.module('Bus', { }); env.services['bus_service'].addChannel('lambda'); pyEnv['bus.bus']._sendone('lambda', 'notifType', 'beta'); - pyEnv.simulateConnectionLostAndRecovered(); + pyEnv.simulateConnectionLost(WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE); + // Give websocket worker a tick to try to restart + await nextTick(); pyEnv['bus.bus']._sendone('lambda', 'notifType', 'gamma'); - // Give longpolling bus a tick to try to restart polling + // Give bus service a tick to receive the notification from + // postMessage. await nextTick(); assert.verifySteps([ @@ -80,44 +116,6 @@ QUnit.module('Bus', { ]); }); - QUnit.test('provide notification ID of 0 by default', async function (assert) { - // This test is important in order to ensure that we provide the correct - // sentinel value 0 when we are not aware of the last notification ID - // that we have received. We cannot provide an ID of -1, otherwise it - // may likely be handled incorrectly (before this test was written, - // it was providing -1 to the server, which in return sent every stored - // notifications related to this user). - assert.expect(3); - - // Simulate no ID of last notification in the local storage - patchWithCleanup(browser, { - getItem(key) { - if (key === 'last_ts') { - return 0; - } - return this._super(...arguments); - }, - }); - - var pollPromise = testUtils.makeTestPromise(); - const env = await makeTestEnv({ - mockRPC(route, args) { - if (route === '/longpolling/poll') { - assert.step(route); - assert.strictEqual(args.last, 0, - "provided last notification ID should be 0"); - - pollPromise = testUtils.makeTestPromise(); - return pollPromise; - } - } - }); - - // trigger longpolling poll RPC - env.services['bus_service'].addChannel('lambda'); - assert.verifySteps(['/longpolling/poll']); - }); - QUnit.test('tabs share message from a channel', async function (assert) { assert.expect(3); @@ -130,13 +128,8 @@ QUnit.module('Bus', { mainEnv.services['bus_service'].addChannel('lambda'); // slave - const slaveEnv = await makeTestEnv({ - mockRPC(route, args) { - if (route === '/longpolling/poll') { - throw new Error("Can not use the longpolling of the slave client"); - } - } - }); + const slaveEnv = await makeTestEnv(); + slaveEnv.services['bus_service'].addEventListener('notification', ({ detail: notifications }) => { assert.step('slave - notification - ' + notifications.map(notif => notif.payload).toString()); }); @@ -151,44 +144,6 @@ QUnit.module('Bus', { ]); }); - QUnit.test('secondEnv start polling after main unload', async function (assert) { - assert.expect(3); - - // main tab. - const mainEnv = await makeTestEnv({ - mockRPC(route) { - if (route === '/longpolling/poll') { - assert.step('main - poll'); - } - } - }); - // prevent second tab from receiving unload event. - patchWithCleanup(browser, { - addEventListener(eventName, callback) { - if (eventName === 'unload') { - return; - } - this._super(eventName, callback); - }, - }); - // second tab - await makeTestEnv({ - mockRPC(route) { - if (route === '/longpolling/poll') { - assert.step('second - poll'); - } - } - }); - mainEnv.services['bus_service'].startPolling(); - // simulate main unload. - window.dispatchEvent(new Event('unload')); - - assert.verifySteps([ - 'main - poll', - 'second - poll', - ]); - }); - QUnit.test('second tab still receives notifications after main unload', async function (assert) { assert.expect(4); @@ -220,6 +175,7 @@ QUnit.module('Bus', { // simulate unloading main window.dispatchEvent(new Event('unload')); + await nextTick(); pyEnv['bus.bus']._sendone('lambda', 'notifType', 'gamma'); await testUtils.nextTick(); @@ -234,7 +190,7 @@ QUnit.module('Bus', { QUnit.test('two tabs calling addChannel simultaneously', async function (assert) { assert.expect(5); - patchWithCleanup(CrossTab.prototype, { + const channelPatch = { addChannel(channel) { assert.step('Tab ' + this.__tabId__ + ': addChannel ' + channel); this._super.apply(this, arguments); @@ -243,12 +199,13 @@ QUnit.module('Bus', { assert.step('Tab ' + this.__tabId__ + ': deleteChannel ' + channel); this._super.apply(this, arguments); }, - }); - + }; const firstTabEnv = await makeTestEnv({ activateMockServer: true }); const secondTabEnv = await makeTestEnv({ activateMockServer: true }); firstTabEnv.services['bus_service'].__tabId__ = 1; secondTabEnv.services['bus_service'].__tabId__ = 2; + patchWithCleanup(firstTabEnv.services['bus_service'], channelPatch); + patchWithCleanup(secondTabEnv.services['bus_service'], channelPatch); firstTabEnv.services['bus_service'].addChannel('alpha'); secondTabEnv.services['bus_service'].addChannel('alpha'); firstTabEnv.services['bus_service'].addChannel('beta'); @@ -288,6 +245,106 @@ QUnit.module('Bus', { 'second - notification - alpha,beta', ]); }); + + QUnit.test('channel management from multiple tabs', async function (assert) { + assert.expect(4); + + patchWebsocketWorkerWithCleanup({ + _sendToServer({ event_name, data }) { + assert.step(`${event_name} - [${data.channels.toString()}]`); + }, + }); + + const firstTabEnv = await makeTestEnv(); + const secTabEnv = await makeTestEnv(); + firstTabEnv.services['bus_service'].addChannel('channel1'); + await nextTick(); + // this should not trigger a subscription since the channel1 was + // aleady known. + secTabEnv.services['bus_service'].addChannel('channel1'); + await nextTick(); + // removing channel1 from first tab should not trigger + // re-subscription since the second tab still listens to this + // channel. + firstTabEnv.services['bus_service'].deleteChannel('channel1'); + await nextTick(); + // this should trigger a subscription since the channel2 was not + // known. + secTabEnv.services['bus_service'].addChannel('channel2'); + await nextTick(); + + assert.verifySteps([ + 'subscribe - []', + 'subscribe - [channel1]', + 'subscribe - [channel1,channel2]', + ]); + }); + + QUnit.test('channels subscription after disconnection', async function (assert) { + // Patch setTimeout in order for the worker to reconnect immediatly. + patchWithCleanup(window, { + setTimeout: fn => fn(), + }); + const worker = patchWebsocketWorkerWithCleanup({ + _sendToServer({ event_name, data }) { + assert.step(`${event_name} - [${data.channels.toString()}]`); + }, + }); + + await makeTestEnv(); + // wait for the websocket to connect and the first subscription + // to occur. + await nextTick(); + worker.websocket.close(WEBSOCKET_CLOSE_CODES.KEEP_ALIVE_TIMEOUT); + // wait for the websocket to re-connect. + await nextTick(); + + assert.verifySteps([ + 'subscribe - []', + 'subscribe - []', + ]); + }); + + QUnit.test('displays reconnect notification', async (assert) => { + assert.expect(3); + + const pyEnv = await startServer(); + await createWebClient({}); + pyEnv.simulateConnectionLost(WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE); + await nextTick(); + + assert.containsOnce(document.body, '.o_notification'); + assert.strictEqual( + document.querySelector('.o_notification .o_notification_content').textContent, + 'Websocket connection lost. Trying to reconnect...' + ); + // Wait for the worker to reconnect, post a message and the + // bus_service to receive it and remove the notification. + const { afterNextRender } = owl.App; + await afterNextRender(() => {}); + assert.containsNone(document.body, '.o_notification'); + }); + + QUnit.test('does not display connection lost popup when refreshing the session', async (assert) => { + assert.expect(1); + + const pyEnv = await startServer(); + await createWebClient({}); + pyEnv.simulateConnectionLost(WEBSOCKET_CLOSE_CODES.SESSION_EXPIRED); + await nextTick(); + assert.containsNone(document.body, '.o_notification'); + }); + + + QUnit.test('does not display connection lost popup when refreshing the connection upon keep_alive_timeout', async (assert) => { + assert.expect(1); + + const pyEnv = await startServer(); + await createWebClient({}); + pyEnv.simulateConnectionLost(WEBSOCKET_CLOSE_CODES.KEEP_ALIVE_TIMEOUT); + await nextTick(); + assert.containsNone(document.body, '.o_notification'); + }); }); }); diff --git a/addons/bus/static/tests/helpers/mock_python_environment.js b/addons/bus/static/tests/helpers/mock_python_environment.js index 35188eb72a8..7c39e2b00e1 100644 --- a/addons/bus/static/tests/helpers/mock_python_environment.js +++ b/addons/bus/static/tests/helpers/mock_python_environment.js @@ -113,8 +113,8 @@ let pyEnv; getViews() { return views; }, - simulateConnectionLostAndRecovered() { - this.mockServer._simulateConnectionLostAndRecovered(); + simulateConnectionLost(closeCode) { + this.mockServer._simulateConnectionLost(closeCode); }, ...TEST_USER_IDS, }, diff --git a/addons/bus/static/tests/helpers/mock_server.js b/addons/bus/static/tests/helpers/mock_server.js index 9ad073d023d..3cb713ade1b 100644 --- a/addons/bus/static/tests/helpers/mock_server.js +++ b/addons/bus/static/tests/helpers/mock_server.js @@ -1,6 +1,7 @@ /** @odoo-module **/ import { TEST_USER_IDS } from "@bus/../tests/helpers/test_constants"; +import { patchWebsocketWorkerWithCleanup } from '@bus/../tests/helpers/mock_websocket'; import { patch } from "@web/core/utils/patch"; import { ConnectionLostError } from "@web/core/network/rpc_service"; @@ -11,6 +12,7 @@ patch(MockServer.prototype, 'bus', { init() { this._super(...arguments); Object.assign(this, TEST_USER_IDS); + this.websocketWorker = patchWebsocketWorkerWithCleanup(); this.pendingLongpollingPromise = null; this.notificationsToBeResolved = []; }, @@ -57,26 +59,21 @@ patch(MockServer.prototype, 'bus', { const values = []; for (const notification of notifications) { const [type, payload] = notification.slice(1, notification.length); - values.push({ message: { payload, type } }); - } - if (this.pendingLongpollingPromise) { - this.pendingLongpollingPromise.resolve(values); - this.pendingLongpollingPromise = null; - } else { - this.notificationsToBeResolved.push(...values); + values.push({ payload, type }); } + this.websocketWorker.broadcast('notification', values); + }, /** - * Simulate the lost of the connection by rejecting the pending - * promise with ConnectionLostError. If there is no pending promise, - * reject the next one. + * Simulate the lost of the connection by simulating a closeEvent on + * the worker websocket. + * + * @param {number} clodeCode the code to close the connection with. */ - _simulateConnectionLostAndRecovered() { - if (this.pendingLongpollingPromise) { - this.pendingLongpollingPromise.reject(new ConnectionLostError()); - } else { - this.hasLostConnection = true; - } + _simulateConnectionLost(closeCode) { + this.websocketWorker.websocket.dispatchEvent(new CloseEvent('close', { + code: closeCode, + })); }, }); diff --git a/addons/bus/static/tests/helpers/mock_websocket.js b/addons/bus/static/tests/helpers/mock_websocket.js new file mode 100644 index 00000000000..fb96e4cad67 --- /dev/null +++ b/addons/bus/static/tests/helpers/mock_websocket.js @@ -0,0 +1,84 @@ +/** @odoo-module **/ + +import { WebsocketWorker } from "@bus/workers/websocket_worker"; +import { browser } from "@web/core/browser/browser"; +import { patchWithCleanup } from "@web/../tests/helpers/utils"; +import { registerCleanup } from "@web/../tests/helpers/cleanup"; + +class WebSocketMock extends EventTarget { + constructor(url) { + super(); + this.readyState = 0; + this.url = url; + + queueMicrotask(() => { + this.readyState = 1; + const openEv = new Event('open'); + this.onopen(openEv); + this.dispatchEvent(openEv); + }); + } + + close(code, reason) { + this.readyState = 3; + const closeEv = new CloseEvent('close', { + code, + reason, + wasClean: code === 1000, + }); + this.onclose(closeEv); + this.dispatchEvent(closeEv); + } + + onclose(closeEv) {} + onerror(errorEv) {} + onopen(openEv) {} + + send(data) { + if (this.readyState !== 1) { + const errorEv = new Event('error'); + this.onerror(errorEv); + this.dispatchEvent(errorEv); + throw new DOMException("Failed to execute 'send' on 'WebSocket': State is not OPEN"); + } + } +} + +class SharedWorkerMock { + constructor(websocketWorker) { + this._websocketWorker = websocketWorker; + this._messageChannel = new MessageChannel(); + this.port = this._messageChannel.port1; + // port 1 should be started by the service itself. + this._messageChannel.port2.start(); + this._websocketWorker.registerClient(this._messageChannel.port2); + } + } + + +let websocketWorker; +/** + * @param {*} params Parameters used to patch the websocket worker. + * @returns {WebsocketWorker} Instance of the worker which will run during the + * test. Usefull to interact with the worker in order to test the + * websocket behavior. + */ +export function patchWebsocketWorkerWithCleanup(params = {}) { + patchWithCleanup(window, { + WebSocket: function () { + return new WebSocketMock(); + }, + }, { pure: true }); + patchWithCleanup(websocketWorker || WebsocketWorker.prototype, params); + websocketWorker = websocketWorker || new WebsocketWorker('wss://odoo.com/websocket'); + patchWithCleanup(browser, { + SharedWorker: function () { + return new SharedWorkerMock(websocketWorker); + }, + }, { pure: true }); + registerCleanup(() => { + clearTimeout(websocketWorker.connectTimeout); + websocketWorker = null; + }); + return websocketWorker; +} diff --git a/addons/bus/static/tests/websocket_worker_tests.js b/addons/bus/static/tests/websocket_worker_tests.js new file mode 100644 index 00000000000..51ab185825f --- /dev/null +++ b/addons/bus/static/tests/websocket_worker_tests.js @@ -0,0 +1,98 @@ +/** @odoo-module */ + +import { WEBSOCKET_CLOSE_CODES } from "@bus/workers/websocket_worker"; +import { patchWebsocketWorkerWithCleanup } from '@bus/../tests/helpers/mock_websocket'; + +import { nextTick, patchWithCleanup } from "@web/../tests/helpers/utils"; + +QUnit.module('Websocket Worker'); +QUnit.test('connect event is broadcasted', async function (assert) { + assert.expect(2); + + patchWebsocketWorkerWithCleanup({ + broadcast(type) { + assert.step(`broadcast ${type}`); + }, + }); + // Wait for the websocket to connect. + await nextTick(); + assert.verifySteps(['broadcast connect']); +}); + +QUnit.test('disconnect event is broadcasted', async function (assert) { + assert.expect(3); + + const worker = patchWebsocketWorkerWithCleanup({ + broadcast(type) { + assert.step(`broadcast ${type}`); + }, + }); + // Wait for the websocket to connect. + await nextTick(); + worker.websocket.close(WEBSOCKET_CLOSE_CODES.CLEAN); + // Wait for the websocket to disconnect. + await nextTick(); + + assert.verifySteps([ + 'broadcast connect', + 'broadcast disconnect', + ]); +}); + +QUnit.test('reconnecting/reconnect event is broadcasted', async function (assert) { + assert.expect(5); + + // Patch setTimeout in order for the worker to reconnect immediatly. + patchWithCleanup(window, { + setTimeout: fn => fn(), + }); + const worker = patchWebsocketWorkerWithCleanup({ + broadcast(type) { + assert.step(`broadcast ${type}`); + }, + }); + // Wait for the websocket to connect. + await nextTick(); + worker.websocket.close(WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE); + // Wait for the disconnect/reconnecting/reconnect events. + await nextTick(); + + assert.verifySteps([ + 'broadcast connect', + 'broadcast disconnect', + 'broadcast reconnecting', + 'broadcast reconnect', + ]); +}); + +QUnit.test('notification event is broadcasted', async function (assert) { + assert.expect(3); + + const notifications = [{ + id: 70, + message: { + type: "bundle_changed", + payload: { + server_version: '15.5alpha1+e', + }, + }, + }]; + const worker = patchWebsocketWorkerWithCleanup({ + broadcast(type, message) { + if (type === 'notification') { + assert.step(`broadcast ${type}`); + assert.deepEqual(message, notifications.map(notif => notif.message)); + } + }, + }); + // Wait for the websocket to connect. + await nextTick(); + + worker.websocket.dispatchEvent(new MessageEvent('message', { + data: JSON.stringify(notifications), + })); + + assert.verifySteps([ + 'broadcast notification', + ]); +}); diff --git a/addons/calendar/static/src/js/services/calendar_notification_service.js b/addons/calendar/static/src/js/services/calendar_notification_service.js index d54a4d2605e..12192209720 100644 --- a/addons/calendar/static/src/js/services/calendar_notification_service.js +++ b/addons/calendar/static/src/js/services/calendar_notification_service.js @@ -19,7 +19,6 @@ export const calendarNotificationService = { } } }); - bus_service.startPolling(); /** * Displays the Calendar notification on user's screen diff --git a/addons/hr_attendance/static/src/js/kiosk_mode.js b/addons/hr_attendance/static/src/js/kiosk_mode.js index 53e124f46c5..3d420970644 100644 --- a/addons/hr_attendance/static/src/js/kiosk_mode.js +++ b/addons/hr_attendance/static/src/js/kiosk_mode.js @@ -40,8 +40,8 @@ var KioskMode = AbstractAction.extend({ }, on_attach_callback: function () { - // Stop polling to avoid notifications in kiosk mode - this.call('bus_service', 'stopPolling'); + // Stop the bus_service to avoid notifications in kiosk mode + this.call('bus_service', 'stop'); $('body').find('.o_ChatWindowHeader_commandClose').click(); }, diff --git a/addons/iap_mail/static/src/js/services/iap_notification_service.js b/addons/iap_mail/static/src/js/services/iap_notification_service.js index c409e87598e..72045d2b00c 100644 --- a/addons/iap_mail/static/src/js/services/iap_notification_service.js +++ b/addons/iap_mail/static/src/js/services/iap_notification_service.js @@ -18,7 +18,6 @@ export const iapNotificationService = { } } }); - bus_service.startPolling(); /** * Displays the IAP success notification on user's screen diff --git a/addons/im_livechat/__manifest__.py b/addons/im_livechat/__manifest__.py index e768ff89eb1..f8e2720db74 100644 --- a/addons/im_livechat/__manifest__.py +++ b/addons/im_livechat/__manifest__.py @@ -177,10 +177,12 @@ Help your customers with this chat, and analyse their feedback. 'web/static/src/legacy/js/services/ajax_service.js', 'web/static/src/legacy/js/services/local_storage_service.js', # Bus, Mail, Livechat - 'bus/static/src/longpolling_bus.js', - 'bus/static/src/crosstab_bus.js', - 'bus/static/src/services/bus_service.js', + 'bus/static/src/im_status_service.js', 'bus/static/src/multi_tab_service.js', + 'bus/static/src/services/bus_service.js', + 'bus/static/src/services/legacy/make_bus_service_to_legacy_env.js', + 'bus/static/src/workers/websocket_worker.js', + 'bus/static/src/workers/websocket_worker_utils.js', 'mail/static/src/js/utils.js', 'im_livechat/static/src/legacy/public_livechat_chatbot.js', @@ -202,9 +204,7 @@ Help your customers with this chat, and analyse their feedback. 'mail/static/src/services/messaging_service.js', # Framework JS 'bus/static/src/*.js', - 'bus/static/src/services/bus_service.js', 'bus/static/src/services/presence_service.js', - 'bus/static/src/services/legacy/make_bus_service_to_legacy_env.js', 'web/static/lib/luxon/luxon.js', 'web/static/src/core/**/*', # FIXME: debug menu currently depends on webclient, once it doesn't we don't need to remove the contents of the debug folder diff --git a/addons/im_livechat/static/src/models/public_livechat_global_notification_handler.js b/addons/im_livechat/static/src/models/public_livechat_global_notification_handler.js index 10c2cb8f634..abd6fad3314 100644 --- a/addons/im_livechat/static/src/models/public_livechat_global_notification_handler.js +++ b/addons/im_livechat/static/src/models/public_livechat_global_notification_handler.js @@ -12,7 +12,6 @@ registerModel({ lifecycleHooks: { _created() { this.env.services['bus_service'].addChannel(this.messaging.publicLivechatGlobal.publicLivechat.uuid); - this.env.services['bus_service'].startPolling(); this.env.services['bus_service'].addEventListener('notification', this._onNotification); }, }, diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py index 17c61b13d08..561d3534771 100644 --- a/addons/mail/__manifest__.py +++ b/addons/mail/__manifest__.py @@ -152,11 +152,6 @@ For more specific needs, you may also assign custom-defined actions ('remove', 'mail/static/src/services/systray_service.js'), 'mail/static/src/utils/*.js', # Framework JS - 'bus/static/src/*.js', - 'bus/static/src/services/bus_service.js', - 'bus/static/src/services/presence_service.js', - 'bus/static/src/multi_tab_service.js', - 'bus/static/src/services/legacy/make_bus_service_to_legacy_env.js', 'web/static/lib/luxon/luxon.js', 'web/static/src/core/**/*', # FIXME: debug menu currently depends on webclient, once it doesn't we don't need to remove the contents of the debug folder diff --git a/addons/mail/static/src/models/message.js b/addons/mail/static/src/models/message.js index 6150febf24a..f1f9eb91373 100644 --- a/addons/mail/static/src/models/message.js +++ b/addons/mail/static/src/models/message.js @@ -133,7 +133,7 @@ registerModel({ }, /** * Mark provided messages as read. Messages that have been marked as - * read are acknowledged by server with response as longpolling + * read are acknowledged by server with response as bus. * notification of following format: * * [[dbname, 'res.partner', partnerId], { type: 'mark_as_read' }] diff --git a/addons/mail/static/src/models/messaging.js b/addons/mail/static/src/models/messaging.js index 042a7e21261..edbbef40127 100644 --- a/addons/mail/static/src/models/messaging.js +++ b/addons/mail/static/src/models/messaging.js @@ -221,7 +221,7 @@ registerModel({ } if (partnerIds.length !== 0 || guestIds.length !== 0) { const dataList = await this.messaging.rpc({ - route: '/longpolling/im_status', + route: '/bus/im_status', params: { partner_ids: partnerIds, guest_ids: guestIds, @@ -310,6 +310,14 @@ registerModel({ _computeNotificationHandler() { return {}; }, + /** + * @private + */ + _onChangeAllCurrentClientThreads() { + if (this.isInitialized) { + this.env.services.bus_service.forceUpdateChannels(); + } + }, /** * @private */ @@ -333,6 +341,12 @@ registerModel({ inverse: 'messaging', isCausal: true, }), + /** + * This field contains all current client channels. + */ + allCurrentClientThreads: many('Thread', { + inverse: 'messagingAsAllCurrentClientThreads', + }), browser: attr({ compute: '_computeBrowser', }), @@ -513,5 +527,9 @@ registerModel({ dependencies: ['ringingThreads'], methodName: '_onChangeRingingThreads', }, + { + dependencies: ['allCurrentClientThreads'], + methodName: '_onChangeAllCurrentClientThreads', + }, ], }); diff --git a/addons/mail/static/src/models/messaging_notification_handler.js b/addons/mail/static/src/models/messaging_notification_handler.js index cb1f9f14091..acdcb5819d8 100644 --- a/addons/mail/static/src/models/messaging_notification_handler.js +++ b/addons/mail/static/src/models/messaging_notification_handler.js @@ -16,7 +16,6 @@ registerModel({ lifecycleHooks: { _willDelete() { this.env.services['bus_service'].removeEventListener('notification', this._handleNotifications); - this.env.services['bus_service'].stopPolling(); }, }, recordMethods: { @@ -26,7 +25,6 @@ registerModel({ */ start() { this.env.services['bus_service'].addEventListener('notification', this._handleNotifications); - this.env.services['bus_service'].startPolling(); }, /** * @private diff --git a/addons/mail/static/src/models/thread.js b/addons/mail/static/src/models/thread.js index 38a1713b0c6..c28571a2bba 100644 --- a/addons/mail/static/src/models/thread.js +++ b/addons/mail/static/src/models/thread.js @@ -1430,6 +1430,25 @@ registerModel({ return total + 1; }, baseCounter); }, + /** + * @private + * @return {FieldCommand} + */ + _computeMessagingAsAllCurrentClientThreads() { + // The current client is linked to this thread if any of + // those conditions is met: + // - `isServerPinned` is true. + // - `messaging.currentPartner` is set and this partner is + // a member of this thread. + // - `messaging.currentGuest` is set and this guest is a + // guest member of this thread. + const isPartnerMember = this.members.includes(this.messaging.currentPartner); + const isGuestMember = this.guestMembers.includes(this.messaging.currentGuest); + if (!this.messaging || !isPartnerMember && !isGuestMember && !this.isServerPinned) { + return clear(); + } + return this.messaging; + }, /** * @private * @returns {FieldCommand} @@ -2129,6 +2148,10 @@ registerModel({ inverse: 'thread', isCausal: true, }), + messagingAsAllCurrentClientThreads: one('Messaging', { + compute: '_computeMessagingAsAllCurrentClientThreads', + inverse: 'allCurrentClientThreads', + }), messagingAsRingingThread: one('Messaging', { compute: '_computeMessagingAsRingingThread', inverse: 'ringingThreads', diff --git a/addons/mail/static/src/models/thread_cache.js b/addons/mail/static/src/models/thread_cache.js index cfd3640b4a7..ada78bea797 100644 --- a/addons/mail/static/src/models/thread_cache.js +++ b/addons/mail/static/src/models/thread_cache.js @@ -270,7 +270,7 @@ registerModel({ * cache (@see messages field for that): it just contains list * of successive messages that have been explicitly fetched by this * cache. For all non-main caches, this corresponds to all messages. - * For the main cache, however, messages received from longpolling + * For the main cache, however, messages received from the bus * should be displayed on main cache but they have not been explicitly * fetched by cache, so they ARE NOT in this list (at least, not until a * fetch on this thread cache contains this message). diff --git a/addons/mail/static/tests/helpers/mock_server.js b/addons/mail/static/tests/helpers/mock_server.js index a5f6c8956e8..e838b2d97cd 100644 --- a/addons/mail/static/tests/helpers/mock_server.js +++ b/addons/mail/static/tests/helpers/mock_server.js @@ -62,7 +62,7 @@ patch(MockServer.prototype, 'mail', { */ async _performRPC(route, args) { // routes - if (route === '/longpolling/im_status') { + if (route === '/bus/im_status') { const { partner_ids } = args; return { 'partners': this.pyEnv['res.partner'].searchRead([['id', 'in', partner_ids]], { context: { 'active_test': false }, fields: ['im_status'] }) diff --git a/addons/mail/static/tests/helpers/webclient_setup.js b/addons/mail/static/tests/helpers/webclient_setup.js index 5ef25aef926..ea9dd212be4 100644 --- a/addons/mail/static/tests/helpers/webclient_setup.js +++ b/addons/mail/static/tests/helpers/webclient_setup.js @@ -2,8 +2,8 @@ import { busService } from '@bus/services/bus_service'; import { multiTabService } from '@bus/multi_tab_service'; -import { makeBusServiceToLegacyEnv } from '@bus/services/legacy/make_bus_service_to_legacy_env'; import { makeMultiTabToLegacyEnv } from '@bus/services/legacy/make_multi_tab_to_legacy_env'; +import { makeBusServiceToLegacyEnv } from '@bus/services/legacy/make_bus_service_to_legacy_env'; import { makeFakePresenceService } from '@bus/../tests/helpers/mock_services'; import { ChatWindowManagerContainer } from '@mail/components/chat_window_manager_container/chat_window_manager_container'; @@ -19,7 +19,6 @@ import { patchWithCleanup } from "@web/../tests/helpers/utils"; import { createWebClient } from "@web/../tests/webclient/helpers"; const ROUTES_TO_IGNORE = [ - '/longpolling/poll', '/web/webclient/load_menus', '/web/dataset/call_kw/res.users/load_views', '/web/dataset/call_kw/res.users/systray_get_activities' @@ -86,20 +85,8 @@ function setupMessagingServiceRegistries({ } }; - const customBusService = { - ...busService, - start() { - const originalService = busService.start(...arguments); - Object.assign(originalService, { - _registerWindowUnload() {}, // Do nothing - updateOption() {}, - }); - return originalService; - }, - }; - services = { - bus_service: customBusService, + bus_service: busService, messaging: messagingService, messagingValues, presence: makeFakePresenceService({ diff --git a/addons/mail/static/tests/qunit_suite_tests/chatter_tests.js b/addons/mail/static/tests/qunit_suite_tests/chatter_tests.js index 5398a3b2278..8bbf2e66309 100644 --- a/addons/mail/static/tests/qunit_suite_tests/chatter_tests.js +++ b/addons/mail/static/tests/qunit_suite_tests/chatter_tests.js @@ -21,7 +21,7 @@ QUnit.test('list activity widget with no activity', async function (assert) { mockRPC: function (route, args) { if ( args.method !== 'get_views' && - !['/mail/init_messaging', '/mail/load_message_failures', '/longpolling/im_status', ...ROUTES_TO_IGNORE].includes(route) + !['/mail/init_messaging', '/mail/load_message_failures', '/bus/im_status', ...ROUTES_TO_IGNORE].includes(route) ) { assert.step(route); } @@ -70,7 +70,7 @@ QUnit.test('list activity widget with activities', async function (assert) { mockRPC: function (route, args) { if ( args.method !== 'get_views' && - !['/mail/init_messaging', '/mail/load_message_failures', '/longpolling/im_status', ...ROUTES_TO_IGNORE].includes(route) + !['/mail/init_messaging', '/mail/load_message_failures', '/bus/im_status', ...ROUTES_TO_IGNORE].includes(route) ) { assert.step(route); } @@ -115,7 +115,7 @@ QUnit.test('list activity widget with exception', async function (assert) { mockRPC: function (route, args) { if ( args.method !== 'get_views' && - !['/mail/init_messaging', '/mail/load_message_failures', '/longpolling/im_status', ...ROUTES_TO_IGNORE].includes(route) + !['/mail/init_messaging', '/mail/load_message_failures', '/bus/im_status', ...ROUTES_TO_IGNORE].includes(route) ) { assert.step(route); } @@ -172,7 +172,7 @@ QUnit.test('list activity widget: open dropdown', async function (assert) { mockRPC: function (route, args) { if ( args.method !== 'get_views' && - !['/mail/init_messaging', '/mail/load_message_failures', '/longpolling/im_status', ...ROUTES_TO_IGNORE].includes(route) + !['/mail/init_messaging', '/mail/load_message_failures', '/bus/im_status', ...ROUTES_TO_IGNORE].includes(route) ) { assert.step(args.method || route); } diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index a8108a8914c..90b314cbc3c 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -111,7 +111,6 @@ ('include', 'web.assets_backend'), # Remove Longpolling bus and packages needed this bus - ('remove', 'bus/static/src/services/assets_watchdog_service.js'), ('remove', 'mail/static/src/services/messaging_service.js'), ('remove', 'mail/static/src/components/dialog_manager/dialog_manager.js'), diff --git a/addons/snailmail/static/src/models/message.js b/addons/snailmail/static/src/models/message.js index b7a1db224cf..a5d174369c3 100644 --- a/addons/snailmail/static/src/models/message.js +++ b/addons/snailmail/static/src/models/message.js @@ -11,7 +11,7 @@ addRecordMethods('Message', { * @returns {Deferred} */ async cancelLetter() { - // the result will come from longpolling: message_notification_update + // the result will come from the bus: message_notification_update await this.messaging.rpc({ model: 'mail.message', method: 'cancel_letter', @@ -53,7 +53,7 @@ addRecordMethods('Message', { * Retries to send the 'snailmail.letter' corresponding to this message. */ async resendLetter() { - // the result will come from longpolling: message_notification_update + // the result will come from the bus: message_notification_update await this.messaging.rpc({ model: 'mail.message', method: 'send_letter', diff --git a/addons/survey/static/src/js/survey_form.js b/addons/survey/static/src/js/survey_form.js index ef8dcbfb235..eb2905a11c0 100644 --- a/addons/survey/static/src/js/survey_form.js +++ b/addons/survey/static/src/js/survey_form.js @@ -956,7 +956,6 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa var self = this; if (this.options.surveyToken && this.options.sessionInProgress) { this.call('bus_service', 'addChannel', this.options.surveyToken); - this.call('bus_service', 'startPolling'); if (!this._checkisOnMainTab()) { this.shouldReloadMasterTab = true; diff --git a/addons/web/static/src/boot.js b/addons/web/static/src/boot.js index 7c5cf0bd691..a3912dee211 100644 --- a/addons/web/static/src/boot.js +++ b/addons/web/static/src/boot.js @@ -37,11 +37,10 @@ var commentRegExp = /(\/\*([\s\S]*?)\*\/|([^:]|^)\/\/(.*)$)/gm; var cjsRequireRegExp = /[^.]\s*require\s*\(\s*["']([^'"\s]+)["']\s*\)/g; - - if (!window.odoo) { - window.odoo = {}; + if (!globalThis.odoo) { + globalThis.odoo = {}; } - var odoo = window.odoo; + var odoo = globalThis.odoo; var debug = odoo.debug; var didLogInfoResolve; @@ -223,9 +222,9 @@ }); if (debug || failed.length || unloaded.length) { - var log = window.console[ + var log = globalThis.console[ !failed.length || !unloaded.length ? "info" : "error" - ].bind(window.console); + ].bind(globalThis.console); log( (failed.length ? "error" : unloaded.length ? "warning" : "info") + ": Some modules could not be started" @@ -360,7 +359,7 @@ }; // Automatically log errors detected when loading modules - window.addEventListener("load", function logWhenLoaded() { + globalThis.addEventListener("load", function logWhenLoaded() { const len = jobPromises.length; Promise.all(jobPromises).then(function () { if (len === jobPromises.length) { diff --git a/addons/web/static/src/core/browser/browser.js b/addons/web/static/src/core/browser/browser.js index f7baaaea5b0..dc2bb7324fd 100644 --- a/addons/web/static/src/core/browser/browser.js +++ b/addons/web/static/src/core/browser/browser.js @@ -34,6 +34,8 @@ export const browser = { navigator: navigator, Notification: window.Notification, open: window.open.bind(window), + SharedWorker: window.SharedWorker, + Worker: window.Worker, XMLHttpRequest: window.XMLHttpRequest, localStorage, sessionStorage, diff --git a/addons/web_editor/__manifest__.py b/addons/web_editor/__manifest__.py index e0a1ca669ef..f7286559771 100644 --- a/addons/web_editor/__manifest__.py +++ b/addons/web_editor/__manifest__.py @@ -9,7 +9,7 @@ Odoo Web Editor widget. ========================== """, - 'depends': ['web'], + 'depends': ['bus', 'web'], 'data': [ 'security/ir.model.access.csv', 'data/editor_assets.xml', diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js index 4193b943748..033937c3d14 100644 --- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js @@ -397,7 +397,6 @@ const Wysiwyg = Widget.extend({ } }); this.call('bus_service', 'addChannel', this._collaborationChannelName); - this.call('bus_service', 'startPolling'); // const syncHistory = async (fromClientId) => { // } diff --git a/addons/website_livechat/static/src/models/livechat_button_view.js b/addons/website_livechat/static/src/models/livechat_button_view.js index c7e15543547..a4a69300009 100644 --- a/addons/website_livechat/static/src/models/livechat_button_view.js +++ b/addons/website_livechat/static/src/models/livechat_button_view.js @@ -51,7 +51,6 @@ patchRecordMethods('LivechatButtonView', { this.widget._sendWelcomeMessage(); this.widget._renderMessages(); this.env.services.bus_service.addChannel(this.messaging.publicLivechatGlobal.publicLivechat.uuid); - this.env.services.bus_service.startPolling(); set_cookie('im_livechat_session', unaccent(JSON.stringify(this.messaging.publicLivechatGlobal.publicLivechat.legacyPublicLivechat.toData()), true), 60 * 60); this.update({ isOpeningChat: false }); },