feat: add display kiosk host
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 59s

This commit is contained in:
ekklesia
2026-08-23 20:50:56 -05:00
parent be5ed7853d
commit 2e0fe059c3
80 changed files with 4868 additions and 397 deletions
+152
View File
@@ -0,0 +1,152 @@
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { type ReactElement, useEffect, useState } from "react";
import {
bootstrapDisplayHost,
type DisplayHostBootstrap,
type EkklesiaHostConfig,
HostProtocolError,
} from "../host-protocol.js";
import { DisplayUiFrame } from "./DisplayUiFrame.js";
type DisplayState =
| { readonly status: "loading"; readonly generation: number }
| { readonly status: "ready"; readonly bootstrap: DisplayHostBootstrap & { status: "ready" } }
| { readonly status: "signed-out" }
| { readonly status: "unavailable" }
| { readonly status: "failed" };
export interface DisplayAppProps {
readonly config: EkklesiaHostConfig;
readonly moduleId: string;
/** Test/composition seam only. Production uses the generic loader's native dynamic import. */
readonly importModule?: ModuleUiImporter;
}
const displayStyles = {
host: {
height: "100%",
minHeight: "100dvh",
minWidth: 0,
overflow: "hidden",
width: "100%",
},
message: {
alignItems: "center",
color: "#57534e",
display: "flex",
flexDirection: "column",
fontFamily: "system-ui, sans-serif",
gap: "0.75rem",
height: "100%",
justifyContent: "center",
margin: 0,
minHeight: "100dvh",
padding: "1rem",
textAlign: "center",
},
retry: {
background: "transparent",
border: "1px solid currentColor",
borderRadius: "0.25rem",
color: "inherit",
cursor: "pointer",
font: "inherit",
padding: "0.375rem 0.75rem",
},
} as const;
function DisplayMessage(props: {
readonly children: string;
readonly testId: string;
readonly onRetry?: () => void;
}): ReactElement {
return (
<div aria-live="polite" data-testid={props.testId} style={displayStyles.message}>
<p style={{ margin: 0 }}>{props.children}</p>
{props.onRetry ? (
<button onClick={props.onRetry} style={displayStyles.retry} type="button">
Retry
</button>
) : undefined}
</div>
);
}
/**
* The host for one display-capable module, with no Dashboard application mounted around it.
*
* Bootstrap is principal-first, so an anonymous browser never probes a module descriptor. The
* descriptor itself is permission-protected server-side; only after it succeeds can an artifact URL
* reach the generic loader. A changed `moduleId` or retry aborts the old fetch and keys a new frame,
* so neither a late descriptor nor a late import can replace the current display.
*/
export function DisplayApp(props: DisplayAppProps): ReactElement {
const [state, setState] = useState<DisplayState>({ generation: 0, status: "loading" });
const [attempt, setAttempt] = useState(0);
const { config, importModule, moduleId } = props;
useEffect(() => {
const abort = new AbortController();
setState({ generation: attempt, status: "loading" });
void bootstrapDisplayHost(config, moduleId, abort.signal)
.then((bootstrap) => {
if (abort.signal.aborted) {
return;
}
setState(
bootstrap.status === "signed-out"
? { status: "signed-out" }
: { bootstrap, status: "ready" },
);
})
.catch((error: unknown) => {
if (abort.signal.aborted) {
return;
}
console.error("[ekklesia] display host bootstrap failed", error);
setState(
error instanceof HostProtocolError &&
(error.code === "DISPLAY_ACCESS_DENIED" || error.code === "DISPLAY_NOT_AVAILABLE")
? { status: "unavailable" }
: error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED"
? { status: "signed-out" }
: { status: "failed" },
);
});
return () => abort.abort();
}, [attempt, config, moduleId]);
const retry = (): void => setAttempt((current) => current + 1);
return (
<div data-testid="display-host" style={displayStyles.host}>
{state.status === "loading" ? (
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
) : undefined}
{state.status === "signed-out" ? (
<DisplayMessage onRetry={retry} testId="display-host-signed-out">
Sign in to use this display.
</DisplayMessage>
) : undefined}
{state.status === "unavailable" ? (
<DisplayMessage onRetry={retry} testId="display-host-unavailable">
This display is unavailable.
</DisplayMessage>
) : undefined}
{state.status === "failed" ? (
<DisplayMessage onRetry={retry} testId="display-host-error">
This display could not be reached.
</DisplayMessage>
) : undefined}
{state.status === "ready" ? (
<DisplayUiFrame
config={config}
descriptor={state.bootstrap.descriptor}
{...(importModule ? { importModule } : {})}
key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${attempt}`}
principal={state.bootstrap.principal}
/>
) : undefined}
</div>
);
}
@@ -0,0 +1,233 @@
import {
loadModuleUi,
ModuleUiHostError,
type ModuleUiHostErrorCode,
type ModuleUiImporter,
} from "@ekklesia/module-ui-host";
import {
ModuleUiContractError,
type ModuleUiContractErrorCode,
type ModuleUiPrincipal,
} from "@ekklesia/module-ui-sdk";
import { type ReactElement, useEffect, useRef, useState } from "react";
import type { EkklesiaHostConfig, HostModuleUiDescriptor } from "../host-protocol.js";
import { createModuleUiContext, moduleUiSourceUrl } from "../module-context.js";
type FrameStatus = "loading" | "ready" | "failed";
type DisplayUiFailureCode =
| ModuleUiContractErrorCode
| ModuleUiHostErrorCode
| "DISPLAY_UI_LOAD_FAILED"
| "DISPLAY_UI_RUNTIME_ERROR"
| "DISPLAY_UI_UNHANDLED_REJECTION";
function loadFailureCode(error: unknown): DisplayUiFailureCode {
if (error instanceof ModuleUiHostError || error instanceof ModuleUiContractError) {
return error.code;
}
return "DISPLAY_UI_LOAD_FAILED";
}
export interface DisplayUiFrameProps {
readonly config: EkklesiaHostConfig;
readonly descriptor: HostModuleUiDescriptor;
readonly principal: ModuleUiPrincipal;
/** Test/composition seam only. Production uses the generic loader's native dynamic import. */
readonly importModule?: ModuleUiImporter;
}
interface DisplayUiAttemptProps extends DisplayUiFrameProps {
readonly onRetry: () => void;
}
const frameStyles = {
container: {
height: "100%",
minHeight: 0,
minWidth: 0,
overflow: "hidden",
width: "100%",
},
fallback: {
alignItems: "center",
color: "#57534e",
display: "flex",
flexDirection: "column",
fontFamily: "system-ui, sans-serif",
gap: "0.75rem",
inset: 0,
justifyContent: "center",
padding: "1rem",
position: "absolute",
textAlign: "center",
},
frame: {
height: "100%",
minHeight: 0,
minWidth: 0,
overflow: "hidden",
position: "relative",
width: "100%",
},
message: { margin: 0 },
retry: {
background: "transparent",
border: "1px solid currentColor",
borderRadius: "0.25rem",
color: "inherit",
cursor: "pointer",
font: "inherit",
padding: "0.375rem 0.75rem",
},
} as const;
/**
* One disposable display mount attempt.
*
* Its container is never reused by another attempt. A dynamic import cannot be aborted, so this is
* what makes a late result harmless: cleanup detaches this whole attempt; if the import later
* resolves, it mounts only into that detached element and is immediately unmounted. A failed mount
* may have written partial DOM before throwing, and the generic loader intentionally leaves that to
* its host, so this boundary always clears the container before exposing its fixed failure state.
*/
function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
const containerRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<FrameStatus>("loading");
const { config, descriptor, importModule, onRetry, principal } = props;
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
let cancelled = false;
let failed = false;
let listening = true;
let mounted: Awaited<ReturnType<typeof loadModuleUi>> | undefined;
setStatus("loading");
const stopListening = (): void => {
if (!listening) {
return;
}
listening = false;
window.removeEventListener("error", handlePageError);
window.removeEventListener("unhandledrejection", handleUnhandledRejection);
};
const failSafely = (code: DisplayUiFailureCode): void => {
if (cancelled || failed) {
return;
}
failed = true;
stopListening();
container.replaceChildren();
console.error(`[ekklesia] display UI failure module=${descriptor.id} code=${code}`);
setStatus("failed");
const cleanup = mounted?.unmount();
if (cleanup) {
void cleanup.catch(() => undefined).finally(() => container.replaceChildren());
}
};
// A display route is a dedicated, single-module page. Treat any uncaught page exception or
// rejection as fatal for that display attempt: browser frameworks may report render failures
// after their mount call has already returned, beyond the generic loader's synchronous/awaited
// boundary. This is intentionally page-wide rather than error attribution; there is no robust
// cross-framework way to map a global exception back to a particular component tree.
function handlePageError(event: ErrorEvent): void {
// Element-targeted `error` events represent resource/media failures. Those are module state,
// not a crashed runtime, and must not blank a whole display.
if (event.target !== event.currentTarget) {
return;
}
event.preventDefault();
failSafely("DISPLAY_UI_RUNTIME_ERROR");
}
function handleUnhandledRejection(event: PromiseRejectionEvent): void {
event.preventDefault();
failSafely("DISPLAY_UI_UNHANDLED_REJECTION");
}
window.addEventListener("error", handlePageError);
window.addEventListener("unhandledrejection", handleUnhandledRejection);
void (async () => {
try {
const instance = await loadModuleUi({
container,
context: createModuleUiContext({ config, descriptor, principal }),
...(importModule ? { importModule } : {}),
source: {
moduleId: descriptor.id,
url: moduleUiSourceUrl(config, descriptor),
version: descriptor.version,
},
});
if (cancelled || failed) {
await instance.unmount().catch(() => undefined);
container.replaceChildren();
return;
}
mounted = instance;
setStatus("ready");
} catch (error) {
failSafely(loadFailureCode(error));
}
})();
return () => {
cancelled = true;
stopListening();
const cleanup = mounted?.unmount();
if (cleanup) {
void cleanup.catch(() => undefined).finally(() => container.replaceChildren());
} else {
container.replaceChildren();
}
};
}, [config, descriptor, importModule, principal]);
return (
<section aria-live="polite" data-testid="display-frame" style={frameStyles.frame}>
<div
data-testid="display-module-container"
ref={containerRef}
style={frameStyles.container}
/>
{status === "loading" ? (
<div data-testid="display-loading" style={frameStyles.fallback}>
<p style={frameStyles.message}>Loading display…</p>
</div>
) : undefined}
{status === "failed" ? (
<div data-testid="display-error" style={frameStyles.fallback}>
<p style={frameStyles.message}>This display could not be loaded.</p>
<button
data-testid="display-retry"
onClick={onRetry}
style={frameStyles.retry}
type="button"
>
Retry
</button>
</div>
) : undefined}
</section>
);
}
/** Full-viewport module frame with a fresh container for every manual retry. */
export function DisplayUiFrame(props: DisplayUiFrameProps): ReactElement {
const [attempt, setAttempt] = useState(0);
return (
<DisplayUiAttempt
{...props}
key={attempt}
onRetry={() => setAttempt((current) => current + 1)}
/>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { createRoot } from "react-dom/client";
import type { EkklesiaHostConfig } from "../host-protocol.js";
import { parseDisplayRoute } from "../host-protocol.js";
import { DisplayApp } from "./DisplayApp.js";
declare const __EKKLESIA_API_BASE_URL__: string;
const config: EkklesiaHostConfig = {
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin,
};
const container = document.getElementById("ekklesia-display");
if (!container) {
throw new Error("The Ekklesia display host requires a #ekklesia-display element.");
}
let moduleId: string | undefined;
try {
moduleId = parseDisplayRoute(window.location.pathname);
} catch {
moduleId = undefined;
}
createRoot(container).render(
moduleId ? (
<DisplayApp config={config} moduleId={moduleId} />
) : (
<div
data-testid="display-route-invalid"
style={{
alignItems: "center",
color: "#57534e",
display: "flex",
fontFamily: "system-ui, sans-serif",
height: "100%",
justifyContent: "center",
minHeight: "100dvh",
padding: "1rem",
textAlign: "center",
}}
>
This display is unavailable.
</div>
),
);