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