diff --git a/.playwright-cli/page-2026-04-18T15-00-44-467Z.yml b/.playwright-cli/page-2026-04-18T15-00-44-467Z.yml new file mode 100644 index 0000000000..aceffd6a94 --- /dev/null +++ b/.playwright-cli/page-2026-04-18T15-00-44-467Z.yml @@ -0,0 +1,11 @@ +- generic [active] [ref=e1]: + - heading "Actual browser API playground" [level=1] [ref=e2] + - paragraph [ref=e3]: + - text: Fill in + - code [ref=e4]: src/config.ts + - text: first. + - heading "Log" [level=2] [ref=e5] + - heading "Accounts" [level=2] [ref=e7] + - generic [ref=e8]: (loading…) + - heading "Transactions (first account)" [level=2] [ref=e9] + - generic [ref=e10]: (loading…) diff --git a/.playwright-cli/page-2026-04-18T15-01-01-655Z.yml b/.playwright-cli/page-2026-04-18T15-01-01-655Z.yml new file mode 100644 index 0000000000..af93e92631 --- /dev/null +++ b/.playwright-cli/page-2026-04-18T15-01-01-655Z.yml @@ -0,0 +1,12 @@ +- generic [active] [ref=e1]: + - heading "Actual browser API playground" [level=1] [ref=e2] + - paragraph [ref=e3]: + - text: Fill in + - code [ref=e4]: src/config.ts + - text: first. + - heading "Log" [level=2] [ref=e5] + - generic [ref=e6]: → init + - heading "Accounts" [level=2] [ref=e7] + - generic [ref=e8]: (loading…) + - heading "Transactions (first account)" [level=2] [ref=e9] + - generic [ref=e10]: (loading…) diff --git a/packages/desktop-client/src/browser-preload.js b/packages/desktop-client/src/browser-preload.js index dfdf1f4e5a..6a15a5b13e 100644 --- a/packages/desktop-client/src/browser-preload.js +++ b/packages/desktop-client/src/browser-preload.js @@ -1,4 +1,4 @@ -import { createBackendWorker as initSQLBackend } from '@actual-app/core/platform/client/backend-worker'; +import { startBrowserBackend } from '@actual-app/core/platform/client/browser-preload'; import * as Platform from '@actual-app/core/shared/platform'; import { registerSW } from 'virtual:pwa-register'; @@ -22,256 +22,23 @@ 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; - -/** - * 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; - - // Listen for all messages from the SharedWorker port - sharedPort.addEventListener('message', e => this._onSharedMessage(e)); - } - - 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'); - if (localBackendWorker) { - localBackendWorker.terminate(); - localBackendWorker = 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') { - 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 - this._dispatch(event); - } - - _createLocalWorker(initMsg, budgetToRestore, pendingMsg) { - if (localBackendWorker) { - localBackendWorker.terminate(); - } - 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. - // The SharedWorker never touches SharedArrayBuffer, so this works on all - // platforms including iOS/Safari. - if (typeof SharedWorker !== 'undefined' && !Platform.isPlaywright) { - 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', - ), - }); - - window.addEventListener('beforeunload', () => { - sharedPort.postMessage({ type: 'tab-closing' }); - }); - - return; - } catch (e) { - console.log('SharedWorker failed, falling back to Worker:', e); - } - } - - // Fallback: regular Worker (Playwright, no SharedWorker support, or failure) - console.log('[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), direct +// Worker fallback, and absurd-sql bridge now all live in loot-core +// (packages/loot-core/src/platform/client/browser-preload). We only +// hand it the desktop-specific inputs. +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, +}); let isUpdateReadyForDownload = false; let markUpdateReadyForDownload; diff --git a/packages/loot-core/package.json b/packages/loot-core/package.json index ab51793253..ec2a6fbcd2 100644 --- a/packages/loot-core/package.json +++ b/packages/loot-core/package.json @@ -61,6 +61,7 @@ }, "#mocks": "./src/mocks/index.ts", "#platform/client/backend-worker": "./src/platform/client/backend-worker/index.ts", + "#platform/client/browser-preload": "./src/platform/client/browser-preload/index.ts", "#platform/client/undo": "./src/platform/client/undo/index.ts", "#platform/exceptions": "./src/platform/exceptions/index.ts", "#platform/server/indexeddb": "./src/platform/server/indexeddb/index.ts", @@ -106,6 +107,7 @@ "./client/undo": "./src/client/undo.ts", "./mocks": "./src/mocks/index.ts", "./platform/client/backend-worker": "./src/platform/client/backend-worker/index.ts", + "./platform/client/browser-preload": "./src/platform/client/browser-preload/index.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..0a0fda7f9a --- /dev/null +++ b/packages/loot-core/src/platform/client/browser-preload/start.ts @@ -0,0 +1,143 @@ +// @ts-strict-ignore +// Parameterized backend bootstrap moved out of desktop-client's +// browser-preload.js. Picks between a SharedWorker-coordinated multi-tab +// setup and a direct Worker fallback. Consumers hand in the concrete URLs, +// init payload, and (if they want multi-tab coordination) a SharedWorker +// factory — keeping loot-core free of Vite-specific asset imports. + +import { createBackendWorker as initSQLBackend } from '#platform/client/backend-worker'; +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 a platform flag (e.g. Playwright tests). + */ + 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 ( + (globalThis as unknown as { SharedArrayBuffer?: unknown }) + .SharedArrayBuffer + ) { + try { + localStorage.removeItem('SharedArrayBufferOverride'); + } catch { + // localStorage may be unavailable in some embeddings; ignore. + } + } + + let isSharedArrayBufferOverrideEnabled: string | null = null; + try { + isSharedArrayBufferOverrideEnabled = localStorage.getItem( + 'SharedArrayBufferOverride', + ); + } catch { + // ignore + } + + sharedPort.postMessage({ + type: 'init', + ...initPayload, + isSharedArrayBufferOverrideEnabled, + }); + + 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, no SharedWorker support, or the + // consumer opted out by omitting createSharedWorker). + logger.log('[WorkerBridge] No SharedWorker available, using direct Worker'); + const worker = new Worker(backendWorkerUrl); + initSQLBackend(worker); + + if ( + (globalThis as unknown as { SharedArrayBuffer?: unknown }).SharedArrayBuffer + ) { + try { + localStorage.removeItem('SharedArrayBufferOverride'); + } catch { + // ignore + } + } + + let isSharedArrayBufferOverrideEnabled: string | null = null; + try { + isSharedArrayBufferOverrideEnabled = localStorage.getItem( + 'SharedArrayBufferOverride', + ); + } catch { + // ignore + } + + worker.postMessage({ + type: 'init', + ...initPayload, + hasSharedArrayBuffer: !!( + globalThis as unknown as { SharedArrayBuffer?: unknown } + ).SharedArrayBuffer, + isSharedArrayBufferOverrideEnabled, + }); + + 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..9c8f1c2157 --- /dev/null +++ b/packages/loot-core/src/platform/client/browser-preload/worker-bridge.ts @@ -0,0 +1,202 @@ +// @ts-strict-ignore +// Moved verbatim from packages/desktop-client/src/browser-preload.js — this +// is the SharedWorker-port → Worker-like adapter loot-core's client +// connection layer consumes. Works identically for any browser consumer +// that opts into multi-tab coordination. + +import { createBackendWorker as initSQLBackend } from '#platform/client/backend-worker'; +import { logger } from '#platform/server/log'; + +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; + localBackendWorker: Worker | null; + backendWorkerUrl: URL; + + constructor(sharedPort: MessagePort, backendWorkerUrl: URL) { + this._sharedPort = sharedPort; + this._onmessage = null; + this._listeners = []; + this._started = false; + this.localBackendWorker = null; + this.backendWorkerUrl = backendWorkerUrl; + + // Listen for all messages from the SharedWorker port + sharedPort.addEventListener('message', e => this._onSharedMessage(e)); + } + + 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 (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'); + if (this.localBackendWorker) { + this.localBackendWorker.terminate(); + this.localBackendWorker = 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') { + logger.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 + this._dispatch(event); + } + + _createLocalWorker(initMsg, budgetToRestore, pendingMsg) { + if (this.localBackendWorker) { + this.localBackendWorker.terminate(); + } + this.localBackendWorker = new Worker(this.backendWorkerUrl); + initSQLBackend(this.localBackendWorker); + + const sharedPort = this._sharedPort; + const localWorker = this.localBackendWorker; + localWorker.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) { + 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); + } +}