19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -0,0 +1,19 @@
declare module "services" {
import { busParametersService } from "@bus/bus_parameters_service";
import { legacyMultiTabService } from "@bus/legacy_multi_tab_service";
import { outdatedPageWatcherService } from "@bus/outdated_page_watcher_service";
import { busMonitoringservice } from "@bus/services/bus_monitoring_service";
import { busService } from "@bus/services/bus_service";
import { busLogsService } from "@bus/services/debug/bus_logs_service";
import { presenceService } from "@bus/services/presence_service";
export interface Services {
"bus.monitoring_service": typeof busMonitoringservice,
"bus.outdated_page_watcher": typeof outdatedPageWatcherService,
"bus.parameters": typeof busParametersService,
bus_service: typeof busService,
"bus.logs_service": typeof busLogsService,
legacy_multi_tab: typeof legacyMultiTabService,
presence: typeof presenceService,
}
}

View file

@ -0,0 +1,11 @@
import { registry } from "@web/core/registry";
export const busParametersService = {
start() {
return {
serverURL: window.origin,
};
},
};
registry.category("services").add("bus.parameters", busParametersService);

View file

@ -0,0 +1,33 @@
import { Component, useRef } from "@odoo/owl";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
export class BusLogsMenuItem extends Component {
static components = { DropdownItem };
static template = "bus.BusLogsMenuItem";
static props = {};
setup() {
this.busLogsService = useService("bus.logs_service");
this.downloadButton = useRef("downloadButton");
}
onClickToggle() {
this.busLogsService.toggleLogging();
}
onClickDownload() {
this.env.services.bus_service.downloadLogs();
}
}
registry
.category("debug")
.category("default")
.add("bus.download_logs", () => ({
Component: BusLogsMenuItem,
sequence: 550,
section: "tools",
type: "component",
}));

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="bus.BusLogsMenuItem">
<DropdownItem>
<div class="d-flex align-items-center justify-content-between" style="padding-left: 12px;">
<div class="form-check form-switch">
<label class="form-check-label" t-on-click.prevent.stop="onClickToggle">
Enable Bus Logging <input class="form-check-input" t-att-checked="busLogsService.enabled" type="checkbox"/>
</label>
</div>
<button class="btn btn-light text-muted ms-2" title="Download logs" t-ref="downloadButton" t-on-click="onClickDownload"><i class="fa fa-download"/></button>
</div>
</DropdownItem>
</t>
</templates>

View file

@ -0,0 +1,44 @@
import { reactive } from "@odoo/owl";
import { registry } from "@web/core/registry";
export const busLogsService = {
dependencies: ["bus_service", "legacy_multi_tab", "worker_service"],
/**
* @param {import("@web/env").OdooEnv}
* @param {Partial<import("services").Services>} services
*/
start(env, { bus_service, legacy_multi_tab, worker_service }) {
const state = reactive({
enabled: legacy_multi_tab.getSharedValue("bus_log_menu.enabled", false),
toggleLogging() {
state.enabled = !state.enabled;
if (bus_service.isActive) {
bus_service.setLoggingEnabled(state.enabled);
}
legacy_multi_tab.setSharedValue("bus_log_menu.enabled", state.enabled);
},
});
legacy_multi_tab.bus.addEventListener("shared_value_updated", ({ detail }) => {
if (detail.key === "bus_log_menu.enabled") {
state.enabled = JSON.parse(detail.newValue);
}
});
worker_service.connectionInitializedDeferred.then(() => {
bus_service.setLoggingEnabled(state.enabled);
});
odoo.busLogging = {
stop: () => state.enabled && state.toggleLogging(),
start: () => !state.enabled && state.toggleLogging(),
download: () => bus_service.downloadLogs(),
};
if (state.enabled) {
console.log(
"Bus logging is enabled. To disable it, use `odoo.busLogging.stop()`. To download the logs, use `odoo.busLogging.download()`."
);
}
return state;
},
};
registry.category("services").add("bus.logs_service", busLogsService);

View file

@ -1,74 +0,0 @@
/** @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,80 @@
import { registry } from "@web/core/registry";
import { browser } from "@web/core/browser/browser";
import { EventBus } from "@odoo/owl";
export const legacyMultiTabService = {
start() {
const bus = new EventBus();
// PROPERTIES
const sanitizedOrigin = location.origin.replace(/:\/{0,2}/g, "_");
const localStoragePrefix = `${this.name}.${sanitizedOrigin}.`;
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 onStorage({ key, newValue }) {
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 });
}
}
browser.addEventListener("storage", onStorage);
return {
bus,
generateLocalStorageKey,
getItemFromStorage,
setItemInStorage,
/**
* 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("legacy_multi_tab", legacyMultiTabService);

View file

@ -0,0 +1,63 @@
import { browser } from "@web/core/browser/browser";
/**
* Returns a function, that, when invoked, will only be triggered at most once
* during a given window of time. Normally, the throttled function will run
* as much as it can, without ever going more than once per `wait` duration;
* but if you'd like to disable the execution on the leading edge, pass
* `{leading: false}`. To disable execution on the trailing edge, ditto.
*
* credit to `underscore.js`
*/
function throttle(func, wait, options) {
let timeout, context, args, result;
let previous = 0;
if (!options) {
options = {};
}
const later = function () {
previous = options.leading === false ? 0 : luxon.DateTime.now().ts;
timeout = null;
result = func.apply(context, args);
if (!timeout) {
context = args = null;
}
};
const throttled = function () {
const _now = luxon.DateTime.now().ts;
if (!previous && options.leading === false) {
previous = _now;
}
const remaining = wait - (_now - previous);
context = this;
args = arguments;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
browser.clearTimeout(timeout);
timeout = null;
}
previous = _now;
result = func.apply(context, args);
if (!timeout) {
context = args = null;
}
} else if (!timeout && options.trailing !== false) {
timeout = browser.setTimeout(later, remaining);
}
return result;
};
throttled.cancel = function () {
browser.clearTimeout(timeout);
previous = 0;
timeout = context = args = null;
};
return throttled;
}
export const timings = {
throttle,
};

View file

