Initial commit: Core packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:45 +02:00
commit 12c29a983b
9512 changed files with 8379910 additions and 0 deletions

View file

@ -0,0 +1,74 @@
/** @odoo-module **/
import { browser } from '@web/core/browser/browser';
import { registry } from '@web/core/registry';
export const UPDATE_BUS_PRESENCE_DELAY = 60000;
/**
* This service updates periodically the user presence in order for the
* im_status to be up to date.
*
* In order to receive bus notifications related to im_status, one must
* register model/ids to monitor to this service.
*/
export const imStatusService = {
dependencies: ['bus_service', 'multi_tab', 'presence'],
start(env, { bus_service, multi_tab, presence }) {
const imStatusModelToIds = {};
let updateBusPresenceTimeout;
const throttledUpdateBusPresence = _.throttle(
function updateBusPresence() {
clearTimeout(updateBusPresenceTimeout);
if (!multi_tab.isOnMainTab()) {
return;
}
const now = new Date().getTime();
bus_service.send("update_presence", {
inactivity_period: now - presence.getLastPresence(),
im_status_ids_by_model: { ...imStatusModelToIds },
});
updateBusPresenceTimeout = browser.setTimeout(throttledUpdateBusPresence, UPDATE_BUS_PRESENCE_DELAY);
},
UPDATE_BUS_PRESENCE_DELAY
);
bus_service.addEventListener('connect', () => {
// wait for im_status model/ids to be registered before starting.
browser.setTimeout(throttledUpdateBusPresence, 250);
});
multi_tab.bus.addEventListener('become_main_tab', throttledUpdateBusPresence);
bus_service.addEventListener('reconnect', throttledUpdateBusPresence);
multi_tab.bus.addEventListener('no_longer_main_tab', () => clearTimeout(updateBusPresenceTimeout));
bus_service.addEventListener('disconnect', () => clearTimeout(updateBusPresenceTimeout));
return {
/**
* Register model/ids whose im_status should be monitored.
* Notification related to the im_status are then sent
* through the bus. Overwrite registration if already
* present.
*
* @param {string} model model related to the given ids.
* @param {Number[]} ids ids whose im_status should be
* monitored.
*/
registerToImStatus(model, ids) {
if (!ids.length) {
return this.unregisterFromImStatus(model);
}
imStatusModelToIds[model] = ids;
},
/**
* Unregister model from im_status notifications.
*
* @param {string} model model to unregister.
*/
unregisterFromImStatus(model) {
delete imStatusModelToIds[model];
},
};
},
};
registry.category('services').add('im_status', imStatusService);

View file

