Files
Ekklesia/surfaces/shell/src/display/offline-status.ts
T
ekklesiaandClaude Opus 5 ca3f37d5f9 feat: let a display survive its network — as code, not as authorization
A display is the one surface expected to outlive its network: a television on
a wall in a building whose uplink is a consumer router, with nobody standing
in front of it. When that uplink drops and the screen reboots, the browser
shows its own error page — not the display shell, not an Ekklesia message,
nothing that recovers on its own, because there is no Ekklesia code left
running to recover.

Cache the shell and the declared code, in a service worker scoped to
`/display/`: the page, the host script, the module's display artifact, and
every contributed renderer that display actually mounted. All of them are code
whose bytes already confer no authority, and all of them are addressed by a
URL carrying an exact version.

Cache nothing else, ever. Not the descriptor, not `/api/v1/principal`, not a
module or platform API response, not the realtime stream, not a pairing
exchange, and not any cross-origin request — which is what a signed
object-delivery URL is. It is an allowlist rather than a deny list, so the
cost of forgetting a new endpoint is that code goes uncached rather than that
a permission decision gets stored.

The consequence is stated rather than papered over: a display cannot render
module content while offline. Mounting needs a descriptor, a descriptor is
authorization-bearing, and it is never cached — so a revoked device stays
revoked and cached code can never manufacture current authorization. What the
cache buys is that a kiosk rebooting mid-outage boots into Ekklesia's own
offline state and keeps retrying, that reconnecting mounts with nothing left
to download, and that a hiccup fetching 600 KB of artifact after the tiny
descriptor already resolved falls back to identical bytes instead of failing
the display.

Network-first everywhere, so a screen picks up new code the moment the server
returns and can never be trapped on an obsolete build — the failure mode a
cache-first worker produces on a screen nobody visits. Only a complete
same-origin 200 is stored. Cache names carry a policy version and superseded
generations are deleted; artifacts are pruned to the current version per
module, keeping every artifact belonging to that version because a display
artifact and its contributed renderers share one. The worker never caches
itself.

The page gets a small signal built from two sources, because either alone
lies: `navigator.onLine` is optimistic behind a captive portal, and only the
worker knows it actually served from cache. A display shows a quiet corner
indicator rather than blanking a lobby wall, and resolves its descriptor again
by itself when the network returns — nobody presses Retry on a lobby TV.

Every caching decision lives in a module that touches no global, so the
behaviour is exercised directly rather than through a worker a test runner
cannot host. Registration is an enhancement: no service workers, plain HTTP,
or a refusing policy all leave a display that works exactly as before.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 16:08:34 -05:00

156 lines
6.0 KiB
TypeScript

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<DisplayConnectivityListener>();
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<boolean> {
if (!container) {
return false;
}
try {
await container.register(DISPLAY_SERVICE_WORKER_URL, { type: "module" });
return true;
} catch {
return false;
}
}