@ -0,0 +1,186 @@
import { browser } from "@web/core/browser/browser";
import { EventBus } from "@odoo/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:
* - multi_tab_service.lastPresenceByTab: mapping of tab ids to their last
* recorded presence.
* - multi_tab_service.main: a boolean indicating whether a main tab is already
* present.
* - multi_tab_service.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.
*/
export const multiTabFallbackService = {
start(env) {
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
// PROPERTIES
let _isOnMainTab = false;
let lastHeartbeat = 0;
let heartbeatTimeout;
const now = new Date().getTime();
const tabId = `${this.name}${multiTabId++}:${now}`;
function startElection() {
if (_isOnMainTab) {
return;
}
// Check who's next.
const now = new Date().getTime();
const lastPresenceByTab =
JSON.parse(localStorage.getItem("multi_tab_service.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;
localStorage.setItem("multi_tab_service.heartbeat", lastHeartbeat);
localStorage.setItem("multi_tab_service.main", true);
_isOnMainTab = true;
bus.trigger("become_main_tab");
// Removing main peer from queue.
delete lastPresenceByTab[newMain];
localStorage.setItem(
"multi_tab_service.lastPresenceByTab",
JSON.stringify(lastPresenceByTab)
);
}
}
function heartbeat() {
const now = new Date().getTime();
let heartbeatValue = parseInt(localStorage.getItem("multi_tab_service.heartbeat") ?? 0);
const lastPresenceByTab =
JSON.parse(localStorage.getItem("multi_tab_service.lastPresenceByTab")) ?? {};
if (heartbeatValue + HEARTBEAT_OUT_OF_DATE_PERIOD < now) {
// Heartbeat is out of date. Electing new main.
startElection();
heartbeatValue = parseInt(localStorage.getItem("multi_tab_service.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;
localStorage.setItem(
"multi_tab_service.lastPresenceByTab",
JSON.stringify(lastPresenceByTab)
);
bus.trigger("no_longer_main_tab");
} else {
lastHeartbeat = now;
localStorage.setItem("multi_tab_service.heartbeat", now);
localStorage.setItem(
"multi_tab_service.lastPresenceByTab",
JSON.stringify(cleanedTabs)
);
}
} else {
// Update own heartbeat.
lastPresenceByTab[tabId] = now;
localStorage.setItem(
"multi_tab_service.lastPresenceByTab",
JSON.stringify(lastPresenceByTab)
);
}
const hbPeriod = _isOnMainTab ? MAIN_TAB_HEARTBEAT_PERIOD : TAB_HEARTBEAT_PERIOD;
heartbeatTimeout = browser.setTimeout(heartbeat, hbPeriod);
}
function onStorage({ key, newValue }) {
if (key === "multi_tab_service.main" && !newValue) {
// Main was unloaded.
startElection();
}
}
/**
* Unregister this tab from the multi-tab service. It will no longer
* be able to become the main tab.
*/
function unregister() {
clearTimeout(heartbeatTimeout);
const lastPresenceByTab =
JSON.parse(localStorage.getItem("multi_tab_service.lastPresenceByTab")) ?? {};
delete lastPresenceByTab[tabId];
localStorage.setItem(
"multi_tab_service.lastPresenceByTab",
JSON.stringify(lastPresenceByTab)
);
// Unload main.
if (_isOnMainTab) {
_isOnMainTab = false;
bus.trigger("no_longer_main_tab");
browser.localStorage.removeItem("multi_tab_service.main");
}
}
browser.addEventListener("pagehide", unregister);
browser.addEventListener("storage", onStorage);
// REGISTER THIS TAB
const lastPresenceByTab =
JSON.parse(localStorage.getItem("multi_tab_service.lastPresenceByTab")) ?? {};
lastPresenceByTab[tabId] = now;
localStorage.setItem(
"multi_tab_service.lastPresenceByTab",
JSON.stringify(lastPresenceByTab)
);
if (!localStorage.getItem("multi_tab_service.main")) {
startElection();
}
heartbeat();
return {
bus,
/**
* Determine whether or not this tab is the main one.
* it's intentionally an async function to match the API of
* multiTabSharedWorkerService
*
* @returns {boolean}
*/
async isOnMainTab() {
return _isOnMainTab;
},
/**
* Unregister this tab from the multi-tab service. It will no longer
* be able to become the main tab.
*/
unregister,
};
},
};

View file

@ -1,224 +1,10 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { multiTabFallbackService } from "@bus/multi_tab_fallback_service";
import { multiTabSharedWorkerService } from "@bus/multi_tab_shared_worker_service";
import { registry } from '@web/core/registry';
import { browser } from '@web/core/browser/browser';
export const multiTabService = browser.SharedWorker
? multiTabSharedWorkerService
: multiTabFallbackService;
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);
registry.category("services").add("multi_tab", multiTabService);

View file

@ -0,0 +1,79 @@
import { browser } from "@web/core/browser/browser";
import { Deferred } from "@web/core/utils/concurrency";
import { EventBus } from "@odoo/owl";
const STATE = Object.freeze({
INIT: "INIT",
MASTER: "MASTER",
REGISTERED: "REGISTERED",
UNREGISTERED: "UNREGISTERED",
});
export const multiTabSharedWorkerService = {
dependencies: ["worker_service"],
start(env, { worker_service: workerService }) {
const bus = new EventBus();
let responseDeferred = null;
let state = STATE.INIT;
browser.addEventListener("pagehide", unregister);
function messageHandler(messageEv) {
const { type, data } = messageEv.data;
if (!type?.startsWith("ELECTION:")) {
return;
}
switch (type) {
case "ELECTION:IS_MASTER_RESPONSE":
responseDeferred?.resolve(data.answer);
responseDeferred = null;
break;
case "ELECTION:HEARTBEAT_REQUEST":
workerService.send("ELECTION:HEARTBEAT");
break;
case "ELECTION:ASSIGN_MASTER":
state = STATE.MASTER;
bus.trigger("become_main_tab");
break;
case "ELECTION:UNASSIGN_MASTER":
if (state !== STATE.UNREGISTERED) {
state = STATE.REGISTERED;
}
bus.trigger("no_longer_main_tab");
break;
default:
console.warn(
"multiTabSharedWorkerService received unknown message type:",
type
);
}
}
async function startWorker() {
await workerService.ensureWorkerStarted();
await workerService.registerHandler(messageHandler);
workerService.send("ELECTION:REGISTER");
state = STATE.REGISTERED;
}
function unregister() {
workerService.send("ELECTION:UNREGISTER");
state = STATE.UNREGISTERED;
}
return {
bus,
isOnMainTab: async () => {
if (state === STATE.UNREGISTERED) {
return false;
}
if (state === STATE.INIT) {
await startWorker();
}
responseDeferred = new Deferred();
workerService.send("ELECTION:IS_MASTER?");
return responseDeferred;
},
unregister,
};
},
};

View file

@ -0,0 +1,90 @@
import { browser } from "@web/core/browser/browser";
import { _t } from "@web/core/l10n/translation";
import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
export class OutdatedPageWatcherService {
constructor(env, services) {
this.setup(env, services);
}
/**
* @param {import("@web/env").OdooEnv}
* @param {Partial<import("services").Services>} services
*/
setup(env, { bus_service, multi_tab, legacy_multi_tab, notification }) {
this.notification = notification;
this.multi_tab = multi_tab;
this.legacy_multi_tab = legacy_multi_tab;
this.lastNotificationId = legacy_multi_tab.getSharedValue("last_notification_id");
this.closeNotificationFn;
let wasBusAlreadyConnected;
bus_service.addEventListener(
"BUS:WORKER_STATE_UPDATED",
({ detail: state }) => {
wasBusAlreadyConnected = state !== "IDLE";
},
{ once: true }
);
bus_service.addEventListener(
"BUS:DISCONNECT",
() =>
(this.lastNotificationId = legacy_multi_tab.getSharedValue("last_notification_id"))
);
bus_service.addEventListener("BUS:CONNECT", async () => {
if (wasBusAlreadyConnected) {
this.checkHasMissedNotifications();
}
wasBusAlreadyConnected = true;
});
bus_service.addEventListener("BUS:RECONNECT", () => this.checkHasMissedNotifications());
legacy_multi_tab.bus.addEventListener("shared_value_updated", ({ detail: { key } }) => {
if (key === "bus.has_missed_notifications") {
this.showOutdatedPageNotification();
}
});
}
async checkHasMissedNotifications() {
if (!this.lastNotificationId || !(await this.multi_tab.isOnMainTab())) {
return;
}
const hasMissedNotifications = await rpc(
"/bus/has_missed_notifications",
{ last_notification_id: this.lastNotificationId },
{ silent: true }
);
if (hasMissedNotifications) {
this.showOutdatedPageNotification();
this.legacy_multi_tab.setSharedValue("bus.has_missed_notifications", Date.now());
}
}
showOutdatedPageNotification() {
this.closeNotificationFn?.();
this.closeNotificationFn = this.notification.add(
_t("Save your work and refresh to get the latest updates and avoid potential issues."),
{
title: _t("The page is out of date"),
type: "warning",
sticky: true,
buttons: [
{
name: _t("Refresh"),
primary: true,
onClick: () => browser.location.reload(),
},
],
}
);
}
}
export const outdatedPageWatcherService = {
dependencies: ["bus_service", "multi_tab", "legacy_multi_tab", "notification"],
start(env, services) {
return new OutdatedPageWatcherService(env, services);
},
};
registry.category("services").add("bus.outdated_page_watcher", outdatedPageWatcherService);

View file

@ -1,5 +1,4 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { session } from "@web/session";
@ -11,7 +10,11 @@ export const assetsWatchdogService = {
let isNotificationDisplayed = false;
let bundleNotifTimerID = null;
bus_service.addEventListener('notification', onNotification.bind(this));
bus_service.subscribe("bundle_changed", ({ server_version }) => {
if (server_version !== session.server_version) {
displayBundleChangedNotification();
}
});
bus_service.start();
/**
@ -24,26 +27,23 @@ export const assetsWatchdogService = {
// 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();
},
notification.add(_t("The page appears to be out of date."), {
title: _t("Refresh"),
type: "warning",
sticky: true,
buttons: [
{
name: _t("Refresh"),
primary: true,
onClick: () => {
browser.location.reload();
},
],
onClose: () => {
isNotificationDisplayed = false;
},
}
);
],
onClose: () => {
isNotificationDisplayed = false;
},
});
isNotificationDisplayed = true;
}, getBundleNotificationDelay());
}
@ -59,23 +59,6 @@ export const assetsWatchdogService = {
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;
}
}
}
}
},
};

View file

@ -0,0 +1,64 @@
import { WORKER_STATE } from "@bus/workers/websocket_worker";
import { reactive } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
/**
* Detect lost connections to the bus. A connection is considered as lost if it
* couldn't be established after a reconnect attempt.
*/
export class BusMonitoringService {
isConnectionLost = false;
constructor(env, services) {
const reactiveThis = reactive(this);
reactiveThis.setup(env, services);
return reactiveThis;
}
/**
* @param {import("@web/env").OdooEnv} env
* @param {Partial<import("services").Services>} services
*/
setup(env, { bus_service }) {
bus_service.addEventListener("BUS:WORKER_STATE_UPDATED", ({ detail }) =>
this.workerStateOnChange(detail)
);
browser.addEventListener("offline", () => (this.isReconnecting = false));
}
/**
* Handle state changes for the WebSocket worker.
*
* @param {WORKER_STATE[keyof WORKER_STATE]} state
*/
workerStateOnChange(state) {
switch (state) {
case WORKER_STATE.CONNECTING: {
this.isReconnecting = true;
break;
}
case WORKER_STATE.CONNECTED: {
this.isReconnecting = false;
this.isConnectionLost = false;
break;
}
case WORKER_STATE.DISCONNECTED: {
if (this.isReconnecting) {
this.isConnectionLost = true;
this.isReconnecting = false;
}
break;
}
}
}
}
export const busMonitoringservice = {
dependencies: ["bus_service"],
start(env, services) {
return new BusMonitoringService(env, services);
},
};
registry.category("services").add("bus.monitoring_service", busMonitoringservice);

View file

@ -1,56 +1,57 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { _t } from "@web/core/l10n/translation";
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;
import { registry } from "@web/core/registry";
import { session } from "@web/session";
import { EventBus, reactive } from "@odoo/owl";
import { user } from "@web/core/user";
// List of worker events that should not be broadcasted.
const INTERNAL_EVENTS = new Set([
"BUS:INITIALIZED",
"BUS:OUTDATED",
"BUS:NOTIFICATION",
"BUS:PROVIDE_LOGS",
]);
// Slightly delay the reconnection when coming back online as the network is not
// ready yet and the exponential backoff would delay the reconnection by a lot.
export const BACK_ONLINE_RECONNECT_DELAY = 5000;
/**
* 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
* @emits BUS:CONNECT
* @emits BUS:DISCONNECT
* @emits BUS:RECONNECT
* @emits BUS:RECONNECTING
* @emits BUS:WORKER_STATE_UPDATED
*/
export const busService = {
dependencies: ['localization', 'multi_tab'],
dependencies: [
"bus.parameters",
"localization",
"multi_tab",
"legacy_multi_tab",
"notification",
"worker_service",
],
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);
}
start(
env,
{
multi_tab: multiTab,
legacy_multi_tab: legacyMultiTab,
notification,
"bus.parameters": params,
worker_service: workerService,
}
) {
const bus = new EventBus();
const notificationBus = new EventBus();
const subscribeFnToWrapper = new Map();
let backOnlineTimeout;
const startedAt = luxon.DateTime.now().set({ milliseconds: 0 });
let connectionInitializedDeferred;
/**
* Handle messages received from the shared worker and fires an
@ -60,129 +61,186 @@ export const busService = {
* @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.');
const { type, data } = messageEv.data;
switch (type) {
case "BUS:PROVIDE_LOGS": {
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `bus_logs_${luxon.DateTime.now().toFormat(
"yyyy-LL-dd-HH-mm-ss"
)}.json`;
a.click();
URL.revokeObjectURL(url);
break;
}
case "BUS:NOTIFICATION": {
const notifications = data.map(({ id, message }) => ({ id, ...message }));
state.lastNotificationId = notifications.at(-1).id;
legacyMultiTab.setSharedValue("last_notification_id", state.lastNotificationId);
for (const { id, type, payload } of notifications) {
notificationBus.trigger(type, { id, payload });
busService._onMessage(env, id, type, payload);
}
break;
}
case "BUS:INITIALIZED": {
connectionInitializedDeferred.resolve();
break;
}
case "BUS:WORKER_STATE_UPDATED":
state.workerState = data;
break;
case "BUS:OUTDATED": {
multiTab.unregister();
notification.add(
_t(
"Save your work and refresh to get the latest updates and avoid potential issues."
),
{
title: _t("The page is out of date"),
type: "warning",
sticky: true,
buttons: [
{
name: _t("Refresh"),
primary: true,
onClick: () => {
browser.location.reload();
},
},
],
}
);
break;
}
});
if (isUsingSharedWorker) {
worker.port.start();
worker.port.addEventListener('message', handleMessage);
} else {
worker.addEventListener('message', handleMessage);
}
initializeWorkerConnection();
if (!INTERNAL_EVENTS.has(type)) {
bus.trigger(type, data);
}
}
browser.addEventListener('pagehide', ({ persisted }) => {
/**
* Start the "bus_service" workerService.
*/
async function ensureWorkerStarted() {
if (!connectionInitializedDeferred) {
connectionInitializedDeferred = new Deferred();
let uid = Array.isArray(session.user_id) ? session.user_id[0] : user.userId;
if (!uid && uid !== undefined) {
uid = false;
}
await workerService.ensureWorkerStarted();
await workerService.registerHandler(handleMessage);
workerService.send("BUS:INITIALIZE_CONNECTION", {
websocketURL: `${params.serverURL.replace("http", "ws")}/websocket?version=${
session.websocket_worker_version
}`,
db: session.db,
debug: odoo.debug,
lastNotificationId: legacyMultiTab.getSharedValue("last_notification_id", 0),
uid,
startTs: startedAt.valueOf(),
});
}
await connectionInitializedDeferred;
}
browser.addEventListener("pagehide", ({ persisted }) => {
if (!persisted) {
// Page is gonna be unloaded, disconnect this client
// from the worker.
send('leave');
workerService.send("BUS: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;
browser.addEventListener(
"online",
() => {
backOnlineTimeout = browser.setTimeout(() => {
if (state.isActive) {
workerService.send("BUS:START");
}
}, BACK_ONLINE_RECONNECT_DELAY);
},
deleteChannel: channel => send('delete_channel', channel),
forceUpdateChannels: () => send('force_update_channels'),
{ capture: true }
);
browser.addEventListener(
"offline",
() => {
clearTimeout(backOnlineTimeout);
workerService.send("BUS:STOP");
},
{
capture: true,
}
);
const state = reactive({
addEventListener: bus.addEventListener.bind(bus),
addChannel: async (channel) => {
await ensureWorkerStarted();
workerService.send("BUS:ADD_CHANNEL", channel);
workerService.send("BUS:START");
state.isActive = true;
},
deleteChannel: (channel) => {
workerService.send("BUS:DELETE_CHANNEL", channel);
},
setLoggingEnabled: (isEnabled) =>
workerService.send("BUS:SET_LOGGING_ENABLED", isEnabled),
downloadLogs: () => workerService.send("BUS:REQUEST_LOGS"),
forceUpdateChannels: () => workerService.send("BUS:FORCE_UPDATE_CHANNELS"),
trigger: bus.trigger.bind(bus),
removeEventListener: bus.removeEventListener.bind(bus),
send: (eventName, data) => send('send', { event_name: eventName, data }),
send: (eventName, data) =>
workerService.send("BUS:SEND", { event_name: eventName, data }),
start: async () => {
if (!worker) {
startWorker();
await connectionInitializedDeferred;
}
send('start');
isActive = true;
await ensureWorkerStarted();
workerService.send("BUS:START");
state.isActive = true;
},
stop: () => {
send('leave');
isActive = false;
workerService.send("BUS:LEAVE");
state.isActive = false;
},
};
isActive: false,
/**
* Subscribe to a single notification type.
*
* @param {string} notificationType
* @param {function} callback
*/
subscribe(notificationType, callback) {
const wrapper = ({ detail }) => {
const { id, payload } = detail;
callback(JSON.parse(JSON.stringify(payload)), { id });
};
subscribeFnToWrapper.set(callback, wrapper);
notificationBus.addEventListener(notificationType, wrapper);
},
/**
* Unsubscribe from a single notification type.
*
* @param {string} notificationType
* @param {function} callback
*/
unsubscribe(notificationType, callback) {
notificationBus.removeEventListener(
notificationType,
subscribeFnToWrapper.get(callback)
);
subscribeFnToWrapper.delete(callback);
},
startedAt,
workerState: null,
/** The id of the last notification received by this tab. */
lastNotificationId: null,
});
return state;
},
/** Overriden to provide logs in tests. Use subscribe() in production. */
_onMessage(env, id, type, payload) {},
};
registry.category('services').add('bus_service', busService);
registry.category("services").add("bus_service", busService);

View file

@ -1,14 +0,0 @@
/** @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

@ -1,14 +0,0 @@
/** @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

@ -1,66 +1,66 @@
/** @odoo-module **/
import { browser } from '@web/core/browser/browser';
import { registry } from '@web/core/registry';
import core from 'web.core';
import { EventBus } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
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);
});
const LOCAL_STORAGE_PREFIX = "presence";
const bus = new EventBus();
let isOdooFocused = true;
let lastPresenceTime = (
browser.localStorage.getItem(`${LOCAL_STORAGE_PREFIX}.lastPresence`)
|| new Date().getTime()
);
let lastPresenceTime =
browser.localStorage.getItem(`${LOCAL_STORAGE_PREFIX}.lastPresence`) ||
luxon.DateTime.now().ts;
function onPresence() {
lastPresenceTime = new Date().getTime();
lastPresenceTime = luxon.DateTime.now().ts;
browser.localStorage.setItem(`${LOCAL_STORAGE_PREFIX}.lastPresence`, lastPresenceTime);
bus.trigger("presence");
}
function onFocusChange(isFocused) {
try {
isFocused = parent.document.hasFocus();
} catch {}
} catch {
// noop
}
isOdooFocused = isFocused;
browser.localStorage.setItem(`${LOCAL_STORAGE_PREFIX}.focus`, isOdooFocused);
if (isOdooFocused) {
lastPresenceTime = new Date().getTime();
env.bus.trigger('window_focus', isOdooFocused);
lastPresenceTime = luxon.DateTime.now().ts;
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);
env.bus.trigger("window_focus", newValue);
}
if (key === `${LOCAL_STORAGE_PREFIX}.lastPresence`) {
lastPresenceTime = JSON.parse(newValue);
bus.trigger("presence");
}
}
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);
browser.addEventListener("storage", onStorage);
browser.addEventListener("focus", () => onFocusChange(true));
browser.addEventListener("blur", () => onFocusChange(false));
browser.addEventListener("pagehide", () => onFocusChange(false));
browser.addEventListener("click", onPresence, true);
browser.addEventListener("keydown", onPresence, true);
return {
bus,
getLastPresence() {
return lastPresenceTime;
},
isOdooFocused() {
return isOdooFocused;
}
},
getInactivityPeriod() {
return luxon.DateTime.now().ts - this.getLastPresence();
},
};
},
};
registry.category('services').add('presence', presenceService);
registry.category("services").add("presence", presenceService);

