feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
+267
View File
@@ -0,0 +1,267 @@
import {
defineModuleRuntime,
type ModuleRuntimeContext,
type ModuleRuntimeDefinition,
} from "@ekklesia/module-sdk";
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<typeof createSignageStore> | 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<string, PlaybackSnapshot>();
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<typeof createSignageStore> {
if (!store) {
throw new Error("Signage's database facade 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 {
context.realtime.publish(SIGNAGE_DISPLAY_CHANNEL, {
groups,
serverNowMs: now().getTime(),
});
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({
id: "signage",
version: "0.1.0",
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.0",
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();
},
};
}