import { defineModuleRuntime, type ModuleRuntimeContext, type ModuleRuntimeDefinition, } 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_IMAGE_MAX_BYTES, SIGNAGE_IMAGE_MAX_PIXELS, SIGNAGE_UPLOAD_BATCH_MAX_FILES, SIGNAGE_UPLOAD_CONCURRENCY, } from "./images/limits.js"; import { createPlaybackCapability } from "./playback/capability.js"; import { createPlaybackService, type PlaybackSnapshot, type SignagePlaybackService, } from "./playback/service.js"; import type { SignageProviderHandle } from "./providers/contract.js"; import { createNativeImageProvider, type NativeImageProvider, } from "./providers/native-provider.js"; import { createProviderRegistry, type SignageProviderRegistry } from "./providers/registry.js"; import { registerImageRoutes } from "./routes/images.js"; import { registerPlaybackRoutes } from "./routes/playback.js"; import { registerProviderRoutes } from "./routes/providers.js"; import { registerTimelineRoutes } from "./routes/timelines.js"; import { registerViewerRoutes } from "./routes/viewers.js"; import { readSignageSettings } from "./settings.js"; import { createSignageStore } from "./store/drizzle.js"; import { createDisplaysCapability } from "./viewers/capability.js"; /** * Signage's server runtime, as a factory rather than a module-level singleton. * * A factory because the runtime owns real state — the live provider registry, the built-in image * provider's registration handle, the playback service and its timers — and a test that exercised * registration twice against one shared instance would be testing leftovers. `index.ts` calls this * once; a test calls it per case. * * Registration stays declarative and I/O-free: nothing here connects to PostgreSQL, contacts object * storage, reads the environment, opens a socket, or applies a migration. Live services reach * Signage only through the contexts the platform hands `start`, `stop`, and route handlers. */ export interface SignageRuntimeOptions { readonly now?: () => Date; } export interface SignageModuleRuntime { readonly definition: ModuleRuntimeDefinition; /** The live registry, exposed for tests and for the routes that report provider health. */ readonly registry: SignageProviderRegistry; } /** Realtime carries compact authoritative state and revisions, never content (ADR 0005). Large * state — a timeline, a history, a renderer payload — is fetched over authorized HTTP keyed by * revision, which closes the GET-then-subscribe race without a second transport. */ export const SIGNAGE_DISPLAY_CHANNEL = "display-state"; export const SIGNAGE_MANAGEMENT_CHANNEL = "management-state"; export function createSignageModuleRuntime( options: SignageRuntimeOptions = {}, ): SignageModuleRuntime { const now = options.now ?? (() => new Date()); let registry: SignageProviderRegistry | undefined; let nativeProvider: NativeImageProvider | undefined; let nativeHandle: SignageProviderHandle | undefined; let playback: SignagePlaybackService | undefined; let store: ReturnType | undefined; /** The latest snapshot per group, so a publication never has to read the database from inside the * very lock that produced it. */ const snapshots = new Map(); function requireRegistry(): SignageProviderRegistry { if (!registry) { throw new Error("Signage's provider registry is only available after registration."); } return registry; } function requirePlayback(): SignagePlaybackService { if (!playback) { throw new Error("Signage's playback service is only available after start."); } return playback; } function requireStore(): ReturnType { if (!store) { throw new Error("Signage's store is only available after start."); } return store; } function publish(context: ModuleRuntimeContext): void { const groups = [...snapshots.values()].sort((left, right) => left.groupId.localeCompare(right.groupId), ); try { // The shape the display reads (`src/shared/display-protocol.ts`): each group's whole snapshot // carries the revisions it compares. const display: DisplayRealtimePayload = { groups, serverNowMs: now().getTime() }; context.realtime.publish(SIGNAGE_DISPLAY_CHANNEL, display); context.realtime.publish(SIGNAGE_MANAGEMENT_CHANNEL, { // Revisions only. The dashboard refetches what changed over authorized HTTP. groups: groups.map((snapshot) => ({ groupId: snapshot.groupId, revision: snapshot.revision, selectionRevision: snapshot.selectionRevision, transport: snapshot.transport, })), providers: requireRegistry() .health() .map((provider) => ({ consecutiveFailures: provider.consecutiveFailures, lastFailureCode: provider.lastFailureCode, providerId: provider.providerId, })), serverNowMs: now().getTime(), }); } catch { // Realtime is best effort and never blocks or changes committed playback state. } } const definition = defineModuleRuntime({ // Matches `database.abi` in the manifest: `context.services.database` is this module's Module // Database ABI v1 executor, which `createSignageStore` binds Signage's own Drizzle adapter to. database: { abi: 1 }, id: "signage", version: "0.1.3", register({ capabilities, config, http, realtime }) { const settings = readSignageSettings(config); registry = createProviderRegistry({ now, onInvalidate: (notice) => { // A notification, never a command: playback decides what to refresh and when, because it // is the only thing that knows what is on screen. void playback?.invalidate(notice).catch(() => undefined); }, timeoutMs: settings.providerTimeoutMs, }); // The extension seam every future content module reaches Signage through (ADR 0002). capabilities.provide("signage.content-providers", registry.capability); // The browser half of the same seam. Nothing registers against it server-side: a contribution // is declared in a *contributing* module's manifest and published by the platform, and this // declaration is what makes `capabilityRange` mean something. Resolving it tells a module // which version of Signage's renderer input contract this installation speaks. capabilities.provide( "signage.slide-renderer", Object.freeze({ inputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION }), ); // Sanitized playback state and commands for a future consumer such as an Ekklesia Companion. // Exactly what an operator has in the dashboard, over the same serialized service — and // deliberately no transport, topic, broker, or integration vocabulary of any kind. capabilities.provide( "signage.playback", createPlaybackCapability(requirePlayback, async () => { const groups = await requireStore().listChannelGroups(); return groups.map((group) => group.group.id); }), ); // Sanitized viewer and channel health, and assignment, for another module. Names, online // state, and what a screen last reported — never a credential, a pairing token, or a host // internal. capabilities.provide("signage.displays", createDisplaysCapability(requireStore, now)); realtime.channel({ channel: SIGNAGE_DISPLAY_CHANNEL, permission: "signage.display.view" }); realtime.channel({ channel: SIGNAGE_MANAGEMENT_CHANNEL, permission: "signage.view" }); http.route({ method: "GET", path: "/settings", permission: "signage.view", // A GET route accepts no request body at all. maxBodyBytes: 0, handler(_request, context) { const current = readSignageSettings(context.config); // Operator settings and Signage's own declared limits. Signage declares no `secret` // configuration key, and this route would still never serialize one: what an operator may // see of a module's configuration is the module's deliberate decision, value by value. // // The limits travel with the settings so the dashboard never hardcodes a number the // server enforces — a UI believing a stale limit would offer an upload the server refuses. return Response.json({ crossfadeMs: current.crossfadeMs, moduleVersion: "0.1.3", nativeImageDurationMs: current.nativeImageDurationMs, nativePresentation: current.nativePresentation, rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION, timezone: current.timezone, uploads: { batchMaxFiles: SIGNAGE_UPLOAD_BATCH_MAX_FILES, concurrency: SIGNAGE_UPLOAD_CONCURRENCY, maxBytes: SIGNAGE_IMAGE_MAX_BYTES, maxPixels: SIGNAGE_IMAGE_MAX_PIXELS, }, }); }, }); registerImageRoutes(http); registerProviderRoutes(http, requireRegistry); registerTimelineRoutes(http); registerPlaybackRoutes(http, requirePlayback); registerViewerRoutes(http, requirePlayback, requireRegistry); }, async start(context: ModuleRuntimeContext) { const settings = readSignageSettings(context.config); store = createSignageStore(context.services.database); // Signage's own content provider registers through the identical capability an external // provider uses. There is one selection pipeline, not a native special case (ADR 0001). nativeProvider = createNativeImageProvider({ defaultDurationMs: settings.nativeImageDurationMs, now, store, }); nativeHandle = requireRegistry().capability.register(nativeProvider.registration); playback = createPlaybackService({ now: () => now().getTime(), onSnapshot: (snapshot) => { snapshots.set(snapshot.groupId, snapshot); publish(context); }, registry: requireRegistry(), settings, store, }); // One default channel group with one default channel, and nothing else. `in-person` and // `online` are an administrator's decision, not an install-time one (ADR 0004). await playback.ensureDefaultGroup(); await playback.start(); // Every declared channel must receive an initial publication before `start` completes, so a // subscriber that connects immediately is given state rather than silence. publish(context); }, stop() { // Idempotent, and safe after a `start` that failed partway: `stop` runs before rollback, and // may run again during reverse-order shutdown. playback?.stop(); playback = undefined; store = undefined; snapshots.clear(); nativeHandle?.unregister(); nativeHandle = undefined; nativeProvider = undefined; registry?.clear(); }, }); return { definition, get registry(): SignageProviderRegistry { return requireRegistry(); }, }; }