mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-20 10:24:01 +02:00
197 lines
7.0 KiB
TypeScript
197 lines
7.0 KiB
TypeScript
/// <reference lib="webworker" />
|
|
|
|
/**
|
|
* The process that owns the database.
|
|
*
|
|
* On the desktop that is the Rust binary: it holds SQLite, every window talks
|
|
* to it, and it pushes model writes to all of them. In a browser this worker
|
|
* plays that part. It loads the model layer compiled to wasm, opens the one
|
|
* database, answers each tab's commands over its port, and fans every
|
|
* `model_writes` out to every port — so two tabs are coherent for the same
|
|
* reason two desktop windows are, not because of a side channel.
|
|
*
|
|
* It is a SharedWorker, and only that: the browser hands every tab on the
|
|
* origin the same one, which is what makes "one database owner" true without
|
|
* anyone coordinating. It still takes a Web Lock before opening the database,
|
|
* for the one overlap the browser doesn't rule out — a tab reloading itself,
|
|
* whose old worker may still be letting go while the new one comes up.
|
|
*/
|
|
|
|
import { DB_LOCK_NAME, type FromWorker, type ToWorker } from "./protocol";
|
|
|
|
/**
|
|
* The wasm is imported lazily, inside `boot()`, rather than at the top of the
|
|
* module. That keeps this script's own evaluation instant, so a tab's connect
|
|
* gets its `hello` immediately regardless of how long the model layer takes to
|
|
* download and compile — and the tab can therefore tell "this worker is dead"
|
|
* from "this worker is busy" with a short timeout.
|
|
*/
|
|
type Engine = typeof import("@yaakapp-internal/wasm");
|
|
let engine: Engine | null = null;
|
|
|
|
const ports = new Set<MessagePort>();
|
|
|
|
/** Resolves once `boot()` has, or rejects with why it couldn't. */
|
|
let booted: Promise<void> | null = null;
|
|
let bootError: string | null = null;
|
|
|
|
function send(port: MessagePort, message: FromWorker, transfer: Transferable[] = []): void {
|
|
port.postMessage(message, transfer);
|
|
}
|
|
|
|
function broadcast(message: FromWorker): void {
|
|
for (const port of ports) send(port, message);
|
|
}
|
|
|
|
function errorMessage(err: unknown): string {
|
|
if (err instanceof Error) return err.message;
|
|
return String(err);
|
|
}
|
|
|
|
/**
|
|
* How long to wait for the database lock before concluding someone else has it
|
|
* for good. The wait exists for one case: a tab reloading itself. Its old
|
|
* worker still holds the lock while it is torn down, and the new worker only
|
|
* needs it to let go — which takes milliseconds, not seconds. Anything longer
|
|
* is a live worker in another tab, and the honest answer is to say so.
|
|
*/
|
|
const LOCK_TIMEOUT_MS = 3000;
|
|
|
|
const ALREADY_OPEN =
|
|
"Yaak's database is held by another worker that isn't letting go. Close Yaak's other tabs and reload.";
|
|
|
|
/**
|
|
* Take the lock, or explain why not.
|
|
*
|
|
* Held for the life of the worker: the callback's promise never settles, so
|
|
* the browser keeps the lock until this worker is gone. Requested with a
|
|
* timeout rather than `ifAvailable`, so a dying predecessor's brief hold is
|
|
* waited out but a live one is reported.
|
|
*/
|
|
function acquireDatabaseLock(): Promise<void> {
|
|
// The tab checked for Web Locks before it ever connected; a worker without
|
|
// them would be a browser lying about its own features.
|
|
return new Promise<void>((resolve, reject) => {
|
|
navigator.locks
|
|
.request(DB_LOCK_NAME, { signal: AbortSignal.timeout(LOCK_TIMEOUT_MS) }, () => {
|
|
resolve();
|
|
return new Promise<void>(() => {});
|
|
})
|
|
.catch((err: unknown) => {
|
|
const name = (err as { name?: string } | null)?.name;
|
|
reject(name === "AbortError" || name === "TimeoutError" ? new Error(ALREADY_OPEN) : err);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Open the database, once, for everyone.
|
|
*
|
|
* Lock first, then load the model layer, then open — in that order, so a
|
|
* worker that will never own the database also never downloads and compiles
|
|
* the wasm for it.
|
|
*/
|
|
function bootOnce(): Promise<void> {
|
|
if (booted != null) return booted;
|
|
|
|
booted = (async () => {
|
|
await acquireDatabaseLock();
|
|
const loaded = await import("@yaakapp-internal/wasm");
|
|
await loaded.boot();
|
|
engine = loaded;
|
|
})();
|
|
|
|
booted.catch((err) => {
|
|
bootError = errorMessage(err);
|
|
});
|
|
|
|
return booted;
|
|
}
|
|
|
|
async function handle(port: MessagePort, message: ToWorker): Promise<void> {
|
|
if (message.type === "goodbye") {
|
|
ports.delete(port);
|
|
return;
|
|
}
|
|
|
|
// Every command waits for boot rather than the tab having to. Tabs post
|
|
// the moment they load; the port queues; this drains once the DB is open.
|
|
try {
|
|
await booted;
|
|
} catch {
|
|
send(port, { type: "error", id: message.id, message: bootError ?? "Database failed to open" });
|
|
return;
|
|
}
|
|
const { rpc, blob_get, blob_put, blob_delete, prepare_http_send } = engine!;
|
|
|
|
try {
|
|
switch (message.type) {
|
|
case "rpc": {
|
|
const outcome = rpc(message.cmd, message.payload, message.label) as {
|
|
result: unknown;
|
|
events: unknown[];
|
|
};
|
|
// Result first, to the caller; then the writes, to everyone including
|
|
// the caller. The store applies its own echo the same as any other
|
|
// window's, so it must arrive — and the caller's `await` resolving
|
|
// before its echo lands is fine, because it resolves on the same tick
|
|
// and the store reads on the next.
|
|
send(port, { type: "result", id: message.id, result: outcome.result });
|
|
if (outcome.events.length > 0) {
|
|
broadcast({ type: "event", event: "model_writes", payload: outcome.events });
|
|
}
|
|
return;
|
|
}
|
|
case "prepare_http_send": {
|
|
const prepared = await prepare_http_send(message.payload);
|
|
send(port, { type: "result", id: message.id, result: prepared });
|
|
return;
|
|
}
|
|
case "blob_get": {
|
|
const bytes = blob_get(message.blobId);
|
|
if (bytes == null) {
|
|
send(port, { type: "result", id: message.id, result: null });
|
|
} else {
|
|
// Copy into a fresh buffer we can transfer: the wasm's memory
|
|
// cannot leave the worker.
|
|
const out = new Uint8Array(bytes.byteLength);
|
|
out.set(bytes);
|
|
send(port, { type: "result", id: message.id, result: out.buffer }, [out.buffer]);
|
|
}
|
|
return;
|
|
}
|
|
case "blob_put": {
|
|
blob_put(message.blobId, new Uint8Array(message.bytes));
|
|
send(port, { type: "result", id: message.id, result: null });
|
|
return;
|
|
}
|
|
case "blob_delete": {
|
|
blob_delete(message.blobId);
|
|
send(port, { type: "result", id: message.id, result: null });
|
|
return;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
send(port, { type: "error", id: message.id, message: errorMessage(err) });
|
|
}
|
|
}
|
|
|
|
function attach(port: MessagePort): void {
|
|
ports.add(port);
|
|
port.onmessage = (e: MessageEvent<ToWorker>) => void handle(port, e.data);
|
|
port.start?.();
|
|
|
|
// Proof of life, before boot: the tab is timing this.
|
|
send(port, { type: "hello" });
|
|
|
|
bootOnce().then(
|
|
() => send(port, { type: "ready" }),
|
|
(err) => send(port, { type: "boot_error", message: errorMessage(err) }),
|
|
);
|
|
}
|
|
|
|
// Each tab arrives as a connect event with its own port.
|
|
(self as unknown as SharedWorkerGlobalScope).onconnect = (e: MessageEvent) => {
|
|
attach(e.ports[0]!);
|
|
};
|