mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-06 20:15:33 -05:00
Removes the last piece of bespoke browser wiring in @actual-app/api so
consumers call api.init({...}) with no worker construction of their own.
Under the hood, the api package now:
- Spawns its Web Worker itself via `new Worker(new URL('./worker.js',
import.meta.url), { type: 'module' })`. Consumer bundlers resolve the
sibling asset at their own build time.
- Speaks loot-core's existing {id, name, args} / {type:'reply', id,
result} backend protocol, including the {type:'connect'} handshake
— same protocol desktop-client's browser-preload.js already feeds.
- Delegates sqlite bootstrap to loot-core's public init(config) via a
worker-registered `api-browser/init` handler; server-side dispatch is
handled by the existing packages/loot-core/src/platform/server/
connection layer, so no custom {op, payload} shape remains.
The absurd-sql main-thread plumbing (initSQLBackend + __absurd:* filter)
is now a single function in loot-core:
`packages/loot-core/src/platform/client/backend-worker/createBackendWorker`,
consumed by both desktop-client's browser-preload.js and the api's
browser rpc.ts.
Test split moves accordingly: browser-facade.test.ts swaps in a
Worker mock that speaks the new protocol (id, name, args / reply handshake)
and confirms init forwards config via api-browser/init.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
133 lines
3.7 KiB
TypeScript
133 lines
3.7 KiB
TypeScript
// Main-thread RPC bridge to the api worker.
|
|
//
|
|
// Reuses `createBackendWorker` from loot-core so absurd-sql's main-thread
|
|
// plumbing (IDB helper worker, __absurd:* filtering) stays in one place.
|
|
// Speaks loot-core's existing backend protocol:
|
|
// out: {id, name, args, catchErrors?}
|
|
// in : {type:'reply', id, result, error?}
|
|
// {type:'error', id, error}
|
|
// {type:'connect'} (handshake heartbeat)
|
|
// {type:'push', name, args}
|
|
//
|
|
// We handle the handshake by replying {name:'client-connected-to-backend'}
|
|
// on the first 'connect'. Messages sent before handshake completes are
|
|
// queued.
|
|
|
|
import { createBackendWorker } from '@actual-app/core/platform/client/backend-worker';
|
|
import type { BackendWorker } from '@actual-app/core/platform/client/backend-worker';
|
|
|
|
type Pending = {
|
|
resolve: (v: unknown) => void;
|
|
reject: (e: unknown) => void;
|
|
};
|
|
|
|
type Reply =
|
|
| {
|
|
type: 'reply';
|
|
id: string;
|
|
result?: unknown;
|
|
error?: { type?: string; message?: string; [k: string]: unknown };
|
|
}
|
|
| {
|
|
type: 'error';
|
|
id: string;
|
|
error: { type?: string; message?: string; [k: string]: unknown };
|
|
};
|
|
|
|
let backend: BackendWorker | null = null;
|
|
let connected = false;
|
|
let queue: Array<{ id: string; name: string; args?: unknown }> = [];
|
|
const pending = new Map<string, Pending>();
|
|
|
|
function nextId(): string {
|
|
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
|
return crypto.randomUUID();
|
|
}
|
|
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
|
}
|
|
|
|
function toError(info: { type?: string; message?: string } | undefined) {
|
|
const msg = info?.message || info?.type || 'api worker error';
|
|
const err = new Error(msg);
|
|
if (info?.type) err.name = info.type;
|
|
return err;
|
|
}
|
|
|
|
export function setWorker(worker: Worker): BackendWorker {
|
|
if (backend) {
|
|
backend.terminate();
|
|
}
|
|
|
|
connected = false;
|
|
queue = [];
|
|
pending.clear();
|
|
|
|
backend = createBackendWorker(worker);
|
|
|
|
backend.onMessage((data: unknown) => {
|
|
if (!data || typeof data !== 'object') return;
|
|
const msg = data as { type?: string; name?: string };
|
|
|
|
if (msg.type === 'connect') {
|
|
if (!connected) {
|
|
connected = true;
|
|
backend!.postMessage({ name: 'client-connected-to-backend' });
|
|
// Drain anything queued while waiting for the handshake.
|
|
const drained = queue;
|
|
queue = [];
|
|
for (const m of drained) backend!.postMessage(m);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (msg.type === 'reply' || msg.type === 'error') {
|
|
const reply = msg as Reply;
|
|
const p = pending.get(reply.id);
|
|
if (!p) return;
|
|
pending.delete(reply.id);
|
|
if (reply.type === 'error') {
|
|
p.reject(toError(reply.error));
|
|
} else if ('error' in reply && reply.error) {
|
|
// api/* handlers funnel errors through the reply envelope.
|
|
p.reject(toError(reply.error));
|
|
} else {
|
|
p.resolve(reply.result);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// push/capture-exception/etc. — ignore for now; the api consumer
|
|
// doesn't subscribe to loot-core's server events.
|
|
});
|
|
|
|
return backend;
|
|
}
|
|
|
|
export function rpc(name: string, args?: unknown): Promise<unknown> {
|
|
if (!backend) {
|
|
return Promise.reject(
|
|
new Error('@actual-app/api: init() must be called before any api method'),
|
|
);
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const id = nextId();
|
|
pending.set(id, { resolve, reject });
|
|
const msg = { id, name, args };
|
|
if (connected) {
|
|
backend!.postMessage(msg);
|
|
} else {
|
|
queue.push(msg);
|
|
}
|
|
});
|
|
}
|
|
|
|
export function terminate() {
|
|
if (backend) {
|
|
backend.terminate();
|
|
backend = null;
|
|
}
|
|
connected = false;
|
|
queue = [];
|
|
pending.clear();
|
|
}
|