feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user