fix(display): define the display-server messages once, in src/shared
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:
co-authored by
Claude Opus 5.5
parent
7483d7cbbe
commit
776540ff78
+36
-46
@@ -18,6 +18,13 @@ import {
|
|||||||
type SignageMediaCache,
|
type SignageMediaCache,
|
||||||
signageMediaKey,
|
signageMediaKey,
|
||||||
} from "../browser/storage.js";
|
} 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.
|
* 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. */
|
* 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;
|
const REFRESH_INTERVAL_MS = 4 * 60 * 1000;
|
||||||
|
|
||||||
export interface DisplayChannelStateView {
|
/** The channel state a display renders, exactly as the server sends it (`src/shared`). */
|
||||||
readonly serverNowMs: number;
|
export type DisplayChannelStateView = DisplayChannelState;
|
||||||
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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DisplaySessionView {
|
/** The display's registration, exactly as the server answers it (`src/shared`). */
|
||||||
readonly viewerId: string;
|
export type DisplaySessionView = ViewerSessionResponse;
|
||||||
readonly channelId: string;
|
|
||||||
readonly channelKey: string;
|
|
||||||
readonly groupId: string;
|
|
||||||
readonly heartbeatIntervalMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What an operator can see about this screen, and what a test asserts on. Sanitized by
|
/** What an operator can see about this screen, and what a test asserts on. Sanitized by
|
||||||
* construction: identity, counts, and a code. */
|
* construction: identity, counts, and a code. */
|
||||||
@@ -533,21 +515,22 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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`, {
|
await options.context.api.request(`viewers/${session.viewerId}/heartbeat`, {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(report),
|
||||||
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,
|
|
||||||
}),
|
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
@@ -602,8 +585,12 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
session = (await response.json()) as DisplaySessionView;
|
// Read, not cast: a registration the display cannot understand is no registration.
|
||||||
online = true;
|
const parsed = parseViewerSessionResponse(await response.json());
|
||||||
|
if (parsed.ok) {
|
||||||
|
session = parsed.value;
|
||||||
|
online = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
online = false;
|
online = false;
|
||||||
@@ -656,7 +643,10 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
if (stopped || !session) {
|
if (stopped || !session) {
|
||||||
return;
|
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)) {
|
if (!Array.isArray(groups)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-70
@@ -1,4 +1,9 @@
|
|||||||
import type { ModuleStorage } from "@ekklesia/module-sdk";
|
import type { ModuleStorage } from "@ekklesia/module-sdk";
|
||||||
|
import type {
|
||||||
|
DisplayChannelState as DisplayChannelStateWire,
|
||||||
|
DisplaySelection,
|
||||||
|
DisplayTimeline,
|
||||||
|
} from "../../shared/display-protocol.js";
|
||||||
import {
|
import {
|
||||||
resolveNativeScene,
|
resolveNativeScene,
|
||||||
SIGNAGE_NATIVE_PROVIDER_ID,
|
SIGNAGE_NATIVE_PROVIDER_ID,
|
||||||
@@ -35,78 +40,21 @@ import type {
|
|||||||
* and a display refreshes state long before one lapses.
|
* and a display refreshes state long before one lapses.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface DisplayMedia {
|
export type {
|
||||||
readonly url: string;
|
DisplayMedia,
|
||||||
readonly expiresAtMs: number;
|
DisplaySelection,
|
||||||
readonly width: number;
|
DisplayTimeline,
|
||||||
readonly height: number;
|
DisplayTimelineSlot,
|
||||||
}
|
} from "../../shared/display-protocol.js";
|
||||||
|
|
||||||
export interface DisplaySelection {
|
/**
|
||||||
readonly id: string;
|
* The channel state the server builds: the wire shape a display reads (`src/shared`), plus the
|
||||||
readonly revision: number;
|
* channel's name and the group's whole playback snapshot. Declared as an extension so that a field
|
||||||
readonly slotId: string | null;
|
* the display relies on cannot be renamed or retyped here without failing to compile.
|
||||||
readonly providerId: string;
|
*/
|
||||||
readonly itemId: string;
|
export interface DisplayChannelState extends DisplayChannelStateWire {
|
||||||
readonly itemRevision: string;
|
readonly channel: DisplayChannelStateWire["channel"] & { readonly name: string };
|
||||||
readonly startedAtMs: number | null;
|
|
||||||
readonly deadlineAtMs: number | null;
|
|
||||||
readonly durationMs: number;
|
|
||||||
readonly availableUntilMs: number | null;
|
|
||||||
readonly withdrawn: boolean;
|
|
||||||
readonly stale: boolean;
|
|
||||||
/** `native-image` is drawn by Signage's own display code; `contribution` is mounted through the
|
|
||||||
* platform's contribution ABI by id. A display never receives a URL for either. */
|
|
||||||
readonly renderer:
|
|
||||||
| { readonly kind: "native-image" }
|
|
||||||
| {
|
|
||||||
readonly kind: "contribution";
|
|
||||||
readonly contributionId: string;
|
|
||||||
readonly contributionVersion: string;
|
|
||||||
}
|
|
||||||
| { readonly kind: "unavailable" };
|
|
||||||
/** The provider's own payload for this channel's audience, or its default. */
|
|
||||||
readonly payload: unknown;
|
|
||||||
readonly presentation: unknown;
|
|
||||||
/** Native images only: fresh delivery URLs, and the already-resolved scene. */
|
|
||||||
readonly media?: { readonly display: DisplayMedia; readonly thumbnail: DisplayMedia };
|
|
||||||
readonly scene?: ReturnType<typeof resolveNativeScene>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DisplayTimelineSlot {
|
|
||||||
readonly id: string;
|
|
||||||
readonly position: number;
|
|
||||||
readonly enabled: boolean;
|
|
||||||
readonly eligibleFromMs: number | null;
|
|
||||||
readonly eligibleUntilMs: number | null;
|
|
||||||
readonly estimatedDurationMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DisplayTimeline {
|
|
||||||
readonly id: string;
|
|
||||||
readonly revision: number;
|
|
||||||
readonly eligibleFromMs: number | null;
|
|
||||||
readonly eligibleUntilMs: number | null;
|
|
||||||
readonly slots: readonly DisplayTimelineSlot[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DisplayChannelState {
|
|
||||||
readonly serverNowMs: number;
|
|
||||||
readonly channel: {
|
|
||||||
readonly id: string;
|
|
||||||
readonly key: string;
|
|
||||||
readonly name: string;
|
|
||||||
readonly groupId: string;
|
|
||||||
};
|
|
||||||
readonly crossfadeMs: number;
|
|
||||||
readonly playback: PlaybackSnapshot;
|
readonly playback: PlaybackSnapshot;
|
||||||
readonly timeline: DisplayTimeline | null;
|
|
||||||
readonly current: DisplaySelection | null;
|
|
||||||
/** The held image, already resolved as a scene. Separate from `current` because a static hold sits
|
|
||||||
* *over* a preserved cursor rather than replacing it. */
|
|
||||||
readonly staticHold: DisplaySelection | null;
|
|
||||||
/** Bounded, newest first. Material for offline continuation, never a substitute for authority. */
|
|
||||||
readonly selections: readonly DisplaySelection[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How many resolved selections travel to a display. Enough to cover a pattern and then some, small
|
/** How many resolved selections travel to a display. Enough to cover a pattern and then some, small
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
|
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
|
||||||
|
import {
|
||||||
|
type DisplayHeartbeatResponse,
|
||||||
|
type ProtocolResult,
|
||||||
|
parseDisplayHeartbeat,
|
||||||
|
parseViewerSessionRequest,
|
||||||
|
type ViewerSessionResponse,
|
||||||
|
} from "../../shared/display-protocol.js";
|
||||||
import { buildDisplayChannelState } from "../display/state.js";
|
import { buildDisplayChannelState } from "../display/state.js";
|
||||||
import { SignageError, signageBoundary } from "../errors.js";
|
import { SignageError, signageBoundary } from "../errors.js";
|
||||||
import {
|
import { optionalString, readJsonObject, requireUuidParam } from "../http.js";
|
||||||
optionalBoolean,
|
|
||||||
optionalInteger,
|
|
||||||
optionalString,
|
|
||||||
readJsonObject,
|
|
||||||
requireUuidParam,
|
|
||||||
} from "../http.js";
|
|
||||||
import type { SignagePlaybackService } from "../playback/service.js";
|
import type { SignagePlaybackService } from "../playback/service.js";
|
||||||
import type { SignageProviderRegistry } from "../providers/registry.js";
|
import type { SignageProviderRegistry } from "../providers/registry.js";
|
||||||
import { readSignageSettings } from "../settings.js";
|
import { readSignageSettings } from "../settings.js";
|
||||||
@@ -21,10 +22,20 @@ import {
|
|||||||
updateViewerAssignment,
|
updateViewerAssignment,
|
||||||
} from "../viewers/service.js";
|
} from "../viewers/service.js";
|
||||||
|
|
||||||
const OPAQUE_ID = /^[\w][\w.:-]{0,199}$/u;
|
|
||||||
|
|
||||||
/** How a route reaches Signage's tables. Production uses the module database; the display contract
|
/** How a route reaches Signage's tables. Production uses the module database; the display contract
|
||||||
* test supplies the in-memory store so the real handlers can answer the real display controller. */
|
* test supplies the in-memory store so the real handlers can answer the real display controller. */
|
||||||
|
/** How often a display checks in. Sent to the display rather than built into it, so the server can
|
||||||
|
* change its own expectations without a display deployment. */
|
||||||
|
export const SIGNAGE_HEARTBEAT_INTERVAL_MS = 12_000;
|
||||||
|
|
||||||
|
/** A display message the shared protocol refused, as Signage's own sanitized code naming the field. */
|
||||||
|
function accepted<Value>(result: ProtocolResult<Value>): Value {
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: result.field });
|
||||||
|
}
|
||||||
|
return result.value;
|
||||||
|
}
|
||||||
|
|
||||||
export type ViewerRouteStore = (context: ModuleRouteHandlerContext) => SignageStore;
|
export type ViewerRouteStore = (context: ModuleRouteHandlerContext) => SignageStore;
|
||||||
|
|
||||||
const databaseStore: ViewerRouteStore = (context) => createSignageStore(context.services.database);
|
const databaseStore: ViewerRouteStore = (context) => createSignageStore(context.services.database);
|
||||||
@@ -68,11 +79,7 @@ export function registerViewerRoutes(
|
|||||||
maxBodyBytes: 2048,
|
maxBodyBytes: 2048,
|
||||||
handler(request, context) {
|
handler(request, context) {
|
||||||
return signageBoundary(async () => {
|
return signageBoundary(async () => {
|
||||||
const body = await readJsonObject(request);
|
const { viewerKey } = accepted(parseViewerSessionRequest(await readJsonObject(request)));
|
||||||
const viewerKey = body.viewerKey;
|
|
||||||
if (typeof viewerKey !== "string") {
|
|
||||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "viewerKey" });
|
|
||||||
}
|
|
||||||
// A display registers with the identity it keeps in its own IndexedDB and lands on the
|
// A display registers with the identity it keeps in its own IndexedDB and lands on the
|
||||||
// default channel. It never chooses one: a public screen that could would let anyone who
|
// default channel. It never chooses one: a public screen that could would let anyone who
|
||||||
// reaches the page decide what a wall shows (ADR 0004).
|
// reaches the page decide what a wall shows (ADR 0004).
|
||||||
@@ -82,14 +89,15 @@ export function registerViewerRoutes(
|
|||||||
store: store(context),
|
store: store(context),
|
||||||
viewerKey,
|
viewerKey,
|
||||||
});
|
});
|
||||||
return Response.json({
|
const response: ViewerSessionResponse = {
|
||||||
channelId: session.channel.id,
|
channelId: session.channel.id,
|
||||||
channelKey: session.channel.key,
|
channelKey: session.channel.key,
|
||||||
groupId: session.groupId,
|
groupId: session.groupId,
|
||||||
heartbeatIntervalMs: 12_000,
|
heartbeatIntervalMs: SIGNAGE_HEARTBEAT_INTERVAL_MS,
|
||||||
viewerId: session.viewer.id,
|
viewerId: session.viewer.id,
|
||||||
viewerName: session.viewer.name,
|
viewerName: session.viewer.name,
|
||||||
});
|
};
|
||||||
|
return Response.json(response);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -102,52 +110,21 @@ export function registerViewerRoutes(
|
|||||||
handler(request, context) {
|
handler(request, context) {
|
||||||
return signageBoundary(async () => {
|
return signageBoundary(async () => {
|
||||||
const viewerId = requireUuidParam(context, "viewerId");
|
const viewerId = requireUuidParam(context, "viewerId");
|
||||||
const body = await readJsonObject(request);
|
const heartbeat = accepted(parseDisplayHeartbeat(await readJsonObject(request)));
|
||||||
const settings = readSignageSettings(context.config);
|
const settings = readSignageSettings(context.config);
|
||||||
const rawError = body.error;
|
|
||||||
// Telemetry only. Nothing a display reports here changes authoritative playback, and a
|
// Telemetry only. Nothing a display reports here changes authoritative playback, and a
|
||||||
// local cursor is never accepted as truth.
|
// local cursor is never accepted as truth.
|
||||||
const result = await recordViewerHeartbeat({
|
const result = await recordViewerHeartbeat({
|
||||||
at: new Date(),
|
at: new Date(),
|
||||||
heartbeat: {
|
heartbeat,
|
||||||
cachedAssetCount:
|
|
||||||
optionalInteger(body, "cachedAssetCount", {
|
|
||||||
maximum: 100_000,
|
|
||||||
minimum: 0,
|
|
||||||
}) ?? undefined,
|
|
||||||
offlineReady: optionalBoolean(body, "offlineReady") ?? undefined,
|
|
||||||
reportedContributionId:
|
|
||||||
optionalString(body, "reportedContributionId", { maxLength: 200 }) ?? null,
|
|
||||||
reportedPlaybackRevision:
|
|
||||||
optionalInteger(body, "reportedPlaybackRevision", {
|
|
||||||
maximum: Number.MAX_SAFE_INTEGER,
|
|
||||||
minimum: 0,
|
|
||||||
}) ?? null,
|
|
||||||
reportedProviderId:
|
|
||||||
optionalString(body, "reportedProviderId", { maxLength: 200 }) ?? null,
|
|
||||||
reportedSelectionId:
|
|
||||||
optionalString(body, "reportedSelectionId", { maxLength: 64 }) ?? null,
|
|
||||||
reportedTimelineId:
|
|
||||||
optionalString(body, "reportedTimelineId", { maxLength: 64 }) ?? null,
|
|
||||||
...(rawError === undefined
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
error:
|
|
||||||
rawError === null
|
|
||||||
? null
|
|
||||||
: {
|
|
||||||
code: readErrorCode(rawError),
|
|
||||||
detail: readErrorDetail(rawError),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
minimumWriteIntervalMs: settings.viewerHeartbeatWriteMs,
|
minimumWriteIntervalMs: settings.viewerHeartbeatWriteMs,
|
||||||
store: store(context),
|
store: store(context),
|
||||||
viewerId,
|
viewerId,
|
||||||
});
|
});
|
||||||
// The response says what a display needs to keep itself honest, and nothing about other
|
// The response says what a display needs to keep itself honest, and nothing about other
|
||||||
// displays or the installation.
|
// displays or the installation.
|
||||||
return Response.json({ acknowledged: true, recorded: result.written });
|
const response: DisplayHeartbeatResponse = { acknowledged: true, recorded: result.written };
|
||||||
|
return Response.json(response);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -238,25 +215,3 @@ export function registerViewerRoutes(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function readErrorCode(value: unknown): string {
|
|
||||||
if (typeof value !== "object" || value === null) {
|
|
||||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error" });
|
|
||||||
}
|
|
||||||
const code = (value as { code?: unknown }).code;
|
|
||||||
if (typeof code !== "string" || !OPAQUE_ID.test(code)) {
|
|
||||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.code" });
|
|
||||||
}
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readErrorDetail(value: unknown): string | null {
|
|
||||||
const detail = (value as { detail?: unknown }).detail;
|
|
||||||
if (detail === undefined || detail === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (typeof detail !== "string") {
|
|
||||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.detail" });
|
|
||||||
}
|
|
||||||
return detail;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
type ModuleRuntimeContext,
|
type ModuleRuntimeContext,
|
||||||
type ModuleRuntimeDefinition,
|
type ModuleRuntimeDefinition,
|
||||||
} from "@ekklesia/module-sdk";
|
} from "@ekklesia/module-sdk";
|
||||||
|
import type { DisplayRealtimePayload } from "../shared/display-protocol.js";
|
||||||
import { SIGNAGE_SLIDE_RENDERER_INPUT_VERSION } from "../shared/renderer-input.js";
|
import { SIGNAGE_SLIDE_RENDERER_INPUT_VERSION } from "../shared/renderer-input.js";
|
||||||
import {
|
import {
|
||||||
SIGNAGE_IMAGE_MAX_BYTES,
|
SIGNAGE_IMAGE_MAX_BYTES,
|
||||||
@@ -98,10 +99,10 @@ export function createSignageModuleRuntime(
|
|||||||
left.groupId.localeCompare(right.groupId),
|
left.groupId.localeCompare(right.groupId),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
context.realtime.publish(SIGNAGE_DISPLAY_CHANNEL, {
|
// The shape the display reads (`src/shared/display-protocol.ts`): each group's whole snapshot
|
||||||
groups,
|
// carries the revisions it compares.
|
||||||
serverNowMs: now().getTime(),
|
const display: DisplayRealtimePayload = { groups, serverNowMs: now().getTime() };
|
||||||
});
|
context.realtime.publish(SIGNAGE_DISPLAY_CHANNEL, display);
|
||||||
context.realtime.publish(SIGNAGE_MANAGEMENT_CHANNEL, {
|
context.realtime.publish(SIGNAGE_MANAGEMENT_CHANNEL, {
|
||||||
// Revisions only. The dashboard refetches what changed over authorized HTTP.
|
// Revisions only. The dashboard refetches what changed over authorized HTTP.
|
||||||
groups: groups.map((snapshot) => ({
|
groups: groups.map((snapshot) => ({
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
/**
|
||||||
|
* What a display and Signage's server say to each other, defined once.
|
||||||
|
*
|
||||||
|
* The display controller builds these messages and the viewer routes read them, from this one file.
|
||||||
|
* They used to be described twice — once in the controller's own interfaces and once in each
|
||||||
|
* route's field-by-field parsing — and the two descriptions disagreed about one thing: the display
|
||||||
|
* sent `null` for "nothing to report" and the heartbeat route accepted only a *missing* field. Every
|
||||||
|
* heartbeat from a screen showing a native image was refused with a 400, silently, for as long as it
|
||||||
|
* ran. Nothing here can drift that way again, because there is nothing to drift between.
|
||||||
|
*
|
||||||
|
* Three rules hold for every message:
|
||||||
|
*
|
||||||
|
* - **A nullable field accepts `null` explicitly.** `null` means "I have nothing", and is a value.
|
||||||
|
* - **An absent field means "not reported".** A receiver keeps whatever it already had.
|
||||||
|
* - **An unknown field is ignored, never refused.** A newer display reporting something an older
|
||||||
|
* server does not know yet must still be heard; refusing it would be this same bug in reverse.
|
||||||
|
*
|
||||||
|
* Like the rest of `src/shared`, it imports nothing from outside `src/shared`: it is compiled into the browser
|
||||||
|
* artifact as well as the server, and must pull neither the module SDK nor Node into a display.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SignageNativeScene } from "./native-images.js";
|
||||||
|
|
||||||
|
// --- Parsing ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The outcome of reading one message: the value, or the one field that was wrong. Never a parser's
|
||||||
|
* own message, which is not something a client or an operator should see. */
|
||||||
|
export type ProtocolResult<Value> =
|
||||||
|
| { readonly ok: true; readonly value: Value }
|
||||||
|
| { readonly ok: false; readonly field: string };
|
||||||
|
|
||||||
|
type Reader<Value> = (raw: unknown) => { readonly ok: true; readonly value: Value } | undefined;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(options: { readonly maxLength: number; readonly pattern?: RegExp }): Reader<string> {
|
||||||
|
return (raw) => {
|
||||||
|
if (typeof raw !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = raw.trim();
|
||||||
|
if (value.length === 0 || value.length > options.maxLength) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (options.pattern && !options.pattern.test(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { ok: true, value };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function integer(options: { readonly minimum: number; readonly maximum: number }): Reader<number> {
|
||||||
|
return (raw) =>
|
||||||
|
typeof raw === "number" &&
|
||||||
|
Number.isSafeInteger(raw) &&
|
||||||
|
raw >= options.minimum &&
|
||||||
|
raw <= options.maximum
|
||||||
|
? { ok: true, value: raw }
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flag: Reader<boolean> = (raw) =>
|
||||||
|
typeof raw === "boolean" ? { ok: true, value: raw } : undefined;
|
||||||
|
|
||||||
|
function nullable<Value>(read: Reader<Value>): Reader<Value | null> {
|
||||||
|
return (raw) => (raw === null ? { ok: true, value: null } : read(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the named fields of a message. Absent fields are left out of the result; any other field
|
||||||
|
* of the object is ignored. */
|
||||||
|
function readFields<Shape extends object>(
|
||||||
|
raw: unknown,
|
||||||
|
readers: { readonly [Key in keyof Shape]-?: Reader<Exclude<Shape[Key], undefined>> },
|
||||||
|
): ProtocolResult<Shape> {
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
return { field: "body", ok: false };
|
||||||
|
}
|
||||||
|
const value: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(readers) as (keyof Shape & string)[]) {
|
||||||
|
if (raw[key] === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const read = readers[key](raw[key]);
|
||||||
|
if (!read) {
|
||||||
|
return { field: key, ok: false };
|
||||||
|
}
|
||||||
|
value[key] = read.value;
|
||||||
|
}
|
||||||
|
return { ok: true, value: value as Shape };
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireFields<Shape>(
|
||||||
|
result: ProtocolResult<Partial<Shape>>,
|
||||||
|
required: readonly (keyof Shape & string)[],
|
||||||
|
): ProtocolResult<Shape> {
|
||||||
|
if (!result.ok) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (const key of required) {
|
||||||
|
if (result.value[key] === undefined) {
|
||||||
|
return { field: key, ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result as ProtocolResult<Shape>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Limits ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A display's own identity, kept in its IndexedDB. */
|
||||||
|
export const DISPLAY_VIEWER_KEY = /^[\w-]{8,128}$/u;
|
||||||
|
/** The longest error detail a display may report. Operator-facing text a browser supplied, so it is
|
||||||
|
* bounded, and because an unbounded one is a way to fill a table. */
|
||||||
|
export const DISPLAY_ERROR_DETAIL_MAX = 200;
|
||||||
|
export const DISPLAY_ERROR_CODE = /^[\w][\w.:-]{0,63}$/u;
|
||||||
|
export const DISPLAY_CACHED_ASSET_MAX = 100_000;
|
||||||
|
|
||||||
|
// --- Viewer session: POST viewers/session ----------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ViewerSessionRequest {
|
||||||
|
readonly viewerKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewerSessionResponse {
|
||||||
|
readonly viewerId: string;
|
||||||
|
readonly viewerName: string;
|
||||||
|
readonly channelId: string;
|
||||||
|
readonly channelKey: string;
|
||||||
|
readonly groupId: string;
|
||||||
|
readonly heartbeatIntervalMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifier = text({ maxLength: 200 });
|
||||||
|
|
||||||
|
export function parseViewerSessionRequest(raw: unknown): ProtocolResult<ViewerSessionRequest> {
|
||||||
|
return requireFields<ViewerSessionRequest>(
|
||||||
|
readFields<Partial<ViewerSessionRequest>>(raw, {
|
||||||
|
viewerKey: text({ maxLength: 128, pattern: DISPLAY_VIEWER_KEY }),
|
||||||
|
}),
|
||||||
|
["viewerKey"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseViewerSessionResponse(raw: unknown): ProtocolResult<ViewerSessionResponse> {
|
||||||
|
return requireFields<ViewerSessionResponse>(
|
||||||
|
readFields<Partial<ViewerSessionResponse>>(raw, {
|
||||||
|
channelId: identifier,
|
||||||
|
channelKey: identifier,
|
||||||
|
groupId: identifier,
|
||||||
|
heartbeatIntervalMs: integer({ maximum: 600_000, minimum: 1_000 }),
|
||||||
|
viewerId: identifier,
|
||||||
|
viewerName: text({ maxLength: 200 }),
|
||||||
|
}),
|
||||||
|
["viewerId", "viewerName", "channelId", "channelKey", "groupId", "heartbeatIntervalMs"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Heartbeat: POST viewers/:viewerId/heartbeat ---------------------------------------------------
|
||||||
|
|
||||||
|
export interface DisplayReportedError {
|
||||||
|
readonly code: string;
|
||||||
|
readonly detail: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a display reports about itself. Telemetry, never authority: nothing here changes playback.
|
||||||
|
*
|
||||||
|
* Every field is optional on the wire (absent = not reported). The display sends all of them.
|
||||||
|
*/
|
||||||
|
export interface DisplayHeartbeat {
|
||||||
|
readonly cachedAssetCount?: number;
|
||||||
|
readonly offlineReady?: boolean;
|
||||||
|
readonly reportedTimelineId?: string | null;
|
||||||
|
readonly reportedSelectionId?: string | null;
|
||||||
|
readonly reportedProviderId?: string | null;
|
||||||
|
readonly reportedContributionId?: string | null;
|
||||||
|
readonly reportedPlaybackRevision?: number | null;
|
||||||
|
readonly error?: DisplayReportedError | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportedError: Reader<DisplayReportedError> = (raw) => {
|
||||||
|
const read = readFields<{ code?: string; detail?: string | null }>(raw, {
|
||||||
|
code: text({ maxLength: 64, pattern: DISPLAY_ERROR_CODE }),
|
||||||
|
detail: nullable(text({ maxLength: DISPLAY_ERROR_DETAIL_MAX })),
|
||||||
|
});
|
||||||
|
if (!read.ok || read.value.code === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { ok: true, value: { code: read.value.code, detail: read.value.detail ?? null } };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseDisplayHeartbeat(raw: unknown): ProtocolResult<DisplayHeartbeat> {
|
||||||
|
return readFields<DisplayHeartbeat>(raw, {
|
||||||
|
cachedAssetCount: integer({ maximum: DISPLAY_CACHED_ASSET_MAX, minimum: 0 }),
|
||||||
|
error: nullable(reportedError),
|
||||||
|
offlineReady: flag,
|
||||||
|
reportedContributionId: nullable(text({ maxLength: 200 })),
|
||||||
|
reportedPlaybackRevision: nullable(integer({ maximum: Number.MAX_SAFE_INTEGER, minimum: 0 })),
|
||||||
|
reportedProviderId: nullable(text({ maxLength: 200 })),
|
||||||
|
reportedSelectionId: nullable(text({ maxLength: 64 })),
|
||||||
|
reportedTimelineId: nullable(text({ maxLength: 64 })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisplayHeartbeatResponse {
|
||||||
|
readonly acknowledged: true;
|
||||||
|
readonly recorded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Channel state: GET channels/:channelId/display-state -----------------------------------------
|
||||||
|
|
||||||
|
/** A fresh, signed delivery URL. A bearer capability with an expiry: never persisted by a display. */
|
||||||
|
export interface DisplayMedia {
|
||||||
|
readonly url: string;
|
||||||
|
readonly expiresAtMs: number;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DisplayRenderer =
|
||||||
|
| { readonly kind: "native-image" }
|
||||||
|
| {
|
||||||
|
readonly kind: "contribution";
|
||||||
|
readonly contributionId: string;
|
||||||
|
readonly contributionVersion: string;
|
||||||
|
}
|
||||||
|
| { readonly kind: "unavailable" };
|
||||||
|
|
||||||
|
export interface DisplaySelection {
|
||||||
|
readonly id: string;
|
||||||
|
readonly revision: number;
|
||||||
|
readonly slotId: string | null;
|
||||||
|
readonly providerId: string;
|
||||||
|
readonly itemId: string;
|
||||||
|
readonly itemRevision: string;
|
||||||
|
readonly startedAtMs: number | null;
|
||||||
|
readonly deadlineAtMs: number | null;
|
||||||
|
readonly durationMs: number;
|
||||||
|
readonly availableUntilMs: number | null;
|
||||||
|
readonly withdrawn: boolean;
|
||||||
|
readonly stale: boolean;
|
||||||
|
/** `native-image` is drawn by Signage's own display code; `contribution` is mounted through the
|
||||||
|
* platform's contribution ABI by id. A display never receives a URL for either. */
|
||||||
|
readonly renderer: DisplayRenderer;
|
||||||
|
/** The provider's own payload for this channel's audience, or its default. */
|
||||||
|
readonly payload: unknown;
|
||||||
|
readonly presentation: unknown;
|
||||||
|
/** Native images only: fresh delivery URLs, and the already-resolved scene. */
|
||||||
|
readonly media?: { readonly display: DisplayMedia; readonly thumbnail: DisplayMedia };
|
||||||
|
readonly scene?: SignageNativeScene;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisplayTimelineSlot {
|
||||||
|
readonly id: string;
|
||||||
|
readonly position: number;
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly eligibleFromMs: number | null;
|
||||||
|
readonly eligibleUntilMs: number | null;
|
||||||
|
readonly estimatedDurationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisplayTimeline {
|
||||||
|
readonly id: string;
|
||||||
|
readonly revision: number;
|
||||||
|
readonly eligibleFromMs: number | null;
|
||||||
|
readonly eligibleUntilMs: number | null;
|
||||||
|
readonly slots: readonly DisplayTimelineSlot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The part of the group's playback a display acts on. The server sends its whole snapshot; this is
|
||||||
|
* what a display may rely on being there. */
|
||||||
|
export interface DisplayPlayback {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisplayChannelState {
|
||||||
|
readonly serverNowMs: number;
|
||||||
|
readonly channel: {
|
||||||
|
readonly id: string;
|
||||||
|
readonly key: string;
|
||||||
|
readonly groupId: string;
|
||||||
|
};
|
||||||
|
readonly crossfadeMs: number;
|
||||||
|
readonly playback: DisplayPlayback;
|
||||||
|
readonly timeline: DisplayTimeline | null;
|
||||||
|
readonly current: DisplaySelection | null;
|
||||||
|
/** The held image, already resolved as a scene. Separate from `current` because a static hold sits
|
||||||
|
* *over* a preserved cursor rather than replacing it. */
|
||||||
|
readonly staticHold: DisplaySelection | null;
|
||||||
|
/** Bounded, newest first. Material for offline continuation, never a substitute for authority. */
|
||||||
|
readonly selections: readonly DisplaySelection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Realtime: the display-state channel ----------------------------------------------------------
|
||||||
|
|
||||||
|
/** One group's revisions on the realtime channel. Compact on purpose: a revision this display has
|
||||||
|
* not applied means "fetch the state you are authorized for", never content. */
|
||||||
|
export interface DisplayRealtimeGroup {
|
||||||
|
readonly groupId: string;
|
||||||
|
readonly revision: number;
|
||||||
|
readonly selectionRevision: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisplayRealtimePayload {
|
||||||
|
readonly groups: readonly DisplayRealtimeGroup[];
|
||||||
|
readonly serverNowMs: number;
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ const SELECTION = {
|
|||||||
},
|
},
|
||||||
revision: 1,
|
revision: 1,
|
||||||
slotId: "slot-trivia",
|
slotId: "slot-trivia",
|
||||||
|
stale: false,
|
||||||
startedAtMs: 0,
|
startedAtMs: 0,
|
||||||
withdrawn: false,
|
withdrawn: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type DisplayChannelStateView,
|
type DisplayChannelStateView,
|
||||||
type DisplayController,
|
type DisplayController,
|
||||||
} from "../../src/display/controller.js";
|
} from "../../src/display/controller.js";
|
||||||
|
import type { ViewerSessionResponse } from "../../src/shared/display-protocol.js";
|
||||||
import { createFakeHost, jsonResponse } from "./support/fake-host.js";
|
import { createFakeHost, jsonResponse } from "./support/fake-host.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,12 +115,14 @@ function state(overrides: Partial<DisplayChannelStateView> = {}): DisplayChannel
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const SESSION = {
|
/** Typed as the shared protocol message, so a stub cannot drift from what the server sends. */
|
||||||
|
const SESSION: ViewerSessionResponse = {
|
||||||
channelId: "chan-1",
|
channelId: "chan-1",
|
||||||
channelKey: "default",
|
channelKey: "default",
|
||||||
groupId: "group-1",
|
groupId: "group-1",
|
||||||
heartbeatIntervalMs: 12_000,
|
heartbeatIntervalMs: 12_000,
|
||||||
viewerId: "11111111-1111-4111-8111-111111111111",
|
viewerId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
viewerName: "Foyer",
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("display controller", () => {
|
describe("display controller", () => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
createDisplayController,
|
createDisplayController,
|
||||||
type DisplayChannelStateView,
|
type DisplayChannelStateView,
|
||||||
} from "../../src/display/controller.js";
|
} from "../../src/display/controller.js";
|
||||||
|
import type { DisplaySelection, ViewerSessionResponse } from "../../src/shared/display-protocol.js";
|
||||||
import { createFakeHost, jsonResponse } from "./support/fake-host.js";
|
import { createFakeHost, jsonResponse } from "./support/fake-host.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -309,14 +310,26 @@ describe("a display that loses its network", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function nativeSelection(id: string, slotId: string): SceneSelection {
|
/** A native selection exactly as the server sends one in channel state. */
|
||||||
|
function nativeSelection(id: string, slotId: string): DisplaySelection {
|
||||||
return {
|
return {
|
||||||
...selection({ id, slotId }),
|
...selection({ id, slotId }),
|
||||||
media: {
|
media: {
|
||||||
display: { url: `https://cdn.invalid/${id}?sig=a` },
|
display: {
|
||||||
thumbnail: { url: `https://cdn.invalid/${id}-t?sig=a` },
|
expiresAtMs: 900_000,
|
||||||
|
height: 1080,
|
||||||
|
url: `https://cdn.invalid/${id}?sig=a`,
|
||||||
|
width: 1920,
|
||||||
|
},
|
||||||
|
thumbnail: {
|
||||||
|
expiresAtMs: 900_000,
|
||||||
|
height: 270,
|
||||||
|
url: `https://cdn.invalid/${id}-t?sig=a`,
|
||||||
|
width: 480,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as SceneSelection;
|
stale: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function state(currentId: string): DisplayChannelStateView {
|
function state(currentId: string): DisplayChannelStateView {
|
||||||
@@ -373,7 +386,8 @@ describe("a display that loses its network", () => {
|
|||||||
groupId: "group-1",
|
groupId: "group-1",
|
||||||
heartbeatIntervalMs: 12_000,
|
heartbeatIntervalMs: 12_000,
|
||||||
viewerId: "11111111-1111-4111-8111-111111111111",
|
viewerId: "11111111-1111-4111-8111-111111111111",
|
||||||
}),
|
viewerName: "Foyer",
|
||||||
|
} satisfies ViewerSessionResponse),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
DISPLAY_ERROR_DETAIL_MAX,
|
||||||
|
parseDisplayHeartbeat,
|
||||||
|
parseViewerSessionRequest,
|
||||||
|
parseViewerSessionResponse,
|
||||||
|
} from "../src/shared/display-protocol.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rules every display message follows, pinned one by one, because the production failure was
|
||||||
|
* exactly a disagreement about one of them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FULL_HEARTBEAT = {
|
||||||
|
cachedAssetCount: 3,
|
||||||
|
error: null,
|
||||||
|
offlineReady: true,
|
||||||
|
reportedContributionId: null,
|
||||||
|
reportedPlaybackRevision: 4,
|
||||||
|
reportedProviderId: "signage.native-images",
|
||||||
|
reportedSelectionId: "sel-1",
|
||||||
|
reportedTimelineId: "timeline-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("display heartbeat", () => {
|
||||||
|
it("accepts null for every nullable field: null is a value, not an omission", () => {
|
||||||
|
const result = parseDisplayHeartbeat({
|
||||||
|
...FULL_HEARTBEAT,
|
||||||
|
reportedContributionId: null,
|
||||||
|
reportedPlaybackRevision: null,
|
||||||
|
reportedProviderId: null,
|
||||||
|
reportedSelectionId: null,
|
||||||
|
reportedTimelineId: null,
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
...FULL_HEARTBEAT,
|
||||||
|
reportedContributionId: null,
|
||||||
|
reportedPlaybackRevision: null,
|
||||||
|
reportedProviderId: null,
|
||||||
|
reportedSelectionId: null,
|
||||||
|
reportedTimelineId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an absent field as not reported, so the receiver keeps what it had", () => {
|
||||||
|
const result = parseDisplayHeartbeat({ offlineReady: false });
|
||||||
|
expect(result).toEqual({ ok: true, value: { offlineReady: false } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a field it does not know, so a newer display is never refused by an older server", () => {
|
||||||
|
const result = parseDisplayHeartbeat({ ...FULL_HEARTBEAT, reportedSomethingNew: { a: 1 } });
|
||||||
|
expect(result).toEqual({ ok: true, value: FULL_HEARTBEAT });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["cachedAssetCount", null],
|
||||||
|
["cachedAssetCount", -1],
|
||||||
|
["cachedAssetCount", 1.5],
|
||||||
|
["offlineReady", null],
|
||||||
|
["offlineReady", "yes"],
|
||||||
|
["reportedSelectionId", ""],
|
||||||
|
["reportedSelectionId", "x".repeat(65)],
|
||||||
|
["reportedProviderId", 7],
|
||||||
|
["reportedPlaybackRevision", -1],
|
||||||
|
["error", { code: "" }],
|
||||||
|
["error", { code: "BAD CODE" }],
|
||||||
|
["error", { code: "OK", detail: "y".repeat(DISPLAY_ERROR_DETAIL_MAX + 1) }],
|
||||||
|
["error", "SLIDE_UNRENDERABLE"],
|
||||||
|
])("refuses %s = %j, naming that field and nothing else", (field, value) => {
|
||||||
|
expect(parseDisplayHeartbeat({ ...FULL_HEARTBEAT, [field]: value })).toEqual({
|
||||||
|
field,
|
||||||
|
ok: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a reported error with or without detail", () => {
|
||||||
|
expect(parseDisplayHeartbeat({ error: { code: "SLIDE_UNRENDERABLE", detail: null } })).toEqual({
|
||||||
|
ok: true,
|
||||||
|
value: { error: { code: "SLIDE_UNRENDERABLE", detail: null } },
|
||||||
|
});
|
||||||
|
expect(parseDisplayHeartbeat({ error: { code: "SLIDE_UNRENDERABLE" } })).toEqual({
|
||||||
|
ok: true,
|
||||||
|
value: { error: { code: "SLIDE_UNRENDERABLE", detail: null } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses something that is not an object at all", () => {
|
||||||
|
for (const body of [null, [], "heartbeat", 3]) {
|
||||||
|
expect(parseDisplayHeartbeat(body)).toEqual({ field: "body", ok: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("viewer session", () => {
|
||||||
|
it("requires a well-formed viewer key", () => {
|
||||||
|
expect(parseViewerSessionRequest({ viewerKey: "kiosk-lobby-0001" })).toEqual({
|
||||||
|
ok: true,
|
||||||
|
value: { viewerKey: "kiosk-lobby-0001" },
|
||||||
|
});
|
||||||
|
for (const viewerKey of [undefined, null, "short", "has spaces in it", "x".repeat(129)]) {
|
||||||
|
expect(parseViewerSessionRequest({ viewerKey })).toEqual({ field: "viewerKey", ok: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a display accepts only a complete registration", () => {
|
||||||
|
const complete = {
|
||||||
|
channelId: "chan-1",
|
||||||
|
channelKey: "default",
|
||||||
|
groupId: "group-1",
|
||||||
|
heartbeatIntervalMs: 12_000,
|
||||||
|
viewerId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
viewerName: "New display",
|
||||||
|
};
|
||||||
|
expect(parseViewerSessionResponse(complete)).toEqual({ ok: true, value: complete });
|
||||||
|
const { viewerId: _omitted, ...missing } = complete;
|
||||||
|
expect(parseViewerSessionResponse(missing)).toEqual({ field: "viewerId", ok: false });
|
||||||
|
expect(parseViewerSessionResponse({ ...complete, heartbeatIntervalMs: 0 })).toEqual({
|
||||||
|
field: "heartbeatIntervalMs",
|
||||||
|
ok: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user