mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
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.
149 lines
4.7 KiB
TypeScript
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;
|
|
}
|
|
});
|