/** * The page's way to the local store: one StoreClient per tab, accounts opened * while this tab is the owner, sessions and auth handed over. Knows nothing of * the app's stores: lib/offline/offline-mode.ts wires those in with * configureStore, so the worker bundle stays free of them. */ import { DEFAULT_SCOPE, type StoreEvent, type StoreScope } from '../area'; import type { JmapResponse, MethodCall, StoreSession } from '../engine/transport'; import { forgetAccountKey, getAccountRecord, openAccountKey, type WrapLayer } from './keys'; import { StoreClient } from './store-client'; export interface StoreAccountInputs { session: StoreSession | null; auth: string | null; scope?: StoreScope; invertedKeywordSort: boolean | null; } export interface StorePageHooks { /** What this tab knows about an account right now. */ inputs(account: string): StoreAccountInputs; /** Runs the client's refresh path; the fresh Authorization header, or null. */ refreshAuth(account: string): Promise; onEvent(event: StoreEvent): void; /** Wrap layers on top of the WebCrypto one (desktop keychain, app lock). */ keyLayers?(): readonly WrapLayer[]; } /** What lib/offline uses. A seam, so its tests run the real engine on node:sqlite. */ export interface StoreBackend { supported(): boolean; /** Asks for the account to be open. `session` is for a first open with no client yet. */ ensureOpen(account: string, session?: StoreSession | null): Promise; /** Hands the owner what this tab now knows: session, auth, sort polarity. */ refresh(account: string): void; answer(account: string, calls: MethodCall[]): Promise; area(account: string, name: string, params?: unknown): Promise; sync(account: string): Promise; noteStateChange(account: string, changed: Record>): Promise; setOnline(online: boolean): Promise; /** Closes the account, forgets its key and deletes its file. */ remove(account: string): Promise; /** Stops this tab's worker at once (a sign-out; the files go separately). */ stop(): void; } /** How long a call waits for the owner to have opened an account. */ const OPEN_RETRIES = 25; const OPEN_RETRY_MS = 200; class PageStore implements StoreBackend { private client: StoreClient | null = null; private readonly wanted = new Map(); private readonly opening = new Map>(); /** The engine can't start in this browser: don't keep every caller waiting for an owner. */ private broken = false; constructor(private readonly hooks: () => StorePageHooks | null) {} supported(): boolean { return StoreClient.supported(); } private started(): StoreClient { if (this.client) return this.client; this.broken = false; const client = new StoreClient({ basePath: (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, ''), buildId: process.env.NEXT_PUBLIC_BUILD_ID, onEvent: (event) => { if (event.type === 'error' && event.area === 'store' && !event.account) this.broken = true; this.hooks()?.onEvent(event); }, onOwner: () => { this.opening.clear(); for (const account of this.wanted.keys()) void this.openHere(account).catch(() => {}); }, onNeedAuth: (account) => { void (this.hooks()?.refreshAuth(account) ?? Promise.resolve(null)) .catch(() => null) .then((auth) => this.client?.setAuth(account, auth)); }, onHalted: () => this.reset(), }); client.start(); this.client = client; return client; } private reset(): void { this.client = null; this.wanted.clear(); this.opening.clear(); this.broken = false; } /** Only the owner may open: the key goes to this tab's own worker, never over the channel. */ private openHere(account: string): Promise { const running = this.opening.get(account); if (running) return running; const run = (async () => { const client = this.started(); const { keyHex, record } = await openAccountKey(account, this.hooks()?.keyLayers?.() ?? []); const inputs = this.hooks()?.inputs(account) ?? { session: null, auth: null, invertedKeywordSort: null }; await client.openAccount({ account, file: record.file, keyHex, session: inputs.session ?? this.wanted.get(account) ?? null, scope: inputs.scope ?? DEFAULT_SCOPE, invertedKeywordSort: inputs.invertedKeywordSort, auth: inputs.auth, }); })(); this.opening.set(account, run); run.catch(() => this.opening.delete(account)); return run; } async ensureOpen(account: string, session?: StoreSession | null): Promise { this.wanted.set(account, session ?? this.wanted.get(account) ?? null); const client = this.started(); if (client.ownsStore) await this.openHere(account); // Otherwise the owner opens it: calls below wait for that. } refresh(account: string): void { const client = this.client; if (!client?.ownsStore) return; const inputs = this.hooks()?.inputs(account); if (!inputs) return; if (inputs.session) client.setSession(account, inputs.session); client.setAuth(account, inputs.auth); if (inputs.invertedKeywordSort !== null) client.setInvertedKeywordSort(account, inputs.invertedKeywordSort); } /** A call for an account: the owner may still be opening it, in this tab or another. */ private async whenOpen(account: string, call: (client: StoreClient) => Promise): Promise { if (this.broken) throw new Error('The local store could not start'); await this.ensureOpen(account); for (let attempt = 0; ; attempt += 1) { try { return await call(this.started()); } catch (error) { if ((error as Error)?.name !== 'StoreAccountClosedError' || attempt >= OPEN_RETRIES) throw error; await new Promise((resolve) => setTimeout(resolve, OPEN_RETRY_MS)); } } } answer(account: string, calls: MethodCall[]): Promise { return this.whenOpen(account, (client) => client.answer(account, calls)); } area(account: string, name: string, params?: unknown): Promise { return this.whenOpen(account, (client) => client.area(account, name, params)); } sync(account: string): Promise { return this.whenOpen(account, (client) => client.sync(account)); } noteStateChange(account: string, changed: Record>): Promise { return this.whenOpen(account, (client) => client.noteStateChange(account, changed)); } async setOnline(online: boolean): Promise { if (!this.client || this.broken) return; await this.client.setOnline(online); } async remove(account: string): Promise { const record = await getAccountRecord(account); await forgetAccountKey(account); this.wanted.delete(account); this.opening.delete(account); if (record && !this.broken) await this.started().removeAccount(account, record.file); } stop(): void { this.client?.halt(); this.reset(); } } let hooks: StorePageHooks | null = null; let backend: StoreBackend | null = null; /** Registers what the store needs from the app. */ export function configureStore(next: StorePageHooks | null): void { hooks = next; } export function getStore(): StoreBackend { backend ??= new PageStore(() => hooks); return backend; } /** Tests run the real engine in-process. */ export function setStoreBackend(next: StoreBackend | null): void { backend = next; }