CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 38s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m9s
CI / image-build (pull_request) Successful in 57s
CI / deploy-smoke (same-origin) (pull_request) Successful in 53s
CI / deploy-smoke (split) (pull_request) Successful in 2m35s
CI / package-publication (pull_request) Successful in 1m1s
A kiosk that booted before its server, as after a power cut, stopped on 'This display could not be reached' until someone pressed Retry: with the network up, no online event ever came. It now retries on its own, from two seconds doubling to a minute. A running display also re-resolves its descriptor every five minutes in the background, so a module upgraded on the server is picked up without a reload, and the same answer disturbs nothing. Co-Authored-By: Claude Opus 5.5 <[email protected]>
403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
import { designToken } from "@ekklesia/design-tokens";
|
|
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
|
|
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
|
|
import {
|
|
bootstrapDisplayHost,
|
|
type DisplayHostBootstrap,
|
|
type EkklesiaHostConfig,
|
|
HostProtocolError,
|
|
} from "../host-protocol.js";
|
|
import { DisplayUiFrame } from "./DisplayUiFrame.js";
|
|
import {
|
|
type DisplayConnectivity,
|
|
type DisplayConnectivitySource,
|
|
watchDisplayConnectivity,
|
|
} from "./offline-status.js";
|
|
import { PairingScreen } from "./PairingScreen.js";
|
|
|
|
type DisplayState =
|
|
| { readonly status: "loading"; readonly generation: number }
|
|
| {
|
|
readonly status: "ready";
|
|
readonly bootstrap: DisplayHostBootstrap & { status: "ready" };
|
|
/** Changes only when the mounted display must be replaced, never merely because the network
|
|
* came back. It keys the frame, so it is the one thing that can remount a running display. */
|
|
readonly generation: number;
|
|
}
|
|
/** No identity this display can use yet: show a pairing code rather than a human login form. */
|
|
| { readonly status: "pairing" }
|
|
| { 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;
|
|
/** 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;
|
|
/** How long the display must stay offline before the corner indicator appears. Test seam only. */
|
|
readonly offlineIndicatorDelayMs?: number;
|
|
/** The shortest interval between two revalidations prompted by refused module requests. Test seam
|
|
* only. */
|
|
readonly revalidationIntervalMs?: number;
|
|
/** How often a running display re-resolves its descriptor on its own. Test seam only. */
|
|
readonly periodicRevalidationMs?: number;
|
|
/** The first automatic retry delay after a failed start; doubles to a one-minute ceiling. Test seam
|
|
* only. */
|
|
readonly retryBaseMs?: number;
|
|
}
|
|
|
|
/** A Wi-Fi reassociation or a switch reboot lasts seconds and needs no comment on a lobby wall. Only
|
|
* an outage that outlasts this is worth the corner indicator. */
|
|
export const DISPLAY_OFFLINE_INDICATOR_DELAY_MS = 10_000;
|
|
|
|
/** A module whose requests are being refused prompts at most one revalidation in this interval. A
|
|
* display that is merely missing one permission must not turn every refused request into two more. */
|
|
export const DISPLAY_REVALIDATION_INTERVAL_MS = 15_000;
|
|
|
|
/** A running display asks again this often, so a module upgraded on the server is picked up and a
|
|
* revocation is noticed even by a module that makes no requests of its own. */
|
|
export const DISPLAY_PERIODIC_REVALIDATION_MS = 5 * 60_000;
|
|
|
|
/** A display that could not start tries again on its own: from this delay, doubling to a minute. A
|
|
* kiosk that boots before its server — after a power cut, say — must come up without anyone pressing
|
|
* Retry. */
|
|
export const DISPLAY_RETRY_BASE_MS = 2_000;
|
|
export const DISPLAY_RETRY_MAX_MS = 60_000;
|
|
|
|
/** Whether a fresh resolution describes exactly the display already mounted. Compared structurally:
|
|
* the descriptor and principal are small, server-produced, and carry nothing volatile, so any
|
|
* difference — a new version, a new contribution, a different principal — is a real one. */
|
|
function sameDisplay(left: DisplayHostBootstrap, right: DisplayHostBootstrap): boolean {
|
|
return (
|
|
JSON.stringify([left.descriptor, left.principal]) ===
|
|
JSON.stringify([right.descriptor, right.principal])
|
|
);
|
|
}
|
|
|
|
/** `value`, but only once it has been continuously true for `delayMs`. Becoming false is immediate. */
|
|
function useSustained(value: boolean, delayMs: number): boolean {
|
|
const [sustained, setSustained] = useState(value && delayMs <= 0);
|
|
useEffect(() => {
|
|
if (!value) {
|
|
setSustained(false);
|
|
return;
|
|
}
|
|
if (delayMs <= 0) {
|
|
setSustained(true);
|
|
return;
|
|
}
|
|
const timer = setTimeout(() => setSustained(true), delayMs);
|
|
return () => clearTimeout(timer);
|
|
}, [delayMs, value]);
|
|
return sustained;
|
|
}
|
|
|
|
function isDenied(error: unknown): boolean {
|
|
return (
|
|
error instanceof HostProtocolError &&
|
|
(error.code === "DISPLAY_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED")
|
|
);
|
|
}
|
|
|
|
const displayStyles = {
|
|
host: {
|
|
height: "100%",
|
|
minHeight: "100dvh",
|
|
minWidth: 0,
|
|
overflow: "hidden",
|
|
width: "100%",
|
|
},
|
|
message: {
|
|
alignItems: "center",
|
|
color: designToken("text.secondary"),
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
fontFamily: designToken("typography.family-sans"),
|
|
gap: designToken("spacing.md"),
|
|
height: "100%",
|
|
justifyContent: "center",
|
|
margin: 0,
|
|
minHeight: "100dvh",
|
|
padding: "1rem",
|
|
textAlign: "center",
|
|
},
|
|
retry: {
|
|
background: "transparent",
|
|
border: `${designToken("border.width")} solid currentColor`,
|
|
borderRadius: designToken("radius.md"),
|
|
color: "inherit",
|
|
cursor: "pointer",
|
|
font: "inherit",
|
|
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;
|
|
|
|
/**
|
|
* 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 {
|
|
// Neutral wording: this is on a wall the congregation reads, and the product name is the
|
|
// installation's to choose, not the platform's (ADR 0046).
|
|
const label = props.connectivity.usingFallback
|
|
? props.connectivity.fallbackKind === "artifact"
|
|
? "Offline — showing the last downloaded display code"
|
|
: "Offline — showing the last downloaded display"
|
|
: "Offline — reconnecting";
|
|
return (
|
|
<div data-testid="display-offline" role="status" style={displayStyles.offline}>
|
|
{label}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 [connectivity, setConnectivity] = useState<DisplayConnectivity>({
|
|
online: true,
|
|
usingFallback: false,
|
|
});
|
|
const {
|
|
config,
|
|
connectivity: connectivitySource,
|
|
importModule,
|
|
moduleId,
|
|
offlineIndicatorDelayMs = DISPLAY_OFFLINE_INDICATOR_DELAY_MS,
|
|
periodicRevalidationMs = DISPLAY_PERIODIC_REVALIDATION_MS,
|
|
retryBaseMs = DISPLAY_RETRY_BASE_MS,
|
|
revalidationIntervalMs = DISPLAY_REVALIDATION_INTERVAL_MS,
|
|
} = props;
|
|
/** Consecutive automatic retries since the display last started successfully. */
|
|
const failures = useRef(0);
|
|
const stateRef = useRef(state);
|
|
stateRef.current = state;
|
|
const revalidation = useRef<{ inFlight: AbortController | undefined; lastAt: number }>({
|
|
inFlight: undefined,
|
|
lastAt: 0,
|
|
});
|
|
|
|
/**
|
|
* Resolves the descriptor again **without** taking the running display down (ADR 0043).
|
|
*
|
|
* A display that is showing something keeps showing it while the question is asked. Only the
|
|
* answer decides what happens: the same display means nothing changes at all; a new version or a
|
|
* different principal replaces the frame; a refusal is a revoked or expired device and returns the
|
|
* screen to pairing (ADR 0036); and any other failure is the network still settling, so the frame
|
|
* stays. A display that is not showing anything simply starts over.
|
|
*/
|
|
const revalidate = useCallback(
|
|
(reason: "online" | "authorization" | "periodic"): void => {
|
|
const current = stateRef.current;
|
|
if (current.status !== "ready") {
|
|
if (reason === "online") {
|
|
setAttempt((value) => value + 1);
|
|
}
|
|
return;
|
|
}
|
|
const tracker = revalidation.current;
|
|
const nowMs = Date.now();
|
|
if (
|
|
tracker.inFlight !== undefined ||
|
|
(reason === "authorization" && nowMs - tracker.lastAt < revalidationIntervalMs)
|
|
) {
|
|
return;
|
|
}
|
|
tracker.lastAt = nowMs;
|
|
const abort = new AbortController();
|
|
tracker.inFlight = abort;
|
|
void bootstrapDisplayHost(config, moduleId, abort.signal)
|
|
.then((next) => {
|
|
const latest = stateRef.current;
|
|
if (abort.signal.aborted || latest.status !== "ready") {
|
|
return;
|
|
}
|
|
if (!sameDisplay(latest.bootstrap, next)) {
|
|
setState({ bootstrap: next, generation: latest.generation + 1, status: "ready" });
|
|
}
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!abort.signal.aborted && isDenied(error)) {
|
|
setState({ status: "pairing" });
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (tracker.inFlight === abort) {
|
|
tracker.inFlight = undefined;
|
|
}
|
|
});
|
|
},
|
|
[config, moduleId, revalidationIntervalMs],
|
|
);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
revalidation.current.inFlight?.abort();
|
|
revalidation.current.inFlight = undefined;
|
|
},
|
|
[],
|
|
);
|
|
|
|
const onAuthorizationResponse = useCallback(() => revalidate("authorization"), [revalidate]);
|
|
|
|
// While running: ask again now and then, without disturbing anything when the answer is the same.
|
|
useEffect(() => {
|
|
if (state.status !== "ready" || periodicRevalidationMs <= 0) {
|
|
return;
|
|
}
|
|
const timer = setInterval(() => revalidate("periodic"), periodicRevalidationMs);
|
|
return () => clearInterval(timer);
|
|
}, [periodicRevalidationMs, revalidate, state.status]);
|
|
|
|
// Could not start: try again on its own, backing off. Pairing is not a failure — it has its own
|
|
// polling — and a ready display has nothing to retry.
|
|
useEffect(() => {
|
|
if (state.status === "ready") {
|
|
failures.current = 0;
|
|
return;
|
|
}
|
|
if (state.status !== "failed" && state.status !== "unavailable") {
|
|
return;
|
|
}
|
|
const delay = Math.min(DISPLAY_RETRY_MAX_MS, retryBaseMs * 2 ** Math.min(failures.current, 10));
|
|
failures.current += 1;
|
|
const timer = setTimeout(() => setAttempt((value) => value + 1), delay);
|
|
return () => clearTimeout(timer);
|
|
}, [retryBaseMs, state.status]);
|
|
|
|
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 a fresh descriptor naming a new version replaces
|
|
// the frame and the network-first worker fetches it (ADR 0043). The same answer leaves the
|
|
// running display exactly where it was.
|
|
if (next.online) {
|
|
revalidate("online");
|
|
}
|
|
});
|
|
return () => {
|
|
unsubscribe();
|
|
monitor.close();
|
|
};
|
|
}, [connectivitySource, revalidate]);
|
|
|
|
useEffect(() => {
|
|
const abort = new AbortController();
|
|
revalidation.current.inFlight?.abort();
|
|
setState({ generation: attempt, status: "loading" });
|
|
void bootstrapDisplayHost(config, moduleId, abort.signal)
|
|
.then((bootstrap) => {
|
|
if (abort.signal.aborted) {
|
|
return;
|
|
}
|
|
setState({ bootstrap, generation: attempt, status: "ready" });
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (abort.signal.aborted) {
|
|
return;
|
|
}
|
|
// A denied or unauthenticated descriptor is the ordinary state of a screen nobody has
|
|
// paired yet, not an error worth logging on every wall in the building.
|
|
if (isDenied(error)) {
|
|
setState({ status: "pairing" });
|
|
return;
|
|
}
|
|
console.error("[ekklesia] display host bootstrap failed", error);
|
|
setState(
|
|
error instanceof HostProtocolError && error.code === "DISPLAY_NOT_AVAILABLE"
|
|
? { status: "unavailable" }
|
|
: { status: "failed" },
|
|
);
|
|
});
|
|
return () => abort.abort();
|
|
}, [attempt, config, moduleId]);
|
|
|
|
const retry = (): void => setAttempt((current) => current + 1);
|
|
const showOffline = useSustained(
|
|
!connectivity.online || connectivity.usingFallback,
|
|
offlineIndicatorDelayMs,
|
|
);
|
|
|
|
return (
|
|
<div data-testid="display-host" style={displayStyles.host}>
|
|
{showOffline ? <OfflineIndicator connectivity={connectivity} /> : undefined}
|
|
{state.status === "loading" ? (
|
|
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
|
|
) : undefined}
|
|
{state.status === "pairing" ? (
|
|
<PairingScreen config={config} moduleId={moduleId} onRetry={retry} />
|
|
) : 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}:${state.generation}`}
|
|
onAuthorizationResponse={onAuthorizationResponse}
|
|
principal={state.bootstrap.principal}
|
|
/>
|
|
) : undefined}
|
|
</div>
|
|
);
|
|
}
|