A display is the one surface expected to outlive its network: a television on a wall in a building whose uplink is a consumer router, with nobody standing in front of it. When that uplink drops and the screen reboots, the browser shows its own error page — not the display shell, not an Ekklesia message, nothing that recovers on its own, because there is no Ekklesia code left running to recover. Cache the shell and the declared code, in a service worker scoped to `/display/`: the page, the host script, the module's display artifact, and every contributed renderer that display actually mounted. All of them are code whose bytes already confer no authority, and all of them are addressed by a URL carrying an exact version. Cache nothing else, ever. Not the descriptor, not `/api/v1/principal`, not a module or platform API response, not the realtime stream, not a pairing exchange, and not any cross-origin request — which is what a signed object-delivery URL is. It is an allowlist rather than a deny list, so the cost of forgetting a new endpoint is that code goes uncached rather than that a permission decision gets stored. The consequence is stated rather than papered over: a display cannot render module content while offline. Mounting needs a descriptor, a descriptor is authorization-bearing, and it is never cached — so a revoked device stays revoked and cached code can never manufacture current authorization. What the cache buys is that a kiosk rebooting mid-outage boots into Ekklesia's own offline state and keeps retrying, that reconnecting mounts with nothing left to download, and that a hiccup fetching 600 KB of artifact after the tiny descriptor already resolved falls back to identical bytes instead of failing the display. Network-first everywhere, so a screen picks up new code the moment the server returns and can never be trapped on an obsolete build — the failure mode a cache-first worker produces on a screen nobody visits. Only a complete same-origin 200 is stored. Cache names carry a policy version and superseded generations are deleted; artifacts are pruned to the current version per module, keeping every artifact belonging to that version because a display artifact and its contributed renderers share one. The worker never caches itself. The page gets a small signal built from two sources, because either alone lies: `navigator.onLine` is optimistic behind a captive portal, and only the worker knows it actually served from cache. A display shows a quiet corner indicator rather than blanking a lobby wall, and resolves its descriptor again by itself when the network returns — nobody presses Retry on a lobby TV. Every caching decision lives in a module that touches no global, so the behaviour is exercised directly rather than through a worker a test runner cannot host. Registration is an enhancement: no service workers, plain HTTP, or a refusing policy all leave a display that works exactly as before. Co-Authored-By: Claude Opus 5 <[email protected]>
88 lines
4.0 KiB
JavaScript
88 lines
4.0 KiB
JavaScript
import { copyFile, mkdir, rm } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import esbuild from "esbuild";
|
|
|
|
/**
|
|
* Builds the Ekklesia shell into a static browser application.
|
|
*
|
|
* This is **not** the module UI build pipeline, and the two must not be conflated. ADR 0023's
|
|
* `@ekklesia/module-ui-build` compiles a *module's* `src/ui/index.tsx` into the artifact its
|
|
* manifest declares, is owned by the platform so module authors write no bundler configuration, and
|
|
* runs when a module is packaged. This script builds *this application*, is owned by this package
|
|
* alone, and knows nothing about manifests. They happen to share a bundler; they share no
|
|
* configuration, no contract, and no output.
|
|
*
|
|
* Installed module UIs are deliberately **not** bundled in. The shell has no import of any module,
|
|
* so there is nothing for esbuild to reach: every module artifact is fetched at run time from the
|
|
* URL the inventory returned. That is the property that makes modules independently installable, and
|
|
* bundling one would silently destroy it.
|
|
*/
|
|
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
const outputDirectory = path.join(packageDirectory, "dist", "browser");
|
|
const serve = process.argv.includes("--serve");
|
|
|
|
/**
|
|
* Empty means "the API is on this page's own origin" — the expected production shape. Set it to an
|
|
* absolute origin for split-origin development (`EKKLESIA_API_BASE_URL=http://127.0.0.1:3000`), and
|
|
* add this page's own origin to `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` so the API reflects it for CORS
|
|
* and the session cookie travels.
|
|
*/
|
|
const apiBaseUrl = process.env.EKKLESIA_API_BASE_URL ?? "";
|
|
|
|
/** @type {import("esbuild").BuildOptions} */
|
|
const buildOptions = {
|
|
absWorkingDir: packageDirectory,
|
|
// Everything the shell needs is bundled; a browser resolves nothing at run time except the module
|
|
// artifacts it is told about, which are real URLs rather than bare specifiers.
|
|
bundle: true,
|
|
define: {
|
|
__EKKLESIA_API_BASE_URL__: JSON.stringify(apiBaseUrl),
|
|
// React reads this at module scope and a browser has no `process`.
|
|
"process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"),
|
|
},
|
|
entryPoints: {
|
|
app: path.join(packageDirectory, "src", "browser", "main.tsx"),
|
|
"display/app": path.join(packageDirectory, "src", "display", "main.tsx"),
|
|
// The display service worker (ADR 0043). A separate entry because a worker is a separate
|
|
// realm with its own globals; bundling it into the page would make it unregisterable and its
|
|
// `self` meaningless.
|
|
"display/sw": path.join(packageDirectory, "src", "display", "sw.ts"),
|
|
},
|
|
format: "esm",
|
|
jsx: "automatic",
|
|
logLevel: "info",
|
|
// Unminified, matching the module UI build: an operator is expected to be able to read what their
|
|
// installation actually serves, and nothing else in this repository minifies.
|
|
minify: false,
|
|
outdir: outputDirectory,
|
|
platform: "browser",
|
|
sourcemap: true,
|
|
// Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree.
|
|
target: "es2022",
|
|
};
|
|
|
|
await rm(outputDirectory, { force: true, recursive: true });
|
|
await mkdir(outputDirectory, { recursive: true });
|
|
await mkdir(path.join(outputDirectory, "display"), { recursive: true });
|
|
await copyFile(path.join(packageDirectory, "index.html"), path.join(outputDirectory, "index.html"));
|
|
await copyFile(
|
|
path.join(packageDirectory, "display.html"),
|
|
path.join(outputDirectory, "display", "index.html"),
|
|
);
|
|
|
|
if (serve) {
|
|
const context = await esbuild.context(buildOptions);
|
|
await context.watch();
|
|
const { host, port } = await context.serve({ servedir: outputDirectory });
|
|
console.log(`Ekklesia shell (development) on http://${host}:${port}`);
|
|
console.log(
|
|
apiBaseUrl === ""
|
|
? "API origin: this page's own origin. Set EKKLESIA_API_BASE_URL for a split-origin API."
|
|
: `API origin: ${apiBaseUrl}`,
|
|
);
|
|
} else {
|
|
await esbuild.build(buildOptions);
|
|
console.log(`Built the Ekklesia shell into ${path.relative(packageDirectory, outputDirectory)}`);
|
|
}
|