@ -0,0 +1,224 @@
/** @odoo-module **/
import { registry } from '@web/core/registry';
import { browser } from '@web/core/browser/browser';
const { EventBus } = owl;
let multiTabId = 0;
/**
* This service uses a Master/Slaves with Leader Election architecture in
* order to keep track of the main tab. Tabs are synchronized thanks to the
* localStorage.
*
* localStorage used keys are:
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.lastPresenceByTab:
* mapping of tab ids to their last recorded presence.
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.main : id of the current
* main tab.
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.heartbeat : last main tab
* heartbeat time.
*
* trigger:
* - become_main_tab : when this tab became the main.
* - no_longer_main_tab : when this tab is no longer the main.
* - shared_value_updated: when one of the shared values changes.
*/
export const multiTabService = {
start() {
const bus = new EventBus();
// CONSTANTS
const TAB_HEARTBEAT_PERIOD = 10000; // 10 seconds
const MAIN_TAB_HEARTBEAT_PERIOD = 1500; // 1.5 seconds
const HEARTBEAT_OUT_OF_DATE_PERIOD = 5000; // 5 seconds
const HEARTBEAT_KILL_OLD_PERIOD = 15000; // 15 seconds
// Keys that should not trigger the `shared_value_updated` event.
const PRIVATE_LOCAL_STORAGE_KEYS = ['main', 'heartbeat'];
// PROPERTIES
let _isOnMainTab = false;
let lastHeartbeat = 0;
let heartbeatTimeout;
const sanitizedOrigin = location.origin.replace(/:\/{0,2}/g, '_');
const localStoragePrefix = `${this.name}.${sanitizedOrigin}.`;
const now = new Date().getTime();
const tabId = `${this.name}${multiTabId++}:${now}`;
function generateLocalStorageKey(baseKey) {
return localStoragePrefix + baseKey;
}
function getItemFromStorage(key, defaultValue) {
const item = browser.localStorage.getItem(generateLocalStorageKey(key));
try {
return item ? JSON.parse(item) : defaultValue;
} catch {
return item;
}
}
function setItemInStorage(key, value) {
browser.localStorage.setItem(generateLocalStorageKey(key), JSON.stringify(value));
}
function startElection() {
if (_isOnMainTab) {
return;
}
// Check who's next.
const now = new Date().getTime();
const lastPresenceByTab = getItemFromStorage('lastPresenceByTab', {});
const heartbeatKillOld = now - HEARTBEAT_KILL_OLD_PERIOD;
let newMain;
for (const [tab, lastPresence] of Object.entries(lastPresenceByTab)) {
// Check for dead tabs.
if (lastPresence < heartbeatKillOld) {
continue;
}
newMain = tab;
break;
}
if (newMain === tabId) {
// We're next in queue. Electing as main.
lastHeartbeat = now;
setItemInStorage('heartbeat', lastHeartbeat);
setItemInStorage('main', true);
_isOnMainTab = true;
bus.trigger('become_main_tab');
// Removing main peer from queue.
delete lastPresenceByTab[newMain];
setItemInStorage('lastPresenceByTab', lastPresenceByTab);
}
}
function heartbeat() {
const now = new Date().getTime();
let heartbeatValue = getItemFromStorage('heartbeat', 0);
const lastPresenceByTab = getItemFromStorage('lastPresenceByTab', {});
if (heartbeatValue + HEARTBEAT_OUT_OF_DATE_PERIOD < now) {
// Heartbeat is out of date. Electing new main.
startElection();
heartbeatValue = getItemFromStorage('heartbeat', 0);
}
if (_isOnMainTab) {
// Walk through all tabs and kill old ones.
const cleanedTabs = {};
for (const [tabId, lastPresence] of Object.entries(lastPresenceByTab)) {
if (lastPresence + HEARTBEAT_KILL_OLD_PERIOD > now) {
cleanedTabs[tabId] = lastPresence;
}
}
if (heartbeatValue !== lastHeartbeat) {
// Someone else is also main...
// It should not happen, except in some race condition situation.
_isOnMainTab = false;
lastHeartbeat = 0;
lastPresenceByTab[tabId] = now;
setItemInStorage('lastPresenceByTab', lastPresenceByTab);
bus.trigger('no_longer_main_tab');
} else {
lastHeartbeat = now;
setItemInStorage('heartbeat', now);
setItemInStorage('lastPresenceByTab', cleanedTabs);
}
} else {
// Update own heartbeat.
lastPresenceByTab[tabId] = now;
setItemInStorage('lastPresenceByTab', lastPresenceByTab);
}
const hbPeriod = _isOnMainTab ? MAIN_TAB_HEARTBEAT_PERIOD : TAB_HEARTBEAT_PERIOD;
heartbeatTimeout = browser.setTimeout(heartbeat, hbPeriod);
}
function onStorage({ key, newValue }) {
if (key === generateLocalStorageKey('main') && !newValue) {
// Main was unloaded.
startElection();
}
if (PRIVATE_LOCAL_STORAGE_KEYS.includes(key)) {
return;
}
if (key && key.includes(localStoragePrefix)) {
// Only trigger the shared_value_updated event if the key is
// related to this service/origin.
const baseKey = key.replace(localStoragePrefix, '');
bus.trigger('shared_value_updated', { key: baseKey, newValue });
}
}
function onPagehide() {
clearTimeout(heartbeatTimeout);
const lastPresenceByTab = getItemFromStorage('lastPresenceByTab', {});
delete lastPresenceByTab[tabId];
setItemInStorage('lastPresenceByTab', lastPresenceByTab);
// Unload main.
if (_isOnMainTab) {
_isOnMainTab = false;
bus.trigger('no_longer_main_tab');
browser.localStorage.removeItem(generateLocalStorageKey('main'));
}
}
browser.addEventListener('pagehide', onPagehide);
browser.addEventListener('storage', onStorage);
// REGISTER THIS TAB
const lastPresenceByTab = getItemFromStorage('lastPresenceByTab', {});
lastPresenceByTab[tabId] = now;
setItemInStorage('lastPresenceByTab', lastPresenceByTab);
if (!getItemFromStorage('main')) {
startElection();
}
heartbeat();
return {
bus,
get currentTabId() {
return tabId;
},
/**
* Determine whether or not this tab is the main one.
*
* @returns {boolean}
*/
isOnMainTab() {
return _isOnMainTab;
},
/**
* Get value shared between all the tabs.
*
* @param {string} key
* @param {any} defaultValue Value to be returned if this
* key does not exist.
*/
getSharedValue(key, defaultValue) {
return getItemFromStorage(key, defaultValue);
},
/**
* Set value shared between all the tabs.
*
* @param {string} key
* @param {any} value
*/
setSharedValue(key, value) {
if (value === undefined) {
return this.removeSharedValue(key);
}
setItemInStorage(key, value);
},
/**
* Remove value shared between all the tabs.
*
* @param {string} key
*/
removeSharedValue(key) {
browser.localStorage.removeItem(generateLocalStorageKey(key));
},
};
},
};
registry.category('services').add('multi_tab', multiTabService);

