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
458043280a
commit
f1b7d67aa5
@@ -1,13 +1,14 @@
|
||||
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 { SignageError, signageBoundary } from "../errors.js";
|
||||
import {
|
||||
optionalBoolean,
|
||||
optionalInteger,
|
||||
optionalString,
|
||||
readJsonObject,
|
||||
requireUuidParam,
|
||||
} from "../http.js";
|
||||
import { optionalString, readJsonObject, requireUuidParam } from "../http.js";
|
||||
import type { SignagePlaybackService } from "../playback/service.js";
|
||||
import type { SignageProviderRegistry } from "../providers/registry.js";
|
||||
import { readSignageSettings } from "../settings.js";
|
||||
@@ -21,10 +22,20 @@ import {
|
||||
updateViewerAssignment,
|
||||
} 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
|
||||
* 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;
|
||||
|
||||
const databaseStore: ViewerRouteStore = (context) => createSignageStore(context.services.database);
|
||||
@@ -68,11 +79,7 @@ export function registerViewerRoutes(
|
||||
maxBodyBytes: 2048,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const body = await readJsonObject(request);
|
||||
const viewerKey = body.viewerKey;
|
||||
if (typeof viewerKey !== "string") {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "viewerKey" });
|
||||
}
|
||||
const { viewerKey } = accepted(parseViewerSessionRequest(await readJsonObject(request)));
|
||||
// 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
|
||||
// reaches the page decide what a wall shows (ADR 0004).
|
||||
@@ -82,14 +89,15 @@ export function registerViewerRoutes(
|
||||
store: store(context),
|
||||
viewerKey,
|
||||
});
|
||||
return Response.json({
|
||||
const response: ViewerSessionResponse = {
|
||||
channelId: session.channel.id,
|
||||
channelKey: session.channel.key,
|
||||
groupId: session.groupId,
|
||||
heartbeatIntervalMs: 12_000,
|
||||
heartbeatIntervalMs: SIGNAGE_HEARTBEAT_INTERVAL_MS,
|
||||
viewerId: session.viewer.id,
|
||||
viewerName: session.viewer.name,
|
||||
});
|
||||
};
|
||||
return Response.json(response);
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -102,52 +110,21 @@ export function registerViewerRoutes(
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const viewerId = requireUuidParam(context, "viewerId");
|
||||
const body = await readJsonObject(request);
|
||||
const heartbeat = accepted(parseDisplayHeartbeat(await readJsonObject(request)));
|
||||
const settings = readSignageSettings(context.config);
|
||||
const rawError = body.error;
|
||||
// Telemetry only. Nothing a display reports here changes authoritative playback, and a
|
||||
// local cursor is never accepted as truth.
|
||||
const result = await recordViewerHeartbeat({
|
||||
at: new Date(),
|
||||
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),
|
||||
},
|
||||
}),
|
||||
},
|
||||
heartbeat,
|
||||
minimumWriteIntervalMs: settings.viewerHeartbeatWriteMs,
|
||||
store: store(context),
|
||||
viewerId,
|
||||
});
|
||||
// The response says what a display needs to keep itself honest, and nothing about other
|
||||
// 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user