feat: initial Signage 0.1.0
This commit is contained in:
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user