feat: add display kiosk host
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user