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
+3 -1
View File
@@ -67,7 +67,9 @@ pnpm infrastructure:config
- Dedicated displays are module-owned renderers mounted by a separate full-viewport surface at - Dedicated displays are module-owned renderers mounted by a separate full-viewport surface at
`/display/<module-id>`, never Dashboard UI with hidden chrome. Its page and descriptor require the `/display/<module-id>`, never Dashboard UI with hidden chrome. Its page and descriptor require the
module-owned, non-public permission declared by `entrypoints.display`; artifact bytes remain module-owned, non-public permission declared by `entrypoints.display`; artifact bytes remain
anonymous trusted code. Public/device identity, display inventory, and variants are deferred. anonymous trusted code. Public/device identity, display inventory, and variants are deferred. A
display-scoped service worker caches the shell and declared artifacts only, network-first, and
never a descriptor, an API response, or anything else authorization-bearing (ADR 0043).
- Dashboard groups are presentation configuration, never module structure. Do not add a parent - Dashboard groups are presentation configuration, never module structure. Do not add a parent
module, child module, owning module, or group-derived dependency: real module relationships are module, child module, owning module, or group-derived dependency: real module relationships are
the dependency contract's job alone, and grouping must never influence discovery, dependency the dependency contract's job alone, and grouping must never influence discovery, dependency
+16 -5
View File
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets"; import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
/** /**
* The two immutable, platform-owned files that make up Ekklesia's display browser host. * The three immutable, platform-owned files that make up Ekklesia's display browser host.
* *
* They are read once, before the HTTP listener is bound, from the exact URLs exported by the * They are read once, before the HTTP listener is bound, from the exact URLs exported by the
* surface package. Request input is never involved in this read and the package is never exposed as * surface package. Request input is never involved in this read and the package is never exposed as
@@ -14,6 +14,10 @@ export interface DisplayHostAssets {
readonly html: string; readonly html: string;
readonly script: string; readonly script: string;
readonly scriptEtag: string; readonly scriptEtag: string;
/** The display's offline service worker (ADR 0043) — a third fixed, platform-owned file, served
* at a path whose directory is its scope so it controls display pages and nothing else. */
readonly serviceWorker: string;
readonly serviceWorkerEtag: string;
} }
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package /** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
@@ -36,18 +40,24 @@ export class DisplayHostAssetError extends Error {
* cannot accidentally pair bytes from one build with an ETag from another. Empty output is a * cannot accidentally pair bytes from one build with an ETag from another. Empty output is a
* failed host build rather than a valid page that can never mount anything. * failed host build rather than a valid page that can never mount anything.
*/ */
export function createDisplayHostAssets(html: string, script: string): DisplayHostAssets { export function createDisplayHostAssets(
if (html.trim().length === 0 || script.trim().length === 0) { html: string,
script: string,
serviceWorker: string,
): DisplayHostAssets {
if (html.trim().length === 0 || script.trim().length === 0 || serviceWorker.trim().length === 0) {
throw new DisplayHostAssetError(); throw new DisplayHostAssetError();
} }
return Object.freeze({ return Object.freeze({
html, html,
script, script,
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`, scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
serviceWorker,
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
}); });
} }
/** Loads only the two surface-declared files. A missing or unreadable build fails startup rather /** Loads only the three surface-declared files. A missing or unreadable build fails startup rather
* than leaving a display route that can authorize successfully and then return a broken shell. */ * than leaving a display route that can authorize successfully and then return a broken shell. */
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> { export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
try { try {
@@ -55,7 +65,8 @@ export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
// rejected sibling promise is left running after this function has already failed. // rejected sibling promise is left running after this function has already failed.
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8"); const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
const script = await readFile(new URL(displayHostAssetUrls.script), "utf8"); const script = await readFile(new URL(displayHostAssetUrls.script), "utf8");
return createDisplayHostAssets(html, script); const serviceWorker = await readFile(new URL(displayHostAssetUrls.serviceWorker), "utf8");
return createDisplayHostAssets(html, script, serviceWorker);
} catch { } catch {
throw new DisplayHostAssetError(); throw new DisplayHostAssetError();
} }
+44 -2
View File
@@ -21,6 +21,18 @@ import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays"; export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
export const MODULE_DISPLAY_PAGE_PREFIX = "/display"; export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js"; export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
/**
* Where the display's offline service worker is served (ADR 0043).
*
* The path is chosen for its **directory**: a service worker's default scope is the directory it was
* served from, so `/display/sw.js` controls display pages and nothing else. Serving it from
* `/assets/…` beside the host script would scope it to `/assets/`, where no page lives, and widening
* it with `Service-Worker-Allowed` would put the Dashboard under a worker it has no use for.
*
* `sw.js` cannot collide with a display page: a display page is registered at the exact literal
* `/display/<module-id>` for each display-capable module, and `sw.js` is not a valid module id.
*/
export const DISPLAY_SERVICE_WORKER_PATH = "/display/sw.js";
export function moduleDisplayPageUrl(moduleId: string): string { export function moduleDisplayPageUrl(moduleId: string): string {
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`; return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
@@ -107,8 +119,11 @@ function etagMatches(headerValue: string | undefined, etag: string): boolean {
* bytes with a stale validator. */ * bytes with a stale validator. */
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets { function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
try { try {
const snapshot = createDisplayHostAssets(input.html, input.script); const snapshot = createDisplayHostAssets(input.html, input.script, input.serviceWorker);
if (snapshot.scriptEtag !== input.scriptEtag) { if (
snapshot.scriptEtag !== input.scriptEtag ||
snapshot.serviceWorkerEtag !== input.serviceWorkerEtag
) {
throw new ModuleDisplayConfigurationError(); throw new ModuleDisplayConfigurationError();
} }
return snapshot; return snapshot;
@@ -252,6 +267,33 @@ export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutes
}); });
}); });
/**
* The worker itself is `no-cache` with a strong validator, exactly like the host script: a browser
* re-checks it on its own schedule, and the worker deliberately never caches *itself*, which is
* how a screen avoids being trapped on an obsolete worker.
*
* Like the host script and module artifact bytes, fetching this code grants no authority. It
* caches only public code and never a permission decision, so serving it anonymously withholds
* nothing the platform was protecting.
*/
app.get(DISPLAY_SERVICE_WORKER_PATH, (c) => {
if (etagMatches(c.req.header("if-none-match"), assets.serviceWorkerEtag)) {
return new Response(null, {
headers: { "Cache-Control": "no-cache", ETag: assets.serviceWorkerEtag },
status: 304,
});
}
return new Response(assets.serviceWorker, {
headers: {
"Cache-Control": "no-cache",
"Content-Type": "text/javascript; charset=utf-8",
ETag: assets.serviceWorkerEtag,
"X-Content-Type-Options": "nosniff",
},
status: 200,
});
});
for (const display of displays) { for (const display of displays) {
mountDisplay(app, { ...options, assets }, display); mountDisplay(app, { ...options, assets }, display);
} }
+18 -7
View File
@@ -8,6 +8,7 @@ vi.mock("@ekklesia/shell/display-assets", () => ({
displayHostAssetUrls: Object.freeze({ displayHostAssetUrls: Object.freeze({
html: "file:///surface/browser/display/index.html", html: "file:///surface/browser/display/index.html",
script: "file:///surface/browser/display/app.js", script: "file:///surface/browser/display/app.js",
serviceWorker: "file:///surface/browser/display/sw.js",
}), }),
})); }));
@@ -20,21 +21,28 @@ import {
describe("display host asset loading", () => { describe("display host asset loading", () => {
beforeEach(() => readFileMock.mockReset()); beforeEach(() => readFileMock.mockReset());
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => { it("reads only the three declared file URLs in order and freezes one consistent snapshot", async () => {
const html = '<!doctype html><div id="ekklesia-display"></div>'; const html = '<!doctype html><div id="ekklesia-display"></div>';
const script = "export const displayHost = true;\n"; const script = "export const displayHost = true;\n";
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script); const serviceWorker = "export const displayWorker = true;\n";
readFileMock
.mockResolvedValueOnce(html)
.mockResolvedValueOnce(script)
.mockResolvedValueOnce(serviceWorker);
const assets = await loadDisplayHostAssets(); const assets = await loadDisplayHostAssets();
expect(readFileMock.mock.calls).toEqual([ expect(readFileMock.mock.calls).toEqual([
[new URL("file:///surface/browser/display/index.html"), "utf8"], [new URL("file:///surface/browser/display/index.html"), "utf8"],
[new URL("file:///surface/browser/display/app.js"), "utf8"], [new URL("file:///surface/browser/display/app.js"), "utf8"],
[new URL("file:///surface/browser/display/sw.js"), "utf8"],
]); ]);
expect(assets).toEqual({ expect(assets).toEqual({
html, html,
script, script,
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`, scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
serviceWorker,
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
}); });
expect(Object.isFrozen(assets)).toBe(true); expect(Object.isFrozen(assets)).toBe(true);
}); });
@@ -62,12 +70,15 @@ describe("display host asset loading", () => {
} }
}); });
it("rejects an empty page or script as an unavailable host build", () => { it("rejects an empty page, script, or service worker as an unavailable host build", () => {
for (const [html, script] of [ for (const [html, script, serviceWorker] of [
["", "export {};"], ["", "export {};", "export {};"],
["<html></html>", " \n"], ["<html></html>", " \n", "export {};"],
["<html></html>", "export {};", " \n"],
] as const) { ] as const) {
expect(() => createDisplayHostAssets(html, script)).toThrow(DisplayHostAssetError); expect(() => createDisplayHostAssets(html, script, serviceWorker)).toThrow(
DisplayHostAssetError,
);
} }
}); });
}); });
+58 -1
View File
@@ -28,6 +28,7 @@ import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js"; import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
import { import {
DISPLAY_HOST_SCRIPT_PATH, DISPLAY_HOST_SCRIPT_PATH,
DISPLAY_SERVICE_WORKER_PATH,
ModuleDisplayConfigurationError, ModuleDisplayConfigurationError,
moduleDisplayDescriptorUrl, moduleDisplayDescriptorUrl,
moduleDisplayPageUrl, moduleDisplayPageUrl,
@@ -51,6 +52,7 @@ const displayPermission = "announcements.display.view";
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets( const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
`<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`, `<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`,
"globalThis.__ekklesiaDisplayHostLoaded = true;\n", "globalThis.__ekklesiaDisplayHostLoaded = true;\n",
"globalThis.__ekklesiaDisplayWorkerLoaded = true;\n",
); );
const temporaryDirectories: string[] = []; const temporaryDirectories: string[] = [];
@@ -477,7 +479,11 @@ describe("module display host asset publication", () => {
}); });
it("snapshots injected assets and rejects a validator that does not describe its script", async () => { it("snapshots injected assets and rejects a validator that does not describe its script", async () => {
const source = createDisplayHostAssets("<html>display host</html>", "export const host = 1;\n"); const source = createDisplayHostAssets(
"<html>display host</html>",
"export const host = 1;\n",
"export const worker = 1;\n",
);
const mutable = { ...source }; const mutable = { ...source };
const { app } = await install({ assets: mutable, display: true }); const { app } = await install({ assets: mutable, display: true });
mutable.html = "<html>mutated secret</html>"; mutable.html = "<html>mutated secret</html>";
@@ -498,4 +504,55 @@ describe("module display host asset publication", () => {
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404); expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
}); });
it("serves the offline service worker from the path whose directory is its scope", async () => {
const { app } = await install({ display: true });
const response = await app.request(DISPLAY_SERVICE_WORKER_PATH);
// `/display/sw.js`, so the worker's default scope is `/display/` — display pages and nothing
// else. The Dashboard is never controlled by it (ADR 0043).
expect(DISPLAY_SERVICE_WORKER_PATH).toBe("/display/sw.js");
expect(response.status).toBe(200);
expect(await response.text()).toBe(displayHostAssets.serviceWorker);
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
// `no-cache` with a strong validator, exactly like the host script: a browser re-checks the
// worker on its own schedule, which is how a screen avoids being trapped on an obsolete one.
expect(response.headers.get("cache-control")).toBe("no-cache");
expect(response.headers.get("etag")).toBe(displayHostAssets.serviceWorkerEtag);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
it("revalidates the service worker with its own validator, never the host script's", async () => {
const { app } = await install({ display: true });
const notModified = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
headers: { "if-none-match": displayHostAssets.serviceWorkerEtag },
});
expect(notModified.status).toBe(304);
const wrongValidator = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
headers: { "if-none-match": displayHostAssets.scriptEtag },
});
expect(wrongValidator.status).toBe(200);
});
it("does not publish the service worker when no eligible display uses it", async () => {
const { app } = await install({ ui: true, withAssets: false });
expect((await app.request(DISPLAY_SERVICE_WORKER_PATH)).status).toBe(404);
});
it("cannot be reached through a neighbouring or request-selected path", async () => {
const { app } = await install({ display: true });
for (const candidate of [
`${DISPLAY_SERVICE_WORKER_PATH}.map`,
"/display/sw.js/",
"/sw.js",
"/assets/ekklesia-display/sw.js",
]) {
expect((await app.request(candidate)).status, candidate).toBe(404);
}
});
}); });
@@ -278,6 +278,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
displayHostAssets: createDisplayHostAssets( displayHostAssets: createDisplayHostAssets(
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>', '<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
"export const displayHost = true;\n", "export const displayHost = true;\n",
"export const displayWorker = true;\n",
), ),
}), }),
moduleLifecycle: { moduleLifecycle: {
+1
View File
@@ -240,6 +240,7 @@ async function install(
displayHostAssets: createDisplayHostAssets( displayHostAssets: createDisplayHostAssets(
'<!doctype html><div id="display-root"></div>', '<!doctype html><div id="display-root"></div>',
"export {};\n", "export {};\n",
"export {};\n",
), ),
} }
: {}), : {}),
@@ -0,0 +1,173 @@
# 0043: The display caches its shell and declared code, and nothing else
- Status: Accepted
- Date: 2026-08-27
- Supersedes: none
- Related: [ADR 0025](0025-trusted-module-ui-asset-publication.md),
[ADR 0031](0031-direct-object-delivery.md),
[ADR 0032](0032-native-module-realtime-transport.md),
[ADR 0033](0033-display-kiosk-host-contract.md),
[ADR 0036](0036-managed-device-identity-and-pairing.md),
[ADR 0041](0041-module-browser-contributions.md)
## Context
A display is the one surface in this platform that is expected to outlive its network. It is a
television on a wall in a building whose uplink is a consumer router, nobody is standing in front of
it, and the person who could press a button is not in the building on a Tuesday.
Today, when that network drops and the screen reboots — a power blip, a scheduled restart, a
janitor's foot — the browser shows **its own error page**. Not the display shell, not an Ekklesia
message, not a retry: a network failure with no Ekklesia in it at all, until someone notices and
does something about it. Nothing about that page recovers on its own, because there is no Ekklesia
code running on it to recover.
The temptation is to solve this by caching more: the descriptor, the principal, the module's data,
so a screen can keep showing content through an outage. That is exactly the wrong instinct. A cached
descriptor is a cached permission decision, and a screen replaying one is a revoked device still
showing content it is no longer entitled to.
## Decision
Cache **the shell and the declared code**, and nothing else, in a service worker scoped to the
display.
### What is cached
- The display page (`/display/<module-id>`).
- The fixed display host script.
- The versioned browser artifacts the display actually loaded: its module's display artifact, and
every contributed renderer it mounted (ADR 0041).
All three are code. All three are already public bytes that confer no authority (ADR 0025, ADR 0041).
All three are addressed by a URL that carries an exact version, so a cache entry is unambiguous about
what it is.
### What is never cached, under any condition
The display descriptor, `/api/v1/principal`, every other platform and 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), a bearer capability with an expiry that a cache would
outlive.
This is an **allowlist**, not a deny list. A deny list has to be updated every time the platform gains
an endpoint, and the cost of forgetting is a stored permission decision. Here the cost of forgetting
is that a new kind of code is simply not cached, which is a bug rather than a vulnerability.
### The consequence, stated rather than papered over
**A display cannot render module content while its network is down.** Mounting requires a descriptor;
a descriptor is authorization-bearing; it is never served from a cache. A revoked device therefore
stays revoked, and cached code can never manufacture current authorization — the two properties this
pass must not trade away.
What the cache buys is real and worth having on its own:
- A kiosk that reboots during an outage boots into **Ekklesia's** honest offline state instead of the
browser's error page, and keeps retrying by itself.
- The moment connectivity returns it resolves and mounts with **nothing left to download** — the
600 KB artifact is already there.
- A momentary failure fetching an artifact, after a descriptor already resolved, falls back to the
identical bytes instead of failing the whole display. The descriptor is tiny and the artifact is
not, so this is the failure that actually happens.
Module-owned offline content — a module persisting its own state and media so a screen can keep
showing yesterday's slides — remains the module's responsibility. This is the code foundation it
would build on.
### Network first, always
Every cached kind is fetched from the network first and only falls back 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 precisely the failure mode a cache-first worker produces on an unattended
screen, and the one that requires physically visiting it to fix.
Only a complete, successful, same-origin `200` is ever stored. A `304`, a `404`, a redirect, an
opaque response, and a `503` from an API mid-restart are all things a screen must never later replay
as though they were the artifact.
### Versioned keys and pruning
Cache names carry a policy version; superseded generations are deleted on activation, so a policy
change cannot leave a screen serving entries from rules that no longer apply.
Artifact URLs already carry an exact module version, so a new build lands *beside* the old one. After
storing an artifact the worker removes every entry for the same module at a different version — and
deliberately keeps every entry at the current version, because a module's display artifact and each
contributed renderer are separate URLs under one version and dropping any of them would half-cache a
working display. Another module's artifacts are never touched.
The worker never caches **itself**, which is the other way a screen gets stuck: it is served
`no-cache` with a strong validator, exactly like the host script.
### Scope is the directory
The worker is served at `/display/sw.js`, chosen for its *directory*: a service worker's default
scope is where it was served from, so this one controls display pages and nothing else. The Dashboard
is never controlled by it and no Dashboard request is ever intercepted. Serving it beside the host
script under `/assets/` would scope it to a directory where no page lives; widening it with
`Service-Worker-Allowed` would put the Dashboard under a worker it has no use for.
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 scope to the whole origin.
### Reporting, not deciding
The page gets a small browser-safe signal built from two independent sources, because either alone
would lie: `navigator.onLine` (cheap, immediate, famously optimistic — a captive portal reports
`true`) and the worker's own report that it actually served from cache (ground truth about this
application). A display renders a quiet corner indicator, never a full-page failure: a screen whose
switch rebooted must not blank a lobby wall.
When the browser reports the network is back, the display resolves its descriptor again on its own.
Nobody presses Retry on a lobby television, and that fresh descriptor is also what stops a screen
sitting on cached code after a deployment — it names the current version, and the network-first worker
fetches it.
Registration is an **enhancement and never a prerequisite**: a browser without service workers, a page
served over plain HTTP in development, and a policy that refuses all leave a display that works
exactly as it did before, online only.
### Testable by construction
Every caching decision lives in a module that touches no global: the cache storage, `fetch`, and the
way a status reaches the page all arrive as an environment. The worker file is thin wiring that
supplies the real ones. That is what lets the behaviour be exercised directly — first load, offline
reload, a missing uncached artifact, contribution fallback, version upgrade and pruning, reconnect,
and the proof that nothing authorization-bearing is ever stored — rather than through a service
worker a test runner cannot host.
## Consequences
- An unattended screen degrades to an Ekklesia message and recovers by itself, instead of degrading
to a browser error page and staying there.
- The platform serves a third fixed display asset, read once before the listener binds like the other
two.
- A display page is now controlled by a service worker, which is a real operational surface: an
operator debugging a screen must know a worker is in front of it. It is `no-cache`, claims clients
immediately, and caches only code, so the ways it can go wrong are bounded.
- Offline *content* is still absent, and that is the honest state of this foundation rather than an
oversight.
## Alternatives
- **Cache the descriptor so a display can mount offline.** Rejected outright. It is a permission
decision and a device identity; replaying one is a revoked screen still showing content.
- **Cache-first for speed.** Rejected: it is how an unattended screen ends up serving a build from
months ago with no way to notice, and fixing it means visiting the screen.
- **Precache everything at install.** Rejected: the worker would have to know which module a screen
runs and at which version — facts only the descriptor carries — and an install would fail whenever
an asset a future version needs did not exist yet.
- **Cache at the whole origin with `Service-Worker-Allowed`.** Rejected: the Dashboard gains a worker
it has no use for, for no benefit.
- **Use `navigator.onLine` alone.** Rejected: it reports `true` behind a captive portal and a dead
uplink, so a screen would insist it was fine while showing nothing.
- **Store artifacts in IndexedDB and import them from a blob URL.** Rejected: it costs a host its
`script-src` CSP, exactly as ADR 0024 rejected for the loader, and reimplements what a cache
already does correctly.
## Deferred
Module-owned offline content and media persistence, background sync, periodic background update,
push, a Dashboard service worker, offline pairing, precaching a display's artifact before its first
visit, and any cached response that carries identity or authorization.
+1
View File
@@ -51,3 +51,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0040: Module stylesheets, and every browser artifact gets an isolated root](0040-module-stylesheets-and-artifact-isolation.md) — Accepted - [0040: Module stylesheets, and every browser artifact gets an isolated root](0040-module-stylesheets-and-artifact-isolation.md) — Accepted
- [0041: One module may contribute a browser renderer to another module's capability](0041-module-browser-contributions.md) — Accepted - [0041: One module may contribute a browser renderer to another module's capability](0041-module-browser-contributions.md) — Accepted
- [0042: Explicit, bounded streaming request bodies](0042-bounded-streaming-request-bodies.md) — Accepted - [0042: Explicit, bounded streaming request bodies](0042-bounded-streaming-request-bodies.md) — Accepted
- [0043: The display caches its shell and declared code, and nothing else](0043-display-offline-code-fallback.md) — Accepted
+7 -1
View File
@@ -119,7 +119,13 @@ These rules map platform ownership to filesystem and package boundaries. The exe
receiving only the host-neutral principal and permission hints already in the UI SDK. receiving only the host-neutral principal and permission hints already in the UI SDK.
- The display browser application is a separate surface entrypoint under `surfaces/shell`; it also - The display browser application is a separate surface entrypoint under `surfaces/shell`; it also
statically imports no module and mounts only the exact display descriptor/artifact supplied by the statically imports no module and mounts only the exact display descriptor/artifact supplied by the
API. It contains no Dashboard chrome and does not move display rendering into the surface—the API. It ships a third fixed asset, a service worker scoped to `/display/`, that caches **only** the
display shell and the declared browser artifacts it actually loaded
([ADR 0043](decisions/0043-display-offline-code-fallback.md)). It never caches the descriptor,
`/api/v1/principal`, any module or platform API response, the realtime stream, a pairing exchange,
or any cross-origin request — so a revoked device stays revoked and cached code can never
manufacture current authorization. Caching is network-first, so a screen cannot be trapped on
obsolete code. It contains no Dashboard chrome and does not move display rendering into the surface—the
module still owns that renderer. module still owns that renderer.
- Separate surfaces do not import each other's internals. - Separate surfaces do not import each other's internals.
- Builder code composes contracts and surface renderer identifiers; it does not import module or - Builder code composes contracts and surface renderer identifiers; it does not import module or
+26 -3
View File
@@ -746,7 +746,7 @@ eligible, running, and declares `entrypoints.display`, Ekklesia registers exactl
The page and descriptor both require the manifest's display permission through the ordinary Core The page and descriptor both require the manifest's display permission through the ordinary Core
authorization evaluator and an authenticated cookie session. A `public` display permission is authorization evaluator and an authenticated cookie session. A `public` display permission is
rejected in manifest version 6; there is no anonymous exception or device token. Artifact bytes are rejected; there is no anonymous exception or device token. Artifact bytes are
anonymous for the same reason as primary UI bytes: code alone carries no data authority. Unknown, anonymous for the same reason as primary UI bytes: code alone carries no data authority. Unknown,
primary-only, disabled, and dependency-blocked modules have no display routes and return the normal primary-only, disabled, and dependency-blocked modules have no display routes and return the normal
sanitized `404`. Enabling, disabling, installing, or changing the manifest takes effect on restart, sanitized `404`. Enabling, disabling, installing, or changing the manifest takes effect on restart,
@@ -764,8 +764,31 @@ artifact remains compatible with a future standalone host.
The display host fills the viewport but deliberately does **not** invoke the browser Fullscreen API. The display host fills the viewport but deliberately does **not** invoke the browser Fullscreen API.
It shows fixed loading/failure states and offers manual retry; your module owns only the rendered It shows fixed loading/failure states and offers manual retry; your module owns only the rendered
feature experience and cleanup inside its container. There is one display per module. Display feature experience and cleanup inside its container. There is one display per module. Display
inventory/management, named variants, targeting, public/device identity, a standalone host, shared inventory/management, named variants, targeting, public/device identity, a standalone host,
CSS, Announcements migration, and Companion integration are deferred. Announcements migration, and Companion integration are deferred.
### Your display survives a network outage — as code, not as content
A display page is controlled by a service worker that caches **the shell and the declared code**: the
page, the host script, your display artifact, and every contributed renderer that display actually
mounted ([ADR 0043](../architecture/decisions/0043-display-offline-code-fallback.md)). Caching is
network-first, so a screen picks up a new build the moment the server returns and can never be
trapped on an obsolete one.
What that means for you, concretely:
- **Your artifact will be served from cache when a fetch fails.** The bytes are identical and the
loader still verifies the artifact's declared id and version, so a fallback can never mount
something the descriptor did not name.
- **Your display cannot render while the network is down.** Mounting requires the descriptor, the
descriptor is authorization-bearing, and it is never cached — so a device whose access was revoked
stays revoked, and cached code can never manufacture authorization it no longer has. The screen
shows Ekklesia's own quiet offline indicator and resolves again by itself when connectivity
returns.
- **Nothing of yours is cached but code.** Your module's API responses, your realtime stream, and
every signed object-delivery URL are deliberately never stored. If you want a screen to keep
showing content through an outage, persisting that content is *your module's* job — this is the
foundation it would build on, not a substitute for it.
### What a module UI will never own ### What a module UI will never own
+4
View File
@@ -44,6 +44,10 @@ const buildOptions = {
entryPoints: { entryPoints: {
app: path.join(packageDirectory, "src", "browser", "main.tsx"), app: path.join(packageDirectory, "src", "browser", "main.tsx"),
"display/app": path.join(packageDirectory, "src", "display", "main.tsx"), "display/app": path.join(packageDirectory, "src", "display", "main.tsx"),
// The display service worker (ADR 0043). A separate entry because a worker is a separate
// realm with its own globals; bundling it into the page would make it unregisterable and its
// `self` meaningless.
"display/sw": path.join(packageDirectory, "src", "display", "sw.ts"),
}, },
format: "esm", format: "esm",
jsx: "automatic", jsx: "automatic",
+3
View File
@@ -11,4 +11,7 @@ const packageRoot = new URL("../", import.meta.url);
export const displayHostAssetUrls = Object.freeze({ export const displayHostAssetUrls = Object.freeze({
html: new URL("dist/browser/display/index.html", packageRoot).href, html: new URL("dist/browser/display/index.html", packageRoot).href,
script: new URL("dist/browser/display/app.js", packageRoot).href, script: new URL("dist/browser/display/app.js", packageRoot).href,
/** The display's offline service worker (ADR 0043). A third exact file, served at a fixed
* host-owned path whose directory is its scope — so it controls display pages and nothing else. */
serviceWorker: new URL("dist/browser/display/sw.js", packageRoot).href,
}); });
+77 -7
View File
@@ -1,3 +1,4 @@
import { designToken } from "@ekklesia/design-tokens";
import type { ModuleUiImporter } from "@ekklesia/module-ui-host"; import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { type ReactElement, useEffect, useState } from "react"; import { type ReactElement, useEffect, useState } from "react";
import { import {
@@ -7,6 +8,11 @@ import {
HostProtocolError, HostProtocolError,
} from "../host-protocol.js"; } from "../host-protocol.js";
import { DisplayUiFrame } from "./DisplayUiFrame.js"; import { DisplayUiFrame } from "./DisplayUiFrame.js";
import {
type DisplayConnectivity,
type DisplayConnectivitySource,
watchDisplayConnectivity,
} from "./offline-status.js";
import { PairingScreen } from "./PairingScreen.js"; import { PairingScreen } from "./PairingScreen.js";
type DisplayState = type DisplayState =
@@ -22,6 +28,9 @@ export interface DisplayAppProps {
readonly moduleId: string; readonly moduleId: string;
/** Test/composition seam only. Production uses the generic loader's native dynamic import. */ /** Test/composition seam only. Production uses the generic loader's native dynamic import. */
readonly importModule?: ModuleUiImporter; 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 = { const displayStyles = {
@@ -34,11 +43,11 @@ const displayStyles = {
}, },
message: { message: {
alignItems: "center", alignItems: "center",
color: "#57534e", color: designToken("text.secondary"),
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
fontFamily: "system-ui, sans-serif", fontFamily: designToken("typography.family-sans"),
gap: "0.75rem", gap: designToken("spacing.md"),
height: "100%", height: "100%",
justifyContent: "center", justifyContent: "center",
margin: 0, margin: 0,
@@ -48,15 +57,47 @@ const displayStyles = {
}, },
retry: { retry: {
background: "transparent", background: "transparent",
border: "1px solid currentColor", border: `${designToken("border.width")} solid currentColor`,
borderRadius: "0.25rem", borderRadius: designToken("radius.md"),
color: "inherit", color: "inherit",
cursor: "pointer", cursor: "pointer",
font: "inherit", 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; } 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: { function DisplayMessage(props: {
readonly children: string; readonly children: string;
readonly testId: string; readonly testId: string;
@@ -85,7 +126,33 @@ function DisplayMessage(props: {
export function DisplayApp(props: DisplayAppProps): ReactElement { export function DisplayApp(props: DisplayAppProps): ReactElement {
const [state, setState] = useState<DisplayState>({ generation: 0, status: "loading" }); const [state, setState] = useState<DisplayState>({ generation: 0, status: "loading" });
const [attempt, setAttempt] = useState(0); 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(() => { useEffect(() => {
const abort = new AbortController(); const abort = new AbortController();
@@ -124,6 +191,9 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
return ( return (
<div data-testid="display-host" style={displayStyles.host}> <div data-testid="display-host" style={displayStyles.host}>
{connectivity.online && !connectivity.usingFallback ? undefined : (
<OfflineIndicator connectivity={connectivity} />
)}
{state.status === "loading" ? ( {state.status === "loading" ? (
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage> <DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
) : undefined} ) : undefined}
+23 -1
View File
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
import type { EkklesiaHostConfig } from "../host-protocol.js"; import type { EkklesiaHostConfig } from "../host-protocol.js";
import { parseDisplayRoute } from "../host-protocol.js"; import { parseDisplayRoute } from "../host-protocol.js";
import { DisplayApp } from "./DisplayApp.js"; import { DisplayApp } from "./DisplayApp.js";
import { type DisplayConnectivitySource, registerDisplayServiceWorker } from "./offline-status.js";
declare const __EKKLESIA_API_BASE_URL__: string; 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."); 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; let moduleId: string | undefined;
try { try {
moduleId = parseDisplayRoute(window.location.pathname); moduleId = parseDisplayRoute(window.location.pathname);
@@ -31,7 +53,7 @@ createRoot(container).render(
<> <>
<EkklesiaUiStyles /> <EkklesiaUiStyles />
{moduleId ? ( {moduleId ? (
<DisplayApp config={config} moduleId={moduleId} /> <DisplayApp config={config} connectivity={connectivity} moduleId={moduleId} />
) : ( ) : (
<div <div
data-testid="display-route-invalid" 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,
});
}
});
+170
View File
@@ -9,6 +9,7 @@ import type {
HostEventSourceFactory, HostEventSourceFactory,
HostEventSourceOptions, HostEventSourceOptions,
} from "../src/host-protocol.js"; } from "../src/host-protocol.js";
import type { DisplayConnectivitySource } from "../src/display/offline-status.js";
import { deepQuery, isolatedMountTarget, isolatedText } from "./support/isolated-dom.js"; import { deepQuery, isolatedMountTarget, isolatedText } from "./support/isolated-dom.js";
declare global { declare global {
@@ -177,11 +178,13 @@ async function render(
config: EkklesiaHostConfig, config: EkklesiaHostConfig,
importModule?: (url: string) => Promise<unknown>, importModule?: (url: string) => Promise<unknown>,
moduleId = "announcements", moduleId = "announcements",
connectivity?: DisplayConnectivitySource,
): Promise<void> { ): Promise<void> {
await act(async () => { await act(async () => {
root.render( root.render(
<DisplayApp <DisplayApp
config={config} config={config}
{...(connectivity ? { connectivity } : {})}
{...(importModule ? { importModule } : {})} {...(importModule ? { importModule } : {})}
moduleId={moduleId} moduleId={moduleId}
/>, />,
@@ -753,3 +756,170 @@ describe("display host — independent instances and stale work", () => {
expect(staleUnmounts).toEqual([staleContainer]); expect(staleUnmounts).toEqual([staleContainer]);
}); });
}); });
/**
* How an unattended screen behaves when its network comes and goes (ADR 0043).
*
* The caching itself is proven directly against the worker's logic in `display-offline-cache.test.ts`.
* What is proven here is the *page's* half: it says so honestly, it recovers without anyone touching
* it, and it never keeps showing content a revoked device is no longer entitled to.
*/
describe("display host — offline behaviour", () => {
/** A controllable stand-in for the browser's own connectivity events. */
function connectivitySource(initialOnline: boolean) {
const listeners = new Map<string, Set<() => void>>();
const workerListeners = new Set<(event: MessageEvent) => void>();
return {
source: {
addEventListener(type: "online" | "offline", listener: () => void) {
const existing = listeners.get(type) ?? new Set();
existing.add(listener);
listeners.set(type, existing);
},
initialOnline,
removeEventListener(type: "online" | "offline", listener: () => void) {
listeners.get(type)?.delete(listener);
},
serviceWorkerMessages: {
addEventListener(_type: "message", listener: (event: MessageEvent) => void) {
workerListeners.add(listener);
},
removeEventListener(_type: "message", listener: (event: MessageEvent) => void) {
workerListeners.delete(listener);
},
},
} satisfies DisplayConnectivitySource,
async emit(type: "online" | "offline") {
await act(async () => {
for (const listener of listeners.get(type) ?? []) {
listener();
}
});
},
async emitFallback(kind: "shell" | "artifact") {
await act(async () => {
for (const listener of workerListeners) {
listener({
data: { ekklesiaDisplayOffline: { kind, usingFallback: true } },
} as MessageEvent);
}
});
},
};
}
it("shows no indicator at all while the display is genuinely online", async () => {
const server = platform();
const connectivity = connectivitySource(true);
await render(
{ apiBaseUrl, fetch: server.fetch },
async () => artifact(),
"announcements",
connectivity.source,
);
expect(query("display-offline")).toBeNull();
});
it("says so, quietly and honestly, when the browser goes offline", async () => {
const server = platform();
const connectivity = connectivitySource(true);
await render(
{ apiBaseUrl, fetch: server.fetch },
async () => artifact(),
"announcements",
connectivity.source,
);
await connectivity.emit("offline");
// A corner indicator, not a full-page failure: a screen whose switch rebooted must not blank a
// lobby wall.
const indicator = query("display-offline");
expect(indicator?.getAttribute("role")).toBe("status");
expect(indicator?.textContent).toContain("Offline");
expect(query("display-module-container")).not.toBeNull();
});
it("reports a cache fallback even while the browser still claims to be online", async () => {
const server = platform();
const connectivity = connectivitySource(true);
await render(
{ apiBaseUrl, fetch: server.fetch },
async () => artifact(),
"announcements",
connectivity.source,
);
await connectivity.emitFallback("artifact");
// A captive portal reports `online: true`. Only the worker knows this application actually
// served code from its cache.
expect(query("display-offline")?.textContent).toContain("last downloaded display code");
});
it("resolves again on its own when the network returns — nobody presses Retry on a lobby TV", async () => {
const server = platform();
const connectivity = connectivitySource(false);
await render(
{ apiBaseUrl, fetch: server.fetch },
async () => artifact(),
"announcements",
connectivity.source,
);
const descriptorCalls = () =>
server.calls.filter((call) => call.url.endsWith("/api/v1/module-displays/announcements"))
.length;
const before = descriptorCalls();
await connectivity.emit("online");
// A fresh descriptor is exactly what stops a screen sitting on cached code after the server
// returns: it names the current version, and the network-first worker fetches it.
expect(descriptorCalls()).toBeGreaterThan(before);
});
it("drops a revoked device back to pairing the moment connectivity returns", async () => {
// The descriptor is never cached, so a device whose access was revoked while it was offline
// cannot keep showing content: the first successful resolution after reconnecting is the
// server's refusal.
let revoked = false;
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return json({ kind: "authenticated", userId: "user-1" });
}
if (url.endsWith("/api/v1/module-displays/announcements")) {
return revoked
? json({ code: "AUTHORIZATION_DENIED" }, 403)
: json(descriptorPayload(), 200);
}
if (url.endsWith("/api/v1/device-pairing/requests")) {
return json(
{ code: "482731", expiresAt: new Date().toISOString(), pollIntervalMs: 60_000 },
201,
);
}
if (url.endsWith("/api/v1/device-pairing/claims")) {
return json({ status: "pending" });
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
const connectivity = connectivitySource(true);
await render(
{ apiBaseUrl, fetch },
async () => artifact(),
"announcements",
connectivity.source,
);
expect(query("display-module-container")).not.toBeNull();
revoked = true;
await connectivity.emit("offline");
await connectivity.emit("online");
expect(query("display-module-container")).toBeNull();
expect(query("display-pairing")).not.toBeNull();
});
});
+3 -1
View File
@@ -8,14 +8,16 @@ import { displayHostAssetUrls } from "../src/display-assets.js";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
describe("display host asset locator", () => { describe("display host asset locator", () => {
it("exports only frozen immutable href strings for the two exact built files", () => { it("exports only frozen immutable href strings for the three exact built files", () => {
expect(Object.isFrozen(displayHostAssetUrls)).toBe(true); expect(Object.isFrozen(displayHostAssetUrls)).toBe(true);
expect(displayHostAssetUrls).toEqual({ expect(displayHostAssetUrls).toEqual({
html: new URL("dist/browser/display/index.html", `file://${packageDirectory}/`).href, html: new URL("dist/browser/display/index.html", `file://${packageDirectory}/`).href,
script: new URL("dist/browser/display/app.js", `file://${packageDirectory}/`).href, script: new URL("dist/browser/display/app.js", `file://${packageDirectory}/`).href,
serviceWorker: new URL("dist/browser/display/sw.js", `file://${packageDirectory}/`).href,
}); });
expect(typeof displayHostAssetUrls.html).toBe("string"); expect(typeof displayHostAssetUrls.html).toBe("string");
expect(typeof displayHostAssetUrls.script).toBe("string"); expect(typeof displayHostAssetUrls.script).toBe("string");
expect(typeof displayHostAssetUrls.serviceWorker).toBe("string");
}); });
it("publishes the locator through a narrow package subpath, not the shell root barrel", async () => { it("publishes the locator through a narrow package subpath, not the shell root barrel", async () => {
@@ -0,0 +1,401 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
classifyDisplayRequest,
DISPLAY_ARTIFACT_CACHE,
DISPLAY_SHELL_CACHE,
type DisplayCacheLike,
type DisplayCacheStorageLike,
type DisplayOfflineEnvironment,
type DisplayOfflineStatus,
handleDisplayFetch,
pruneSupersededArtifacts,
pruneSupersededCaches,
} from "../src/display/offline-cache.js";
/**
* The display's offline behaviour (ADR 0043), tested against the real caching logic rather than
* against a service worker a test runner cannot host.
*
* That is the whole reason `offline-cache.ts` touches no global: every capability arrives as an
* environment, so a fake cache and a controllable `fetch` exercise the exact code the worker runs.
* `sw.ts` is the thin wiring that supplies the real ones.
*/
const origin = "https://ekklesia.example.test";
/** A faithful enough `Cache`: keyed by URL, storing whole responses, with real `keys()` ordering. */
function fakeCache(): DisplayCacheLike & { readonly entries: Map<string, Response> } {
const entries = new Map<string, Response>();
return {
entries,
async delete(request) {
return entries.delete(typeof request === "string" ? request : request.url);
},
async keys() {
return [...entries.keys()].map((url) => new Request(url));
},
async match(request) {
return entries.get(typeof request === "string" ? request : request.url);
},
async put(request, response) {
entries.set(typeof request === "string" ? request : request.url, response);
},
};
}
interface Harness {
readonly environment: DisplayOfflineEnvironment;
readonly caches: Map<string, ReturnType<typeof fakeCache>>;
readonly statuses: DisplayOfflineStatus[];
online: boolean;
/** Responses the "server" will give, by URL. Absent means a 404. */
readonly served: Map<string, () => Response>;
readonly requested: string[];
}
function harness(): Harness {
const caches = new Map<string, ReturnType<typeof fakeCache>>();
const statuses: DisplayOfflineStatus[] = [];
const served = new Map<string, () => Response>();
const requested: string[] = [];
const state: Harness = {
caches,
environment: {
caches: {
async delete(name) {
return caches.delete(name);
},
async keys() {
return [...caches.keys()];
},
async open(name) {
const existing = caches.get(name) ?? fakeCache();
caches.set(name, existing);
return existing;
},
} satisfies DisplayCacheStorageLike,
async fetch(request) {
requested.push(request.url);
if (!state.online) {
throw new TypeError("Failed to fetch");
}
const responder = served.get(request.url);
return responder ? responder() : new Response("missing", { status: 404 });
},
notify: (status) => statuses.push(status),
},
online: true,
requested,
served,
statuses,
};
return state;
}
function code(body: string): Response {
return new Response(body, {
headers: { "content-type": "text/javascript; charset=utf-8" },
status: 200,
});
}
const page = `${origin}/display/signage`;
const script = `${origin}/assets/ekklesia-display/app.js`;
const artifact = (version: string) => `${origin}/api/v1/module-ui/signage/${version}/display`;
const contribution = (version: string) =>
`${origin}/api/v1/module-ui/announcements/${version}/contributions/announcements.signage-slide`;
/**
* A navigation request, as a service worker actually receives one.
*
* `mode: "navigate"` cannot be passed to the `Request` constructor — the spec forbids it, precisely
* so page code cannot forge a navigation — so the property is defined on the instance instead. That
* is what a browser hands `event.request` for a top-level load.
*/
function navigation(url: string): Request {
const request = new Request(url);
Object.defineProperty(request, "mode", { value: "navigate" });
return request;
}
let live: Harness;
beforeEach(() => {
live = harness();
live.served.set(page, () => new Response("<html>display</html>", { status: 200 }));
live.served.set(script, () => code("export const host = 1;"));
live.served.set(artifact("1.0.0"), () => code("export default { version: '1.0.0' };"));
live.served.set(contribution("2.0.0"), () => code("export default { slide: true };"));
});
async function visit(url: string, request = new Request(url)): Promise<Response | undefined> {
return handleDisplayFetch(request, origin, live.environment);
}
describe("what the display worker will and will not cache", () => {
it.each([
["the display page", page, "shell"],
["the fixed host script", script, "shell"],
["a module display artifact", artifact("1.0.0"), "artifact"],
["a contributed renderer", contribution("2.0.0"), "artifact"],
])("caches %s", (_label, url, kind) => {
const request = kind === "shell" && url === page ? navigation(url) : new Request(url);
expect(classifyDisplayRequest(request, origin)).toBe(kind);
});
it.each([
["the display descriptor", `${origin}/api/v1/module-displays/signage`],
["the principal endpoint", `${origin}/api/v1/principal`],
["a module API call", `${origin}/api/v1/modules/signage/slides`],
["a realtime stream", `${origin}/api/v1/module-realtime/signage/state`],
["a pairing request", `${origin}/api/v1/devices/pairing`],
["an auth route", `${origin}/api/auth/sign-in/email`],
["the Dashboard page", `${origin}/`],
["the worker itself", `${origin}/display/sw.js`],
["a signed object URL", "https://objects.example.test/bucket/slide.jpg?X-Amz-Signature=opaque"],
["any other origin", "https://evil.example.test/api/v1/module-ui/signage/1.0.0/display"],
])("never caches %s", (_label, url) => {
// An allowlist, so a platform endpoint added later is *not* cached by default rather than
// silently becoming a stored permission decision.
expect(classifyDisplayRequest(new Request(url), origin)).toBeUndefined();
});
it("never caches a non-GET request or a query-bearing URL", () => {
expect(
classifyDisplayRequest(new Request(artifact("1.0.0"), { method: "POST" }), origin),
).toBeUndefined();
expect(classifyDisplayRequest(new Request(`${artifact("1.0.0")}?t=1`), origin)).toBeUndefined();
});
it("passes an unrecognized request through untouched, storing nothing", async () => {
const descriptor = `${origin}/api/v1/module-displays/signage`;
live.served.set(descriptor, () => Response.json({ module: {} }));
const handled = await visit(descriptor);
// `undefined` means "do what the browser would have done anyway" — the worker's presence must
// not change how any other request behaves.
expect(handled).toBeUndefined();
expect(live.requested).toEqual([]);
expect(live.caches.size).toBe(0);
});
});
describe("first online load", () => {
it("serves everything from the network and stores the shell and artifacts", async () => {
await visit(page, navigation(page));
await visit(script);
await visit(artifact("1.0.0"));
await visit(contribution("2.0.0"));
expect(live.requested).toEqual([page, script, artifact("1.0.0"), contribution("2.0.0")]);
expect([...(live.caches.get(DISPLAY_SHELL_CACHE)?.entries.keys() ?? [])]).toEqual([
page,
script,
]);
expect([...(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.keys() ?? [])]).toEqual([
artifact("1.0.0"),
contribution("2.0.0"),
]);
expect(live.statuses.every((status) => !status.usingFallback)).toBe(true);
});
it("stores nothing for a failed, redirected, or not-modified response", async () => {
for (const [url, response] of [
[artifact("1.0.0"), new Response(null, { status: 304 })],
[contribution("2.0.0"), new Response("nope", { status: 404 })],
[script, new Response("gone", { status: 503 })],
] as const) {
live.served.set(url, () => response.clone());
await visit(url);
}
// A screen must never later replay an API restart's 503 as though it were the artifact.
expect(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.size ?? 0).toBe(0);
expect(live.caches.get(DISPLAY_SHELL_CACHE)?.entries.size ?? 0).toBe(0);
});
});
describe("offline reload after one successful visit", () => {
it("serves the shell from cache and reports the fallback", async () => {
await visit(page, navigation(page));
await visit(script);
live.online = false;
live.statuses.length = 0;
const reloadedPage = await visit(page, navigation(page));
const reloadedScript = await visit(script);
expect(await reloadedPage?.text()).toBe("<html>display</html>");
expect(await reloadedScript?.text()).toBe("export const host = 1;");
// A kiosk that reboots during an outage boots into Ekklesia's own shell rather than the
// browser's error page — which is the entire point of this phase.
expect(live.statuses).toEqual([
{ kind: "shell", usingFallback: true },
{ kind: "shell", usingFallback: true },
]);
});
it("falls back to the last validated artifact and contributed renderer", async () => {
await visit(artifact("1.0.0"));
await visit(contribution("2.0.0"));
live.online = false;
expect(await (await visit(artifact("1.0.0")))?.text()).toBe(
"export default { version: '1.0.0' };",
);
expect(await (await visit(contribution("2.0.0")))?.text()).toBe(
"export default { slide: true };",
);
expect(live.statuses.filter((status) => status.usingFallback)).toHaveLength(2);
});
it("fails honestly when nothing was ever cached for that exact URL", async () => {
live.online = false;
// A first visit during an outage, or a version this screen has never run. There is no honest
// answer, so the failure propagates and the page renders its own state rather than a fabricated
// one.
await expect(visit(artifact("9.9.9"))).rejects.toBeInstanceOf(TypeError);
expect(live.statuses).toEqual([{ kind: "artifact", usingFallback: true }]);
});
it("does not fall back across versions: a cached 1.0.0 never answers for 1.1.0", async () => {
await visit(artifact("1.0.0"));
live.online = false;
await expect(visit(artifact("1.1.0"))).rejects.toBeInstanceOf(TypeError);
});
});
describe("version upgrade and pruning", () => {
it("keeps only the current version of a module's artifacts", async () => {
await visit(artifact("1.0.0"));
live.served.set(artifact("1.1.0"), () => code("export default { version: '1.1.0' };"));
await visit(artifact("1.1.0"));
// Version-keyed URLs mean a new build lands beside the old one; without pruning an unattended
// screen accumulates every version it has ever run.
expect([...(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.keys() ?? [])]).toEqual([
artifact("1.1.0"),
]);
});
it("keeps every artifact belonging to the version it just stored", async () => {
const primary = `${origin}/api/v1/module-ui/signage/1.0.0`;
live.served.set(primary, () => code("export default { primary: true };"));
await visit(artifact("1.0.0"));
await visit(primary);
// A module's display artifact and its other artifacts share one version and are separate URLs;
// dropping either would half-cache a working display.
expect(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.size).toBe(2);
});
it("never prunes another module's artifacts", async () => {
await visit(artifact("1.0.0"));
await visit(contribution("2.0.0"));
live.served.set(artifact("1.1.0"), () => code("export default { version: '1.1.0' };"));
await visit(artifact("1.1.0"));
expect([...(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.keys() ?? [])]).toEqual([
contribution("2.0.0"),
artifact("1.1.0"),
]);
});
it("leaves an unrecognized artifact URL shape alone rather than guessing", async () => {
const cache = fakeCache();
await cache.put(`${origin}/api/v1/module-ui/signage`, code("odd"));
expect(await pruneSupersededArtifacts(cache, `${origin}/api/v1/module-ui/signage`)).toEqual([]);
expect(cache.entries.size).toBe(1);
});
it("deletes superseded cache generations and keeps unrelated ones", async () => {
await live.environment.caches.open("ekklesia-display-shell-v0");
await live.environment.caches.open("some-other-application-cache");
await live.environment.caches.open(DISPLAY_SHELL_CACHE);
const removed = await pruneSupersededCaches(live.environment);
expect(removed).toEqual(["ekklesia-display-shell-v0"]);
expect([...live.caches.keys()]).toEqual(["some-other-application-cache", DISPLAY_SHELL_CACHE]);
});
});
describe("reconnecting to current server code", () => {
it("prefers the network on every request, so a screen is never trapped on obsolete code", async () => {
await visit(artifact("1.0.0"));
live.served.set(artifact("1.0.0"), () => code("export default { rebuilt: true };"));
const afterRebuild = await visit(artifact("1.0.0"));
// Network first, always: the newer bytes win, and they replace the stored copy.
expect(await afterRebuild?.text()).toBe("export default { rebuilt: true };");
expect(
await live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.get(artifact("1.0.0"))?.text(),
).toBe("export default { rebuilt: true };");
});
it("reports that it is no longer falling back once the network returns", async () => {
await visit(script);
live.online = false;
await visit(script);
live.online = true;
live.statuses.length = 0;
await visit(script);
expect(live.statuses).toEqual([{ usingFallback: false }]);
});
});
describe("what the cache can never do", () => {
it("cannot answer for the descriptor, so a revoked device stays revoked when online", async () => {
const descriptor = `${origin}/api/v1/module-displays/signage`;
live.served.set(descriptor, () => Response.json({ module: {} }, { status: 200 }));
await visit(descriptor);
// It was never stored while online...
expect(live.caches.size).toBe(0);
// ...so once the server revokes the device, the refusal is what the display sees. There is no
// cached copy of the previous success anywhere to fall back to.
live.served.set(descriptor, () =>
Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }),
);
expect(await visit(descriptor)).toBeUndefined();
});
it("stores no credential, permission decision, or live state even after many visits", async () => {
for (const url of [
`${origin}/api/v1/principal`,
`${origin}/api/v1/module-displays/signage`,
`${origin}/api/v1/modules/signage/slides`,
`${origin}/api/v1/module-realtime/signage/state`,
`${origin}/api/auth/sign-out`,
]) {
live.served.set(url, () => Response.json({ secret: "do-not-store" }));
await visit(url);
await visit(url);
}
const stored = [...live.caches.values()].flatMap((cache) => [...cache.entries.keys()]);
expect(stored).toEqual([]);
});
it("mounts nothing on its own: it only ever answers a request the page made", async () => {
// The worker has no notion of a display, a module, or a version — it classifies URLs. Every
// decision about *what* to mount stays with the page, which resolves a fresh descriptor first.
const fetchSpy = vi.fn(live.environment.fetch);
const environment: DisplayOfflineEnvironment = { ...live.environment, fetch: fetchSpy };
await pruneSupersededCaches(environment);
expect(fetchSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,188 @@
import { describe, expect, it, vi } from "vitest";
import {
type DisplayConnectivity,
type DisplayConnectivitySource,
DISPLAY_SERVICE_WORKER_URL,
registerDisplayServiceWorker,
watchDisplayConnectivity,
} from "../src/display/offline-status.js";
/**
* The display's connectivity signal (ADR 0043): what it reports, and what it deliberately refuses to
* decide.
*/
interface FakeSource extends DisplayConnectivitySource {
emitOnline(): void;
emitOffline(): void;
emitWorkerMessage(data: unknown): void;
readonly attached: () => number;
}
function fakeSource(initialOnline = true): FakeSource {
const windowListeners = new Map<string, Set<() => void>>();
const workerListeners = new Set<(event: MessageEvent) => void>();
return {
addEventListener(type, listener) {
const existing = windowListeners.get(type) ?? new Set();
existing.add(listener);
windowListeners.set(type, existing);
},
attached: () =>
[...windowListeners.values()].reduce((total, set) => total + set.size, 0) +
workerListeners.size,
emitOffline() {
for (const listener of windowListeners.get("offline") ?? []) {
listener();
}
},
emitOnline() {
for (const listener of windowListeners.get("online") ?? []) {
listener();
}
},
emitWorkerMessage(data: unknown) {
for (const listener of workerListeners) {
listener({ data } as MessageEvent);
}
},
initialOnline,
removeEventListener(type, listener) {
windowListeners.get(type)?.delete(listener);
},
serviceWorkerMessages: {
addEventListener(_type, listener) {
workerListeners.add(listener);
},
removeEventListener(_type, listener) {
workerListeners.delete(listener);
},
},
};
}
describe("display connectivity monitor", () => {
it("starts from what the browser believes and reports nothing else", () => {
const monitor = watchDisplayConnectivity(fakeSource(false));
expect(monitor.current()).toEqual({ online: false, usingFallback: false });
monitor.close();
});
it("reports the browser's online and offline transitions", () => {
const source = fakeSource(true);
const monitor = watchDisplayConnectivity(source);
const seen: DisplayConnectivity[] = [];
monitor.subscribe((next) => seen.push(next));
source.emitOffline();
source.emitOnline();
expect(seen).toEqual([
{ online: false, usingFallback: false },
{ online: true, usingFallback: false },
]);
monitor.close();
});
it("reports a cache fallback the worker actually performed, with its kind", () => {
const source = fakeSource(true);
const monitor = watchDisplayConnectivity(source);
const seen: DisplayConnectivity[] = [];
monitor.subscribe((next) => seen.push(next));
// The browser still claims to be online — a captive portal, or a dead uplink behind a live
// Wi-Fi association. Only the worker knows this application actually fell back.
source.emitWorkerMessage({ ekklesiaDisplayOffline: { kind: "artifact", usingFallback: true } });
expect(seen).toEqual([{ fallbackKind: "artifact", online: true, usingFallback: true }]);
monitor.close();
});
it("clears the fallback flag once the worker serves from the network again", () => {
const source = fakeSource(true);
const monitor = watchDisplayConnectivity(source);
source.emitWorkerMessage({ ekklesiaDisplayOffline: { kind: "shell", usingFallback: true } });
source.emitWorkerMessage({ ekklesiaDisplayOffline: { usingFallback: false } });
expect(monitor.current()).toEqual({ online: true, usingFallback: false });
monitor.close();
});
it("ignores a message that is not the worker's own status", () => {
const source = fakeSource(true);
const monitor = watchDisplayConnectivity(source);
const listener = vi.fn();
monitor.subscribe(listener);
for (const data of [null, "offline", { other: true }, { ekklesiaDisplayOffline: "yes" }, 7]) {
source.emitWorkerMessage(data);
}
expect(listener).not.toHaveBeenCalled();
monitor.close();
});
it("publishes only genuine changes, so an unattended screen is not re-rendered on every event", () => {
const source = fakeSource(true);
const monitor = watchDisplayConnectivity(source);
const listener = vi.fn();
monitor.subscribe(listener);
source.emitOnline();
source.emitOnline();
source.emitOffline();
source.emitOffline();
expect(listener).toHaveBeenCalledTimes(1);
monitor.close();
});
it("detaches everything on close, idempotently", () => {
const source = fakeSource(true);
const monitor = watchDisplayConnectivity(source);
monitor.subscribe(() => undefined);
expect(source.attached()).toBeGreaterThan(0);
monitor.close();
monitor.close();
expect(source.attached()).toBe(0);
});
it("works with no sources at all, reporting a display that simply never knows", () => {
const monitor = watchDisplayConnectivity();
expect(monitor.current()).toEqual({ online: true, usingFallback: false });
monitor.close();
});
});
describe("registering the display service worker", () => {
it("registers the one fixed platform path as a module worker", async () => {
const register = vi.fn().mockResolvedValue({});
const registered = await registerDisplayServiceWorker({
register,
} as unknown as ServiceWorkerContainer);
expect(registered).toBe(true);
expect(register).toHaveBeenCalledWith(DISPLAY_SERVICE_WORKER_URL, { type: "module" });
// The path's *directory* is the scope: `/display/`, so the worker controls display pages and
// nothing else.
expect(DISPLAY_SERVICE_WORKER_URL).toBe("/display/sw.js");
});
it("never lets a missing or refused registration break the display", async () => {
// Offline resilience is an enhancement. A browser without service workers, a page served over
// plain HTTP in development, and a policy that refuses all leave a display that works exactly as
// it did before — online only.
expect(await registerDisplayServiceWorker(undefined)).toBe(false);
expect(
await registerDisplayServiceWorker({
register: vi.fn().mockRejectedValue(new Error("SecurityError")),
} as unknown as ServiceWorkerContainer),
).toBe(false);
});
});