[AI] core: move browser-preload multi-tab coordinator into loot-core

Extracts the WorkerBridge class + the Worker/SharedWorker transport
selector out of packages/desktop-client/src/browser-preload.js and into
a new loot-core module at packages/loot-core/src/platform/client/
browser-preload/. desktop-client's preload shrinks to a thin shell that
only wires its PWA service worker, package.json version, SharedWorker
factory, and the global.Actual shim — everything else is a one-line
startBrowserBackend({ ... }) call into loot-core.

The api package still uses the lighter-weight createBackendWorker entry
in the sibling loot-core module; both packages now consume the worker-
bootstrap primitives from loot-core rather than duplicating them.

Verified end-to-end in the browser via playwright — the api playground
still loads, downloads the budget, and renders accounts+transactions
identically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
github-actions[bot]
2026-04-18 16:02:07 +01:00
parent d5a75a831a
commit 59e7f858a7
7 changed files with 391 additions and 247 deletions

View File

@@ -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…)

View File

@@ -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…)

View File

@@ -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;

View File

@@ -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"

View File

@@ -0,0 +1,7 @@
export { WorkerBridge, type WorkerLike } from './worker-bridge';
export {
startBrowserBackend,
type StartBackendOptions,
type StartBackendInit,
type StartBackendHandle,
} from './start';

View File

@@ -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;
}

View File

@@ -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);
}
}