[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:
Matiss Janis Aboltins
2026-06-08 21:50:59 +01:00
committed by GitHub
parent edc0cd9fbc
commit ebcb86ab7d
12 changed files with 440 additions and 328 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
declare module 'absurd-sql/dist/indexeddb-main-thread' {
export function initBackend(worker: Worker): void;
}

View 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.

View File

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