View file

@ -0,0 +1,82 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { session } from "@web/session";
export const assetsWatchdogService = {
dependencies: ["bus_service", "notification"],
start(env, { bus_service, notification }) {
let isNotificationDisplayed = false;
let bundleNotifTimerID = null;
bus_service.addEventListener('notification', onNotification.bind(this));
bus_service.start();
/**
* Displays one notification on user's screen when assets have changed
*/
function displayBundleChangedNotification() {
if (!isNotificationDisplayed) {
// Wrap the notification inside a delay.
// The server may be overwhelmed with recomputing assets
// We wait until things settle down
browser.clearTimeout(bundleNotifTimerID);
bundleNotifTimerID = browser.setTimeout(() => {
notification.add(
env._t("The page appears to be out of date."),
{
title: env._t("Refresh"),
type: "warning",
sticky: true,
buttons: [
{
name: env._t("Refresh"),
primary: true,
onClick: () => {
browser.location.reload();
},
},
],
onClose: () => {
isNotificationDisplayed = false;
},
}
);
isNotificationDisplayed = true;
}, getBundleNotificationDelay());
}
}
/**
* Computes a random delay to avoid hammering the server
* when bundles change with all the users reloading
* at the same time
*
* @return {number} delay in milliseconds
*/
function getBundleNotificationDelay() {
return 10000 + Math.floor(Math.random() * 50) * 1000;
}
/**
* Reacts to bus's notification
*
* @param {CustomEvent} ev
* @param {Array} [ev.detail] list of received notifications
*/
function onNotification({ detail: notifications }) {
for (const { payload, type } of notifications) {
if (type === 'bundle_changed') {
if (payload.server_version !== session.server_version) {
displayBundleChangedNotification();
break;
}
}
}
}
},
};
registry.category("services").add("assetsWatchdog", assetsWatchdogService);

View file

