Files
webmail/lib/store/worker/worker.ts
T
Linus Rath 687e7cc012 feat: move offline mode onto the encrypted local store
The IndexedDB cache, its crypto, the responder and the page-side sync
go. lib/offline keeps its structure, policy, wipes, sign-out gates and
notices; answers, the outbox and sync now live in lib/store, in a worker
behind lib/store/client/api.ts.

What users get besides that:
- every folder is kept, not just the Inbox and opened ones
- delete for good and empty trash work offline
- a change the server refuses, or that meets a message changed on
  another device, is shown instead of dropped
- a send whose response got lost is looked up by a Message-ID fixed when
  it was queued, then finished, submitted or sent again
- with the store on, a start paints from the local copy, then connects

An account the app drops by itself no longer wipes its data: only a
sign-out or an explicit removal does. The outbox of the old database
moves over once, in lib/offline/migrate-legacy.ts, and the old database
goes only after the store committed. `isNetworkFailure` now counts
timeouts and proxy 5xx. Sends and outbox requests are never answered
from the device.

Tests moved with the code: cache.test.ts and responder.test.ts are
replaced by lib/store/__tests__/, and review-fixes.test.ts, outbox.test.ts
and client-fallback.test.ts now run the real engine on node:sqlite.
Changed assertions: an uncertain send is looked up rather than left alone
forever, destroy and empty trash are queued rather than refused, and #9
(opened folders) became "a folder the server deleted goes", since all
folders are kept.
2026-11-03 05:54:39 +01:00

149 lines
4.7 KiB
TypeScript

/**
* The store worker. Every tab has one, parked on the owner lock, and only the
* holder loads the wasm and touches OPFS. The lock is held for the worker's
* whole life, so handing over means: close, pause the VFS, then let go.
*
* Built to public/store-worker.js by scripts/build-store-worker.mjs.
*/
import { WasmStorage } from '../adapters/wasm';
import { STORE_AREAS } from '../areas';
import { RPC_CHANNEL, OWNER_LOCK, type ChannelMessage, type PageToWorker, type WorkerToPage } from './protocol';
import { StoreHost } from './host';
const scope = self as unknown as {
postMessage(message: WorkerToPage): void;
addEventListener(type: 'message', listener: (event: MessageEvent<PageToWorker>) => void): void;
close(): void;
};
let clientId = '';
let moduleUrl = '';
let storage: WasmStorage | null = null;
let host: StoreHost | null = null;
let channel: BroadcastChannel | null = null;
let releaseLock: (() => void) | null = null;
function reply(id: string, result?: unknown): void {
scope.postMessage({ kind: 'result', id, result });
}
function replyError(id: string, error: unknown): void {
scope.postMessage({
kind: 'result',
id,
error: error instanceof Error ? error.message : String(error),
errorName: error instanceof Error ? error.name : undefined,
});
}
function requireHost(): StoreHost {
if (!host) throw new Error('This tab does not own the local store');
return host;
}
async function becomeOwner(): Promise<void> {
storage = await WasmStorage.install(moduleUrl);
host = new StoreHost({
areas: STORE_AREAS,
storage,
emit: (event) => {
scope.postMessage({ kind: 'event', event });
channel?.postMessage({ kind: 'event', event } satisfies ChannelMessage);
},
needAuth: (account) => scope.postMessage({ kind: 'need-auth', account }),
});
channel = new BroadcastChannel(RPC_CHANNEL);
channel.onmessage = async (event: MessageEvent<ChannelMessage>) => {
const message = event.data;
if (message.kind === 'who-owns') {
channel?.postMessage({ kind: 'owner-changed', owner: clientId } satisfies ChannelMessage);
return;
}
if (message.kind !== 'rpc') return;
try {
const result = await requireHost().call(message.method, message.params);
channel?.postMessage({ kind: 'reply', id: message.id, to: message.from, result } satisfies ChannelMessage);
} catch (error) {
channel?.postMessage({
kind: 'reply',
id: message.id,
to: message.from,
error: error instanceof Error ? error.message : String(error),
errorName: error instanceof Error ? error.name : undefined,
} satisfies ChannelMessage);
}
};
channel.postMessage({ kind: 'owner-changed', owner: clientId } satisfies ChannelMessage);
scope.postMessage({ kind: 'owner' });
}
async function release(): Promise<void> {
// The order matters: the OPFS handles have to be free before the lock is.
channel?.postMessage({ kind: 'owner-changed', owner: null } satisfies ChannelMessage);
await host?.closeAll();
host = null;
storage?.pause();
storage = null;
channel?.close();
channel = null;
releaseLock?.();
releaseLock = null;
}
scope.addEventListener('message', async (event) => {
const message = event.data;
switch (message.kind) {
case 'init':
clientId = message.clientId;
moduleUrl = message.moduleUrl;
void navigator.locks.request(OWNER_LOCK, () => new Promise<void>((resolve) => {
releaseLock = resolve;
becomeOwner().catch((error) => {
scope.postMessage({
kind: 'event',
event: { type: 'error', account: '', area: 'store', message: error instanceof Error ? error.message : String(error) },
});
resolve();
});
}));
break;
case 'open':
try {
await requireHost().openAccount(message.params);
reply(message.id);
} catch (error) {
replyError(message.id, error);
}
break;
case 'close-account':
try {
await requireHost().closeAccount(message.account);
reply(message.id);
} catch (error) {
replyError(message.id, error);
}
break;
case 'set-auth':
host?.setAuth(message.account, message.auth);
break;
case 'set-session':
host?.setSession(message.account, message.session);
break;
case 'set-scope':
host?.setScope(message.account, message.scope);
break;
case 'set-inverted':
host?.setInvertedKeywordSort(message.account, message.inverted);
break;
case 'release':
try {
await release();
reply(message.id);
} catch (error) {
replyError(message.id, error);
}
break;
}
});