feat: add display kiosk host
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
|
||||
|
||||
/**
|
||||
* The two immutable, platform-owned files that make up Ekklesia's display browser host.
|
||||
*
|
||||
* 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
|
||||
* a static directory. Keeping text rather than a mutable `Buffer` also makes the frozen snapshot a
|
||||
* genuinely immutable value that every response can reuse safely.
|
||||
*/
|
||||
export interface DisplayHostAssets {
|
||||
readonly html: string;
|
||||
readonly script: string;
|
||||
readonly scriptEtag: string;
|
||||
}
|
||||
|
||||
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||
* location, and machine path are intentionally not retained or surfaced. */
|
||||
export class DisplayHostAssetError extends Error {
|
||||
readonly code = "DISPLAY_HOST_ASSET_UNAVAILABLE" as const;
|
||||
|
||||
constructor() {
|
||||
super("The display host browser assets are unavailable.");
|
||||
this.name = "DisplayHostAssetError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures one immutable, internally consistent display-host asset snapshot.
|
||||
*
|
||||
* The strong validator is always derived from the exact script text in the same snapshot; callers
|
||||
* cannot accidentally pair bytes from one build with an ETag from another. Empty output is a
|
||||
* failed host build rather than a valid page that can never mount anything.
|
||||
*/
|
||||
export function createDisplayHostAssets(html: string, script: string): DisplayHostAssets {
|
||||
if (html.trim().length === 0 || script.trim().length === 0) {
|
||||
throw new DisplayHostAssetError();
|
||||
}
|
||||
return Object.freeze({
|
||||
html,
|
||||
script,
|
||||
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Loads only the two surface-declared files. A missing or unreadable build fails startup rather
|
||||
* than leaving a display route that can authorize successfully and then return a broken shell. */
|
||||
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
||||
try {
|
||||
// Sequential deliberately: should both files be absent, diagnostics stay deterministic and no
|
||||
// rejected sibling promise is left running after this function has already failed.
|
||||
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
|
||||
const script = await readFile(new URL(displayHostAssetUrls.script), "utf8");
|
||||
return createDisplayHostAssets(html, script);
|
||||
} catch {
|
||||
throw new DisplayHostAssetError();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user