@ -0,0 +1,188 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { Deferred } from "@web/core/utils/concurrency";
import { registry } from '@web/core/registry';
import { session } from '@web/session';
import { isIosApp } from '@web/core/browser/feature_detection';
import { WORKER_VERSION } from "@bus/workers/websocket_worker";
import legacySession from "web.session";
const { EventBus } = owl;
/**
* 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: ['localization', 'multi_tab'],
async start(env, { multi_tab: multiTab }) {
const bus = new EventBus();
let worker;
let isActive = false;
let isInitialized = false;
let isUsingSharedWorker = browser.SharedWorker && !isIosApp();
const startTs = new Date().getTime();
const connectionInitializedDeferred = new Deferred();
/**
* 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) {
if (!worker) {
return;
}
const message = { action, data };
if (isUsingSharedWorker) {
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 } = messageEv.data;
let { data } = messageEv.data;
if (type === 'notification') {
multiTab.setSharedValue('last_notification_id', data[data.length - 1].id);
data = data.map(notification => notification.message);
} else if (type === 'initialized') {
isInitialized = true;
connectionInitializedDeferred.resolve();
return;
}
bus.trigger(type, data);
}
/**
* Initialize the connection to the worker by sending it usefull
* initial informations (last notification id, debug mode,
* ...).
*/
function initializeWorkerConnection() {
// User_id has different values according to its origin:
// - frontend: number or false,
// - backend: array with only one number
// - guest page: array containing null or number
// - public pages: undefined
// Let's format it in order to ease its usage:
// - number if user is logged, false otherwise, keep
// undefined to indicate session_info is not available.
let uid = Array.isArray(session.user_id) ? session.user_id[0] : session.user_id;
if (!uid && uid !== undefined) {
uid = false;
}
send('initialize_connection', {
websocketURL: `${legacySession.prefix.replace("http", "ws")}/websocket`,
debug: odoo.debug,
lastNotificationId: multiTab.getSharedValue('last_notification_id', 0),
uid,
startTs,
});
}
/**
* Start the "bus_service" worker.
*/
function startWorker() {
let workerURL = `${legacySession.prefix}/bus/websocket_worker_bundle?v=${WORKER_VERSION}`;
if (legacySession.prefix !== window.origin) {
// Bus service is loaded from a different origin than the bundle
// URL. The Worker expects an URL from this origin, give it a base64
// URL that will then load the bundle via "importScripts" which
// allows cross origin.
const source = `importScripts("${workerURL}");`;
workerURL = 'data:application/javascript;base64,' + window.btoa(source);
}
const workerClass = isUsingSharedWorker ? browser.SharedWorker : browser.Worker;
worker = new workerClass(workerURL, {
name: isUsingSharedWorker
? 'odoo:websocket_shared_worker'
: 'odoo:websocket_worker',
});
worker.addEventListener("error", (e) => {
if (!isInitialized && workerClass === browser.SharedWorker) {
console.warn(
'Error while loading "bus_service" SharedWorker, fallback on Worker.'
);
isUsingSharedWorker = false;
startWorker();
} else if (!isInitialized) {
isInitialized = true;
connectionInitializedDeferred.resolve();
console.warn('Bus service failed to initialized.');
}
});
if (isUsingSharedWorker) {
worker.port.start();
worker.port.addEventListener('message', handleMessage);
} else {
worker.addEventListener('message', handleMessage);
}
initializeWorkerConnection();
}
browser.addEventListener('pagehide', ({ persisted }) => {
if (!persisted) {
// Page is gonna be unloaded, disconnect this client
// from the worker.
send('leave');
}
});
browser.addEventListener('online', () => {
if (isActive) {
send('start');
}
});
browser.addEventListener('offline', () => send('stop'));
return {
addEventListener: bus.addEventListener.bind(bus),
addChannel: async channel => {
if (!worker) {
startWorker();
await connectionInitializedDeferred;
}
send('add_channel', channel);
send('start');
isActive = true;
},
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 }),
start: async () => {
if (!worker) {
startWorker();
await connectionInitializedDeferred;
}
send('start');
isActive = true;
},
stop: () => {
send('leave');
isActive = false;
},
};
},
};
registry.category('services').add('bus_service', busService);

