import type { DisplayCacheKind, DisplayOfflineStatus } from "./offline-cache.js"; /** * The small browser-safe signal a display renders its offline state from (ADR 0043). * * Two independent sources, because they answer different questions and either one alone would lie: * * - `navigator.onLine` and the `online`/`offline` events say whether the browser believes it has a * network. Cheap and immediate, and famously optimistic — a captive portal or a dead uplink behind * a live Wi-Fi association both report `true`. * - The service worker says whether it actually had to serve something from its cache. That is * ground truth about this application specifically, and it is the only source that can tell a * working network from one that merely exists. * * Reporting both, rather than collapsing them, is what lets a display say "offline" honestly instead * of guessing. Neither is a security signal, and nothing here decides what a display may show — the * descriptor is still fetched from the network on every attempt, so a device whose access was * revoked is refused exactly as it always was. */ export interface DisplayConnectivity { /** What the browser believes about the network. Optimistic; never treated as proof. */ readonly online: boolean; /** Whether this application has actually served the shell or an artifact from its cache. */ readonly usingFallback: boolean; /** Which kind most recently fell back, so an indicator can be specific rather than generic. */ readonly fallbackKind?: DisplayCacheKind; } export type DisplayConnectivityListener = (connectivity: DisplayConnectivity) => void; /** * The browser capabilities this helper uses, named so a test supplies them directly. A display page * passes nothing and gets the real ones. */ export interface DisplayConnectivitySource { readonly initialOnline?: boolean; readonly addEventListener?: (type: "online" | "offline", listener: () => void) => void; readonly removeEventListener?: (type: "online" | "offline", listener: () => void) => void; /** Where service-worker messages arrive. `navigator.serviceWorker` in a browser. */ readonly serviceWorkerMessages?: { addEventListener(type: "message", listener: (event: MessageEvent) => void): void; removeEventListener(type: "message", listener: (event: MessageEvent) => void): void; }; } function isOfflineMessage( data: unknown, ): data is { readonly ekklesiaDisplayOffline: DisplayOfflineStatus } { if (typeof data !== "object" || data === null) { return false; } const status = (data as { readonly ekklesiaDisplayOffline?: unknown }).ekklesiaDisplayOffline; return typeof status === "object" && status !== null && "usingFallback" in status; } export interface DisplayConnectivityMonitor { current(): DisplayConnectivity; subscribe(listener: DisplayConnectivityListener): () => void; /** Detaches every listener. Idempotent. */ close(): void; } /** * Watches connectivity and cache fallback, and tells subscribers when either changes. * * Deliberately reports rather than acts: it never retries, never reloads, and never decides what a * display may render. A display that wants to retry does so through its own bootstrap, where the * descriptor and its authorization are resolved fresh. */ export function watchDisplayConnectivity( source: DisplayConnectivitySource = {}, ): DisplayConnectivityMonitor { let connectivity: DisplayConnectivity = { online: source.initialOnline ?? true, usingFallback: false, }; const listeners = new Set(); let closed = false; const publish = (next: DisplayConnectivity): void => { if ( next.online === connectivity.online && next.usingFallback === connectivity.usingFallback && next.fallbackKind === connectivity.fallbackKind ) { return; } connectivity = next; for (const listener of listeners) { listener(connectivity); } }; const onOnline = (): void => publish({ ...connectivity, online: true }); const onOffline = (): void => publish({ ...connectivity, online: false }); const onMessage = (event: MessageEvent): void => { if (!isOfflineMessage(event.data)) { return; } const status = event.data.ekklesiaDisplayOffline; publish({ online: connectivity.online, usingFallback: status.usingFallback, ...(status.usingFallback && status.kind ? { fallbackKind: status.kind } : {}), }); }; source.addEventListener?.("online", onOnline); source.addEventListener?.("offline", onOffline); source.serviceWorkerMessages?.addEventListener("message", onMessage); return { close(): void { if (closed) { return; } closed = true; listeners.clear(); source.removeEventListener?.("online", onOnline); source.removeEventListener?.("offline", onOffline); source.serviceWorkerMessages?.removeEventListener("message", onMessage); }, current: () => connectivity, subscribe(listener: DisplayConnectivityListener): () => void { listeners.add(listener); return () => listeners.delete(listener); }, }; } /** Where the display service worker is served from — the same fixed, platform-owned path the API * publishes it at. Its directory is its scope, so it controls display pages and nothing else. */ export const DISPLAY_SERVICE_WORKER_URL = "/display/sw.js"; /** * Registers the display service worker, and never lets failing to do so break the display. * * Offline resilience is an enhancement: a browser without service workers, a page served over plain * HTTP during development, and a registration a policy refused all leave a display that works * exactly as it did before — online only. Treating any of them as fatal would trade a real * capability for a hypothetical one. */ export async function registerDisplayServiceWorker( container: ServiceWorkerContainer | undefined, ): Promise { if (!container) { return false; } try { await container.register(DISPLAY_SERVICE_WORKER_URL, { type: "module" }); return true; } catch { return false; } }