290 lines
11 KiB
JavaScript
290 lines
11 KiB
JavaScript
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 { EventBus } from "@odoo/owl";
|
|
import { user } from "@web/core/user";
|
|
|
|
// List of worker events that should not be broadcasted.
|
|
const INTERNAL_EVENTS = new Set(["initialized", "outdated", "log_debug", "notification"]);
|
|
// 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 worker_state_updated
|
|
*/
|
|
export const busService = {
|
|
dependencies: ["bus.parameters", "localization", "multi_tab", "notification"],
|
|
|
|
start(env, { multi_tab: multiTab, notification, "bus.parameters": params }) {
|
|
const bus = new EventBus();
|
|
const notificationBus = new EventBus();
|
|
const subscribeFnToWrapper = new Map();
|
|
let worker;
|
|
/**
|
|
* @typedef {typeof import("@bus/workers/websocket_worker").WORKER_STATE} WORKER_STATE
|
|
* @type {WORKER_STATE[keyof WORKER_STATE]}
|
|
*/
|
|
let workerState;
|
|
let isActive = false;
|
|
let isInitialized = false;
|
|
let isUsingSharedWorker = browser.SharedWorker && !isIosApp();
|
|
let backOnlineTimeout;
|
|
const startedAt = luxon.DateTime.now().set({ milliseconds: 0 });
|
|
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, data } = messageEv.data;
|
|
switch (type) {
|
|
case "notification": {
|
|
const notifications = data.map(({ id, message }) => ({ id, ...message }));
|
|
multiTab.setSharedValue("last_notification_id", notifications.at(-1).id);
|
|
for (const { id, type, payload } of notifications) {
|
|
notificationBus.trigger(type, { id, payload });
|
|
busService._onMessage(id, type, payload);
|
|
}
|
|
break;
|
|
}
|
|
case "initialized": {
|
|
isInitialized = true;
|
|
connectionInitializedDeferred.resolve();
|
|
break;
|
|
}
|
|
case "worker_state_updated":
|
|
workerState = data;
|
|
break;
|
|
case "log_debug":
|
|
console.debug(...data);
|
|
break;
|
|
case "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 (!INTERNAL_EVENTS.has(type)) {
|
|
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:
|
|
// - user service : number or false (key: userId)
|
|
// - 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] : user.userId;
|
|
if (!uid && uid !== undefined) {
|
|
uid = false;
|
|
}
|
|
send("initialize_connection", {
|
|
websocketURL: `${params.serverURL.replace("http", "ws")}/websocket?version=${
|
|
session.websocket_worker_version
|
|
}`,
|
|
db: session.db,
|
|
debug: odoo.debug,
|
|
lastNotificationId: multiTab.getSharedValue("last_notification_id", 0),
|
|
uid,
|
|
startTs: startedAt.valueOf(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Start the "bus_service" worker.
|
|
*/
|
|
function startWorker() {
|
|
let workerURL = `${params.serverURL}/bus/websocket_worker_bundle?v=${session.websocket_worker_version}`;
|
|
if (params.serverURL !== 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",
|
|
() => {
|
|
backOnlineTimeout = browser.setTimeout(() => {
|
|
if (isActive) {
|
|
send("start");
|
|
}
|
|
}, BACK_ONLINE_RECONNECT_DELAY);
|
|
},
|
|
{ capture: true }
|
|
);
|
|
browser.addEventListener(
|
|
"offline",
|
|
() => {
|
|
clearTimeout(backOnlineTimeout);
|
|
send("stop");
|
|
},
|
|
{
|
|
capture: true,
|
|
}
|
|
);
|
|
|
|
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;
|
|
},
|
|
get isActive() {
|
|
return isActive;
|
|
},
|
|
/**
|
|
* Subscribe to a single notification type.
|
|
*
|
|
* @param {string} notificationType
|
|
* @param {function} callback
|
|
*/
|
|
subscribe(notificationType, callback) {
|
|
const wrapper = ({ detail }) => {
|
|
const { id, payload } = detail;
|
|
callback(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,
|
|
get workerState() {
|
|
return workerState;
|
|
},
|
|
};
|
|
},
|
|
/** Overriden to provide logs in tests. Use subscribe() in production. */
|
|
_onMessage(id, type, payload) {},
|
|
};
|
|
registry.category("services").add("bus_service", busService);
|