View file

@ -0,0 +1,14 @@
/** @odoo-module **/
import { registry } from '@web/core/registry';
export function makeBusServiceToLegacyEnv(legacyEnv) {
return {
dependencies: ['bus_service'],
start(_, { bus_service }) {
legacyEnv.services['bus_service'] = bus_service;
},
};
}
registry.category('wowlToLegacyServiceMappers').add('bus_service_to_legacy_env', makeBusServiceToLegacyEnv);

View file

@ -0,0 +1,14 @@
/** @odoo-module **/
import { registry } from '@web/core/registry';
export function makeMultiTabToLegacyEnv(legacyEnv) {
return {
dependencies: ['multi_tab'],
start(_, { multi_tab }) {
legacyEnv.services['multi_tab'] = multi_tab;
},
};
}
registry.category('wowlToLegacyServiceMappers').add('multi_tab_to_legacy_env', makeMultiTabToLegacyEnv);

View file

@ -0,0 +1,66 @@
/** @odoo-module **/
import { browser } from '@web/core/browser/browser';
import { registry } from '@web/core/registry';
import core from 'web.core';
export const presenceService = {
start(env) {
const LOCAL_STORAGE_PREFIX = 'presence';
// map window_focus event from the wowlBus to the legacy one.
env.bus.addEventListener('window_focus', isOdooFocused => {
core.bus.trigger('window_focus', isOdooFocused);
});
let isOdooFocused = true;
let lastPresenceTime = (
browser.localStorage.getItem(`${LOCAL_STORAGE_PREFIX}.lastPresence`)
|| new Date().getTime()
);
function onPresence() {
lastPresenceTime = new Date().getTime();
browser.localStorage.setItem(`${LOCAL_STORAGE_PREFIX}.lastPresence`, lastPresenceTime);
}
function onFocusChange(isFocused) {
try {
isFocused = parent.document.hasFocus();
} catch {}
isOdooFocused = isFocused;
browser.localStorage.setItem(`${LOCAL_STORAGE_PREFIX}.focus`, isOdooFocused);
if (isOdooFocused) {
lastPresenceTime = new Date().getTime();
env.bus.trigger('window_focus', isOdooFocused);
}
}
function onStorage({ key, newValue }) {
if (key === `${LOCAL_STORAGE_PREFIX}.focus`) {
isOdooFocused = JSON.parse(newValue);
env.bus.trigger('window_focus', newValue);
}
if (key === `${LOCAL_STORAGE_PREFIX}.lastPresence`) {
lastPresenceTime = JSON.parse(newValue);
}
}
browser.addEventListener('storage', onStorage);
browser.addEventListener('focus', () => onFocusChange(true));
browser.addEventListener('blur', () => onFocusChange(false));
browser.addEventListener('pagehide', () => onFocusChange(false));
browser.addEventListener('click', onPresence);
browser.addEventListener('keydown', onPresence);
return {
getLastPresence() {
return lastPresenceTime;
},
isOdooFocused() {
return isOdooFocused;
}
};
},
};
registry.category('services').add('presence', presenceService);

View file

@ -0,0 +1,439 @@
/** @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' | 'initialized' } WorkerEvent
*/
/**
* Type of action that can be sent from the client to the worker.
*
* @typedef {'add_channel' | 'delete_channel' | 'force_update_channels' | 'initialize_connection' | 'send' | 'leave' | 'stop' | 'start' } 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,
RECONNECTING: 4003,
});
// Should be incremented on every worker update in order to force
// update of the worker in browser cache.
export const WORKER_VERSION = '1.0.5';
const INITIAL_RECONNECT_DELAY = 1000;
const MAXIMUM_RECONNECT_DELAY = 60000;
/**
* 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() {
// Timestamp of start of most recent bus service sender
this.newestStartTs = undefined;
this.websocketURL = "";
this.currentUID = null;
this.isWaitingForNewUID = true;
this.channelsByClient = new Map();
this.connectRetryDelay = INITIAL_RECONNECT_DELAY;
this.connectTimeout = null;
this.debugModeByClient = new Map();
this.isDebug = false;
this.isReconnecting = false;
this.lastChannelSubscription = null;
this.lastNotificationId = 0;
this.messageWaitQueue = [];
this._forceUpdateChannels = debounce(this._forceUpdateChannels, 300, true);
this._onWebsocketClose = this._onWebsocketClose.bind(this);
this._onWebsocketError = this._onWebsocketError.bind(this);
this._onWebsocketMessage = this._onWebsocketMessage.bind(this);
this._onWebsocketOpen = this._onWebsocketOpen.bind(this);
}
//--------------------------------------------------------------------------
// 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, []);
}
/**
* 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 'start':
return this._start();
case 'stop':
return this._stop();
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();
case 'initialize_connection':
return this._initializeConnection(client, data);
}
}
/**
* 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.debugModeByClient.delete(client);
this.isDebug = Object.values(this.debugModeByClient).some(debugValue => debugValue !== '');
this._updateChannels();
}
/**
* Initialize a client connection to this worker.
*
* @param {Object} param0
* @param {String} [param0.debug] Current debugging mode for the
* given client.
* @param {Number} [param0.lastNotificationId] Last notification id
* known by the client.
* @param {String} [param0.websocketURL] URL of the websocket endpoint.
* @param {Number|false|undefined} [param0.uid] Current user id
* - Number: user is logged whether on the frontend/backend.
* - false: user is not logged.
* - undefined: not available (e.g. livechat support page)
* @param {Number} param0.startTs Timestamp of start of bus service sender.
*/
_initializeConnection(client, { debug, lastNotificationId, uid, websocketURL, startTs }) {
if (this.newestStartTs && this.newestStartTs > startTs) {
this.debugModeByClient[client] = debug;
this.isDebug = Object.values(this.debugModeByClient).some(debugValue => debugValue !== '');
this.sendToClient(client, "initialized");
return;
}
this.newestStartTs = startTs;
this.websocketURL = websocketURL;
this.lastNotificationId = lastNotificationId;
this.debugModeByClient[client] = debug;
this.isDebug = Object.values(this.debugModeByClient).some(debugValue => debugValue !== '');
const isCurrentUserKnown = uid !== undefined;
if (this.isWaitingForNewUID && isCurrentUserKnown) {
this.isWaitingForNewUID = false;
this.currentUID = uid;
}
if (this.currentUID !== uid && isCurrentUserKnown) {
this.currentUID = uid;
if (this.websocket) {
this.websocket.close(WEBSOCKET_CLOSE_CODES.CLEAN);
}
this.channelsByClient.forEach((_, key) => this.channelsByClient.set(key, []));
}
this.sendToClient(client, 'initialized');
}
/**
* Determine whether or not the websocket associated to this worker
* is connected.
*
* @returns {boolean}
*/
_isWebsocketConnected() {
return this.websocket && this.websocket.readyState === 1;
}
/**
* Determine whether or not the websocket associated to this worker
* is connecting.
*
* @returns {boolean}
*/
_isWebsocketConnecting() {
return this.websocket && this.websocket.readyState === 0;
}
/**
* Determine whether or not the websocket associated to this worker
* is in the closing state.
*
* @returns {boolean}
*/
_isWebsocketClosing() {
return this.websocket && this.websocket.readyState === 2;
}
/**
* 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.isDebug) {
console.debug(`%c${new Date().toLocaleString()} - [onClose]`, 'color: #c6e; font-weight: bold;', code, reason);
}
this.lastChannelSubscription = null;
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;
if (code === WEBSOCKET_CLOSE_CODES.KEEP_ALIVE_TIMEOUT) {
// Don't wait to reconnect on keep alive timeout.
this.connectRetryDelay = 0;
}
if (code === WEBSOCKET_CLOSE_CODES.SESSION_EXPIRED) {
this.isWaitingForNewUID = true;
}
this._retryConnectionWithDelay();
}
/**
* Triggered when a connection failed or failed to established.
*/
_onWebsocketError() {
if (this.isDebug) {
console.debug(`%c${new Date().toLocaleString()} - [onError]`, 'color: #c6e; font-weight: bold;');
}
this._retryConnectionWithDelay();
}
/**
* Handle data received from the bus.
*
* @param {MessageEvent} messageEv
*/
_onWebsocketMessage(messageEv) {
const notifications = JSON.parse(messageEv.data);
if (this.isDebug) {
console.debug(`%c${new Date().toLocaleString()} - [onMessage]`, 'color: #c6e; font-weight: bold;', notifications);
}
this.lastNotificationId = notifications[notifications.length - 1].id;
this.broadcast('notification', notifications);
}
/**
* Triggered on websocket open. Send message that were waiting for
* the connection to open.
*/
_onWebsocketOpen() {
if (this.isDebug) {
console.debug(`%c${new Date().toLocaleString()} - [onOpen]`, 'color: #c6e; font-weight: bold;');
}
this._updateChannels();
this.messageWaitQueue.forEach(msg => this.websocket.send(msg));
this.messageWaitQueue = [];
this.broadcast(this.isReconnecting ? 'reconnect' : 'connect');
this.connectRetryDelay = INITIAL_RECONNECT_DELAY;
this.connectTimeout = null;
this.isReconnecting = false;
}
/**
* Try to reconnect to the server, an exponential back off is
* applied to the reconnect attempts.
*/
_retryConnectionWithDelay() {
this.connectRetryDelay = Math.min(this.connectRetryDelay * 1.5, MAXIMUM_RECONNECT_DELAY) + 1000 * Math.random();
this.connectTimeout = setTimeout(this._start.bind(this), this.connectRetryDelay);
}
/**
* 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() {
if (this._isWebsocketConnected() || this._isWebsocketConnecting()) {
return;
}
if (this.websocket) {
this.websocket.removeEventListener('open', this._onWebsocketOpen);
this.websocket.removeEventListener('message', this._onWebsocketMessage);
this.websocket.removeEventListener('error', this._onWebsocketError);
this.websocket.removeEventListener('close', this._onWebsocketClose);
}
if (this._isWebsocketClosing()) {
// close event was not triggered and will never be, broadcast the
// disconnect event for consistency sake.
this.lastChannelSubscription = null;
this.broadcast("disconnect", { code: WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE });
}
this.websocket = new WebSocket(this.websocketURL);
this.websocket.addEventListener('open', this._onWebsocketOpen);
this.websocket.addEventListener('error', this._onWebsocketError);
this.websocket.addEventListener('message', this._onWebsocketMessage);
this.websocket.addEventListener('close', this._onWebsocketClose);
}
/**
* Stop the worker.
*/
_stop() {
clearTimeout(this.connectTimeout);
this.connectRetryDelay = INITIAL_RECONNECT_DELAY;
this.isReconnecting = false;
this.lastChannelSubscription = null;
if (this.websocket) {
this.websocket.close();
}
}
/**
* 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 } });
}
}
}

View file

@ -0,0 +1,23 @@
/** @odoo-module **/
/* eslint-env worker */
/* eslint-disable no-restricted-globals */
import { WebsocketWorker } from "./websocket_worker";
(function () {
const websocketWorker = new WebsocketWorker();
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);
}
})();

View file

@ -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);
}
};
}