From ebcb86ab7db87fbf0232930adaab64f9c312dfe7 Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins Date: Mon, 8 Jun 2026 21:50:59 +0100 Subject: [PATCH] [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) * [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) --- packages/desktop-client/package.json | 1 - .../desktop-client/src/browser-preload.js | 325 +----------------- .../src/shared-browser-server.ts | 8 +- packages/loot-core/package.json | 2 + .../platform/client/browser-preload/index.ts | 7 + .../platform/client/browser-preload/start.ts | 109 ++++++ .../client/browser-preload/worker-bridge.ts | 274 +++++++++++++++ .../browser-server/coordinator.test.ts} | 2 +- .../client/browser-server/coordinator.ts} | 30 +- packages/loot-core/typings/absurd-sql.ts | 3 + upcoming-release-notes/8143.md | 6 + yarn.lock | 1 - 12 files changed, 440 insertions(+), 328 deletions(-) create mode 100644 packages/loot-core/src/platform/client/browser-preload/index.ts create mode 100644 packages/loot-core/src/platform/client/browser-preload/start.ts create mode 100644 packages/loot-core/src/platform/client/browser-preload/worker-bridge.ts rename packages/{desktop-client/src/shared-browser-server-core.test.ts => loot-core/src/platform/client/browser-server/coordinator.test.ts} (99%) rename packages/{desktop-client/src/shared-browser-server-core.ts => loot-core/src/platform/client/browser-server/coordinator.ts} (97%) create mode 100644 packages/loot-core/typings/absurd-sql.ts create mode 100644 upcoming-release-notes/8143.md diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index 809467f29f..aaf16f8bd6 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -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", diff --git a/packages/desktop-client/src/browser-preload.js b/packages/desktop-client/src/browser-preload.js index 7ed41fe6b5..797cc00184 100644 --- a/packages/desktop-client/src/browser-preload.js +++ b/packages/desktop-client/src/browser-preload.js @@ -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; diff --git a/packages/desktop-client/src/shared-browser-server.ts b/packages/desktop-client/src/shared-browser-server.ts index 4bd3de6c10..56ee067c5e 100644 --- a/packages/desktop-client/src/shared-browser-server.ts +++ b/packages/desktop-client/src/shared-browser-server.ts @@ -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 = diff --git a/packages/loot-core/package.json b/packages/loot-core/package.json index fe4c99323b..6588409522 100644 --- a/packages/loot-core/package.json +++ b/packages/loot-core/package.json @@ -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" diff --git a/packages/loot-core/src/platform/client/browser-preload/index.ts b/packages/loot-core/src/platform/client/browser-preload/index.ts new file mode 100644 index 0000000000..aef2bd6065 --- /dev/null +++ b/packages/loot-core/src/platform/client/browser-preload/index.ts @@ -0,0 +1,7 @@ +export { WorkerBridge, type WorkerLike } from './worker-bridge'; +export { + startBrowserBackend, + type StartBackendOptions, + type StartBackendInit, + type StartBackendHandle, +} from './start'; diff --git a/packages/loot-core/src/platform/client/browser-preload/start.ts b/packages/loot-core/src/platform/client/browser-preload/start.ts new file mode 100644 index 0000000000..04e5be0239 --- /dev/null +++ b/packages/loot-core/src/platform/client/browser-preload/start.ts @@ -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; +} diff --git a/packages/loot-core/src/platform/client/browser-preload/worker-bridge.ts b/packages/loot-core/src/platform/client/browser-preload/worker-bridge.ts new file mode 100644 index 0000000000..a124f55ef8 --- /dev/null +++ b/packages/loot-core/src/platform/client/browser-preload/worker-bridge.ts @@ -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 | 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 | 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); + } +} diff --git a/packages/desktop-client/src/shared-browser-server-core.test.ts b/packages/loot-core/src/platform/client/browser-server/coordinator.test.ts similarity index 99% rename from packages/desktop-client/src/shared-browser-server-core.test.ts rename to packages/loot-core/src/platform/client/browser-server/coordinator.test.ts index aa04a04d37..3fb910530d 100644 --- a/packages/desktop-client/src/shared-browser-server-core.test.ts +++ b/packages/loot-core/src/platform/client/browser-server/coordinator.test.ts @@ -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 ─────────────────────────────────────────────────────────────── diff --git a/packages/desktop-client/src/shared-browser-server-core.ts b/packages/loot-core/src/platform/client/browser-server/coordinator.ts similarity index 97% rename from packages/desktop-client/src/shared-browser-server-core.ts rename to packages/loot-core/src/platform/client/browser-server/coordinator.ts index 47707f63ad..3f73219dbb 100644 --- a/packages/desktop-client/src/shared-browser-server-core.ts +++ b/packages/loot-core/src/platform/client/browser-server/coordinator.ts @@ -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); } }; diff --git a/packages/loot-core/typings/absurd-sql.ts b/packages/loot-core/typings/absurd-sql.ts new file mode 100644 index 0000000000..082e811c36 --- /dev/null +++ b/packages/loot-core/typings/absurd-sql.ts @@ -0,0 +1,3 @@ +declare module 'absurd-sql/dist/indexeddb-main-thread' { + export function initBackend(worker: Worker): void; +} diff --git a/upcoming-release-notes/8143.md b/upcoming-release-notes/8143.md new file mode 100644 index 0000000000..ef8965275a --- /dev/null +++ b/upcoming-release-notes/8143.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MatissJanis] +--- + +Move the browser Web Worker bootstrap and multi-tab coordinator from desktop-client into loot-core. diff --git a/yarn.lock b/yarn.lock index 7a51fc8fc0..df6d4277ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -250,7 +250,6 @@ __metadata: "@use-gesture/react": "npm:^10.3.1" "@vitejs/plugin-basic-ssl": "npm:^2.3.0" "@vitejs/plugin-react": "npm:^6.0.1" - absurd-sql: "npm:0.0.54" auto-text-size: "npm:^0.2.3" babel-plugin-react-compiler: "npm:^1.0.0" cmdk: "npm:^1.1.1"