fix(display): define the display-server messages once, in src/shared
CI / lint (pull_request) Successful in 13s
CI / typecheck (pull_request) Successful in 15s
CI / test (pull_request) Successful in 21s
CI / build (pull_request) Successful in 18s
CI / integration (pull_request) Successful in 3m32s

The display controller and the viewer routes each described the
heartbeat their own way and disagreed about one thing: the display sent
null for "nothing to report", and the route accepted only a missing
field. Every heartbeat from a screen showing a native image was refused
with a 400. The roster therefore marked working screens Offline 90 s
after they opened, and never recorded offlineReady or cachedAssetCount.

src/shared/display-protocol.ts now defines every message between a
display and the server:
- viewer session request and response;
- heartbeat request and response;
- channel state;
- the realtime display payload.

Both halves import it. Its rules: a nullable field accepts null
explicitly, an absent field means "not reported", and an unknown field
is ignored, so a newer display is never refused by an older server.

- The routes parse with the shared parsers, and a refusal names the
  field with Signage's sanitized code.
- The server's channel state extends the wire type, so a field the
  display relies on cannot drift without a compile error.
- The display builds its heartbeat as the shared type, and reads its
  registration with the shared parser instead of casting it.
- Test stubs that described server responses more loosely than the
  server sends them (no viewerName, no stale, partial media) now match
  the real responses and are typed as the protocol.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-05 21:25:21 -05:00
co-authored by Claude Opus 5.5
parent 458043280a
commit f1b7d67aa5
9 changed files with 555 additions and 200 deletions
+36 -46
View File
@@ -18,6 +18,13 @@ import {
type SignageMediaCache,
signageMediaKey,
} from "../browser/storage.js";
import {
type DisplayChannelState,
type DisplayHeartbeat,
type DisplayRealtimeGroup,
parseViewerSessionResponse,
type ViewerSessionResponse,
} from "../shared/display-protocol.js";
/**
* The display: what it fetches, what it shows, and what it reports.
@@ -38,36 +45,11 @@ const SNAPSHOT_STORAGE = "last-known-good";
* must not sit on a stale frame until the next; and a delivery URL has a finite life. */
const REFRESH_INTERVAL_MS = 4 * 60 * 1000;
export interface DisplayChannelStateView {
readonly serverNowMs: number;
readonly channel: { readonly id: string; readonly key: string; readonly groupId: string };
readonly crossfadeMs: number;
readonly playback: {
readonly revision: number;
readonly transport: string;
readonly blackout: boolean;
readonly remainingMs: number | null;
readonly selectionRevision: number | null;
readonly currentSelectionId: string | null;
readonly currentSlotId: string | null;
readonly activeTimelineId: string | null;
readonly pendingTimelineId: string | null;
readonly startedAtMs: number | null;
readonly deadlineAtMs: number | null;
};
readonly timeline: OfflineSnapshot["timeline"];
readonly current: SceneSelection | null;
readonly staticHold: SceneSelection | null;
readonly selections: readonly SceneSelection[];
}
/** The channel state a display renders, exactly as the server sends it (`src/shared`). */
export type DisplayChannelStateView = DisplayChannelState;
export interface DisplaySessionView {
readonly viewerId: string;
readonly channelId: string;
readonly channelKey: string;
readonly groupId: string;
readonly heartbeatIntervalMs: number;
}
/** The display's registration, exactly as the server answers it (`src/shared`). */
export type DisplaySessionView = ViewerSessionResponse;
/** What an operator can see about this screen, and what a test asserts on. Sanitized by
* construction: identity, counts, and a code. */
@@ -533,21 +515,22 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
return;
}
try {
// Built as the shared message type, which is the same type the route parses with.
const report: DisplayHeartbeat = {
cachedAssetCount,
error: lastError === undefined ? null : { code: lastError.code, detail: lastError.detail },
offlineReady: snapshot !== undefined && snapshot.selections.length > 0,
reportedContributionId:
lastState?.current?.renderer.kind === "contribution"
? lastState.current.renderer.contributionId
: null,
reportedPlaybackRevision: appliedRevision >= 0 ? appliedRevision : null,
reportedProviderId: lastState?.current?.providerId ?? null,
reportedSelectionId: renderedSelectionId ?? null,
reportedTimelineId: lastState?.playback.activeTimelineId ?? null,
};
await options.context.api.request(`viewers/${session.viewerId}/heartbeat`, {
body: JSON.stringify({
cachedAssetCount,
error:
lastError === undefined ? null : { code: lastError.code, detail: lastError.detail },
offlineReady: snapshot !== undefined && snapshot.selections.length > 0,
reportedContributionId:
lastState?.current?.renderer.kind === "contribution"
? lastState.current.renderer.contributionId
: null,
reportedPlaybackRevision: appliedRevision >= 0 ? appliedRevision : null,
reportedProviderId: lastState?.current?.providerId ?? null,
reportedSelectionId: renderedSelectionId ?? null,
reportedTimelineId: lastState?.playback.activeTimelineId ?? null,
}),
body: JSON.stringify(report),
headers: { "content-type": "application/json" },
method: "POST",
});
@@ -602,8 +585,12 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
method: "POST",
});
if (response.ok) {
session = (await response.json()) as DisplaySessionView;
online = true;
// Read, not cast: a registration the display cannot understand is no registration.
const parsed = parseViewerSessionResponse(await response.json());
if (parsed.ok) {
session = parsed.value;
online = true;
}
}
} catch {
online = false;
@@ -656,7 +643,10 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
if (stopped || !session) {
return;
}
const groups = (payload as { groups?: readonly Record<string, unknown>[] } | null)?.groups;
// Checked at run time as well as typed: realtime state is the platform's transport, and a frame
// this display cannot read is simply not one it acts on.
const groups = (payload as { groups?: readonly Partial<DisplayRealtimeGroup>[] } | null)
?.groups;
if (!Array.isArray(groups)) {
return;
}