import type { SessionPortal } from '../environments/portalStorageKey'; export const CREDENTIAL_SCHEMA = 1; export interface CredentialIdentity { origin: string; stack: string; portal: SessionPortal; } export interface CredentialTuple { accessToken: string; idToken: string; refreshToken?: string; expiresAt: number; clientId: string; } export interface CommittedSession { epoch: string; generation: number; operationId: string; tuple: CredentialTuple; } export interface CredentialRecord { schema: number; key: string; identity: CredentialIdentity; generation: number; committed?: CommittedSession; login?: { ticket: string; epoch: string; clientId: string; at: number }; refresh?: { operationId: string; epoch: string; generation: number; at: number }; retired?: { epoch: string; at: number }; } /** Where a write took its place in the record's transaction order. */ export interface TransactPlacement { /** Placed during the `transact` call itself, so anything any tab begins after that call is ordered after it. */ atCall: boolean; } /** The persistence seam: the browser's IndexedDB in the app, an injected adapter in tests, sessionStorage in a demo tab. */ export interface CredentialStorage { /** One read-write transaction on the record; `change` is synchronous. Resolves only after durable completion. */ transact( key: string, change: (current: CredentialRecord | undefined, placed: TransactPlacement) => { next?: CredentialRecord; result: T }, ): Promise; read(key: string): Promise; /** A wakeup when another context wrote the record; the reread, not the message, is the truth. */ subscribe(key: string, wake: () => void): () => void; notify(key: string): void; } export type CredentialView = | { state: 'loading' } | { state: 'signed-out' } | { state: 'signed-in'; session: CommittedSession; refreshing?: 'elsewhere' | 'unknown' } | { state: 'unavailable' }; export interface LoginTicket { readonly clientId: string; readonly ticket: string; readonly epoch: string; } export interface RefreshReservation { readonly operationId: string; readonly epoch: string; readonly generation: number; readonly tuple: CredentialTuple; } export type RefreshClaim = | { kind: 'reserved'; reservation: RefreshReservation } | { kind: 'elsewhere' } | { kind: 'gone' }; export interface IssuedTokens { AccessToken?: string; IdToken?: string; RefreshToken?: string; ExpiresIn?: number; } /** The record could not be written or read back, so nothing was published as signed in. */ export class CredentialStorageUnavailable extends Error { constructor(message = 'This browser could not keep your sign-in. Allow site storage, reload, and sign in again.') { super(message); this.name = 'CredentialStorageUnavailable'; } } /** A newer login, sign-out or refresh owns the record now; this operation's result is dropped. */ export class CredentialSuperseded extends Error { constructor() { super('A newer sign-in or sign-out replaced this one.'); this.name = 'CredentialSuperseded'; } } /** The provider may have rotated the token but its commit was not confirmed; it is not retried with the old token. */ export class CredentialOutcomeUnknown extends Error { constructor() { super('Your sign-in could not be renewed on this browser. Sign in again to continue.'); this.name = 'CredentialOutcomeUnknown'; } } /** Sign-out could not be recorded for every tab; this tab has ended, the others may not have. */ export class SignOutUnconfirmed extends Error { constructor() { super('You are signed out here, but other tabs on this browser could not be told. Close them, or sign out there too.'); this.name = 'SignOutUnconfirmed'; } } export const credentialKey = (identity: CredentialIdentity): string => [CREDENTIAL_SCHEMA, identity.origin, identity.stack, identity.portal].join('|'); const newId = (): string => globalThis.crypto?.randomUUID?.() ?? Date.now().toString(36) + '-' + Math.random().toString(36).slice(2); export const tupleFrom = (tokens: IssuedTokens | undefined, clientId: string, now: number, refreshToken?: string): CredentialTuple => { if (!tokens?.AccessToken || !tokens.IdToken || !tokens.ExpiresIn || !clientId) { throw new Error('Missing required authentication token data.'); } const tuple: CredentialTuple = { accessToken: tokens.AccessToken, idToken: tokens.IdToken, expiresAt: now + tokens.ExpiresIn * 1000, clientId }; const refresh = tokens.RefreshToken ?? refreshToken; if (refresh) tuple.refreshToken = refresh; return tuple; }; const sameTuple = (a: CredentialTuple | undefined, b: CredentialTuple | undefined): boolean => Boolean(a && b && a.accessToken === b.accessToken && a.idToken === b.idToken && a.refreshToken === b.refreshToken && a.expiresAt === b.expiresAt && a.clientId === b.clientId); const valid = (record: CredentialRecord | undefined, key: string): CredentialRecord | undefined => record && record.schema === CREDENTIAL_SCHEMA && record.key === key ? record : undefined; const OWED_PREFIX = 'akashic_credential_signout_owed:'; const tabStorage = (): Storage | undefined => { try { return typeof sessionStorage === 'undefined' ? undefined : sessionStorage; } catch { return undefined; } }; const emptyRecord = (key: string, identity: CredentialIdentity): CredentialRecord => ({ schema: CREDENTIAL_SCHEMA, key, identity, generation: 0 }); /** What one sign-out ends by name: its committed session, and the sign-ins it knows were admitted before it. */ export interface Retirement { epoch?: string; tickets: string[]; /** A press whose place could not be proven: this tab stays signed out until it signs in again itself. */ unresolved?: boolean; } const owedFrom = (raw: string | null | undefined): Retirement | undefined => { if (!raw) return undefined; try { const parsed = JSON.parse(raw) as Partial; return { epoch: typeof parsed.epoch === 'string' ? parsed.epoch : undefined, tickets: Array.isArray(parsed.tickets) ? parsed.tickets.filter((t): t is string => typeof t === 'string') : [], ...(parsed.unresolved === true ? { unresolved: true } : {}), }; } catch { return { epoch: raw, tickets: [] }; } }; // Its own session, or one committed from a sign-in it named, whether that commit landed before or after the press. const retires = (target: Retirement, session: CommittedSession): boolean => session.epoch === target.epoch || target.tickets.includes(session.operationId); // Every write by any tab changes this, so an equal one means nothing was written in between. const fingerprint = (record: CredentialRecord | undefined): string => [record?.generation ?? 0, record?.login?.ticket, record?.committed?.operationId, record?.committed?.generation, record?.refresh?.operationId, record?.retired?.epoch].map((part) => part ?? '').join('|'); const ISSUED_KEPT = 16; /** * This page's view of one credential record. The in-memory view is filled only from a committed read, and every * writer asks the record, not the view, whether it still owns what it is about to write. */ export class CredentialCoordinator { readonly key: string; private view: CredentialView = { state: 'loading' }; private listeners = new Set<(view: CredentialView) => void>(); private bootstrapped?: Promise; private readonly ownRefreshes = new Set(); private unknownRefresh?: string; private pendingRetirement?: Retirement; private stopWake?: () => void; // Sign-ins this page admitted that have not committed, and the one the record last showed from any tab. private readonly ownLogins = new Set(); private seenLogin?: string; private seen = fingerprint(undefined); // Which committed session issued each recent access token, so a send re-admits the session it was given under. private readonly issued = new Map(); constructor( private readonly storage: CredentialStorage, readonly identity: CredentialIdentity, private readonly clock: () => number = Date.now, // Tab-local, so an unconfirmed sign-out survives this tab's own reload and is never shown signed in again. private readonly owed: Pick | undefined = tabStorage(), ) { this.key = credentialKey(identity); this.pendingRetirement = this.readOwed(); } private readOwed(): Retirement | undefined { try { return owedFrom(this.owed?.getItem(OWED_PREFIX + this.key)); } catch { return undefined; } } private writeOwed(target: Retirement | undefined): void { this.pendingRetirement = target; try { if (target) this.owed?.setItem(OWED_PREFIX + this.key, JSON.stringify(target)); else this.owed?.removeItem(OWED_PREFIX + this.key); } catch { // The in-memory mark still keeps this page signed out. } } /** Settles the first read, and any sign-out this tab still owes; nothing protected may render or send before it. */ ready(): Promise { if (!this.bootstrapped) { this.stopWake = this.storage.subscribe(this.key, () => { void this.reread(); }); const owed = this.pendingRetirement; this.bootstrapped = owed ? this.retire(owed.epoch, owed.tickets, { unresolved: owed.unresolved }).then(() => this.view, () => this.reread()) : this.reread(); } return this.bootstrapped; } current(): CredentialView { return this.view; } /** The committed epoch this page shows, or null. */ epoch(): string | null { return this.view.state === 'signed-in' ? this.view.session.epoch : null; } onChange(listener: (view: CredentialView) => void): () => void { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } /** Reads the record again and publishes it; a storage failure shows as unavailable, never as signed in. */ async reread(): Promise { let record: CredentialRecord | undefined; try { record = valid(await this.storage.read(this.key), this.key); } catch { this.publish({ state: 'unavailable' }); return this.view; } this.publish(this.viewOf(record)); return this.view; } /** * The committed session as the record holds it now, not as the last wakeup left it. Unreadable storage answers * `unreadable`, which is neither signed in nor signed out. */ async readSession(): Promise { let record: CredentialRecord | undefined; try { record = valid(await this.storage.read(this.key), this.key); } catch { return 'unreadable'; } const view = this.viewOf(record); this.publish(view); return view.state === 'signed-in' ? view.session : undefined; } /** The committed session the record holds now, including one this page still owes a retirement. */ async readOwner(): Promise { let record: CredentialRecord | undefined; try { record = valid(await this.storage.read(this.key), this.key); } catch { return 'unreadable'; } this.publish(this.viewOf(record)); return record?.committed; } /** Whether `epoch` is still the committed session in the record now; unreadable storage is never a yes. */ async stillCommitted(epoch: string): Promise { const session = await this.readSession(); return typeof session === 'object' && session.epoch === epoch; } /** The committed session a token handed out by this page was issued under, or null for any other token. */ epochOf(accessToken: string): string | null { return this.issued.get(accessToken) ?? null; } /** The sign-ins a sign-out pressed now must cancel: this page's own, and the one the record last showed. */ pendingLogins(): string[] { return [...new Set([...this.ownLogins, ...(this.seenLogin ? [this.seenLogin] : [])])]; } private viewOf(record: CredentialRecord | undefined): CredentialView { this.seenLogin = record?.login?.ticket; this.seen = fingerprint(record); const session = record?.committed; const owed = this.pendingRetirement; if (!session || (owed && (owed.unresolved || retires(owed, session)))) return { state: 'signed-out' }; const held = record?.refresh; if (held && held.epoch === session.epoch && held.generation === session.generation) { if (this.unknownRefresh === held.operationId) return { state: 'signed-in', session, refreshing: 'unknown' }; if (!this.ownRefreshes.has(held.operationId)) return { state: 'signed-in', session, refreshing: 'elsewhere' }; } return { state: 'signed-in', session }; } private publish(next: CredentialView): void { const before = this.view; this.view = next; if (next.state === 'signed-in') { this.issued.delete(next.session.tuple.accessToken); this.issued.set(next.session.tuple.accessToken, next.session.epoch); if (this.issued.size > ISSUED_KEPT) this.issued.delete(this.issued.keys().next().value as string); } const same = before.state === next.state && (before.state !== 'signed-in' || next.state !== 'signed-in' || (before.session.epoch === next.session.epoch && before.session.generation === next.session.generation && before.refreshing === next.refreshing)); if (same) return; this.listeners.forEach((listener) => { try { listener(next); } catch (error) { console.error('A credential listener failed:', error); } }); } private async write(change: (current: CredentialRecord, placed: TransactPlacement) => { next?: CredentialRecord; result: T }): Promise { const result = await this.storage.transact(this.key, (stored, placed) => change(valid(stored, this.key) ?? emptyRecord(this.key, this.identity), placed)); this.storage.notify(this.key); return result; } private async readBack(): Promise { try { return valid(await this.storage.read(this.key), this.key) ?? emptyRecord(this.key, this.identity); } catch { return undefined; } } /** * Reserves a new epoch before any provider call. A later admission supersedes this one; a session already * committed stays as the prior session, untouched, until this admission commits over it. */ async admitLogin(clientId: string): Promise { await this.ready(); if (this.pendingRetirement) await this.retire(this.pendingRetirement.epoch, this.pendingRetirement.tickets); const ticket: LoginTicket = { ticket: newId(), epoch: newId(), clientId }; this.ownLogins.add(ticket.ticket); try { await this.write((record) => ({ next: { ...record, generation: record.generation + 1, login: { ticket: ticket.ticket, epoch: ticket.epoch, clientId, at: this.clock() } }, result: undefined, })); } catch { this.ownLogins.delete(ticket.ticket); throw new CredentialStorageUnavailable(); } return ticket; } /** Whether this admission is still the latest; asked after each provider await. */ async loginCurrent(ticket: LoginTicket): Promise { try { return valid(await this.storage.read(this.key), this.key)?.login?.ticket === ticket.ticket; } catch { return false; } } /** Commits the complete tuple for this admission, reads it back, and only then publishes the session. */ async commitLogin(ticket: LoginTicket, tokens: IssuedTokens | undefined): Promise { // Kept as an admission of this page until the commit settles, so a sign-out pressed meanwhile cancels it. try { const tuple = tupleFrom(tokens, ticket.clientId, this.clock()); let committed: boolean; try { committed = await this.write((record) => { if (record.login?.ticket !== ticket.ticket) return { result: false }; const generation = record.generation + 1; const session: CommittedSession = { epoch: ticket.epoch, generation, operationId: ticket.ticket, tuple, }; return { next: { ...emptyRecord(this.key, this.identity), generation, committed: session }, result: true }; }); } catch { // A durable write whose answer was lost is found by the reread; anything else was not kept. committed = (await this.readBack())?.committed?.operationId === ticket.ticket; if (!committed) throw new CredentialStorageUnavailable(); } if (!committed) throw new CredentialSuperseded(); const back = await this.readBack(); if (!back) throw new CredentialStorageUnavailable(); this.publish(this.viewOf(back)); if (back.committed?.operationId !== ticket.ticket || !sameTuple(back.committed.tuple, tuple)) throw new CredentialSuperseded(); return back.committed; } finally { this.ownLogins.delete(ticket.ticket); } } /** * Claims the one refresh this committed tuple may spend. Another tab's claim, or one whose outcome is unknown, is * never expired here: its token may already be rotated, so this page waits or asks for a new sign-in instead. */ async reserveRefresh(epoch: string): Promise { const operationId = newId(); let claim: RefreshClaim; try { claim = await this.write((record): { next?: CredentialRecord; result: RefreshClaim } => { const session = record.committed; if (!session || session.epoch !== epoch) return { result: { kind: 'gone' } }; const held = record.refresh; if (held && held.epoch === epoch && held.generation === session.generation) return { result: { kind: 'elsewhere' } }; return { next: { ...record, generation: record.generation + 1, refresh: { operationId, epoch, generation: session.generation, at: this.clock() } }, result: { kind: 'reserved', reservation: { operationId, epoch, generation: session.generation, tuple: session.tuple } }, }; }); } catch { throw new CredentialStorageUnavailable(); } if (claim.kind === 'reserved') this.ownRefreshes.add(operationId); await this.reread(); return claim; } /** Whether a reservation still owns its tuple; asked before the token is sent and after each wait. */ async refreshCurrent(reservation: RefreshReservation): Promise { try { const record = valid(await this.storage.read(this.key), this.key); return record?.committed?.epoch === reservation.epoch && record.committed.generation === reservation.generation && record.refresh?.operationId === reservation.operationId; } catch { return false; } } /** * Commits the whole replacement tuple. An omitted refresh token keeps only this reservation's own original. A commit * or read-back that cannot be confirmed is settled by a reread, and otherwise stays unknown with the claim held. */ async commitRefresh(reservation: RefreshReservation, tokens: IssuedTokens | undefined): Promise { let tuple: CredentialTuple; try { tuple = tupleFrom(tokens, reservation.tuple.clientId, this.clock(), reservation.tuple.refreshToken); } catch { // An incomplete answer may still have rotated the token, so its claim is kept and never spent again. this.ownRefreshes.delete(reservation.operationId); this.unknownRefresh = reservation.operationId; await this.reread(); throw new CredentialOutcomeUnknown(); } let written: boolean; try { written = await this.write((record) => { const session = record.committed; if (!session || session.epoch !== reservation.epoch || session.generation !== reservation.generation || record.refresh?.operationId !== reservation.operationId) return { result: false }; const generation = record.generation + 1; const { refresh: _spent, ...rest } = record; return { next: { ...rest, generation, committed: { epoch: session.epoch, generation, operationId: reservation.operationId, tuple } }, result: true }; }); } catch { return this.settleUnconfirmed(reservation, tuple); } this.ownRefreshes.delete(reservation.operationId); if (!written) { await this.reread(); throw new CredentialSuperseded(); } const back = await this.readBack(); if (!back) return this.settleUnconfirmed(reservation, tuple); this.publish(this.viewOf(back)); if (back.committed?.operationId !== reservation.operationId || !sameTuple(back.committed.tuple, tuple)) throw new CredentialSuperseded(); return back.committed; } private async settleUnconfirmed(reservation: RefreshReservation, tuple: CredentialTuple): Promise { this.ownRefreshes.delete(reservation.operationId); const back = await this.readBack(); if (back?.committed?.operationId === reservation.operationId && sameTuple(back.committed.tuple, tuple)) { this.publish(this.viewOf(back)); return back.committed; } const replaced = back && (back.committed?.epoch !== reservation.epoch || back.committed.generation !== reservation.generation || back.refresh?.operationId !== reservation.operationId); if (replaced) { this.publish(this.viewOf(back)); throw new CredentialSuperseded(); } this.unknownRefresh = reservation.operationId; if (back) this.publish(this.viewOf(back)); throw new CredentialOutcomeUnknown(); } /** * Keeps a claim whose token went to the provider with no verdict back: it may have rotated, so no tab spends it * again, and the claim stays in the record until this session ends. */ async holdUnknown(reservation: RefreshReservation): Promise { this.ownRefreshes.delete(reservation.operationId); this.unknownRefresh = reservation.operationId; await this.reread(); } /** Lets go of a claim whose token was never handed to the provider; the committed tuple stays exactly as it was. */ async releaseRefresh(reservation: RefreshReservation): Promise { this.ownRefreshes.delete(reservation.operationId); try { await this.write((record) => { if (record.refresh?.operationId !== reservation.operationId) return { result: undefined }; const { refresh: _released, ...rest } = record; return { next: { ...rest, generation: record.generation + 1 }, result: undefined }; }); } catch { // Left claimed: no tab may spend this tuple again, so the next request asks for a new sign-in. this.unknownRefresh = reservation.operationId; } await this.reread(); } /** * Retires exactly this epoch, and cancels exactly these admitted sign-ins, for every tab before anything announces * it; answers the tuple it held so only that session's own client and token are revoked. A sign-in admitted after * the sign-out was pressed, and any newer session, are left alone. */ async retire( epoch: string | undefined, tickets: string[] = [], options: { pressed?: boolean; unresolved?: boolean } = {}, ): Promise { let held: CommittedSession | undefined; let unplaced = false; const target: Retirement = { ...(epoch ? { epoch } : {}), tickets, ...(options.unresolved ? { unresolved: true } : {}) }; const seenAtPress = this.seen; // Owed before the write starts, so a reload that cuts it short still finds this tab signed out. this.writeOwed(target); tickets.forEach((ticket) => this.ownLogins.delete(ticket)); if (this.view.state === 'signed-in' && this.view.session.epoch === epoch) this.publish({ state: 'signed-out' }); try { held = await this.write((record, placed) => { const session = record.committed; // A pressed sign-out placed in the record's order at the press: a sign-in pending there, or one that // committed ahead of it, was admitted before the press from some tab, and one admitted after it is // ordered later and left alone. Placed only after an await, it holds that place only if nothing was // written since this tab last read; otherwise, like a retry after a reload, it ends only what it named. const atPress = Boolean(options.pressed && (placed.atCall || fingerprint(record) === seenAtPress)); unplaced = Boolean(options.pressed && !atPress); const ends = Boolean(session && (retires(target, session) || atPress)); const cancels = Boolean(record.login && (tickets.includes(record.login.ticket) || atPress)); if (!ends && !cancels) return { result: undefined }; const generation = record.generation + 1; const { login, ...rest } = record; const keptLogin = login && !cancels ? { login } : {}; const next: CredentialRecord = ends ? { ...emptyRecord(this.key, this.identity), generation, retired: { epoch: (session as CommittedSession).epoch, at: this.clock() }, ...keptLogin } : { ...rest, generation }; return { next, result: ends ? session : undefined }; }); } catch { this.writeOwed(target); this.publish({ state: 'signed-out' }); throw new SignOutUnconfirmed(); } if (unplaced) { // What was written meanwhile may have been admitted before the press or after it, so it is neither ended // nor shown here: the sign-out stays owed and unconfirmed until this tab signs in again. this.writeOwed({ ...target, unresolved: true }); this.publish({ state: 'signed-out' }); throw new SignOutUnconfirmed(); } if (this.pendingRetirement === target && !target.unresolved) this.writeOwed(undefined); if (held) this.unknownRefresh = undefined; await this.reread(); return held; } /** A sign-out pressed now: its session, and any sign-in pending or committed in the record at the press. */ signOut(epoch: string | undefined, tickets: string[] = []): Promise { return this.retire(epoch, tickets, { pressed: true }); } /** Resolves at the next published change or after `ms`, whichever is first; the caller rereads either way. */ nextChange(ms: number): Promise { return new Promise((resolve) => { let stop = (): void => undefined; const timer = setTimeout(() => { stop(); resolve(); }, ms); stop = this.onChange(() => { clearTimeout(timer); stop(); resolve(); }); }); } /** Whether a sign-out this page could not record is still owed; the next login settles it first. */ retirementPending(): boolean { return this.pendingRetirement !== undefined; } dispose(): void { this.stopWake?.(); this.listeners.clear(); } } const DB_NAME = 'akashic-credentials'; const DB_VERSION = 1; const STORE = 'records'; const CHANNEL = 'akashic-credentials'; const openDatabase = (): Promise => new Promise((resolve, reject) => { if (typeof indexedDB === 'undefined') { reject(new Error('IndexedDB unavailable')); return; } const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { if (!request.result.objectStoreNames.contains(STORE)) request.result.createObjectStore(STORE); }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error ?? new Error('IndexedDB open failed')); request.onblocked = () => reject(new Error('IndexedDB open blocked')); }); /** * The browser adapter: one short read-write transaction per write, resolved on the transaction's completion. Its * connection is opened on the first read and kept, so a write made while it is open is created in the call itself. */ export const indexedDbCredentialStorage = (): CredentialStorage => { let connection: IDBDatabase | undefined; let opening: Promise | undefined; const connect = (): Promise => { if (connection) return Promise.resolve(connection); opening ??= openDatabase().then((db) => { const drop = () => { if (connection === db) connection = undefined; }; db.onversionchange = () => { drop(); db.close(); }; db.onclose = drop; connection = db; opening = undefined; return db; }, (error) => { opening = undefined; throw error; }); return opening; }; // Creates the transaction before returning, which is what places it in the order of every tab's writes. const run = ( db: IDBDatabase, key: string, change: (current: CredentialRecord | undefined, placed: TransactPlacement) => { next?: CredentialRecord; result: T }, placed: TransactPlacement, ): Promise => { const tx = db.transaction(STORE, 'readwrite'); return new Promise((resolve, reject) => { const store = tx.objectStore(STORE); let result: T; const got = store.get(key); got.onsuccess = () => { try { const out = change(got.result as CredentialRecord | undefined, placed); result = out.result; if (out.next) store.put(out.next, key); } catch (error) { tx.abort(); reject(error); } }; // Only the transaction's completion means the record is durable; a put's own success does not. tx.oncomplete = () => resolve(result); tx.onabort = () => reject(tx.error ?? new Error('IndexedDB write aborted')); tx.onerror = () => reject(tx.error ?? new Error('IndexedDB write failed')); }); }; let channel: BroadcastChannel | undefined; const open = (): BroadcastChannel | undefined => { try { channel ??= typeof BroadcastChannel === 'undefined' ? undefined : new BroadcastChannel(CHANNEL); } catch { channel = undefined; } return channel; }; return { transact(key, change) { const live = connection; if (live) { try { return run(live, key, change, { atCall: true }); } catch { // Closing under this call: the write is placed after the reopen instead, and says so. if (connection === live) connection = undefined; } } return connect().then((db) => run(db, key, change, { atCall: false })); }, async read(key) { const db = await connect(); return new Promise((resolve, reject) => { const request = db.transaction(STORE, 'readonly').objectStore(STORE).get(key); request.onsuccess = () => resolve(request.result as CredentialRecord | undefined); request.onerror = () => reject(request.error ?? new Error('IndexedDB read failed')); }); }, subscribe(key, wake) { const listen = open(); const onMessage = (event: MessageEvent) => { if (event.data === key) wake(); }; const onReturn = () => { if (typeof document === 'undefined' || document.visibilityState === 'visible') wake(); }; listen?.addEventListener('message', onMessage); if (typeof window !== 'undefined') { window.addEventListener('focus', onReturn); window.addEventListener('pageshow', onReturn); } if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onReturn); return () => { listen?.removeEventListener('message', onMessage); if (typeof window !== 'undefined') { window.removeEventListener('focus', onReturn); window.removeEventListener('pageshow', onReturn); } if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onReturn); }; }, notify(key) { try { open()?.postMessage(key); } catch { // A lost wakeup is repaired by the next focus or visibility reread. } }, }; };