View file

@ -0,0 +1,136 @@
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { Deferred } from "@web/core/utils/concurrency";
import { session } from "@web/session";
export const WORKER_STATE = Object.freeze({
UNINITIALIZED: "UNINITIALIZED",
INITIALIZING: "INITIALIZING",
INITIALIZED: "INITIALIZED",
FAILED: "FAILED",
});
export class WorkerService {
constructor(env, services) {
this.params = services["bus.parameters"];
this.worker = null;
this.isUsingSharedWorker = Boolean(browser.SharedWorker);
this._state = WORKER_STATE.UNINITIALIZED;
this.connectionInitializedDeferred = new Deferred();
}
startWorker() {
this._state = WORKER_STATE.INITIALIZING;
let workerURL = `${this.params.serverURL}/bus/websocket_worker_bundle?v=${session.websocket_worker_version}`;
if (this.params.serverURL !== window.origin) {
// Worker service can be 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 = this.isUsingSharedWorker ? browser.SharedWorker : browser.Worker;
this.worker = new workerClass(workerURL, {
name: this.isUsingSharedWorker ? "odoo:bus_shared_worker" : "odoo:bus_worker",
});
this.worker.onerror = (e) => this.onInitError(e);
this._registerHandler((ev) => {
if (ev.data.type === "BASE:INITIALIZED") {
this._state = WORKER_STATE.INITIALIZED;
this.connectionInitializedDeferred.resolve();
}
});
if (this.isUsingSharedWorker) {
this.worker.port.start();
}
this._send("BASE:INIT");
}
async ensureWorkerStarted() {
if (this._state === WORKER_STATE.UNINITIALIZED) {
this.startWorker();
}
await this.connectionInitializedDeferred;
}
onInitError(e) {
// FIXME: SharedWorker can still fail for unknown reasons even when it is supported.
if (this._state === WORKER_STATE.INITIALIZING && this.isUsingSharedWorker) {
console.warn("Error while loading SharedWorker, fallback on Worker: ", e);
this.isUsingSharedWorker = false;
this.worker?.port?.close?.();
this.startWorker();
} else if (this._state === WORKER_STATE.INITIALIZING) {
this._state = WORKER_STATE.FAILED;
this.connectionInitializedDeferred.resolve();
console.warn("Worker service failed to initialize: ", e);
}
}
_registerHandler(handler) {
if (this.isUsingSharedWorker) {
this.worker.port.addEventListener("message", handler);
} else {
this.worker.addEventListener("message", handler);
}
}
_send(action, data) {
const message = { action, data };
if (this.isUsingSharedWorker) {
this.worker.port.postMessage(message);
} else {
this.worker.postMessage(message);
}
}
/**
* Send a message to the worker. If the worker is not yet started,
* ignore the message. One should call `ensureWorkerStarted` if one
* really needs the message to reach the worker.
*
* @param {String} action Action to be executed by the worker.
* @param {Object|undefined} data Data required for the action to be
* executed.
*/
async send(action, data) {
if (this._state === WORKER_STATE.UNINITIALIZED) {
return;
}
await this.connectionInitializedDeferred;
if (this._state === WORKER_STATE.FAILED) {
console.warn("Worker service failed to initialize, cannot send message.");
}
this._send(action, data);
}
/**
* Register a function to handle messages from the worker.
*
* @param {function} handler
*/
async registerHandler(handler) {
if (this._state === WORKER_STATE.UNINITIALIZED) {
this.startWorker();
}
await this.connectionInitializedDeferred;
if (this._state === WORKER_STATE.FAILED) {
console.warn("Worker service failed to initialize, cannot register handler.");
}
this._registerHandler(handler);
}
get state() {
return this._state;
}
}
export const workerService = {
dependencies: ["bus.parameters"],
start(env, services) {
return new WorkerService(env, services);
},
};
registry.category("services").add("worker_service", workerService);

View file

@ -0,0 +1,13 @@
import { registry } from "@web/core/registry";
export const simpleNotificationService = {
dependencies: ["bus_service", "notification"],
start(env, { bus_service, notification: notificationService }) {
bus_service.subscribe("simple_notification", ({ message, sticky, title, type }) => {
notificationService.add(message, { sticky, title, type });
});
bus_service.start();
},
};
registry.category("services").add("simple_notification", simpleNotificationService);

View file

@ -0,0 +1,17 @@
export class BaseWorker {
constructor(name) {
this.name = name;
this.client = null; // only for testing purposes
}
handleMessage(event) {
const { action } = event.data;
if (action === "BASE:INIT") {
if (this.name.includes("shared")) {
event.target.postMessage({ type: "BASE:INITIALIZED" });
} else {
(this.client || globalThis).postMessage({ type: "BASE:INITIALIZED" });
}
}
}
}

View file

@ -0,0 +1,33 @@
/* eslint-env worker */
/* eslint-disable no-restricted-globals */
import { BaseWorker } from "./base_worker";
import { ElectionWorker } from "./election_worker";
import { WebsocketWorker } from "./websocket_worker";
(function () {
const baseWorker = new BaseWorker(self.name);
const websocketWorker = new WebsocketWorker(self.name);
const electionWorker = new ElectionWorker();
if (self.name.includes("shared")) {
// The script is running in a shared worker.
onconnect = (ev) => {
const client = ev.ports[0];
// Register the base worker to handle first init message.
// Register the current client for main tab election.
client.addEventListener("message", (ev) => {
baseWorker.handleMessage(ev);
electionWorker.handleMessage(ev);
});
// let's register every tab connection to the worker in order to relay
// notifications coming from the websocket.
websocketWorker.registerClient(client);
client.start();
};
} else {
// The script is running in a simple web worker.
self.addEventListener("message", (ev) => baseWorker.handleMessage(ev));
websocketWorker.registerClient(self);
}
})();

View file

@ -0,0 +1,130 @@
/**
* 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);
}
};
}
/**
* Deferred is basically a resolvable/rejectable extension of Promise.
*/
export class Deferred extends Promise {
constructor() {
let resolve;
let reject;
const prom = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return Object.assign(prom, { resolve, reject });
}
}
export class Logger {
static LOG_TTL = 24 * 60 * 60 * 1000; // 24 hours
static gcInterval = null;
static instances = [];
_db;
static async gcOutdatedLogs() {
const threshold = Date.now() - Logger.LOG_TTL;
for (const logger of this.instances) {
try {
await logger._ensureDatabaseAvailable();
await new Promise((res, rej) => {
const transaction = logger._db.transaction("logs", "readwrite");
const store = transaction.objectStore("logs");
const req = store
.index("timestamp")
.openCursor(IDBKeyRange.upperBound(threshold));
req.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
cursor.delete();
cursor.continue();
}
};
req.onerror = (e) => rej(e.target.error);
transaction.oncomplete = res;
transaction.onerror = (e) => rej(e.target.error);
});
} catch (error) {
console.error(`Failed to clear logs for logger "${logger._name}":`, error);
}
}
}
constructor(name) {
this._name = name;
Logger.instances.push(this);
Logger.gcOutdatedLogs();
clearInterval(Logger.gcInterval);
Logger.gcInterval = setInterval(() => Logger.gcOutdatedLogs(), Logger.LOG_TTL);
}
async _ensureDatabaseAvailable() {
if (this._db) {
return;
}
return new Promise((res, rej) => {
const request = indexedDB.open(this._name, 1);
request.onsuccess = (event) => {
this._db = event.target.result;
res();
};
request.onupgradeneeded = (event) => {
if (!event.target.result.objectStoreNames.contains("logs")) {
const store = event.target.result.createObjectStore("logs", {
autoIncrement: true,
});
store.createIndex("timestamp", "timestamp", { unique: false });
}
};
request.onerror = rej;
});
}
async log(message) {
await this._ensureDatabaseAvailable();
const transaction = this._db.transaction("logs", "readwrite");
const store = transaction.objectStore("logs");
const addRequest = store.add({ timestamp: Date.now(), message });
return new Promise((res, rej) => {
addRequest.onsuccess = res;
addRequest.onerror = rej;
});
}
async getLogs() {
await Logger.gcOutdatedLogs();
await this._ensureDatabaseAvailable();
const transaction = this._db.transaction("logs", "readonly");
const store = transaction.objectStore("logs");
const request = store.getAll();
return new Promise((res, rej) => {
request.onsuccess = (ev) => res(ev.target.result.map(({ message }) => message));
request.onerror = rej;
});
}
}

