Odoo18-Base/addons/bus/static/src/multi_tab_service.js
2025-03-10 11:12:23 +07:00

225 lines
8.3 KiB
JavaScript

/** @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);