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]>
This commit is contained in:
ekklesia
2026-08-27 16:08:34 -05:00
co-authored by Claude Opus 5
parent e36485bf99
commit ca3f37d5f9
22 changed files with 1726 additions and 29 deletions
+77 -7
View File
@@ -1,3 +1,4 @@
import { designToken } from "@ekklesia/design-tokens";
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { type ReactElement, useEffect, useState } from "react";
import {
@@ -7,6 +8,11 @@ import {
HostProtocolError,
} from "../host-protocol.js";
import { DisplayUiFrame } from "./DisplayUiFrame.js";
import {
type DisplayConnectivity,
type DisplayConnectivitySource,
watchDisplayConnectivity,
} from "./offline-status.js";
import { PairingScreen } from "./PairingScreen.js";
type DisplayState =
@@ -22,6 +28,9 @@ export interface DisplayAppProps {
readonly moduleId: string;
/** Test/composition seam only. Production uses the generic loader's native dynamic import. */
readonly importModule?: ModuleUiImporter;
/** Where connectivity and cache-fallback signals come from (ADR 0043). The browser entry point
* supplies the real ones; omitting it leaves a display that simply never reports being offline. */
readonly connectivity?: DisplayConnectivitySource;
}
const displayStyles = {
@@ -34,11 +43,11 @@ const displayStyles = {
},
message: {
alignItems: "center",
color: "#57534e",
color: designToken("text.secondary"),
display: "flex",
flexDirection: "column",
fontFamily: "system-ui, sans-serif",
gap: "0.75rem",
fontFamily: designToken("typography.family-sans"),
gap: designToken("spacing.md"),
height: "100%",
justifyContent: "center",
margin: 0,
@@ -48,15 +57,47 @@ const displayStyles = {
},
retry: {
background: "transparent",
border: "1px solid currentColor",
borderRadius: "0.25rem",
border: `${designToken("border.width")} solid currentColor`,
borderRadius: designToken("radius.md"),
color: "inherit",
cursor: "pointer",
font: "inherit",
padding: "0.375rem 0.75rem",
padding: `${designToken("spacing.xs")} ${designToken("spacing.md")}`,
},
offline: {
background: designToken("status.warning-background"),
borderRadius: designToken("radius.pill"),
color: designToken("text.warning"),
fontFamily: designToken("typography.family-sans"),
fontSize: designToken("typography.size-sm"),
insetBlockStart: designToken("spacing.md"),
insetInlineEnd: designToken("spacing.md"),
padding: `${designToken("spacing.2xs")} ${designToken("spacing.md")}`,
position: "absolute",
},
} as const;
/**
* A small, honest indicator: this screen is not currently in touch with Ekklesia, or is running
* from code it cached earlier.
*
* Deliberately unobtrusive and deliberately not an error. An unattended screen showing a full-page
* failure because a switch rebooted is worse than one quietly saying so in a corner, and the screen
* recovers on its own the moment connectivity returns.
*/
function OfflineIndicator(props: { readonly connectivity: DisplayConnectivity }): ReactElement {
const label = props.connectivity.usingFallback
? props.connectivity.fallbackKind === "artifact"
? "Offline — showing the last downloaded display code"
: "Offline — showing the last downloaded Ekklesia display"
: "Offline — waiting for Ekklesia";
return (
<div data-testid="display-offline" role="status" style={displayStyles.offline}>
{label}
</div>
);
}
function DisplayMessage(props: {
readonly children: string;
readonly testId: string;
@@ -85,7 +126,33 @@ function DisplayMessage(props: {
export function DisplayApp(props: DisplayAppProps): ReactElement {
const [state, setState] = useState<DisplayState>({ generation: 0, status: "loading" });
const [attempt, setAttempt] = useState(0);
const { config, importModule, moduleId } = props;
const [connectivity, setConnectivity] = useState<DisplayConnectivity>({
online: true,
usingFallback: false,
});
const { config, connectivity: connectivitySource, importModule, moduleId } = props;
useEffect(() => {
if (!connectivitySource) {
return;
}
const monitor = watchDisplayConnectivity(connectivitySource);
setConnectivity(monitor.current());
const unsubscribe = monitor.subscribe((next) => {
setConnectivity(next);
// Nobody presses Retry on a lobby television. When the browser says the network is back, the
// display resolves its descriptor again — which is also what stops a screen from sitting on
// cached code after the server returns, since the fresh descriptor names the current version
// and the network-first worker fetches it (ADR 0043).
if (next.online) {
setAttempt((current) => current + 1);
}
});
return () => {
unsubscribe();
monitor.close();
};
}, [connectivitySource]);
useEffect(() => {
const abort = new AbortController();
@@ -124,6 +191,9 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
return (
<div data-testid="display-host" style={displayStyles.host}>
{connectivity.online && !connectivity.usingFallback ? undefined : (
<OfflineIndicator connectivity={connectivity} />
)}
{state.status === "loading" ? (
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
) : undefined}
+23 -1
View File
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
import type { EkklesiaHostConfig } from "../host-protocol.js";
import { parseDisplayRoute } from "../host-protocol.js";
import { DisplayApp } from "./DisplayApp.js";
import { type DisplayConnectivitySource, registerDisplayServiceWorker } from "./offline-status.js";
declare const __EKKLESIA_API_BASE_URL__: string;
@@ -16,6 +17,27 @@ if (!container) {
throw new Error("The Ekklesia display host requires a #ekklesia-display element.");
}
/**
* Offline resilience is registered as an enhancement and never as a prerequisite (ADR 0043): a
* browser without service workers, or a policy that refuses the registration, leaves a display that
* works exactly as it did before. The promise is deliberately not awaited — a screen must render
* while its worker is still installing.
*/
void registerDisplayServiceWorker(
typeof navigator === "undefined" ? undefined : navigator.serviceWorker,
);
/** The two real browser sources the connectivity monitor reads. Passed as data so nothing below the
* entry point touches a global. */
const connectivity: DisplayConnectivitySource = {
addEventListener: (type, listener) => window.addEventListener(type, listener),
initialOnline: navigator.onLine,
removeEventListener: (type, listener) => window.removeEventListener(type, listener),
...(typeof navigator === "undefined" || !navigator.serviceWorker
? {}
: { serviceWorkerMessages: navigator.serviceWorker }),
};
let moduleId: string | undefined;
try {
moduleId = parseDisplayRoute(window.location.pathname);
@@ -31,7 +53,7 @@ createRoot(container).render(
<>
<EkklesiaUiStyles />
{moduleId ? (
<DisplayApp config={config} moduleId={moduleId} />
<DisplayApp config={config} connectivity={connectivity} moduleId={moduleId} />
) : (
<div
data-testid="display-route-invalid"
+278
View File
@@ -0,0 +1,278 @@
/**
* Host-owned offline resilience for the Ekklesia display (ADR 0043).
*
* A lobby screen is the one surface in this platform that is expected to survive its network. When a
* building's uplink drops and a kiosk reboots, the browser today shows *its own* error page: not the
* display shell, not an Ekklesia message, just a network failure with no Ekklesia in it at all. That
* is the problem this file exists to solve, and the scope is exactly that — **the shell and the
* declared code**.
*
* ## What is cached, and what is deliberately not
*
* Cached: the display page, the fixed display host script, and the versioned browser artifacts the
* display actually loaded (its module's display artifact, and any contributed renderer it mounted).
* All of them are code, all of them are already public bytes that confer no authority (ADR 0025,
* ADR 0041), and all of them are addressed by a URL that carries an exact version.
*
* **Never cached, under any condition:** the display descriptor, `/api/v1/principal`, any other
* platform or module API response, the realtime stream, pairing requests and responses, and every
* cross-origin request — which is what a signed object-delivery URL is (ADR 0031). Those are live
* state, permission decisions, or credentials, and a cached copy of one is a stale authorization
* waiting to be believed.
*
* The consequence is stated plainly rather than papered over: **a display cannot render module
* content while its network is down.** Mounting requires a descriptor, a descriptor is
* authorization-bearing, and it is never served from a cache — so a revoked device stays revoked and
* cached code can never manufacture current authorization. What the cache buys is that the screen
* shows *Ekklesia's* honest offline state instead of a browser error, and that the moment
* connectivity returns it resolves and mounts with nothing left to download. Module-owned offline
* content — a module persisting its own state and media — is the module's responsibility and is not
* part of this foundation.
*
* ## Network first, always
*
* Every cached kind is fetched from the network first and only falls back to the cache when that
* fetch fails. A kiosk therefore picks up new code the moment the server returns, and can never be
* trapped on an obsolete build — which is the failure mode a cache-first service worker on an
* unattended screen produces, and the one that requires physically visiting the screen to fix.
*
* ## Pure by construction
*
* Nothing here touches a global. Every capability — the cache storage, `fetch`, the clock, the way a
* status change reaches the page — arrives as {@link DisplayOfflineEnvironment}, so this logic is
* exercised directly by tests rather than through a service worker that a test runner cannot host.
* `sw.ts` is the thin wiring that supplies the real globals.
*/
/**
* Bumped whenever the *caching policy* changes in a way that makes previously stored entries
* unsafe or meaningless. Old caches are deleted on activation, so a policy change cannot leave a
* screen serving entries from rules that no longer apply.
*/
export const DISPLAY_CACHE_VERSION = 1;
export const DISPLAY_SHELL_CACHE = `ekklesia-display-shell-v${DISPLAY_CACHE_VERSION}`;
export const DISPLAY_ARTIFACT_CACHE = `ekklesia-display-artifacts-v${DISPLAY_CACHE_VERSION}`;
/** Every cache this file owns. Anything else beginning with the prefix is a superseded generation
* and is removed on activation. */
export const DISPLAY_CACHE_NAMES: readonly string[] = Object.freeze([
DISPLAY_SHELL_CACHE,
DISPLAY_ARTIFACT_CACHE,
]);
const DISPLAY_CACHE_PREFIX = "ekklesia-display-";
/** The display page prefix and the one fixed host script, both platform-owned and both spelled in
* this package's own `display.html`. Neither is derived from a module or from request input. */
export const DISPLAY_PAGE_PREFIX = "/display/";
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
export const DISPLAY_SERVICE_WORKER_PATH = "/display/sw.js";
/** Versioned browser artifacts: `/api/v1/module-ui/<module-id>/<version>[/...]`. Every URL under
* this prefix is compiled code whose identity the loader verifies before mounting (ADR 0025,
* ADR 0040, ADR 0041). */
const MODULE_ARTIFACT_PREFIX = "/api/v1/module-ui/";
/** What this worker will store, or `undefined` for everything else — which is passed through to the
* network untouched and never enters a cache. */
export type DisplayCacheKind = "shell" | "artifact";
/** Whether the shell or an artifact was served from the cache because the network failed. */
export interface DisplayOfflineStatus {
readonly usingFallback: boolean;
/** Which kind most recently fell back, for an honest indicator rather than a generic one. */
readonly kind?: DisplayCacheKind;
}
export interface DisplayCacheLike {
match(request: Request | string): Promise<Response | undefined>;
put(request: Request | string, response: Response): Promise<void>;
delete(request: Request | string): Promise<boolean>;
keys(): Promise<readonly Request[]>;
}
export interface DisplayCacheStorageLike {
open(cacheName: string): Promise<DisplayCacheLike>;
keys(): Promise<readonly string[]>;
delete(cacheName: string): Promise<boolean>;
}
export interface DisplayOfflineEnvironment {
readonly caches: DisplayCacheStorageLike;
readonly fetch: (request: Request) => Promise<Response>;
/** How a fallback reaches the page. The worker posts to its clients; a test records. */
readonly notify?: (status: DisplayOfflineStatus) => void;
}
/**
* Classifies a request, and refuses everything it does not explicitly recognize.
*
* An allowlist rather than a deny list, deliberately. A deny list has to be updated every time the
* platform gains an endpoint, and the failure mode of forgetting is a cached permission decision.
* Here the failure mode of forgetting is that a new kind of code is simply not cached.
*/
export function classifyDisplayRequest(
request: Request,
origin: string,
): DisplayCacheKind | undefined {
if (request.method !== "GET") {
return undefined;
}
let url: URL;
try {
url = new URL(request.url);
} catch {
return undefined;
}
// Cross-origin is never cached. A signed object-delivery URL is cross-origin and is a bearer
// capability with an expiry (ADR 0031); storing one would outlive its own lifetime.
if (url.origin !== origin) {
return undefined;
}
if (url.search !== "") {
// Nothing this worker caches is ever addressed with a query string, and a query is how live
// state is usually parameterized. Refusing them keeps the allowlist exact.
return undefined;
}
if (url.pathname === DISPLAY_SERVICE_WORKER_PATH) {
// The worker script is the browser's own business; caching it would be how a screen gets stuck
// on an obsolete worker.
return undefined;
}
if (url.pathname === DISPLAY_HOST_SCRIPT_PATH) {
return "shell";
}
if (url.pathname.startsWith(DISPLAY_PAGE_PREFIX) && request.mode === "navigate") {
return "shell";
}
if (url.pathname.startsWith(MODULE_ARTIFACT_PREFIX)) {
return "artifact";
}
return undefined;
}
function cacheNameFor(kind: DisplayCacheKind): string {
return kind === "shell" ? DISPLAY_SHELL_CACHE : DISPLAY_ARTIFACT_CACHE;
}
/**
* The module identity a versioned artifact URL carries: `/api/v1/module-ui/<module-id>/<version>`
* and anything below it. Returns `undefined` for a shape this worker does not recognize, so an
* unexpected URL is left alone rather than guessed at.
*/
function artifactIdentity(pathname: string): { module: string; version: string } | undefined {
const segments = pathname.slice(MODULE_ARTIFACT_PREFIX.length).split("/");
const [moduleSegment, versionSegment] = segments;
if (!moduleSegment || !versionSegment) {
return undefined;
}
return { module: moduleSegment, version: versionSegment };
}
/**
* Removes every stored artifact for the same module at a *different* version.
*
* Version-keyed URLs mean a new build lands beside the old one rather than replacing it, so without
* this an unattended screen accumulates every version it has ever run. Pruning is deliberately
* scoped to the one module whose artifact was just stored and deliberately keeps every entry for the
* current version — a module's display artifact and each contributed renderer it mounted are
* separate URLs under one version, and dropping any of them would half-cache a working display.
*/
export async function pruneSupersededArtifacts(
cache: DisplayCacheLike,
storedUrl: string,
): Promise<readonly string[]> {
const stored = new URL(storedUrl);
const identity = artifactIdentity(stored.pathname);
if (!identity) {
return [];
}
const removed: string[] = [];
for (const request of await cache.keys()) {
const candidate = new URL(request.url);
if (candidate.origin !== stored.origin) {
continue;
}
const other = artifactIdentity(candidate.pathname);
if (!other || other.module !== identity.module || other.version === identity.version) {
continue;
}
if (await cache.delete(request)) {
removed.push(request.url);
}
}
return removed;
}
/** Only a complete, successful, same-origin response is ever stored. A `304`, a `404`, a redirect,
* an opaque cross-origin response, and a `503` from an API mid-restart are all things a screen must
* never later replay as though they were the artifact. */
function isStorable(response: Response): boolean {
return response.ok && response.status === 200 && response.type !== "opaque";
}
/**
* Network first, cache as fallback — the one strategy, for both kinds.
*
* Returns `undefined` for anything unrecognized, which the worker turns into "let the browser do
* what it would have done anyway". That is the important default: this worker's *presence* must not
* change how any other request behaves.
*/
export async function handleDisplayFetch(
request: Request,
origin: string,
environment: DisplayOfflineEnvironment,
): Promise<Response | undefined> {
const kind = classifyDisplayRequest(request, origin);
if (!kind) {
return undefined;
}
const cache = await environment.caches.open(cacheNameFor(kind));
try {
const response = await environment.fetch(request);
if (isStorable(response)) {
// Stored as a clone so the live response reaches the page unread — the page must never wait
// for a cache write to finish rendering.
await cache.put(request, response.clone());
if (kind === "artifact") {
await pruneSupersededArtifacts(cache, request.url);
}
environment.notify?.({ usingFallback: false });
}
return response;
} catch {
const cached = await cache.match(request);
if (!cached) {
// Nothing was ever stored for this exact URL — a first visit during an outage, or a version
// this screen has never run. There is no honest answer, so the network failure propagates and
// the page renders its own failure state rather than a fabricated one.
environment.notify?.({ kind, usingFallback: true });
throw new TypeError("Ekklesia display request failed and nothing is cached for it.");
}
environment.notify?.({ kind, usingFallback: true });
return cached;
}
}
/**
* Deletes every cache this worker owns from a superseded generation.
*
* Run on activation. Only names carrying this worker's own prefix are considered, so a deployment
* that shares an origin with something else keeps its caches.
*/
export async function pruneSupersededCaches(
environment: DisplayOfflineEnvironment,
): Promise<readonly string[]> {
const current = new Set(DISPLAY_CACHE_NAMES);
const removed: string[] = [];
for (const name of await environment.caches.keys()) {
if (name.startsWith(DISPLAY_CACHE_PREFIX) && !current.has(name)) {
if (await environment.caches.delete(name)) {
removed.push(name);
}
}
}
return removed;
}
@@ -0,0 +1,155 @@
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;
}
}
+76
View File
@@ -0,0 +1,76 @@
/// <reference lib="webworker" />
import {
DISPLAY_CACHE_NAMES,
type DisplayOfflineEnvironment,
type DisplayOfflineStatus,
handleDisplayFetch,
pruneSupersededCaches,
} from "./offline-cache.js";
/**
* The Ekklesia display's service worker: thin wiring, and nothing else.
*
* Every decision lives in `offline-cache.ts`, which touches no global and is therefore exercised
* directly by tests. This file exists to hand that logic the real `caches`, the real `fetch`, and a
* way to reach the page — the three things a test runner cannot host a real worker to provide.
*
* Scope is `/display/`, because this script is served from `/display/sw.js`. It therefore controls
* display pages only: the Dashboard is never controlled by it, and no Dashboard request is ever
* intercepted. A controlled page's requests are intercepted wherever they point, which is how an
* artifact under `/api/v1/module-ui/` is cached without widening the worker's scope to the whole
* origin.
*/
declare const self: ServiceWorkerGlobalScope;
function broadcast(status: DisplayOfflineStatus): void {
void self.clients.matchAll({ type: "window" }).then((clients) => {
for (const client of clients) {
client.postMessage({ ekklesiaDisplayOffline: status });
}
});
}
const environment: DisplayOfflineEnvironment = {
caches: self.caches as unknown as DisplayOfflineEnvironment["caches"],
fetch: (request) => fetch(request),
notify: broadcast,
};
self.addEventListener("install", () => {
// Nothing is precached. A display's shell and artifacts are stored the first time they are
// genuinely fetched, so this worker never downloads code a screen has not been told to run, and an
// install can never fail because an asset a future version needs did not exist yet.
void self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
await pruneSupersededCaches(environment);
// Claim immediately so the first load after an update is already controlled, rather than
// leaving an unattended screen one reload behind its own worker.
await self.clients.claim();
})(),
);
});
self.addEventListener("fetch", (event) => {
const handled = handleDisplayFetch(event.request, self.location.origin, environment);
event.respondWith(
handled.then(
(response) =>
// `undefined` means this worker recognized nothing and must not change what the browser would
// have done. Re-issuing the request is the only way to say that from inside `respondWith`.
response ?? fetch(event.request),
),
);
});
self.addEventListener("message", (event) => {
if ((event.data as { readonly ekklesiaDisplayQuery?: unknown } | null)?.ekklesiaDisplayQuery) {
(event.source as Client | null)?.postMessage({
ekklesiaDisplayCaches: DISPLAY_CACHE_NAMES,
});
}
});