View file

@ -0,0 +1,105 @@
import { Deferred } from "@bus/workers/bus_worker_utils";
export class ElectionWorker {
MAIN_TAB_TIMEOUT_PERIOD = 3000;
/** @type {Set<MessagePort>} */
candidates = new Set();
/** @type {Deferred|null} */
electionDeferred = null;
/** @type {number|null} */
heartbeatRequestInterval = null;
lastHeartbeat = Date.now();
/** @type {Deferred|null} */
masterReplyDeferred = null;
/** @type {MessagePort|null} */
masterTab = null;
constructor() {
setInterval(() => {
if (Date.now() - this.lastHeartbeat > this.MAIN_TAB_TIMEOUT_PERIOD) {
this.startElection();
}
}, this.MAIN_TAB_TIMEOUT_PERIOD);
}
requestHeartbeat(messagePort) {
if (messagePort) {
messagePort.postMessage({ type: "ELECTION:HEARTBEAT_REQUEST" });
return;
}
for (const candidate of this.candidates) {
candidate.postMessage({ type: "ELECTION:HEARTBEAT_REQUEST" });
}
}
async ensureMasterPresence() {
this.masterReplyDeferred ??= new Deferred();
if (this.masterTab) {
this.requestHeartbeat(this.masterTab);
} else {
this.startElection();
}
await this.masterReplyDeferred;
}
startElection() {
clearInterval(this.heartbeatRequestInterval);
this.masterTab?.postMessage({ type: "ELECTION:UNASSIGN_MASTER" });
this.masterTab = null;
this.electionDeferred ??= new Deferred();
this.requestHeartbeat();
}
finishElection(messagePort) {
this.masterTab = messagePort;
messagePort.postMessage({ type: "ELECTION:ASSIGN_MASTER" });
this.electionDeferred.resolve();
this.electionDeferred = null;
this.heartbeatRequestInterval = setInterval(
() => this.requestHeartbeat(this.masterTab),
this.MAIN_TAB_TIMEOUT_PERIOD / 2
);
}
async handleMessage(event) {
const { action } = event.data;
if (!action?.startsWith("ELECTION:")) {
return;
}
switch (action) {
case "ELECTION:REGISTER":
this.candidates.add(event.target);
await this.electionDeferred;
if (!this.masterTab) {
this.startElection();
}
break;
case "ELECTION:UNREGISTER":
this.candidates.delete(event.target);
if (this.masterTab === event.target) {
this.startElection();
}
break;
case "ELECTION:IS_MASTER?":
await this.ensureMasterPresence();
event.target.postMessage({
type: "ELECTION:IS_MASTER_RESPONSE",
data: { answer: this.masterTab === event.target },
});
break;
case "ELECTION:HEARTBEAT":
if (this.electionDeferred) {
this.finishElection(event.target);
}
if (this.masterTab === event.target) {
this.lastHeartbeat = Date.now();
this.masterReplyDeferred?.resolve();
this.masterReplyDeferred = null;
}
break;
default:
console.warn("Unknown message action:", action);
}
}
}

