mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-22 08:32:04 +02:00
Initial commit: Core packages
This commit is contained in:
commit
12c29a983b
9512 changed files with 8379910 additions and 0 deletions
|
|
@ -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);
|
||||
224
odoo-bringout-oca-ocb-bus/bus/static/src/multi_tab_service.js
Normal file
224
odoo-bringout-oca-ocb-bus/bus/static/src/multi_tab_service.js
Normal 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);
|
||||
|
|
@ -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);
|
||||
188
odoo-bringout-oca-ocb-bus/bus/static/src/services/bus_service.js
Normal file
188
odoo-bringout-oca-ocb-bus/bus/static/src/services/bus_service.js
Normal 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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
})();
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue