0.1.3 ships the external package integration validated in #13 (Pass 2): the canonical linux-x64-glibc Module Package installed and exercised end to end by CI, with bounded external runtime image processing. Only the version moves: the manifest, package.json, the server runtime, both browser artifacts, the settings payload's moduleVersion, the test fixtures, and the README status line. compatibility stays platform ^0.2.0 and moduleSdk ^0.2.0; Core 0.2.4 already satisfies it, so the range is not tightened. pnpm-lock.yaml carries no root version and is unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
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<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 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();
|
|
},
|
|
};
|
|
}
|