mirror of
https://github.com/actualbudget/actual.git
synced 2026-07-15 14:43:36 -05:00
[AI] Move browser web-worker logic into loot-core (#8143)
* [AI] Move browser web-worker logic into loot-core Relocate the browser Web Worker bootstrap (WorkerBridge + startBrowserBackend) and the multi-tab SharedWorker coordinator from desktop-client into loot-core so they can be reused (e.g. by the api package in a browser). Behavior is preserved as faithfully as possible. The two Vite worker entry targets stay in desktop-client (the browser-server.js classic worker and the ?sharedworker entry); the ?sharedworker entry now imports the coordinator from loot-core. console.* calls in the moved code route through loot-core's logger, except the SharedWorker console-forwarding mechanism, which must keep the real console. absurd-sql's initBackend is imported directly (loot-core already depends on absurd-sql); the desktop-client dependency was dropped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [AI] Rename release note to match PR number 8143 * Update release notes for Web Worker migration Removed mention of reuse by the API package in the release notes. * [AI] Drop move-related header comments per review Remove the "moved from desktop-client" header comments that only made sense in the context of this PR. worker-bridge.ts and start.ts had no header comment in the original source, so they're removed entirely; coordinator.ts's header is restored to its original wording. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
edc0cd9fbc
commit
ebcb86ab7d
@@ -159,7 +159,6 @@
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"absurd-sql": "0.0.54",
|
||||
"auto-text-size": "^0.2.3",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"cmdk": "^1.1.1",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { startBrowserBackend } from '@actual-app/core/platform/client/browser-preload';
|
||||
import * as Platform from '@actual-app/core/shared/platform';
|
||||
import { initBackend as initSQLBackend } from 'absurd-sql/dist/indexeddb-main-thread';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
|
||||
// oxlint-disable-next-line typescript-paths/absolute-parent-import
|
||||
@@ -22,319 +22,24 @@ const ACTUAL_VERSION = Platform.isPlaywright
|
||||
: packageJson.version;
|
||||
|
||||
// *** Start the backend ***
|
||||
|
||||
let worker = null;
|
||||
// The regular Worker running the backend, created only on the leader tab
|
||||
let localBackendWorker = null;
|
||||
|
||||
function terminateLocalBackendWorker() {
|
||||
if (localBackendWorker) {
|
||||
localBackendWorker.terminate();
|
||||
localBackendWorker = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WorkerBridge wraps a SharedWorker port and presents a Worker-like interface
|
||||
* (onmessage, postMessage, addEventListener, start) to the connection layer.
|
||||
*
|
||||
* The SharedWorker coordinator assigns each tab a role per budget:
|
||||
* - LEADER: this tab runs the backend in a dedicated Worker
|
||||
* - FOLLOWER: this tab routes messages through the SharedWorker to the leader
|
||||
*
|
||||
* Multiple budgets can be open simultaneously — each has its own leader.
|
||||
*/
|
||||
class WorkerBridge {
|
||||
constructor(sharedPort) {
|
||||
this._sharedPort = sharedPort;
|
||||
this._onmessage = null;
|
||||
this._listeners = [];
|
||||
this._started = false;
|
||||
this._isInitialized = false;
|
||||
this._currentBudgetId = null;
|
||||
this._wasHidden = document.visibilityState === 'hidden';
|
||||
|
||||
this._onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this._wasHidden = true;
|
||||
} else if (this._wasHidden) {
|
||||
this._wasHidden = false;
|
||||
this._resumeAssociation();
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for all messages from the SharedWorker port
|
||||
sharedPort.addEventListener('message', e => this._onSharedMessage(e));
|
||||
document.addEventListener('visibilitychange', this._onVisibilityChange);
|
||||
}
|
||||
|
||||
set onmessage(handler) {
|
||||
this._onmessage = handler;
|
||||
// Setting onmessage on a real MessagePort implicitly starts it.
|
||||
// We need to do this explicitly on the underlying port.
|
||||
if (!this._started) {
|
||||
this._started = true;
|
||||
this._sharedPort.start();
|
||||
}
|
||||
}
|
||||
|
||||
get onmessage() {
|
||||
return this._onmessage;
|
||||
}
|
||||
|
||||
postMessage(msg) {
|
||||
// All messages go through the SharedWorker for coordination.
|
||||
// The SharedWorker forwards to the leader's Worker via __to-worker.
|
||||
this._sharedPort.postMessage(msg);
|
||||
}
|
||||
|
||||
addEventListener(type, handler) {
|
||||
this._listeners.push({ type, handler });
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this._started) {
|
||||
this._started = true;
|
||||
this._sharedPort.start();
|
||||
}
|
||||
}
|
||||
|
||||
_dispatch(event) {
|
||||
if (this._onmessage) this._onmessage(event);
|
||||
for (const { type, handler } of this._listeners) {
|
||||
if (type === 'message') handler(event);
|
||||
}
|
||||
}
|
||||
|
||||
_onSharedMessage(event) {
|
||||
const msg = event.data;
|
||||
|
||||
// Elected as leader: create the real backend Worker on this tab
|
||||
if (msg && msg.type === '__become-leader') {
|
||||
this._createLocalWorker(msg.initMsg, msg.budgetToRestore, msg.pendingMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward requests from SharedWorker to our local Worker
|
||||
if (msg && msg.type === '__to-worker') {
|
||||
if (localBackendWorker) {
|
||||
localBackendWorker.postMessage(msg.msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Leadership transfer: this tab is closing the budget but other tabs
|
||||
// still need it. Terminate our Worker (don't actually close-budget on
|
||||
// the backend) and dispatch a synthetic reply so the UI navigates to
|
||||
// show-budgets normally.
|
||||
if (msg && msg.type === '__close-and-transfer') {
|
||||
console.log('[WorkerBridge] Leadership transferred — terminating Worker');
|
||||
this._applyRole('UNASSIGNED', null);
|
||||
// Only dispatch a synthetic reply if there's an actual close-budget
|
||||
// request to complete. When requestId is null the eviction was
|
||||
// triggered externally (e.g. another tab deleted this budget).
|
||||
if (msg.requestId) {
|
||||
this._dispatch({
|
||||
data: { type: 'reply', id: msg.requestId, data: {} },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Role change notification
|
||||
if (msg && msg.type === '__role-change') {
|
||||
this._applyRole(msg.role, msg.budgetId ?? null);
|
||||
console.log(
|
||||
`[WorkerBridge] Role: ${msg.role}${msg.budgetId ? ` (budget: ${msg.budgetId})` : ''}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Surface SharedWorker console output in this tab's DevTools
|
||||
if (msg && msg.type === '__shared-worker-console') {
|
||||
const method = console[msg.level] || console.log;
|
||||
method(...msg.args);
|
||||
return;
|
||||
}
|
||||
|
||||
// Respond to heartbeat pings
|
||||
if (msg && msg.type === '__heartbeat-ping') {
|
||||
this._sharedPort.postMessage({ type: '__heartbeat-pong' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else goes to the connection layer
|
||||
if (msg && msg.type === 'push' && msg.name === 'show-budgets') {
|
||||
this._applyRole('UNASSIGNED', null);
|
||||
}
|
||||
this._dispatch(event);
|
||||
}
|
||||
|
||||
markInitialized() {
|
||||
this._isInitialized = true;
|
||||
}
|
||||
|
||||
_normalizeBudgetId(budgetId) {
|
||||
if (
|
||||
typeof budgetId === 'string' &&
|
||||
budgetId.length > 0 &&
|
||||
!budgetId.startsWith('__')
|
||||
) {
|
||||
return budgetId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_applyRole(role, budgetId) {
|
||||
this._currentBudgetId = this._normalizeBudgetId(budgetId);
|
||||
|
||||
if (role !== 'LEADER') {
|
||||
terminateLocalBackendWorker();
|
||||
}
|
||||
}
|
||||
|
||||
_resumeAssociation() {
|
||||
if (!this._isInitialized) {
|
||||
return;
|
||||
}
|
||||
this._sharedPort.postMessage({
|
||||
type: '__resume-tab',
|
||||
budgetId: this._currentBudgetId,
|
||||
});
|
||||
}
|
||||
|
||||
_createLocalWorker(initMsg, budgetToRestore, pendingMsg) {
|
||||
terminateLocalBackendWorker();
|
||||
localBackendWorker = new Worker(backendWorkerUrl);
|
||||
initSQLBackend(localBackendWorker);
|
||||
|
||||
const sharedPort = this._sharedPort;
|
||||
localBackendWorker.onmessage = workerEvent => {
|
||||
const workerMsg = workerEvent.data;
|
||||
// absurd-sql internal messages are handled by initSQLBackend
|
||||
if (
|
||||
workerMsg &&
|
||||
workerMsg.type &&
|
||||
workerMsg.type.startsWith('__absurd:')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// After the backend connects, automatically reload the budget that was
|
||||
// open before the leader left (e.g. page refresh). This lets other tabs
|
||||
// continue working without being sent to the budget list.
|
||||
if (workerMsg.type === 'connect') {
|
||||
if (budgetToRestore) {
|
||||
console.log(
|
||||
`[WorkerBridge] Backend connected, restoring budget "${budgetToRestore}"`,
|
||||
);
|
||||
const id = budgetToRestore;
|
||||
budgetToRestore = null;
|
||||
localBackendWorker.postMessage({
|
||||
id: '__restore-budget',
|
||||
name: 'load-budget',
|
||||
args: { id },
|
||||
catchErrors: true,
|
||||
});
|
||||
// Tell SharedWorker to track the restore request so
|
||||
// currentBudgetId gets updated when the reply arrives.
|
||||
sharedPort.postMessage({
|
||||
type: '__track-restore',
|
||||
requestId: '__restore-budget',
|
||||
budgetId: id,
|
||||
});
|
||||
} else if (pendingMsg) {
|
||||
const toSend = pendingMsg;
|
||||
pendingMsg = null;
|
||||
localBackendWorker.postMessage(toSend);
|
||||
}
|
||||
}
|
||||
sharedPort.postMessage({ type: '__from-worker', msg: workerMsg });
|
||||
};
|
||||
|
||||
localBackendWorker.postMessage(initMsg);
|
||||
}
|
||||
}
|
||||
|
||||
function createBackendWorker() {
|
||||
// Use SharedWorker as a coordinator for multi-tab, multi-budget support.
|
||||
// Each budget gets its own leader tab running a dedicated Worker. All other
|
||||
// tabs on the same budget are followers — their messages are routed through
|
||||
// the SharedWorker to the leader's Worker.
|
||||
// iOS is excluded: navigation to an OIDC provider and back creates a new
|
||||
// SharedWorker port connection, leaving the returning tab stuck as UNASSIGNED
|
||||
// with no backend Worker. The direct Worker fallback avoids this entirely.
|
||||
if (
|
||||
typeof SharedWorker !== 'undefined' &&
|
||||
!Platform.isPlaywright &&
|
||||
!Platform.isIOS
|
||||
) {
|
||||
try {
|
||||
const sharedWorker = new SharedBrowserServerWorker({
|
||||
name: 'actual-backend',
|
||||
});
|
||||
|
||||
const sharedPort = sharedWorker.port;
|
||||
worker = new WorkerBridge(sharedPort);
|
||||
console.log('[WorkerBridge] Connected to SharedWorker coordinator');
|
||||
|
||||
// Don't call start() here. The port must remain un-started so that
|
||||
// messages (especially 'connect') are queued until connectWorker()
|
||||
// sets onmessage, which implicitly starts the port via the bridge.
|
||||
|
||||
if (window.SharedArrayBuffer) {
|
||||
localStorage.removeItem('SharedArrayBufferOverride');
|
||||
}
|
||||
|
||||
sharedPort.postMessage({
|
||||
type: 'init',
|
||||
version: ACTUAL_VERSION,
|
||||
isDev: IS_DEV,
|
||||
publicUrl: process.env.PUBLIC_URL,
|
||||
hash: process.env.REACT_APP_BACKEND_WORKER_HASH,
|
||||
isSharedArrayBufferOverrideEnabled: localStorage.getItem(
|
||||
'SharedArrayBufferOverride',
|
||||
),
|
||||
});
|
||||
worker.markInitialized();
|
||||
|
||||
const notifyTabClosing = () => {
|
||||
sharedPort.postMessage({ type: 'tab-closing' });
|
||||
};
|
||||
window.addEventListener('beforeunload', notifyTabClosing);
|
||||
|
||||
return;
|
||||
} catch (e) {
|
||||
console.log('SharedWorker failed, falling back to Worker:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: regular Worker (Playwright, iOS, no SharedWorker support, or failure)
|
||||
console.log(
|
||||
Platform.isIOS
|
||||
? '[WorkerBridge] iOS detected, using direct Worker'
|
||||
: '[WorkerBridge] No SharedWorker available, using direct Worker',
|
||||
);
|
||||
worker = new Worker(backendWorkerUrl);
|
||||
initSQLBackend(worker);
|
||||
|
||||
if (window.SharedArrayBuffer) {
|
||||
localStorage.removeItem('SharedArrayBufferOverride');
|
||||
}
|
||||
|
||||
worker.postMessage({
|
||||
type: 'init',
|
||||
//
|
||||
// The multi-tab coordinator (leader/follower over SharedWorker), the direct
|
||||
// Worker fallback, and the absurd-sql worker bridge now all live in loot-core
|
||||
// (packages/loot-core/src/platform/client/browser-preload). We only hand it
|
||||
// the desktop-specific inputs: the worker asset URL, a SharedWorker factory,
|
||||
// and the init payload.
|
||||
const worker = startBrowserBackend({
|
||||
backendWorkerUrl,
|
||||
initPayload: {
|
||||
version: ACTUAL_VERSION,
|
||||
isDev: IS_DEV,
|
||||
publicUrl: process.env.PUBLIC_URL,
|
||||
hash: process.env.REACT_APP_BACKEND_WORKER_HASH,
|
||||
hasSharedArrayBuffer: !!window.SharedArrayBuffer,
|
||||
isSharedArrayBufferOverrideEnabled: localStorage.getItem(
|
||||
'SharedArrayBufferOverride',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
createBackendWorker();
|
||||
},
|
||||
createSharedWorker: () =>
|
||||
new SharedBrowserServerWorker({ name: 'actual-backend' }),
|
||||
forceDirectWorker: Platform.isPlaywright || Platform.isIOS,
|
||||
});
|
||||
|
||||
let isUpdateReadyForDownload = false;
|
||||
let markUpdateReadyForDownload;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// SharedWorker entry point for multi-tab, multi-budget support.
|
||||
//
|
||||
// All coordinator logic lives in shared-browser-server-core.ts
|
||||
// This file simply creates a coordinator with console forwarding
|
||||
// enabled and wires it to the SharedWorkerGlobalScope.
|
||||
// This is the bundler entry (imported via `?sharedworker`); all coordinator
|
||||
// logic lives in loot-core. This file simply creates a coordinator with
|
||||
// console forwarding enabled and wires it to the SharedWorkerGlobalScope.
|
||||
|
||||
import { createCoordinator } from './shared-browser-server-core';
|
||||
import { createCoordinator } from '@actual-app/core/platform/client/browser-server/coordinator';
|
||||
|
||||
const coordinator = createCoordinator({ enableConsoleForwarding: true });
|
||||
(self as unknown as { onconnect: typeof coordinator.onconnect }).onconnect =
|
||||
|
||||
@@ -114,6 +114,8 @@
|
||||
"./client/transfer": "./src/client/transfer.ts",
|
||||
"./client/undo": "./src/client/undo.ts",
|
||||
"./mocks": "./src/mocks/index.ts",
|
||||
"./platform/client/browser-preload": "./src/platform/client/browser-preload/index.ts",
|
||||
"./platform/client/browser-server/coordinator": "./src/platform/client/browser-server/coordinator.ts",
|
||||
"./platform/client/connection": {
|
||||
"electron-renderer": "./src/platform/client/connection/index.electron.ts",
|
||||
"default": "./src/platform/client/connection/index.ts"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export { WorkerBridge, type WorkerLike } from './worker-bridge';
|
||||
export {
|
||||
startBrowserBackend,
|
||||
type StartBackendOptions,
|
||||
type StartBackendInit,
|
||||
type StartBackendHandle,
|
||||
} from './start';
|
||||
109
packages/loot-core/src/platform/client/browser-preload/start.ts
Normal file
109
packages/loot-core/src/platform/client/browser-preload/start.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { initBackend as initSQLBackend } from 'absurd-sql/dist/indexeddb-main-thread';
|
||||
|
||||
import { logger } from '#platform/server/log';
|
||||
|
||||
import { WorkerBridge } from './worker-bridge';
|
||||
|
||||
export type StartBackendInit = {
|
||||
version: string;
|
||||
isDev: boolean;
|
||||
publicUrl?: string;
|
||||
hash?: string;
|
||||
};
|
||||
|
||||
export type StartBackendOptions = {
|
||||
/** URL of the backend Worker script to spawn. */
|
||||
backendWorkerUrl: URL;
|
||||
/** Payload posted to the worker (or shared coordinator) as its init msg. */
|
||||
initPayload: StartBackendInit;
|
||||
/**
|
||||
* Optional factory returning a SharedWorker instance. When provided, the
|
||||
* backend runs through loot-core's multi-tab coordinator (leader/follower).
|
||||
* Omit to always spawn a direct Worker on this page.
|
||||
*/
|
||||
createSharedWorker?: () => SharedWorker;
|
||||
/**
|
||||
* Skip the SharedWorker path even if `createSharedWorker` is provided.
|
||||
* Typically wired to platform flags (e.g. Playwright tests, iOS).
|
||||
*/
|
||||
forceDirectWorker?: boolean;
|
||||
};
|
||||
|
||||
export type StartBackendHandle = Worker | WorkerBridge;
|
||||
|
||||
export function startBrowserBackend(
|
||||
opts: StartBackendOptions,
|
||||
): StartBackendHandle {
|
||||
const {
|
||||
backendWorkerUrl,
|
||||
initPayload,
|
||||
createSharedWorker,
|
||||
forceDirectWorker,
|
||||
} = opts;
|
||||
|
||||
// Use SharedWorker as a coordinator for multi-tab, multi-budget support.
|
||||
// Each budget gets its own leader tab running a dedicated Worker. All other
|
||||
// tabs on the same budget are followers — their messages are routed through
|
||||
// the SharedWorker to the leader's Worker.
|
||||
// The SharedWorker never touches SharedArrayBuffer, so this works on all
|
||||
// platforms including iOS/Safari.
|
||||
if (
|
||||
!forceDirectWorker &&
|
||||
typeof SharedWorker !== 'undefined' &&
|
||||
createSharedWorker
|
||||
) {
|
||||
try {
|
||||
const sharedWorker = createSharedWorker();
|
||||
|
||||
const sharedPort = sharedWorker.port;
|
||||
const bridge = new WorkerBridge(sharedPort, backendWorkerUrl);
|
||||
logger.log('[WorkerBridge] Connected to SharedWorker coordinator');
|
||||
|
||||
// Don't call start() here. The port must remain un-started so that
|
||||
// messages (especially 'connect') are queued until connectWorker()
|
||||
// sets onmessage, which implicitly starts the port via the bridge.
|
||||
|
||||
if (window.SharedArrayBuffer) {
|
||||
localStorage.removeItem('SharedArrayBufferOverride');
|
||||
}
|
||||
|
||||
sharedPort.postMessage({
|
||||
type: 'init',
|
||||
...initPayload,
|
||||
isSharedArrayBufferOverrideEnabled: localStorage.getItem(
|
||||
'SharedArrayBufferOverride',
|
||||
),
|
||||
});
|
||||
bridge.markInitialized();
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
sharedPort.postMessage({ type: 'tab-closing' });
|
||||
});
|
||||
|
||||
return bridge;
|
||||
} catch (e) {
|
||||
logger.log('SharedWorker failed, falling back to Worker:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: regular Worker (Playwright, iOS, no SharedWorker support, the
|
||||
// consumer opted out by omitting createSharedWorker, or the path above threw).
|
||||
logger.log('[WorkerBridge] No SharedWorker available, using direct Worker');
|
||||
const worker = new Worker(backendWorkerUrl);
|
||||
initSQLBackend(worker);
|
||||
|
||||
if (window.SharedArrayBuffer) {
|
||||
localStorage.removeItem('SharedArrayBufferOverride');
|
||||
}
|
||||
|
||||
worker.postMessage({
|
||||
type: 'init',
|
||||
...initPayload,
|
||||
hasSharedArrayBuffer: !!window.SharedArrayBuffer,
|
||||
isSharedArrayBufferOverrideEnabled: localStorage.getItem(
|
||||
'SharedArrayBufferOverride',
|
||||
),
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { initBackend as initSQLBackend } from 'absurd-sql/dist/indexeddb-main-thread';
|
||||
|
||||
import { logger } from '#platform/server/log';
|
||||
|
||||
type BridgeMessage = { type?: string; [key: string]: unknown };
|
||||
|
||||
/** The Worker-like surface the connection layer interacts with. */
|
||||
export type WorkerLike = {
|
||||
onmessage: ((e: MessageEvent) => void) | null;
|
||||
postMessage: (msg: unknown) => void;
|
||||
addEventListener: (type: string, handler: (e: MessageEvent) => void) => void;
|
||||
start?: () => void;
|
||||
terminate?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* WorkerBridge wraps a SharedWorker port and presents a Worker-like interface
|
||||
* (onmessage, postMessage, addEventListener, start) to the connection layer.
|
||||
*
|
||||
* The SharedWorker coordinator assigns each tab a role per budget:
|
||||
* - LEADER: this tab runs the backend in a dedicated Worker
|
||||
* - FOLLOWER: this tab routes messages through the SharedWorker to the leader
|
||||
*
|
||||
* Multiple budgets can be open simultaneously — each has its own leader.
|
||||
*/
|
||||
export class WorkerBridge {
|
||||
_sharedPort: MessagePort;
|
||||
_onmessage: ((e: MessageEvent) => void) | null;
|
||||
_listeners: Array<{ type: string; handler: (e: MessageEvent) => void }>;
|
||||
_started: boolean;
|
||||
_isInitialized: boolean;
|
||||
_currentBudgetId: string | null;
|
||||
_wasHidden: boolean;
|
||||
_onVisibilityChange: () => void;
|
||||
localBackendWorker: Worker | null;
|
||||
backendWorkerUrl: URL;
|
||||
|
||||
constructor(sharedPort: MessagePort, backendWorkerUrl: URL) {
|
||||
this._sharedPort = sharedPort;
|
||||
this._onmessage = null;
|
||||
this._listeners = [];
|
||||
this._started = false;
|
||||
this._isInitialized = false;
|
||||
this._currentBudgetId = null;
|
||||
this._wasHidden = document.visibilityState === 'hidden';
|
||||
this.localBackendWorker = null;
|
||||
this.backendWorkerUrl = backendWorkerUrl;
|
||||
|
||||
this._onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this._wasHidden = true;
|
||||
} else if (this._wasHidden) {
|
||||
this._wasHidden = false;
|
||||
this._resumeAssociation();
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for all messages from the SharedWorker port
|
||||
sharedPort.addEventListener('message', e => this._onSharedMessage(e));
|
||||
document.addEventListener('visibilitychange', this._onVisibilityChange);
|
||||
}
|
||||
|
||||
set onmessage(handler: ((e: MessageEvent) => void) | null) {
|
||||
this._onmessage = handler;
|
||||
// Setting onmessage on a real MessagePort implicitly starts it.
|
||||
// We need to do this explicitly on the underlying port.
|
||||
if (!this._started) {
|
||||
this._started = true;
|
||||
this._sharedPort.start();
|
||||
}
|
||||
}
|
||||
|
||||
get onmessage() {
|
||||
return this._onmessage;
|
||||
}
|
||||
|
||||
postMessage(msg: unknown) {
|
||||
// All messages go through the SharedWorker for coordination.
|
||||
// The SharedWorker forwards to the leader's Worker via __to-worker.
|
||||
this._sharedPort.postMessage(msg);
|
||||
}
|
||||
|
||||
addEventListener(type: string, handler: (e: MessageEvent) => void) {
|
||||
this._listeners.push({ type, handler });
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this._started) {
|
||||
this._started = true;
|
||||
this._sharedPort.start();
|
||||
}
|
||||
}
|
||||
|
||||
_terminateLocalBackendWorker() {
|
||||
if (this.localBackendWorker) {
|
||||
this.localBackendWorker.terminate();
|
||||
this.localBackendWorker = null;
|
||||
}
|
||||
}
|
||||
|
||||
_dispatch(event: MessageEvent) {
|
||||
if (this._onmessage) this._onmessage(event);
|
||||
for (const { type, handler } of this._listeners) {
|
||||
if (type === 'message') handler(event);
|
||||
}
|
||||
}
|
||||
|
||||
_onSharedMessage(event: MessageEvent) {
|
||||
const msg = event.data as BridgeMessage;
|
||||
|
||||
// Elected as leader: create the real backend Worker on this tab
|
||||
if (msg && msg.type === '__become-leader') {
|
||||
this._createLocalWorker(
|
||||
msg.initMsg,
|
||||
(msg.budgetToRestore as string | null) ?? null,
|
||||
(msg.pendingMsg as Record<string, unknown> | null) ?? null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward requests from SharedWorker to our local Worker
|
||||
if (msg && msg.type === '__to-worker') {
|
||||
if (this.localBackendWorker) {
|
||||
this.localBackendWorker.postMessage(msg.msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Leadership transfer: this tab is closing the budget but other tabs
|
||||
// still need it. Terminate our Worker (don't actually close-budget on
|
||||
// the backend) and dispatch a synthetic reply so the UI navigates to
|
||||
// show-budgets normally.
|
||||
if (msg && msg.type === '__close-and-transfer') {
|
||||
logger.log('[WorkerBridge] Leadership transferred — terminating Worker');
|
||||
this._applyRole('UNASSIGNED', null);
|
||||
// Only dispatch a synthetic reply if there's an actual close-budget
|
||||
// request to complete. When requestId is null the eviction was
|
||||
// triggered externally (e.g. another tab deleted this budget).
|
||||
if (msg.requestId) {
|
||||
this._dispatch({
|
||||
data: { type: 'reply', id: msg.requestId, data: {} },
|
||||
} as MessageEvent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Role change notification
|
||||
if (msg && msg.type === '__role-change') {
|
||||
const role = msg.role as string;
|
||||
const budgetId = (msg.budgetId as string | null) ?? null;
|
||||
this._applyRole(role, budgetId);
|
||||
logger.log(
|
||||
`[WorkerBridge] Role: ${role}${budgetId ? ` (budget: ${budgetId})` : ''}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Surface SharedWorker console output in this tab's DevTools. This is the
|
||||
// replay side of the coordinator's console forwarding, so it must use the
|
||||
// real console (not logger).
|
||||
if (msg && msg.type === '__shared-worker-console') {
|
||||
const level = msg.level as string;
|
||||
const consoleByLevel = console as unknown as Record<
|
||||
string,
|
||||
(...args: unknown[]) => void
|
||||
>;
|
||||
// oxlint-disable-next-line actual/prefer-logger-over-console
|
||||
const method = consoleByLevel[level] || console.log;
|
||||
method(...(msg.args as unknown[]));
|
||||
return;
|
||||
}
|
||||
|
||||
// Respond to heartbeat pings
|
||||
if (msg && msg.type === '__heartbeat-ping') {
|
||||
this._sharedPort.postMessage({ type: '__heartbeat-pong' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else goes to the connection layer
|
||||
if (msg && msg.type === 'push' && msg.name === 'show-budgets') {
|
||||
this._applyRole('UNASSIGNED', null);
|
||||
}
|
||||
this._dispatch(event);
|
||||
}
|
||||
|
||||
markInitialized() {
|
||||
this._isInitialized = true;
|
||||
}
|
||||
|
||||
_normalizeBudgetId(budgetId: string | null): string | null {
|
||||
if (
|
||||
typeof budgetId === 'string' &&
|
||||
budgetId.length > 0 &&
|
||||
!budgetId.startsWith('__')
|
||||
) {
|
||||
return budgetId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_applyRole(role: string, budgetId: string | null) {
|
||||
this._currentBudgetId = this._normalizeBudgetId(budgetId);
|
||||
|
||||
if (role !== 'LEADER') {
|
||||
this._terminateLocalBackendWorker();
|
||||
}
|
||||
}
|
||||
|
||||
_resumeAssociation() {
|
||||
if (!this._isInitialized) {
|
||||
return;
|
||||
}
|
||||
this._sharedPort.postMessage({
|
||||
type: '__resume-tab',
|
||||
budgetId: this._currentBudgetId,
|
||||
});
|
||||
}
|
||||
|
||||
_createLocalWorker(
|
||||
initMsg: unknown,
|
||||
budgetToRestore: string | null,
|
||||
pendingMsg: Record<string, unknown> | null,
|
||||
) {
|
||||
this._terminateLocalBackendWorker();
|
||||
const localWorker = new Worker(this.backendWorkerUrl);
|
||||
this.localBackendWorker = localWorker;
|
||||
initSQLBackend(localWorker);
|
||||
|
||||
const sharedPort = this._sharedPort;
|
||||
localWorker.onmessage = workerEvent => {
|
||||
const workerMsg = workerEvent.data as BridgeMessage;
|
||||
// absurd-sql internal messages are handled by initSQLBackend
|
||||
if (
|
||||
workerMsg &&
|
||||
typeof workerMsg.type === 'string' &&
|
||||
workerMsg.type.startsWith('__absurd:')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// After the backend connects, automatically reload the budget that was
|
||||
// open before the leader left (e.g. page refresh). This lets other tabs
|
||||
// continue working without being sent to the budget list.
|
||||
if (workerMsg.type === 'connect') {
|
||||
if (budgetToRestore) {
|
||||
logger.log(
|
||||
`[WorkerBridge] Backend connected, restoring budget "${budgetToRestore}"`,
|
||||
);
|
||||
const id = budgetToRestore;
|
||||
budgetToRestore = null;
|
||||
localWorker.postMessage({
|
||||
id: '__restore-budget',
|
||||
name: 'load-budget',
|
||||
args: { id },
|
||||
catchErrors: true,
|
||||
});
|
||||
// Tell SharedWorker to track the restore request so
|
||||
// currentBudgetId gets updated when the reply arrives.
|
||||
sharedPort.postMessage({
|
||||
type: '__track-restore',
|
||||
requestId: '__restore-budget',
|
||||
budgetId: id,
|
||||
});
|
||||
} else if (pendingMsg) {
|
||||
const toSend = pendingMsg;
|
||||
pendingMsg = null;
|
||||
localWorker.postMessage(toSend);
|
||||
}
|
||||
}
|
||||
sharedPort.postMessage({ type: '__from-worker', msg: workerMsg });
|
||||
};
|
||||
|
||||
localWorker.postMessage(initMsg);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import { createCoordinator } from './shared-browser-server-core';
|
||||
import { createCoordinator } from './coordinator';
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// The SharedWorker entry point (shared-browser-server.js) calls
|
||||
// createCoordinator() and wires the result to self.onconnect.
|
||||
|
||||
import { logger } from '#platform/server/log';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
type ConsoleLevel = 'log' | 'warn' | 'error' | 'info';
|
||||
@@ -49,6 +51,11 @@ export function createCoordinator({
|
||||
// ── Console forwarding ─────────────────────────────────────────────────
|
||||
|
||||
if (enableConsoleForwarding) {
|
||||
// This block deliberately captures and monkeypatches the real `console`
|
||||
// so the SharedWorker's own logs (and `logger.*` calls, which delegate to
|
||||
// console) get mirrored to every connected tab's DevTools. It must use the
|
||||
// real console, not `logger`.
|
||||
/* oxlint-disable actual/prefer-logger-over-console */
|
||||
const _originalConsole = {
|
||||
log: console.log.bind(console),
|
||||
warn: console.warn.bind(console),
|
||||
@@ -82,6 +89,7 @@ export function createCoordinator({
|
||||
console.warn = (...args: unknown[]) => forwardConsole('warn', args);
|
||||
console.error = (...args: unknown[]) => forwardConsole('error', args);
|
||||
console.info = (...args: unknown[]) => forwardConsole('info', args);
|
||||
/* oxlint-enable actual/prefer-logger-over-console */
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
@@ -113,7 +121,7 @@ export function createCoordinator({
|
||||
for (const [bid, g] of budgetGroups) {
|
||||
groups.push(`"${bid}": leader + ${g.followers.size} follower(s)`);
|
||||
}
|
||||
console.log(
|
||||
logger.log(
|
||||
`[SharedWorker] ${action} — ${connectedPorts.length} tab(s), ${unassignedPorts.size} unassigned, groups: [${groups.join(', ') || 'none'}]`,
|
||||
);
|
||||
}
|
||||
@@ -275,12 +283,12 @@ export function createCoordinator({
|
||||
const candidate = group.followers.values().next()
|
||||
.value as CoordinatorPort;
|
||||
group.followers.delete(candidate);
|
||||
console.log(
|
||||
logger.log(
|
||||
`[SharedWorker] Leader left budget "${budgetId}" — promoting follower`,
|
||||
);
|
||||
electLeader(budgetId, candidate, budgetId);
|
||||
} else {
|
||||
console.log(
|
||||
logger.log(
|
||||
`[SharedWorker] Last tab left budget "${budgetId}" — removing group`,
|
||||
);
|
||||
budgetGroups.delete(budgetId);
|
||||
@@ -327,7 +335,7 @@ export function createCoordinator({
|
||||
portToBudget.set(port, budgetId);
|
||||
unassignedPorts.delete(port);
|
||||
|
||||
console.log(
|
||||
logger.log(
|
||||
`[SharedWorker] Elected leader for "${budgetId}" (${group.followers.size} follower(s))`,
|
||||
);
|
||||
port.postMessage({
|
||||
@@ -408,7 +416,7 @@ export function createCoordinator({
|
||||
|
||||
budgetGroups.delete(budgetId);
|
||||
if (evicted.length > 0) {
|
||||
console.log(
|
||||
logger.log(
|
||||
`[SharedWorker] Evicted ${evicted.length} tab(s) from budget "${budgetId}"`,
|
||||
);
|
||||
}
|
||||
@@ -427,7 +435,7 @@ export function createCoordinator({
|
||||
if (oldGroupId !== newBudgetId) {
|
||||
const existingTarget = budgetGroups.get(newBudgetId);
|
||||
if (existingTarget && existingTarget !== oldGroup) {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`[SharedWorker] handleBudgetLoaded: conflict — group "${newBudgetId}" already exists`,
|
||||
);
|
||||
return;
|
||||
@@ -440,7 +448,7 @@ export function createCoordinator({
|
||||
portToBudget.set(p, newBudgetId);
|
||||
}
|
||||
|
||||
console.log(
|
||||
logger.log(
|
||||
`[SharedWorker] Budget loaded: "${newBudgetId}" (leader + ${oldGroup.followers.size} follower(s))`,
|
||||
);
|
||||
}
|
||||
@@ -702,7 +710,7 @@ export function createCoordinator({
|
||||
unassignedPorts.add(p);
|
||||
}
|
||||
if (group.followers.size > 0) {
|
||||
console.log(
|
||||
logger.log(
|
||||
`[SharedWorker] Leader switching budgets — pushed ${group.followers.size} follower(s) off "${portBudget}"`,
|
||||
);
|
||||
group.followers.clear();
|
||||
@@ -731,7 +739,7 @@ export function createCoordinator({
|
||||
const newLeader = group.followers.values().next()
|
||||
.value as CoordinatorPort;
|
||||
group.followers.delete(newLeader);
|
||||
console.log(
|
||||
logger.log(
|
||||
`[SharedWorker] Leader closing budget "${portBudget}" but ${group.followers.size + 1} tab(s) remain — transferring`,
|
||||
);
|
||||
port.postMessage({
|
||||
@@ -809,7 +817,7 @@ export function createCoordinator({
|
||||
unassignedPorts.add(p);
|
||||
}
|
||||
if (group.followers.size > 0) {
|
||||
console.log(
|
||||
logger.log(
|
||||
`[SharedWorker] Budget-replacing "${msg.name}" — pushed ${group.followers.size} tab(s) off "${portBudget}"`,
|
||||
);
|
||||
group.followers.clear();
|
||||
@@ -867,7 +875,7 @@ export function createCoordinator({
|
||||
targetGroup.leaderPort.postMessage({ type: '__to-worker', msg });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SharedWorker] Error in message handler:', error);
|
||||
logger.error('[SharedWorker] Error in message handler:', error);
|
||||
}
|
||||
};
|
||||
|
||||
3
packages/loot-core/typings/absurd-sql.ts
Normal file
3
packages/loot-core/typings/absurd-sql.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
declare module 'absurd-sql/dist/indexeddb-main-thread' {
|
||||
export function initBackend(worker: Worker): void;
|
||||
}
|
||||
6
upcoming-release-notes/8143.md
Normal file
6
upcoming-release-notes/8143.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Move the browser Web Worker bootstrap and multi-tab coordinator from desktop-client into loot-core.
|
||||
Reference in New Issue
Block a user