Phase 8/module platform prerequisites #40
+3
-1
@@ -67,7 +67,9 @@ pnpm infrastructure:config
|
||||
- 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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
|
||||
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
|
||||
* 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 script: 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
|
||||
@@ -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
|
||||
* failed host build rather than a valid page that can never mount anything.
|
||||
*/
|
||||
export function createDisplayHostAssets(html: string, script: string): DisplayHostAssets {
|
||||
if (html.trim().length === 0 || script.trim().length === 0) {
|
||||
export function createDisplayHostAssets(
|
||||
html: string,
|
||||
script: string,
|
||||
serviceWorker: string,
|
||||
): DisplayHostAssets {
|
||||
if (html.trim().length === 0 || script.trim().length === 0 || serviceWorker.trim().length === 0) {
|
||||
throw new DisplayHostAssetError();
|
||||
}
|
||||
return Object.freeze({
|
||||
html,
|
||||
script,
|
||||
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. */
|
||||
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
||||
try {
|
||||
@@ -55,7 +65,8 @@ export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
||||
// rejected sibling promise is left running after this function has already failed.
|
||||
const html = await readFile(new URL(displayHostAssetUrls.html), "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 {
|
||||
throw new DisplayHostAssetError();
|
||||
}
|
||||
|
||||
@@ -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_PAGE_PREFIX = "/display";
|
||||
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 {
|
||||
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||
@@ -107,8 +119,11 @@ function etagMatches(headerValue: string | undefined, etag: string): boolean {
|
||||
* bytes with a stale validator. */
|
||||
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
|
||||
try {
|
||||
const snapshot = createDisplayHostAssets(input.html, input.script);
|
||||
if (snapshot.scriptEtag !== input.scriptEtag) {
|
||||
const snapshot = createDisplayHostAssets(input.html, input.script, input.serviceWorker);
|
||||
if (
|
||||
snapshot.scriptEtag !== input.scriptEtag ||
|
||||
snapshot.serviceWorkerEtag !== input.serviceWorkerEtag
|
||||
) {
|
||||
throw new ModuleDisplayConfigurationError();
|
||||
}
|
||||
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) {
|
||||
mountDisplay(app, { ...options, assets }, display);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ vi.mock("@ekklesia/shell/display-assets", () => ({
|
||||
displayHostAssetUrls: Object.freeze({
|
||||
html: "file:///surface/browser/display/index.html",
|
||||
script: "file:///surface/browser/display/app.js",
|
||||
serviceWorker: "file:///surface/browser/display/sw.js",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -20,21 +21,28 @@ import {
|
||||
describe("display host asset loading", () => {
|
||||
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 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();
|
||||
|
||||
expect(readFileMock.mock.calls).toEqual([
|
||||
[new URL("file:///surface/browser/display/index.html"), "utf8"],
|
||||
[new URL("file:///surface/browser/display/app.js"), "utf8"],
|
||||
[new URL("file:///surface/browser/display/sw.js"), "utf8"],
|
||||
]);
|
||||
expect(assets).toEqual({
|
||||
html,
|
||||
script,
|
||||
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||
serviceWorker,
|
||||
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
|
||||
});
|
||||
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", () => {
|
||||
for (const [html, script] of [
|
||||
["", "export {};"],
|
||||
["<html></html>", " \n"],
|
||||
it("rejects an empty page, script, or service worker as an unavailable host build", () => {
|
||||
for (const [html, script, serviceWorker] of [
|
||||
["", "export {};", "export {};"],
|
||||
["<html></html>", " \n", "export {};"],
|
||||
["<html></html>", "export {};", " \n"],
|
||||
] as const) {
|
||||
expect(() => createDisplayHostAssets(html, script)).toThrow(DisplayHostAssetError);
|
||||
expect(() => createDisplayHostAssets(html, script, serviceWorker)).toThrow(
|
||||
DisplayHostAssetError,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import { createApiApp } from "../src/app.js";
|
||||
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
|
||||
import {
|
||||
DISPLAY_HOST_SCRIPT_PATH,
|
||||
DISPLAY_SERVICE_WORKER_PATH,
|
||||
ModuleDisplayConfigurationError,
|
||||
moduleDisplayDescriptorUrl,
|
||||
moduleDisplayPageUrl,
|
||||
@@ -51,6 +52,7 @@ const displayPermission = "announcements.display.view";
|
||||
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
|
||||
`<!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.__ekklesiaDisplayWorkerLoaded = true;\n",
|
||||
);
|
||||
|
||||
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 () => {
|
||||
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 { app } = await install({ assets: mutable, display: true });
|
||||
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);
|
||||
});
|
||||
|
||||
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(
|
||||
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
|
||||
"export const displayHost = true;\n",
|
||||
"export const displayWorker = true;\n",
|
||||
),
|
||||
}),
|
||||
moduleLifecycle: {
|
||||
|
||||
@@ -240,6 +240,7 @@ async function install(
|
||||
displayHostAssets: createDisplayHostAssets(
|
||||
'<!doctype html><div id="display-root"></div>',
|
||||
"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.
|
||||
@@ -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
|
||||
- [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
|
||||
- [0043: The display caches its shell and declared code, and nothing else](0043-display-offline-code-fallback.md) — Accepted
|
||||
|
||||
@@ -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.
|
||||
- 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
|
||||
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.
|
||||
- Separate surfaces do not import each other's internals.
|
||||
- Builder code composes contracts and surface renderer identifiers; it does not import module or
|
||||
|
||||
@@ -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
|
||||
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,
|
||||
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,
|
||||
@@ -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.
|
||||
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
|
||||
inventory/management, named variants, targeting, public/device identity, a standalone host, shared
|
||||
CSS, Announcements migration, and Companion integration are deferred.
|
||||
inventory/management, named variants, targeting, public/device identity, a standalone host,
|
||||
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
|
||||
|
||||
|
||||
@@ -44,6 +44,10 @@ const buildOptions = {
|
||||
entryPoints: {
|
||||
app: path.join(packageDirectory, "src", "browser", "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",
|
||||
jsx: "automatic",
|
||||
|
||||
@@ -11,4 +11,7 @@ const packageRoot = new URL("../", import.meta.url);
|
||||
export const displayHostAssetUrls = Object.freeze({
|
||||
html: new URL("dist/browser/display/index.html", 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,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -7,6 +8,11 @@ import {
|
||||
HostProtocolError,
|
||||
} from "../host-protocol.js";
|
||||
import { DisplayUiFrame } from "./DisplayUiFrame.js";
|
||||
import {
|
||||
type DisplayConnectivity,
|
||||
type DisplayConnectivitySource,
|
||||
watchDisplayConnectivity,
|
||||
} from "./offline-status.js";
|
||||
import { PairingScreen } from "./PairingScreen.js";
|
||||
|
||||
type DisplayState =
|
||||
@@ -22,6 +28,9 @@ export interface DisplayAppProps {
|
||||
readonly moduleId: string;
|
||||
/** Test/composition seam only. Production uses the generic loader's native dynamic import. */
|
||||
readonly importModule?: ModuleUiImporter;
|
||||
/** Where connectivity and cache-fallback signals come from (ADR 0043). The browser entry point
|
||||
* supplies the real ones; omitting it leaves a display that simply never reports being offline. */
|
||||
readonly connectivity?: DisplayConnectivitySource;
|
||||
}
|
||||
|
||||
const displayStyles = {
|
||||
@@ -34,11 +43,11 @@ const displayStyles = {
|
||||
},
|
||||
message: {
|
||||
alignItems: "center",
|
||||
color: "#57534e",
|
||||
color: designToken("text.secondary"),
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
gap: "0.75rem",
|
||||
fontFamily: designToken("typography.family-sans"),
|
||||
gap: designToken("spacing.md"),
|
||||
height: "100%",
|
||||
justifyContent: "center",
|
||||
margin: 0,
|
||||
@@ -48,15 +57,47 @@ const displayStyles = {
|
||||
},
|
||||
retry: {
|
||||
background: "transparent",
|
||||
border: "1px solid currentColor",
|
||||
borderRadius: "0.25rem",
|
||||
border: `${designToken("border.width")} solid currentColor`,
|
||||
borderRadius: designToken("radius.md"),
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
padding: "0.375rem 0.75rem",
|
||||
padding: `${designToken("spacing.xs")} ${designToken("spacing.md")}`,
|
||||
},
|
||||
offline: {
|
||||
background: designToken("status.warning-background"),
|
||||
borderRadius: designToken("radius.pill"),
|
||||
color: designToken("text.warning"),
|
||||
fontFamily: designToken("typography.family-sans"),
|
||||
fontSize: designToken("typography.size-sm"),
|
||||
insetBlockStart: designToken("spacing.md"),
|
||||
insetInlineEnd: designToken("spacing.md"),
|
||||
padding: `${designToken("spacing.2xs")} ${designToken("spacing.md")}`,
|
||||
position: "absolute",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* A small, honest indicator: this screen is not currently in touch with Ekklesia, or is running
|
||||
* from code it cached earlier.
|
||||
*
|
||||
* Deliberately unobtrusive and deliberately not an error. An unattended screen showing a full-page
|
||||
* failure because a switch rebooted is worse than one quietly saying so in a corner, and the screen
|
||||
* recovers on its own the moment connectivity returns.
|
||||
*/
|
||||
function OfflineIndicator(props: { readonly connectivity: DisplayConnectivity }): ReactElement {
|
||||
const label = props.connectivity.usingFallback
|
||||
? props.connectivity.fallbackKind === "artifact"
|
||||
? "Offline — showing the last downloaded display code"
|
||||
: "Offline — showing the last downloaded Ekklesia display"
|
||||
: "Offline — waiting for Ekklesia";
|
||||
return (
|
||||
<div data-testid="display-offline" role="status" style={displayStyles.offline}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisplayMessage(props: {
|
||||
readonly children: string;
|
||||
readonly testId: string;
|
||||
@@ -85,7 +126,33 @@ function DisplayMessage(props: {
|
||||
export function DisplayApp(props: DisplayAppProps): ReactElement {
|
||||
const [state, setState] = useState<DisplayState>({ generation: 0, status: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const { config, importModule, moduleId } = props;
|
||||
const [connectivity, setConnectivity] = useState<DisplayConnectivity>({
|
||||
online: true,
|
||||
usingFallback: false,
|
||||
});
|
||||
const { config, connectivity: connectivitySource, importModule, moduleId } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectivitySource) {
|
||||
return;
|
||||
}
|
||||
const monitor = watchDisplayConnectivity(connectivitySource);
|
||||
setConnectivity(monitor.current());
|
||||
const unsubscribe = monitor.subscribe((next) => {
|
||||
setConnectivity(next);
|
||||
// Nobody presses Retry on a lobby television. When the browser says the network is back, the
|
||||
// display resolves its descriptor again — which is also what stops a screen from sitting on
|
||||
// cached code after the server returns, since the fresh descriptor names the current version
|
||||
// and the network-first worker fetches it (ADR 0043).
|
||||
if (next.online) {
|
||||
setAttempt((current) => current + 1);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
monitor.close();
|
||||
};
|
||||
}, [connectivitySource]);
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
@@ -124,6 +191,9 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
||||
|
||||
return (
|
||||
<div data-testid="display-host" style={displayStyles.host}>
|
||||
{connectivity.online && !connectivity.usingFallback ? undefined : (
|
||||
<OfflineIndicator connectivity={connectivity} />
|
||||
)}
|
||||
{state.status === "loading" ? (
|
||||
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
|
||||
) : undefined}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
|
||||
import type { EkklesiaHostConfig } from "../host-protocol.js";
|
||||
import { parseDisplayRoute } from "../host-protocol.js";
|
||||
import { DisplayApp } from "./DisplayApp.js";
|
||||
import { type DisplayConnectivitySource, registerDisplayServiceWorker } from "./offline-status.js";
|
||||
|
||||
declare const __EKKLESIA_API_BASE_URL__: string;
|
||||
|
||||
@@ -16,6 +17,27 @@ if (!container) {
|
||||
throw new Error("The Ekklesia display host requires a #ekklesia-display element.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Offline resilience is registered as an enhancement and never as a prerequisite (ADR 0043): a
|
||||
* browser without service workers, or a policy that refuses the registration, leaves a display that
|
||||
* works exactly as it did before. The promise is deliberately not awaited — a screen must render
|
||||
* while its worker is still installing.
|
||||
*/
|
||||
void registerDisplayServiceWorker(
|
||||
typeof navigator === "undefined" ? undefined : navigator.serviceWorker,
|
||||
);
|
||||
|
||||
/** The two real browser sources the connectivity monitor reads. Passed as data so nothing below the
|
||||
* entry point touches a global. */
|
||||
const connectivity: DisplayConnectivitySource = {
|
||||
addEventListener: (type, listener) => window.addEventListener(type, listener),
|
||||
initialOnline: navigator.onLine,
|
||||
removeEventListener: (type, listener) => window.removeEventListener(type, listener),
|
||||
...(typeof navigator === "undefined" || !navigator.serviceWorker
|
||||
? {}
|
||||
: { serviceWorkerMessages: navigator.serviceWorker }),
|
||||
};
|
||||
|
||||
let moduleId: string | undefined;
|
||||
try {
|
||||
moduleId = parseDisplayRoute(window.location.pathname);
|
||||
@@ -31,7 +53,7 @@ createRoot(container).render(
|
||||
<>
|
||||
<EkklesiaUiStyles />
|
||||
{moduleId ? (
|
||||
<DisplayApp config={config} moduleId={moduleId} />
|
||||
<DisplayApp config={config} connectivity={connectivity} moduleId={moduleId} />
|
||||
) : (
|
||||
<div
|
||||
data-testid="display-route-invalid"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
HostEventSourceFactory,
|
||||
HostEventSourceOptions,
|
||||
} from "../src/host-protocol.js";
|
||||
import type { DisplayConnectivitySource } from "../src/display/offline-status.js";
|
||||
import { deepQuery, isolatedMountTarget, isolatedText } from "./support/isolated-dom.js";
|
||||
|
||||
declare global {
|
||||
@@ -177,11 +178,13 @@ async function render(
|
||||
config: EkklesiaHostConfig,
|
||||
importModule?: (url: string) => Promise<unknown>,
|
||||
moduleId = "announcements",
|
||||
connectivity?: DisplayConnectivitySource,
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<DisplayApp
|
||||
config={config}
|
||||
{...(connectivity ? { connectivity } : {})}
|
||||
{...(importModule ? { importModule } : {})}
|
||||
moduleId={moduleId}
|
||||
/>,
|
||||
@@ -753,3 +756,170 @@ describe("display host — independent instances and stale work", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,14 +8,16 @@ import { displayHostAssetUrls } from "../src/display-assets.js";
|
||||
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
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(displayHostAssetUrls).toEqual({
|
||||
html: new URL("dist/browser/display/index.html", `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.script).toBe("string");
|
||||
expect(typeof displayHostAssetUrls.serviceWorker).toBe("string");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user