mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-06 15:12:35 -05:00
[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:
11
.playwright-cli/page-2026-04-18T15-00-44-467Z.yml
Normal file
11
.playwright-cli/page-2026-04-18T15-00-44-467Z.yml
Normal 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…)
|
||||
12
.playwright-cli/page-2026-04-18T15-01-01-655Z.yml
Normal file
12
.playwright-cli/page-2026-04-18T15-01-01-655Z.yml
Normal 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…)
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export { WorkerBridge, type WorkerLike } from './worker-bridge';
|
||||
export {
|
||||
startBrowserBackend,
|
||||
type StartBackendOptions,
|
||||
type StartBackendInit,
|
||||
type StartBackendHandle,
|
||||
} from './start';
|
||||
143
packages/loot-core/src/platform/client/browser-preload/start.ts
Normal file
143
packages/loot-core/src/platform/client/browser-preload/start.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user