344 lines
14 KiB
Markdown
344 lines
14 KiB
Markdown
# Writing a Signage content provider
|
|
|
|
Signage decides **when** something is on screen. Your module decides **what**.
|
|
|
|
This document is the published contract for `signage.content-providers` version **1.0.0**, and for
|
|
the renderer input Signage hands your browser contribution. It is written to be implementable from
|
|
here alone — you will not import a line of Signage's code, because Ekklesia modules are independently
|
|
installed peers and `pnpm boundaries` refuses a module that imports another module.
|
|
|
|
---
|
|
|
|
## 1. What you declare
|
|
|
|
In your own `ekklesia.module.json`:
|
|
|
|
```json
|
|
{
|
|
"dependencies": {
|
|
"modules": { "required": { "signage": "^0.1.0" } },
|
|
"capabilities": {
|
|
"required": {
|
|
"signage.content-providers": "^1.0.0",
|
|
"signage.slide-renderer": "^1.0.0"
|
|
}
|
|
}
|
|
},
|
|
"contributions": [
|
|
{
|
|
"id": "announcements.signage-slide",
|
|
"version": "1.0.0",
|
|
"capability": "signage.slide-renderer",
|
|
"capabilityRange": "^1.0.0",
|
|
"artifact": "./dist/contributions/signage-slide/index.js"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
The module dependency is what makes the platform start Signage before you and stop you before
|
|
Signage — so your registration always has something to register with, and your unregistration always
|
|
happens first.
|
|
|
|
## 2. What you register
|
|
|
|
During your own `start` hook:
|
|
|
|
```ts
|
|
const signage = context.capabilities.resolve("signage.content-providers");
|
|
if (!signage) {
|
|
return; // Signage is not installed. Nothing else about your module should care.
|
|
}
|
|
const registry = signage.implementation as SignageContentProviders;
|
|
this.handle = registry.register({
|
|
providerId: "announcements",
|
|
providerVersion: "1.0.0",
|
|
contractVersion: "1.0.0",
|
|
renderer: { contributionId: "announcements.signage-slide", contributionVersion: "1.0.0" },
|
|
defaultDurationMs: 10_000,
|
|
listSources,
|
|
listItems,
|
|
resolveNext,
|
|
refresh,
|
|
});
|
|
```
|
|
|
|
And release it in `stop`:
|
|
|
|
```ts
|
|
this.handle?.unregister();
|
|
```
|
|
|
|
`unregister` is idempotent, which matters: your `stop` hook runs **before** rollback if your own
|
|
`start` throws partway, and again during reverse-order shutdown.
|
|
|
|
### The types, to copy into your own module
|
|
|
|
```ts
|
|
type SignageJsonValue =
|
|
| boolean | number | string | null
|
|
| readonly SignageJsonValue[]
|
|
| { readonly [key: string]: SignageJsonValue };
|
|
|
|
interface SignageContentProviders {
|
|
readonly contractVersion: string;
|
|
register(registration: SignageProviderRegistration): SignageProviderHandle;
|
|
}
|
|
|
|
interface SignageProviderHandle {
|
|
unregister(): void;
|
|
invalidate(change?: { sourceId?: string; itemId?: string }): void;
|
|
}
|
|
|
|
interface SignageProviderRegistration {
|
|
providerId: string; // stable, yours, `[\w][\w.:-]*`, ≤ 200 chars
|
|
providerVersion: string; // semantic version
|
|
contractVersion: string; // "1.0.0"
|
|
renderer: { contributionId: string; contributionVersion: string };
|
|
defaultDurationMs: number; // 1000 … 3_600_000
|
|
listSources(request: DiscoveryRequest): Awaitable<readonly SourceDescriptor[]>;
|
|
listItems?(request: DiscoveryRequest & { sourceId?: string }): Awaitable<readonly ItemDescriptor[]>;
|
|
resolveNext(request: ResolveRequest): Awaitable<Resolution | null>;
|
|
refresh(request: RefreshRequest): Awaitable<Resolution | null>;
|
|
}
|
|
|
|
interface DiscoveryRequest { at: string; timezone: string }
|
|
|
|
interface SourceDescriptor { id: string; label: string; description?: string }
|
|
interface ItemDescriptor { id: string; label: string; description?: string; availableUntil?: string | null }
|
|
|
|
interface ResolveRequest {
|
|
selectionScopeId: string; // stable and opaque — see §3
|
|
sourceId: string;
|
|
timelineId: string;
|
|
channelGroupId: string;
|
|
audienceKeys: readonly string[];
|
|
at: string; // absolute ISO 8601 instant
|
|
timezone: string; // IANA zone
|
|
}
|
|
|
|
interface RefreshRequest extends Omit<ResolveRequest, "sourceId" | "selectionScopeId"> {
|
|
itemId: string;
|
|
sourceId?: string;
|
|
selectionScopeId?: string;
|
|
}
|
|
|
|
interface Resolution {
|
|
itemId: string;
|
|
itemRevision: string; // changes when the payload changes, item unchanged
|
|
durationMs?: number; // 1000 … 3_600_000
|
|
availableUntil?: string | null; // absolute ISO 8601 instant
|
|
withdrawn?: boolean;
|
|
presentation?: SignageJsonValue; // yours, for your own renderer
|
|
variants: readonly { audienceKey: string | null; payload: SignageJsonValue }[];
|
|
}
|
|
```
|
|
|
|
## 3. The selection scope is the whole idea
|
|
|
|
An operator's timeline might be:
|
|
|
|
```text
|
|
Announcements Announcements Announcements Trivia Announcements Announcements Scripture ↻
|
|
```
|
|
|
|
Those five Announcement slots are five **turns of one rotation**, not five rotations. Signage
|
|
expresses that by giving every slot that references one configured source instance the same
|
|
`selectionScopeId`, and asking `resolveNext` once per turn.
|
|
|
|
- The scope id is **stable**: it survives edits, reorders, renames, and restarts.
|
|
- It is **opaque**: its shape means nothing, so never parse it.
|
|
- It is **not** derived from the timeline or slot id, because either would silently reset your
|
|
rotation when an operator dragged a row.
|
|
|
|
Keep your cursor, your "already shown in this pass" set, and anything else you need **in your own
|
|
tables, keyed by scope id**. Signage stores none of it and will never tell you what it thinks your
|
|
cursor is.
|
|
|
|
Two configured source instances of the same source are two independent rotations. That is an
|
|
operator deliberately asking for one.
|
|
|
|
## 4. Repeat policy is yours
|
|
|
|
Signage implements none of it:
|
|
|
|
| Your module | Your policy |
|
|
| --- | --- |
|
|
| Announcements | Cycle the active set forever, wrapping where you choose |
|
|
| Trivia | Never repeat until every currently available question has appeared |
|
|
| Scripture | Never repeat until the reading pool is exhausted |
|
|
|
|
Signage asks "what is next in this scope?" and shows what you hand back.
|
|
|
|
## 5. Three answers, and why they stay three
|
|
|
|
| Return | Meaning | What Signage does |
|
|
| --- | --- | --- |
|
|
| `null` | Nothing available for this source right now | **Skips the slot immediately.** No error, no slate, no gap. |
|
|
| `{ withdrawn: true, … }` | This item is explicitly gone | Removes it at the next safe reconciliation boundary, never mid-slide. |
|
|
| throw / reject / exceed the deadline | A temporary failure | **Keeps the last known good selection and marks it stale.** The slot is not reset and playback does not stop. |
|
|
|
|
Collapsing these is the single most damaging mistake a provider can make. An empty question pool that
|
|
throws becomes an error on a lobby wall; a genuine outage that returns `null` silently rewrites the
|
|
running order and nobody finds out.
|
|
|
|
Every call is bounded by a deadline (an installation setting, 2 seconds by default). A call that
|
|
never settles is a temporary failure — Signage does not wait, and does not cancel your work.
|
|
|
|
## 6. One selection, many audiences
|
|
|
|
A channel group has one clock and one or more audience channels (`default`, and optionally
|
|
`in-person`, `online`, …). Signage asks you **once** per slot occurrence, with every audience key,
|
|
and you return **one logical item** with a variant per audience:
|
|
|
|
```ts
|
|
return {
|
|
itemId: "advent-service",
|
|
itemRevision: "3",
|
|
variants: [
|
|
{ audienceKey: null, payload: { headline: "Join us Sunday, 5pm" } },
|
|
{ audienceKey: "online", payload: { headline: "Streaming Sunday, 5pm" } },
|
|
],
|
|
};
|
|
```
|
|
|
|
- The **default variant is required** — `null` or the literal `"default"`.
|
|
- An audience with no variant of its own renders the default. That fallback is the contract, not a
|
|
convenience, and a provider with nothing audience-specific simply returns one variant.
|
|
- Resolving once is what keeps every channel on the same slide with the same deadline. There is no
|
|
per-channel resolution and no way to ask for one.
|
|
|
|
## 7. Invalidation updates a slide without restarting it
|
|
|
|
When your content changes — an event is cancelled, a countdown ticks past a threshold, a service time
|
|
is corrected — tell Signage:
|
|
|
|
```ts
|
|
this.handle.invalidate({ itemId: "advent-service" });
|
|
```
|
|
|
|
Signage decides what to do: it re-resolves the affected selection through `refresh`, and if your
|
|
`itemRevision` changed it stores the new payloads, bumps the selection's revision, and broadcasts.
|
|
A mounted React renderer receives new input **in place**, with the slot's authoritative deadline
|
|
unchanged.
|
|
|
|
This is a **notification, not a command**. You cannot skip a slot, extend a deadline, reorder a
|
|
timeline, or make something appear. That is deliberate: one provider must not be able to take over a
|
|
channel group.
|
|
|
|
## 8. What Signage validates, and rejects
|
|
|
|
Everything crossing the boundary, before it is believed:
|
|
|
|
- A registration whose id, version, renderer identity, duration, or functions are not the shape above
|
|
is refused with `SIGNAGE_PROVIDER_REGISTRATION_INVALID` — and a `contractVersion` outside `1.x` with
|
|
`SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED`, so an operator can see that your module needs an update
|
|
rather than that it is broken.
|
|
- A resolution is refused with `SIGNAGE_PROVIDER_RESULT_INVALID` when it has no default variant, a
|
|
duplicate audience key, more than 16 variants, payloads totalling over 32 KiB, a duration outside
|
|
1 s … 1 h, a non-absolute `availableUntil`, or a payload that is not genuinely finite JSON — a
|
|
`Date`, a function, `NaN`, `undefined`, a class instance, or a cycle.
|
|
- Every rejection carries a stable code and **nothing of your value**: not your payload, not your
|
|
exception, not your message. Signage will not put your words on a screen or in a log.
|
|
|
|
None of this is a sandbox, and it is not described as one. Your module is trusted code in the same
|
|
process. This is the difference between your bug staying your bug and a malformed value reaching a
|
|
television.
|
|
|
|
## 9. Your renderer
|
|
|
|
Your browser contribution is the actual live renderer for your content. Signage mounts it through the
|
|
platform's contribution ABI and treats it as completely opaque.
|
|
|
|
```tsx
|
|
export default defineModuleContribution({
|
|
id: "announcements.signage-slide",
|
|
version: "1.0.0",
|
|
styles,
|
|
mount(container, context) {
|
|
const root = createRoot(container);
|
|
const input = context.input as SignageSlideRendererInput;
|
|
root.render(<Slide input={input} />);
|
|
return {
|
|
update(next) { root.render(<Slide input={next as SignageSlideRendererInput} />); },
|
|
unmount() { root.unmount(); },
|
|
};
|
|
},
|
|
});
|
|
```
|
|
|
|
`mount` is asynchronous by contract. **Resolve it only when you are genuinely ready to be seen** — a
|
|
font loaded, an image decoded, a layout measured. Signage waits for that promise before it fades your
|
|
layer in, which is what makes a transition into a live renderer look like a transition into an image.
|
|
|
|
### The input, version 1.0.0
|
|
|
|
```ts
|
|
interface SignageSlideRendererInput {
|
|
contract: "signage.slide-renderer";
|
|
version: string; // "1.0.0"
|
|
providerId: string;
|
|
itemId: string;
|
|
itemRevision: string;
|
|
audienceKey: string;
|
|
payload: unknown; // yours, exactly as you returned it
|
|
presentation: unknown; // yours, or null
|
|
timing: {
|
|
startedAtMs: number; // absolute, server clock
|
|
deadlineAtMs: number;
|
|
durationMs: number;
|
|
paused: boolean;
|
|
remainingMs: number | null; // when paused
|
|
};
|
|
clock: { serverNowMs: number; localNowMs: number };
|
|
generation: string; // changes whenever anything above changed
|
|
}
|
|
```
|
|
|
|
**Use the clock anchor, not `Date.now()` alone.** A television's clock is routinely wrong by hours.
|
|
The authoritative now is:
|
|
|
|
```ts
|
|
const serverNow = input.clock.serverNowMs + (Date.now() - input.clock.localNowMs);
|
|
```
|
|
|
|
That stays correct however wrong the device's clock is, and it is what makes a countdown on two
|
|
screens agree.
|
|
|
|
### What your renderer must and must not do
|
|
|
|
Must:
|
|
|
|
- Be ready asynchronously, and update in place through `update` without remounting.
|
|
- Handle timing changes: paused, resumed, re-anchored after a reconnect.
|
|
- Unmount deterministically, releasing every timer, listener, and framework root.
|
|
- Survive repeated mount/unmount without leaking.
|
|
- Render sensibly for an input shape older or newer than the one you developed against.
|
|
|
|
Must not:
|
|
|
|
- Reach the network, a database, storage, or a credential. **You are given none of them**, on
|
|
purpose: mounting your renderer grants your module nothing, and everything you need arrives in
|
|
`payload` because Signage is the party that was authorized to fetch it.
|
|
- Assume your module's management UI is mounted, or that any other renderer exists.
|
|
- Extend the slot. `deadlineAtMs` was decided before you mounted.
|
|
- Depend on an external internet service. A renderer must work from its cached artifact and its
|
|
cached payload, because a display's network is the first thing that fails.
|
|
|
|
A provider that genuinely needs cloud access needs a **later** contract version. Version 1 will not
|
|
be weakened to allow it.
|
|
|
|
## 10. A checklist before you ship
|
|
|
|
- [ ] `providerId` is stable and will never change for this module.
|
|
- [ ] Cursor and no-repeat state live in your own tables, keyed by `selectionScopeId`.
|
|
- [ ] `resolveNext` returns `null` for "nothing right now" and throws only for a genuine failure.
|
|
- [ ] `refresh` returns `{ withdrawn: true }` for content that is genuinely gone.
|
|
- [ ] Every resolution has a default variant.
|
|
- [ ] Payloads are finite JSON, well under 32 KiB for all variants together.
|
|
- [ ] `itemRevision` changes exactly when the payload changes.
|
|
- [ ] `invalidate` is called when your content changes, and never used to try to control playback.
|
|
- [ ] Your renderer resolves `mount` only when it is ready, implements `update`, and cleans up.
|
|
- [ ] Your renderer needs no network.
|
|
- [ ] `unregister` is called from `stop`, and calling it twice is safe.
|