View file

@ -1,17 +1,15 @@
/** @odoo-module **/
import { debounce } from '@bus/workers/websocket_worker_utils';
import { debounce, Deferred, Logger } from "@bus/workers/bus_worker_utils";
/**
* Type of events that can be sent from the worker to its clients.
*
* @typedef { 'connect' | 'reconnect' | 'disconnect' | 'reconnecting' | 'notification' | 'initialized' } WorkerEvent
* @typedef { 'BUS:CONNECT' | 'BUS:RECONNECT' | 'BUS:DISCONNECT' | 'BUS:RECONNECTING' | 'BUS:NOTIFICATION' | 'BUS:INITIALIZED' | 'BUS:OUTDATED'| 'BUS:WORKER_STATE_UPDATED' | 'BUS:PROVIDE_LOGS' } 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
* @typedef {'BUS:ADD_CHANNEL' | 'BUS:DELETE_CHANNEL' | 'BUS:FORCE_UPDATE_CHANNELS' | 'BUS:INITIALIZE_CONNECTION' | 'BUS:REQUEST_LOGS' | 'BUS:SEND' | 'BUS:SET_LOGGING_ENABLED' | 'BUS:LEAVE' | 'BUS:STOP' | 'BUS:START'} WorkerAction
*/
export const WEBSOCKET_CLOSE_CODES = Object.freeze({
@ -31,12 +29,17 @@ export const WEBSOCKET_CLOSE_CODES = Object.freeze({
SESSION_EXPIRED: 4001,
KEEP_ALIVE_TIMEOUT: 4002,
RECONNECTING: 4003,
CLOSING_HANDSHAKE_ABORTED: 4004,
});
export const WORKER_STATE = Object.freeze({
CONNECTED: "CONNECTED",
DISCONNECTED: "DISCONNECTED",
IDLE: "IDLE",
CONNECTING: "CONNECTING",
});
// 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;
const UUID = Date.now().toString(36) + Math.random().toString(36).substring(2);
const logger = new Logger("bus_websocket_worker");
/**
* This class regroups the logic necessary in order for the
@ -46,27 +49,49 @@ const MAXIMUM_RECONNECT_DELAY = 60000;
* for SharedWorker and this class implements it.
*/
export class WebsocketWorker {
constructor() {
INITIAL_RECONNECT_DELAY = 1000;
RECONNECT_JITTER = 1000;
CONNECTION_CHECK_DELAY = 60_000;
constructor(name) {
this.name = name;
// Timestamp of start of most recent bus service sender
this.newestStartTs = undefined;
this.websocketURL = "";
this.currentUID = null;
this.currentDB = null;
this.isWaitingForNewUID = true;
this.channelsByClient = new Map();
this.connectRetryDelay = INITIAL_RECONNECT_DELAY;
this.connectRetryDelay = this.INITIAL_RECONNECT_DELAY;
this.connectTimeout = null;
this.debugModeByClient = new Map();
this.isDebug = false;
this.active = true;
this.state = WORKER_STATE.IDLE;
this.isReconnecting = false;
this.lastChannelSubscription = null;
this.loggingEnabled = null;
this.firstSubscribeDeferred = new Deferred();
this.lastNotificationId = 0;
this.messageWaitQueue = [];
this._forceUpdateChannels = debounce(this._forceUpdateChannels, 300, true);
this._forceUpdateChannels = debounce(this._forceUpdateChannels, 300);
this._debouncedUpdateChannels = debounce(this._updateChannels, 300);
this._debouncedSendToServer = debounce(this._sendToServer, 300);
this._onWebsocketClose = this._onWebsocketClose.bind(this);
this._onWebsocketError = this._onWebsocketError.bind(this);
this._onWebsocketMessage = this._onWebsocketMessage.bind(this);
this._onWebsocketOpen = this._onWebsocketOpen.bind(this);
globalThis.addEventListener("error", ({ error }) => {
const params = error instanceof Error ? [error.constructor.name, error.stack] : [error];
this._logDebug("Unhandled error", ...params);
});
globalThis.addEventListener("unhandledrejection", ({ reason }) => {
const params =
reason instanceof Error ? [reason.constructor.name, reason.stack] : [reason];
this._logDebug("Unhandled rejection", params);
});
}
//--------------------------------------------------------------------------
@ -82,8 +107,9 @@ export class WebsocketWorker {
* @param {Object} data
*/
broadcast(type, data) {
this._logDebug("broadcast", type, data);
for (const client of this.channelsByClient.keys()) {
client.postMessage({ type, data });
client.postMessage({ type, data: data ? JSON.parse(JSON.stringify(data)) : undefined });
}
}
@ -93,9 +119,9 @@ export class WebsocketWorker {
* @param {MessagePort} messagePort
*/
registerClient(messagePort) {
messagePort.onmessage = ev => {
messagePort.addEventListener("message", (ev) => {
this._onClientMessage(messagePort, ev.data);
};
});
this.channelsByClient.set(messagePort, []);
}
@ -107,7 +133,10 @@ export class WebsocketWorker {
* @param {Object} data
*/
sendToClient(client, type, data) {
client.postMessage({ type, data });
if (type !== "BUS:PROVIDE_LOGS") {
this._logDebug("sendToClient", type, data);
}
client.postMessage({ type, data: data ? JSON.parse(JSON.stringify(data)) : undefined });
}
//--------------------------------------------------------------------------
@ -126,22 +155,52 @@ export class WebsocketWorker {
* action.
*/
_onClientMessage(client, { action, data }) {
this._logDebug("_onClientMessage", action, data);
switch (action) {
case 'send':
return this._sendToServer(data);
case 'start':
case "BUS:SEND": {
if (data["event_name"] === "update_presence") {
this._debouncedSendToServer(data);
} else {
this._sendToServer(data);
}
return;
}
case "BUS:START":
return this._start();
case 'stop':
case "BUS:STOP":
return this._stop();
case 'leave':
case "BUS:LEAVE":
return this._unregisterClient(client);
case 'add_channel':
case "BUS:ADD_CHANNEL":
return this._addChannel(client, data);
case 'delete_channel':
case "BUS:DELETE_CHANNEL":
return this._deleteChannel(client, data);
case 'force_update_channels':
case "BUS:FORCE_UPDATE_CHANNELS":
return this._forceUpdateChannels();
case 'initialize_connection':
case "BUS:SET_LOGGING_ENABLED":
this.loggingEnabled = data;
break;
case "BUS:REQUEST_LOGS":
logger.getLogs().then((logs) => {
const workerInfo = {
UUID,
active: this.active,
channels: [
...new Set([].concat.apply([], [...this.channelsByClient.values()])),
].sort(),
db: this.currentDB,
is_reconnecting: this.isReconnecting,
last_subscription: this.lastChannelSubscription,
name: this.name,
number_of_clients: this.channelsByClient.size,
reconnect_delay: this.connectRetryDelay,
uid: this.currentUID,
websocket_url: this.websocketURL,
};
this.sendToClient(client, "BUS:PROVIDE_LOGS", { workerInfo, logs });
});
break;
case "BUS:INITIALIZE_CONNECTION":
return this._initializeConnection(client, data);
}
}
@ -154,12 +213,8 @@ export class WebsocketWorker {
* @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();
}
this.channelsByClient.get(client).push(channel);
this._debouncedUpdateChannels();
}
/**
@ -177,7 +232,7 @@ export class WebsocketWorker {
const channelIndex = clientChannels.indexOf(channel);
if (channelIndex !== -1) {
clientChannels.splice(channelIndex, 1);
this._updateChannels();
this._debouncedUpdateChannels();
}
}
@ -199,14 +254,15 @@ export class WebsocketWorker {
_unregisterClient(client) {
this.channelsByClient.delete(client);
this.debugModeByClient.delete(client);
this.isDebug = Object.values(this.debugModeByClient).some(debugValue => debugValue !== '');
this._updateChannels();
this.isDebug = [...this.debugModeByClient.values()].some(Boolean);
this._debouncedUpdateChannels();
}
/**
* Initialize a client connection to this worker.
*
* @param {Object} param0
* @param {string} [param0.db] Database name.
* @param {String} [param0.debug] Current debugging mode for the
* given client.
* @param {Number} [param0.lastNotificationId] Last notification id
@ -218,31 +274,38 @@ export class WebsocketWorker {
* - 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 }) {
_initializeConnection(client, { db, 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");
this.debugModeByClient.set(client, debug);
this.isDebug = [...this.debugModeByClient.values()].some(Boolean);
this.sendToClient(client, "BUS:WORKER_STATE_UPDATED", this.state);
this.sendToClient(client, "BUS:INITIALIZED");
return;
}
this.newestStartTs = startTs;
this.websocketURL = websocketURL;
this.lastNotificationId = lastNotificationId;
this.debugModeByClient[client] = debug;
this.isDebug = Object.values(this.debugModeByClient).some(debugValue => debugValue !== '');
this.debugModeByClient.set(client, debug);
this.isDebug = [...this.debugModeByClient.values()].some(Boolean);
const isCurrentUserKnown = uid !== undefined;
if (this.isWaitingForNewUID && isCurrentUserKnown) {
this.isWaitingForNewUID = false;
this.currentUID = uid;
}
if (this.currentUID !== uid && isCurrentUserKnown) {
this.currentDB ||= db;
if ((this.currentUID !== uid && isCurrentUserKnown) || (db && this.currentDB !== db)) {
this.currentUID = uid;
this.currentDB = db || this.currentDB;
if (this.websocket) {
this.websocket.close(WEBSOCKET_CLOSE_CODES.CLEAN);
}
this.channelsByClient.forEach((_, key) => this.channelsByClient.set(key, []));
}
this.sendToClient(client, 'initialized');
this.sendToClient(client, "BUS:WORKER_STATE_UPDATED", this.state);
this.sendToClient(client, "BUS:INITIALIZED");
if (!this.active) {
this.sendToClient(client, "BUS:OUTDATED");
}
}
/**
@ -287,26 +350,39 @@ export class WebsocketWorker {
* closed.
*/
_onWebsocketClose({ code, reason }) {
if (this.isDebug) {
console.debug(`%c${new Date().toLocaleString()} - [onClose]`, 'color: #c6e; font-weight: bold;', code, reason);
}
clearInterval(this._connectionCheckInterval);
this._logDebug("_onWebsocketClose", code, reason);
this._updateState(WORKER_STATE.DISCONNECTED);
this.lastChannelSubscription = null;
this.firstSubscribeDeferred = new Deferred();
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 });
this.broadcast("BUS:DISCONNECT", { code, reason });
if (code === WEBSOCKET_CLOSE_CODES.CLEAN) {
if (reason === "OUTDATED_VERSION") {
console.warn("Worker deactivated due to an outdated version.");
this.active = false;
this.broadcast("BUS:OUTDATED");
}
// 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.broadcast("BUS:RECONNECTING", { closeCode: code });
this.isReconnecting = true;
if (code === WEBSOCKET_CLOSE_CODES.KEEP_ALIVE_TIMEOUT) {
// Don't wait to reconnect on keep alive timeout.
if (
[
WEBSOCKET_CLOSE_CODES.KEEP_ALIVE_TIMEOUT,
WEBSOCKET_CLOSE_CODES.CLOSING_HANDSHAKE_ABORTED,
].includes(code)
) {
// Don't wait to reconnect: keep-alive shouldn't be noticed, and the
// closing handshake was aborted because the client explicitly tried
// to connect while the socket was stuck in the closing state.
this.connectRetryDelay = 0;
}
if (code === WEBSOCKET_CLOSE_CODES.SESSION_EXPIRED) {
@ -319,24 +395,36 @@ export class WebsocketWorker {
* 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._logDebug("_onWebsocketError");
this._retryConnectionWithDelay();
}
/**
* Handle data received from the bus.
*
* @param {MessageEvent} messageEv
*/
* Handle data received from the bus.
*
* @param {MessageEvent} messageEv
*/
_onWebsocketMessage(messageEv) {
this._restartConnectionCheckInterval();
const notifications = JSON.parse(messageEv.data);
if (this.isDebug) {
console.debug(`%c${new Date().toLocaleString()} - [onMessage]`, 'color: #c6e; font-weight: bold;', notifications);
}
this._logDebug("_onWebsocketMessage", notifications);
this.lastNotificationId = notifications[notifications.length - 1].id;
this.broadcast('notification', notifications);
this.broadcast("BUS:NOTIFICATION", notifications);
}
async _logDebug(title, ...args) {
if (this.loggingEnabled) {
try {
await logger.log({
dt: new Date().toISOString(),
event: title,
args,
worker: UUID,
});
} catch (e) {
console.error(e);
}
}
}
/**
@ -344,16 +432,41 @@ export class WebsocketWorker {
* 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._logDebug("_onWebsocketOpen");
this._updateState(WORKER_STATE.CONNECTED);
this.broadcast(this.isReconnecting ? "BUS:RECONNECT" : "BUS:CONNECT");
this._debouncedUpdateChannels();
this.connectRetryDelay = this.INITIAL_RECONNECT_DELAY;
this.connectTimeout = null;
this.isReconnecting = false;
this.firstSubscribeDeferred.then(() => {
if (!this.websocket) {
return;
}
this.messageWaitQueue.forEach((msg) => this.websocket.send(msg));
this.messageWaitQueue = [];
});
this._restartConnectionCheckInterval();
}
/**
* Sends a custom application-level message to perform a connection check
* on the WebSocket.
*
* Browsers rely on the OS's TCP mechanism, which can take minutes or
* hours to detect a dead connection. Sending data triggers an immediate
* I/O operation, quickly revealing any network-level failure. This must be
* implemented at the application level because the browser WebSocket API
* does not expose the built-in ping/pong mechanism.
*/
_restartConnectionCheckInterval() {
clearInterval(this._connectionCheckInterval);
this._connectionCheckInterval = setInterval(() => {
if (this._isWebsocketConnected()) {
this.websocket.send(new Uint8Array([0x00]));
this._logDebug("connection_checked");
}
}, this.CONNECTION_CHECK_DELAY);
}
/**
@ -361,7 +474,10 @@ export class WebsocketWorker {
* applied to the reconnect attempts.
*/
_retryConnectionWithDelay() {
this.connectRetryDelay = Math.min(this.connectRetryDelay * 1.5, MAXIMUM_RECONNECT_DELAY) + 1000 * Math.random();
this.connectRetryDelay =
Math.min(this.connectRetryDelay * 1.5, MAXIMUM_RECONNECT_DELAY) +
this.RECONNECT_JITTER * Math.random();
this._logDebug("_retryConnectionWithDelay", this.connectRetryDelay);
this.connectTimeout = setTimeout(this._start.bind(this), this.connectRetryDelay);
}
@ -370,53 +486,79 @@ export class WebsocketWorker {
* 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.
* @param {{event_name: string, data: any }} message Message to send to the server.
*/
_sendToServer(message) {
this._logDebug("_sendToServer", message);
const payload = JSON.stringify(message);
if (!this._isWebsocketConnected()) {
this.messageWaitQueue.push(payload);
if (message["event_name"] === "subscribe") {
this.messageWaitQueue = this.messageWaitQueue.filter(
(msg) => JSON.parse(msg).event_name !== "subscribe"
);
this.messageWaitQueue.unshift(payload);
} else {
this.messageWaitQueue.push(payload);
}
} else {
this.websocket.send(payload);
if (message["event_name"] === "subscribe") {
this.websocket.send(payload);
} else {
this.firstSubscribeDeferred.then(() => this.websocket.send(payload));
}
this._restartConnectionCheckInterval();
}
}
_removeWebsocketListeners() {
this.websocket?.removeEventListener("open", this._onWebsocketOpen);
this.websocket?.removeEventListener("message", this._onWebsocketMessage);
this.websocket?.removeEventListener("error", this._onWebsocketError);
this.websocket?.removeEventListener("close", this._onWebsocketClose);
}
/**
* Start the worker by opening a websocket connection.
*/
_start() {
if (this._isWebsocketConnected() || this._isWebsocketConnecting()) {
this._logDebug("_start");
if (!this.active || 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);
}
this._removeWebsocketListeners();
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 });
// The close event didnt trigger. Trigger manually to maintain
// correct state and lifecycle handling.
this._onWebsocketClose(
new CloseEvent("close", { code: WEBSOCKET_CLOSE_CODES.CLOSING_HANDSHAKE_ABORTED })
);
this.websocket = null;
return;
}
this._updateState(WORKER_STATE.CONNECTING);
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);
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() {
this._logDebug("_stop");
clearTimeout(this.connectTimeout);
this.connectRetryDelay = INITIAL_RECONNECT_DELAY;
this.connectRetryDelay = this.INITIAL_RECONNECT_DELAY;
this.isReconnecting = false;
this.lastChannelSubscription = null;
if (this.websocket) {
this.websocket.close();
const shouldBroadcastClose =
this.websocket && this.websocket.readyState !== WebSocket.CLOSED;
this.websocket?.close();
this._removeWebsocketListeners();
this.websocket = null;
if (shouldBroadcastClose) {
this.broadcast("BUS:DISCONNECT", { code: WEBSOCKET_CLOSE_CODES.CLEAN });
}
}
@ -428,12 +570,28 @@ export class WebsocketWorker {
* event if the channels haven't change since last subscription.
*/
_updateChannels({ force = false } = {}) {
const allTabsChannels = [...new Set([].concat.apply([], [...this.channelsByClient.values()]))].sort();
const allTabsChannels = [
...new Set([].concat.apply([], [...this.channelsByClient.values()])),
].sort();
const allTabsChannelsString = JSON.stringify(allTabsChannels);
const shouldUpdateChannelSubscription = allTabsChannelsString !== this.lastChannelSubscription;
const shouldUpdateChannelSubscription =
allTabsChannelsString !== this.lastChannelSubscription;
if (force || shouldUpdateChannelSubscription) {
this.lastChannelSubscription = allTabsChannelsString;
this._sendToServer({ event_name: 'subscribe', data: { channels: allTabsChannels, last: this.lastNotificationId } });
this._sendToServer({
event_name: "subscribe",
data: { channels: allTabsChannels, last: this.lastNotificationId },
});
this.firstSubscribeDeferred.resolve();
}
}
/**
* Update the worker state and broadcast the new state to its clients.
*
* @param {WORKER_STATE[keyof WORKER_STATE]} newState
*/
_updateState(newState) {
this.state = newState;
this.broadcast("BUS:WORKER_STATE_UPDATED", newState);
}
}

View file

@ -1,23 +0,0 @@
/** @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

@ -1,29 +0,0 @@
/** @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);
}
};
}