A module that sees something a tech must act on now (a timer overrun, a meter past its limit, timecode gone) raises an alert on a channel, 1–6, and every dashboard listening to that channel flashes: the menu bar — brought out for the duration if it was hidden — and a frame round the screen alternate between the level's two colours, with the alert's text bold across the bar. Low fades slowly, medium faster, critical snaps with no fade and floods the board behind the tiles too, in a muted cut that shows faintly through the tile panels without costing their content any contrast. Server: the hub. One alert per channel (a new one replaces the last), timed out server-side (1–600 s, 15 by default, 0 = held until cleared), broadcast over the shell stream as `alert` / `alert-clear`; GET /api/alerts for a dashboard that opens or reconnects mid-alert; POST /api/alerts and DELETE /api/alerts/<n>, open like a layout save. Modules raise from their server entry with shell.alert() / shell.clearAlert() or from a tile with moduleApi.alert() / moduleApi.clearAlert(); a module's alerts end when it stops. Colours live as alerts.colors in the data directory's proddash.json. Dashboard: the Layouts menu gains a folded Alerts row with six numbered toggles; the channel list saves with the layout, local and named (the server keeps it in a saved layout). Admin: a folded Alerts section with two colour pickers per level, a swatch running each level's cadence, and a Test row. Docs: docs/ALERTS.md (the feature), a "Raising alerts" section in the module guide, README. Tools: tools/alert.js raises/clears/lists from the command line; tools/alerts.test.js drives it all end to end. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1092 lines
57 KiB
Markdown
1092 lines
57 KiB
Markdown
# ProdDash Module Guide
|
||
|
||
How to build, install and test a ProdDash module. Everything a module author
|
||
needs is on this page — you never have to read the shell's source.
|
||
|
||
A **module** is one kind of dashboard tile: the live transcript, the
|
||
Now/Next display, a clock. Users add any number of tiles of your module to
|
||
their own dashboards, move and resize them, and give each tile its own
|
||
per-tile settings. Your module renders inside the tile and talks to the
|
||
outside world only through the small API the shell hands it.
|
||
|
||
## Rules every module follows
|
||
|
||
These are not style preferences — the admin page, the installer and the
|
||
people running the booth depend on them.
|
||
|
||
1. **All settings live in the ProdDash admin page.** Server-wide settings go
|
||
in `configSchema`, per-tile settings in `instanceSchema`. Never a separate
|
||
setup page, wizard, config file the operator edits by hand, or environment
|
||
variable.
|
||
2. **Dependencies ship inside the module folder.** Zero dependencies is the
|
||
norm (the shell has none). Anything you genuinely need is vendored into
|
||
the module — or fetched by your own `init()` on first start, with the
|
||
admin health line saying what is happening. No `npm install`, no
|
||
installer, no "run this first".
|
||
3. **Everything the module does runs inside the module.** No helper scripts,
|
||
companion programs, daemons or services the operator has to start next to
|
||
ProdDash. If your feature needs a listener, a decoder or a worker, your
|
||
server entry owns it: start it in `init()`, stop it in `stop()`, report
|
||
it in `health()`. A child process the module itself spawns and supervises
|
||
is fine; a program someone launches by hand is not. **A native helper —
|
||
anything that is not JavaScript — ships prebuilt inside the module folder,
|
||
for Apple silicon macOS and 64-bit Windows; nothing is ever compiled on
|
||
the machine running ProdDash** (see "Native helpers" below).
|
||
4. **Declare the ProdDash version you need** (`"proddash": ">=1.1.0"` in the
|
||
manifest) and **bump your own `version`** whenever you change the module.
|
||
The admin page refuses to install a module whose requirement the running
|
||
shell doesn't meet, and it offers updates by comparing versions.
|
||
|
||
### Keeping the interface clean
|
||
|
||
A dashboard is read across a booth, at a glance, by someone doing three
|
||
other things. These rules are what keep every tile looking like one product
|
||
instead of six. They are as binding as the four above.
|
||
|
||
5. **No explanatory text in a tile's gear.** No `help` on an
|
||
`instanceSchema` field or an `instanceGroups` entry. Labels are **three
|
||
words at most, one or two preferred** — "Slide count", "Item tags",
|
||
"Running clock". A control that cannot be understood in three words is
|
||
not a display choice; it belongs in `configSchema`, where `help` is
|
||
welcome and expected. *One exception:* a `template` field may keep a
|
||
single `help` line naming its placeholders, which nobody can guess.
|
||
6. **Server-side settings are admin-only.** Anything the module's server
|
||
acts on — a host, a credential, what to fetch, how often to poll — is
|
||
`configSchema`. `instanceSchema` is display choices and nothing else:
|
||
what this tile shows, and how big.
|
||
7. **A tile setting changes that tile and nothing else.** Never another
|
||
tile, never the module as a whole, never the server. If two tiles must
|
||
agree on something, it is either admin config or page state the shell
|
||
owns — not a setting each tile keeps its own copy of.
|
||
8. **Offer toggles for the elements you draw.** Every tile should let the
|
||
operator switch off the parts it does not need. **One to four toggles:**
|
||
give them no `group` at all, so they sit unlabelled at the top of the
|
||
gear. **Five or more:** one group named **Show**, `collapsed: true`,
|
||
`columns: 2`. Nothing else goes in that group — colours, formats and
|
||
text belong in groups of their own.
|
||
9. **Title-bar controls are icons.** No `label` on `addButton` or
|
||
`addMenu`; the tooltip carries the words, and `docs` above covers drawing
|
||
the icon. A small state indicator may ride alongside an icon — ProdCom's
|
||
channel button shows a count while a filter is on — but a control is
|
||
never named in the bar.
|
||
10. **If a control needs words to explain it, it is not a title-bar
|
||
control.** Put it in the gear. The bar is for actions whose icon and
|
||
tooltip say everything: refresh, clear, jump to latest, bigger text.
|
||
11. **One control per action.** Whatever has a dedicated title-bar button is
|
||
not offered again in the gear, and the reverse. Text size is A− / A+ up
|
||
there, never a "Text size" field; a timestamps button in the bar means
|
||
no timestamps switch below it.
|
||
|
||
## The shape of a module
|
||
|
||
A module is one folder dropped into `modules/`:
|
||
|
||
```
|
||
modules/<id>/
|
||
├── module.json manifest (required)
|
||
├── client.js ES module, client entry (required)
|
||
├── server.js server entry (optional — only if you need server code)
|
||
├── bin/ native helpers, prebuilt per platform (only if you need one)
|
||
│ ├── <name>-macos-arm64
|
||
│ └── <name>-win-x64.exe
|
||
├── native/ their source and the script that builds every bin/ file
|
||
└── ... assets (css, images…) served statically at /modules/<id>/
|
||
```
|
||
|
||
**Installation is exactly this:** in the admin page, open **Available
|
||
modules** and press **Install** — the module is downloaded from the ProdDash
|
||
repo into the server's data directory (`<data dir>/modules/<id>/`) and starts
|
||
immediately. A packaged ProdDash — the macOS app, the Windows bundle — ships
|
||
with **no modules at all**: every one of them arrives this way, and the
|
||
shell's own update never brings one along, so what is in git is exactly what
|
||
runs on a booth. Developing locally? A git checkout runs whatever is in its
|
||
own `modules/` folder (the repo's copies, "bundled"), or drop a folder into
|
||
the data directory's `modules/` and restart. No shell code changes, ever. In
|
||
a checkout the two live side by side; when both have the same id the newer
|
||
version runs. **Uninstall** in the admin page removes an installed module's
|
||
files (in a checkout, the tree's copy is hidden instead, since that folder
|
||
belongs to the repo); its settings are kept for a later reinstall.
|
||
|
||
### Native helpers
|
||
|
||
Some features need a program JavaScript cannot be: reading an audio input,
|
||
decoding timecode, measuring sound. Rule 3 says the module owns that program;
|
||
this section says how it ships: **already built, for every platform it runs
|
||
on, inside the module folder.** The machine running ProdDash is a packaged
|
||
app or the Windows bundle plus modules from the admin page's catalog. It has
|
||
no compiler and never will — the catalog delivers exactly the folder that is
|
||
in the repo, so the binaries live in git next to the code.
|
||
|
||
- **Platforms.** Apple silicon macOS and 64-bit Windows are required. Intel
|
||
macOS is welcome but optional. One file per platform in `bin/`, named for
|
||
what it is:
|
||
|
||
| Platform | File |
|
||
| --- | --- |
|
||
| macOS, Apple silicon | `bin/<name>-macos-arm64` |
|
||
| Windows, 64-bit | `bin/<name>-win-x64.exe` |
|
||
| macOS, Intel (optional) | `bin/<name>-macos-x86_64` |
|
||
|
||
- **Pick it at runtime, and cope without it.** Choose by `process.platform`
|
||
and `process.arch`; when there is no file for this machine, the feature
|
||
says so — in `health()` and in the tile's `setStatus` — and everything else
|
||
in the module keeps working. Never try to build it, never look for a copy
|
||
elsewhere on the machine, never tell the operator to install anything.
|
||
|
||
```js
|
||
const HELPERS = {
|
||
darwin: { arm64: 'ltc-capture-macos-arm64', x64: 'ltc-capture-macos-x86_64' },
|
||
win32: { x64: 'ltc-capture-win-x64.exe' },
|
||
};
|
||
const name = HELPERS[process.platform]?.[process.arch];
|
||
const helper = name ? path.join(__dirname, 'bin', name) : '';
|
||
if (!helper || !fs.existsSync(helper)) {
|
||
// Timecode is off on this platform. Say it once, keep the clock running.
|
||
}
|
||
```
|
||
|
||
- **One contract, any language.** Every platform's binary speaks the same
|
||
command line and output format, so `server.js` has one code path. What is
|
||
behind it is your choice per platform — Swift against CoreAudio on macOS,
|
||
C#, C or Rust on Windows — the module does not care.
|
||
- **Source and build beside it.** Keep the source under `native/` with one
|
||
script that produces every `bin/` file from a checkout, so any binary can be
|
||
rebuilt from what is committed. A helper that cannot be rebuilt does not
|
||
ship.
|
||
- **Bump the module version whenever a binary changes**, like any other
|
||
file — that is how installs learn about it.
|
||
- **Keep them lean.** Optimised builds, no debug symbols, a few megabytes at
|
||
most: the catalog fetches the whole repo as one archive, capped at 80 MB.
|
||
- **Signing.** Apple silicon refuses unsigned code, so sign the macOS binary
|
||
— Developer ID when you have it, ad-hoc at least. Windows needs nothing:
|
||
files the app writes carry no download mark, so SmartScreen never sees
|
||
them. Permissions come from the launcher (see "Permissions macOS has to
|
||
grant"), not from the binary.
|
||
|
||
A helper that ends up existing for one platform only is an exception made
|
||
on purpose, not a default: say so in the module's README and in the health
|
||
line, so a Windows booth knows why the feature is off.
|
||
|
||
## module.json
|
||
|
||
```json
|
||
{
|
||
"id": "my-module",
|
||
"name": "My Module",
|
||
"version": "1.0.0",
|
||
"proddash": ">=1.1.0",
|
||
"description": "One line shown in the Add-tile picker and the admin page",
|
||
"permissions": [
|
||
{ "kind": "microphone", "reason": "Why this module needs it, in one line" }
|
||
],
|
||
"client": "client.js",
|
||
"server": "server.js",
|
||
"style": "style.css",
|
||
"minSize": { "w": 2, "h": 2 },
|
||
"defaultSize": { "w": 4, "h": 3 },
|
||
"configSchema": {
|
||
"upstreamUrl": { "type": "string", "label": "Upstream URL", "default": "http://10.0.0.5:1234" },
|
||
"apiKey": { "type": "password", "label": "API key", "default": "" }
|
||
},
|
||
"instanceSchema": {
|
||
"channel": { "type": "string", "label": "Channel filter", "default": "" }
|
||
}
|
||
}
|
||
```
|
||
|
||
| Field | Required | Meaning |
|
||
| --- | --- | --- |
|
||
| `id` | yes | Must equal the folder name. Lowercase, dashes. |
|
||
| `name` | yes | Human name shown on the tile header and in menus. |
|
||
| `version` | yes | Your module's version (`x.y.z`). Bump it on every change — the admin page offers updates when the repo's copy is newer than the installed one. |
|
||
| `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. |
|
||
| `description` | no | Shown in the admin page / picker. |
|
||
| `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. |
|
||
| `provides` | no | Capabilities other modules can consume — `["timers"]`, `["messaging"]`. Forwarded in `/api/modules` so a consumer can find you. See **Working with other modules**. |
|
||
| `client` | yes | Client entry file, loaded as an ES module. |
|
||
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
||
| `style` | no | A stylesheet the shell injects once per module. |
|
||
| `minSize`, `defaultSize` | no | Tile size in grid cells, declared for the default grid (12 columns, 72 px rows). Defaults: min 1×1, default 4×3. A dashboard may cut its board finer or coarser (8–24 columns, from the Layouts menu; row height follows). `defaultSize` is scaled to that density when a tile is added, so a new tile lands at about the size you meant; `minSize` is enforced in cells as declared, so on a finer grid a tile can be made smaller than it could at 12. |
|
||
| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. |
|
||
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). They apply as they are changed, so keep them to display choices. |
|
||
| `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. |
|
||
|
||
Both schemas map field names to specs:
|
||
|
||
```
|
||
{ "type": "string" | "text" | "number" | "boolean" | "switch" | "select" | "multiselect" | "template" | "color" | "password" | "endpoint",
|
||
"label": "Shown next to the field",
|
||
"default": <value>,
|
||
"group": "Section heading", // optional; see below
|
||
"showWhen": "<other boolean/switch key>", // optional; see below
|
||
"help": "One line shown under the control", // optional, admin config only
|
||
"options": [ { "value": "a", "label": "A", "group": "Optgroup" }, … ], // "select" only
|
||
"optionsRoute": "/devices", // "select" only, admin config only
|
||
"optionsDependsOn": "<other key>" // with optionsRoute; see below
|
||
}
|
||
```
|
||
|
||
`boolean` and `switch` are the same true/false value. In a tile's gear popover
|
||
both render as a switch; in the admin page `switch` is the toggle and
|
||
`boolean` the plainer checkbox (use `switch` for a feature on/off that other
|
||
fields depend on). **Label a switch with the noun of what it turns on** —
|
||
"Seconds", "Top bar", "Section headers" — never "Show seconds": on means
|
||
shown or enabled, off means hidden or disabled, and the label shouldn't say
|
||
so twice.
|
||
`color` renders a swatch picker and stores a `#rrggbb` string (give it a hex
|
||
default; the tile applies it as a CSS custom property — see `pco-plan`).
|
||
`multiselect` is several picks from one list — the same `options` /
|
||
`optionsRoute` as `select`, rendered as one switch per option and stored as
|
||
an array of values. A saved value the list no longer offers stays checked and
|
||
marked, so nothing is dropped behind anyone's back. In a tile's gear popover
|
||
it also takes `columns` (2–4) and `allByDefault: true`: a tile that has never
|
||
chosen shows every option on and saves nothing until someone flips one, so
|
||
options that turn up later are on as well (give it no `default` then).
|
||
`select` and `multiselect` may use `optionsRoute` in `instanceSchema` too —
|
||
the popover GETs `/api/modules/<id><optionsRoute>` like the admin page does.
|
||
|
||
`template` (tile settings) is a text with placeholders, built by dragging or
|
||
clicking the variables the module offers into it: `"variables": [ { "value":
|
||
"{series}", "label": "Series" }, … ]`. Keep `help` to the joining rules; the
|
||
chips carry the names.
|
||
|
||
`text` is a multi-line string — a `<textarea>` in the admin page, stored with
|
||
its newlines; one item per line is the convention (the `slack` module's quick
|
||
replies). Admin config only.
|
||
|
||
`group` gathers consecutive fields under a labeled subsection in the admin
|
||
page, so a module with several distinct concerns (e.g. a ProPresenter
|
||
connection and an LTC audio input) reads as clearly separated blocks. Fields
|
||
with no `group` render loose, before/after the grouped ones in schema order.
|
||
|
||
A manifest-level `configGroups` map can dress a group up further, keyed by
|
||
the group name:
|
||
|
||
```json
|
||
"configGroups": {
|
||
"Planning Center credentials": { "collapsed": "whenSet", "help": "Where to get a token…" },
|
||
"Show / hide": { "collapsed": true, "columns": 3 }
|
||
}
|
||
```
|
||
|
||
- `collapsed`: `true` (starts folded), `false` (starts open but can fold),
|
||
or `"whenSet"` — folded once the group already holds values (a saved
|
||
password, or anything other than the schema default), open while it is
|
||
still empty. Omit it and the group is a plain, always-open block.
|
||
- `columns`: 2–4 lays the group's fields out in a grid — the way to keep a
|
||
long checklist of booleans compact.
|
||
- `help`: guidance for the whole group. The admin page shows it as an
|
||
ⓘ beside the group title (click or hover to read), so a group can carry a
|
||
paragraph without pushing its fields down the page. Say where a value comes
|
||
from, not what a field is.
|
||
- `toggle`: the key of a `switch` field in this group. It renders in the
|
||
group's title row, at the right, as the group's on/off; the rest of the group
|
||
dims while it is off, and the field is not repeated in the body. This is how
|
||
an integration is disabled without unmounting the module — ProPresenter
|
||
timers, say, on a Sunday when ProPresenter isn't running.
|
||
|
||
`help` on a field puts one line of guidance under its control — allowed
|
||
placeholders, where a value comes from. Keep labels short and put the
|
||
explanation there. **This is for `configSchema` only**: a tile's gear
|
||
carries no explanations at all (rule 5), so a setting that needs one is
|
||
telling you it belongs in admin.
|
||
|
||
`group` works on `instanceSchema` fields too, and a manifest
|
||
`instanceGroups` map plays the `configGroups` role for the tile's gear
|
||
popover (`collapsed: true | false`, `columns`, `toggle` — but not `help`,
|
||
per rule 5). That is how a module with a long show/hide checklist keeps the
|
||
popover usable: five or more hide-toggles go in one **Show** group, folded
|
||
by default, and fewer than five go in no group at all — see `pco-plan` for
|
||
the long form and `slack` for the short one. Display choices belong there,
|
||
per tile; admin config is for connections. The popover itself is a small window beside the tile — sized to
|
||
its content, moved by the slim bar on top, closed by clicking anywhere else
|
||
or pressing Escape — so a tile never has to be big enough to hold its own
|
||
settings, and a change can be watched as it lands. Under everything you
|
||
offer, the shell adds **Tile tint** — a colour the operator can wash that
|
||
tile's chrome in, saved with the layout. It is not yours to declare and it
|
||
never remounts you; don't offer one of your own.
|
||
|
||
A picker entry (see **Presenting multiple tiles**) may carry its own
|
||
`instanceSchema` and `instanceGroups`: tiles added from that entry get those
|
||
instead of the module's. A tile that shows one ProPresenter timer has no use
|
||
for a clock's format switches — give it a schema of what applies.
|
||
|
||
`showWhen: "<key>"` hides a field until the boolean/`switch` field named
|
||
`<key>` is on — e.g. the audio device and channel appear only once the LTC
|
||
listener switch is enabled. The hidden field still keeps its stored value.
|
||
It works the same in a tile's gear (`instanceSchema`): "Short day" and
|
||
"Short month" appear once the clock's "Date" is on.
|
||
|
||
A `select` whose choices are only knowable at runtime (audio devices, serial
|
||
ports, discovered sources) sets `optionsRoute` instead of — or as a fallback
|
||
refresh of — static `options`: the admin page GETs
|
||
`/api/modules/<id><optionsRoute>` and expects `{ "options": [ { "value",
|
||
"label" }, … ] }` from one of the module's own server routes. The saved value
|
||
stays selectable even when the route is down or the option has vanished. (See
|
||
`propresenter-timers`' "Audio device" for a worked example.)
|
||
|
||
An option may carry a `group`: options sharing one render under a single
|
||
`<optgroup>`, so a tree (Planning Center folders, device classes) is
|
||
browsable inside the select. `optionsDependsOn: "<key>"` makes the admin
|
||
page fetch the options with that other field's current value appended as a
|
||
query parameter (`?<key>=<value>`), now and again whenever it changes — e.g.
|
||
a plan list that follows the chosen service type. (Both in `pco-plan`.)
|
||
|
||
`password` fields are admin-config only in practice: their values are stored
|
||
in the server's `modules.json` (in the per-machine data directory, see the
|
||
README), handed to your *server* entry, and **never sent to browsers** — not in `/api/modules`, and never echoed back into the admin
|
||
form. Anything upstream that needs the secret must go through your server
|
||
routes.
|
||
|
||
### Connection settings: use `endpoint`
|
||
|
||
Anything that points at a device or service on the network — ProPresenter,
|
||
ProdCom, a camera, a mixer — must be **one `endpoint` field**, so every
|
||
module's connection is entered the same way. The admin page renders an
|
||
endpoint as a single "Host / IP : Port" control; the value is stored and
|
||
handed to your server as an object:
|
||
|
||
```json
|
||
"propresenter": {
|
||
"type": "endpoint",
|
||
"label": "ProPresenter",
|
||
"default": { "host": "", "port": 1025 }
|
||
}
|
||
```
|
||
|
||
```js
|
||
// in your server entry
|
||
const { host, port } = config.propresenter;
|
||
const base = `http://${host}:${port}`;
|
||
```
|
||
|
||
Do **not** split host and port into separate `string`/`number` fields, and
|
||
do **not** ask for a full `http://…` URL unless the setting genuinely is a
|
||
web address where protocol or path matter (a page to embed, a webhook).
|
||
Endpoints belong in admin config, not per-tile settings — connections are
|
||
server-wide by design.
|
||
|
||
Admin config lives in `modules.json` inside the server's data directory
|
||
(written by the admin page — your module never touches that file, and it
|
||
survives updates of the app folder). Per-tile settings live inside each
|
||
browser's layout.
|
||
|
||
### Permissions macOS has to grant
|
||
|
||
Some things a module does need the operating system's permission, not just
|
||
configuration: reading an audio input, reaching gear on the local network.
|
||
macOS grants those to the **application that owns the process**, which for
|
||
ProdDash is the macOS launcher (`launcher/macos`) — the server is its child,
|
||
so the prompts are asked, and remembered, in ProdDash's name. A server
|
||
started over SSH or from a bare launchd job has no such owner, and the
|
||
permission is silently absent rather than refused: an LTC input reads as
|
||
endless zeroes, a local host simply never answers.
|
||
|
||
Declare what you need, and why, so the launcher's Permissions tab can list
|
||
it and ask for it before a service rather than during one:
|
||
|
||
```json
|
||
"permissions": [
|
||
{ "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" },
|
||
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
|
||
]
|
||
```
|
||
|
||
| `kind` | When to declare it |
|
||
| --- | --- |
|
||
| `microphone` | The module reads an audio input (`ltc-capture` and anything like it). |
|
||
| `local-network` | The module talks to gear on this building's network. Not needed for services out on the internet — Planning Center, say. |
|
||
|
||
`reason` is one line, shown to the operator as-is when they hover the
|
||
permission; modules giving the same reason are listed together. A bare `"permissions": ["microphone"]` works when
|
||
the standing explanation is enough. A `kind` the launcher doesn't manage is
|
||
still listed, so nothing goes unmentioned — the shell itself ignores the key
|
||
entirely, and other platforms ignore it too.
|
||
|
||
## Client entry (`client.js`)
|
||
|
||
An ES module whose **default export is a factory**. The shell calls it once
|
||
per tile:
|
||
|
||
```js
|
||
export default function create({ root, moduleApi }) {
|
||
return {
|
||
start() {}, // begin rendering / connecting
|
||
stop() {}, // tear down EVERYTHING (timers, streams, observers)
|
||
onResize(w, h) {}, // optional; tile was resized (w, h in grid cells)
|
||
onConfigChange(cfg) {} // optional; admin config was saved
|
||
};
|
||
}
|
||
```
|
||
|
||
- `root` — the tile's body element. **The module owns everything inside it
|
||
and must never touch DOM outside it.**
|
||
- All four methods are optional, but a module without `stop()` will leak
|
||
timers when its tile is removed. `stop()` must leave nothing running.
|
||
|
||
### moduleApi
|
||
|
||
| Member | What it is |
|
||
| --- | --- |
|
||
| `id` | Your module id. |
|
||
| `instanceId` | Unique per tile — two tiles of the same module get different ids. Handy for keying anything per-tile. |
|
||
| `variant` | For multi-tile modules: the id of the tile-list entry this tile was added as, `''` otherwise (see **Presenting multiple tiles**). |
|
||
| `config` | Admin (server-wide) config, read-only, always current. Password fields are absent — they never reach the client. |
|
||
| `instanceSettings` | This tile's settings: your `instanceSchema` defaults overlaid with whatever this tile has saved. Read it fresh whenever you render — don't cache it. Keys beginning `__` are the shell's own (`__tint`, the tile's colour) — never use that prefix, and ignore the ones you find. |
|
||
| `saveInstanceSettings(patch)` | Merge `patch` into this tile's settings and persist them with the layout. No restart happens — you made the change, you already know. Use it for state the user sets *inside* your tile (a toggled filter, a chosen tab). |
|
||
| `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules/<id>/state`. |
|
||
| `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. |
|
||
| `setStatus(state, msg)` | Drives the tile's status dot: `'ok'`, `'connecting'` or `'error'`, plus a hover message. Call it honestly — during a live service the dot is how an operator spots a dead feed. |
|
||
| `alert(opts)` | Flash every dashboard listening to an alert channel — `{ channel, level, text, duration }`, see **Raising alerts**. Returns a promise. A module with a server entry raises from there (`shell.alert`) instead. |
|
||
| `clearAlert(channel)` | End the alert on a channel early. Returns a promise. |
|
||
| `header.addButton(…)` | Put a small button in the tile's own title bar (see below). |
|
||
| `header.addMenu(…)` | Put a dropdown menu button there (see below). |
|
||
|
||
### Title-bar controls
|
||
|
||
Modules don't build their own toolbars — actions live in the tile's title
|
||
bar, next to the shell's gear/close buttons:
|
||
|
||
```js
|
||
const btn = moduleApi.header.addButton({
|
||
icon: '<svg …>…</svg>', // inline SVG string (13 px), and/or
|
||
label: 'Timers ▾', // short text
|
||
title: 'Larger text', // tooltip — one or two words; the shell shows
|
||
// it the moment the pointer arrives
|
||
onClick() { … },
|
||
});
|
||
btn.classList.add('active'); // toggle styling for on/off buttons
|
||
|
||
const menu = moduleApi.header.addMenu({
|
||
label: 'Channels ▾',
|
||
title: 'Channels',
|
||
build(menuEl) {
|
||
// called on every open with an emptied menu element — fill it with
|
||
// buttons (give them your scoped classes; the container is styled and
|
||
// positioned by the shell, hosted on the body under an element carrying
|
||
// your data-module, so your scoped CSS applies and nothing clips it)
|
||
},
|
||
});
|
||
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
||
```
|
||
|
||
Everything you add is removed automatically when your instance stops, so a
|
||
remount never duplicates controls. **Icons only, no labels** (rule 9): the
|
||
title bar is 32 px tall and shared with the tile's name, and the tooltip
|
||
says what the icon means. Draw icons as inline SVG rather than characters —
|
||
a glyph such as ✕ or + takes its size from whatever font each browser
|
||
picks; text-size buttons are a drawn − and +. A small state indicator may
|
||
ride alongside an icon (ProdCom's channel button carries a count while a
|
||
filter is on); a name may not.
|
||
|
||
**One control per action** (rule 11). Whatever has a dedicated header button
|
||
is not offered again in the gear popover: text size is − / + up here
|
||
(persisted through `saveInstanceSettings({ textSize })`, read back with a
|
||
sane fallback), never a "Text size" field in `instanceSchema`; a timestamps
|
||
or newest-first toggle in the header means no such switch in the gear. The
|
||
gear is for display choices that have no button of their own.
|
||
|
||
One more thing to know: the whole title bar folds away when the dashboard
|
||
auto-hides it, and always in fullscreen (a clean-view mode for wall
|
||
displays) — it comes back only while the pointer is at the tile's top edge.
|
||
Your controls fold away with it, so nothing essential — status, reconnects,
|
||
live data — may exist *only* as a header control.
|
||
|
||
`sse(path, handlers)` handlers:
|
||
|
||
```js
|
||
const stream = moduleApi.sse('/stream', {
|
||
open(e) {}, // (re)connected
|
||
error(e) {}, // connection lost — say so via setStatus
|
||
message(e) {}, // unnamed "data:" events
|
||
events: { state(e) {} }, // named events by name
|
||
});
|
||
// later: stream.close()
|
||
```
|
||
|
||
Your route serves a plain SSE stream; how it reaches the page is the
|
||
shell's business. A dashboard holds **one** connection for every stream on
|
||
it (`GET /api/stream`, which the server feeds from your route over
|
||
loopback) — browsers allow about six connections to a host and a tile per
|
||
stream ran out of them — so `open` also fires whenever that shared
|
||
connection is remade (a tile was added or removed) and whenever your route
|
||
comes back after a drop. Browsers' `EventSource` retries transient drops
|
||
itself but gives up for good when a retry gets a completed non-SSE
|
||
response — which is what a proxy's 502 looks like while the upstream is
|
||
down; the shell recreates a closed connection on a 3-second timer until it
|
||
works again, so **reconnect logic is free**: handle `open` (refresh/backfill
|
||
your state; you may have missed events) and `error` (report degraded
|
||
status), and you're resilient. Streams you forget to close are force-closed
|
||
when the tile unmounts — but close your own in `stop()` anyway.
|
||
|
||
### Lifecycle you must expect
|
||
|
||
- **Any number of instances.** Two transcript tiles with different filters
|
||
is a feature. Keep all state inside `create()`'s closure — no module-level
|
||
mutable state in `client.js`.
|
||
- **Remounts are routine.** Gear settings apply as the user changes them —
|
||
there is no Apply button — so each change calls `stop()` and starts a fresh
|
||
instance (typing is debounced, and an unchanged value remounts nothing).
|
||
Starting cheaply matters: hold connections and timers in the closure and
|
||
tear them down in `stop()`. When admin config changes, the shell calls your
|
||
`onConfigChange(cfg)` if you export one, otherwise it remounts you. Either
|
||
way you must come back cleanly.
|
||
- **The tile can be removed at any moment.** `stop()` is your only notice.
|
||
|
||
### Rules for the client
|
||
|
||
1. **Never touch DOM outside `root`. Never define global CSS.** Scope every
|
||
rule in your stylesheet with the attribute the shell puts on your tiles:
|
||
|
||
```css
|
||
[data-module="my-module"] .my-thing { … }
|
||
```
|
||
|
||
(Shadow DOM works too, if you prefer full isolation.)
|
||
|
||
2. **Style with the shell's CSS variables — no hardcoded colors.** The
|
||
palette: `--bg` (page), `--panel` (tile), `--border`, `--text`,
|
||
`--muted`, `--accent`, `--accent-text` (text that sits on the accent —
|
||
dark on most themes, white where the accent is deep), `--danger`,
|
||
`--warn`. Derive tints with
|
||
`color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes
|
||
every module look native — and what makes the dashboard's **themes**
|
||
(Admin → Theme) reach into every tile: a theme swaps those variables and
|
||
nothing else. A colour a tile is configured with (a `color` instance
|
||
setting, applied as a custom property on the tile) is set closer than the
|
||
theme and wins over it, by design.
|
||
|
||
3. **No external network calls from the client.** Browsers talk only to
|
||
ProdDash. Anything upstream (your device, service, API) goes through your
|
||
module's server routes — same reasoning as the reference apps: no CORS,
|
||
no per-browser credentials, one place that owns the connection.
|
||
|
||
4. **Handle upstream loss.** Reconnect with backoff (or lean on `sse()`),
|
||
and report honestly via `setStatus`. Tiles run unattended during live
|
||
services; a module that silently freezes is worse than one that says
|
||
it's down.
|
||
|
||
## Server entry (`server.js`)
|
||
|
||
Optional — only needed when your module talks to something upstream or keeps
|
||
server-side state. CommonJS, Node built-ins only (keep the zero-dependency
|
||
ethos; Node 18+ so global `fetch` is available). Exports either or both of:
|
||
|
||
```js
|
||
module.exports = {
|
||
init({ config, log, shell }) {
|
||
// Called on startup / re-init, BEFORE routes(). Start your upstream
|
||
// connection here. Return a handle:
|
||
return {
|
||
stop() {}, // tear everything down
|
||
health() { return { status: 'ok', message: '' }; } // optional
|
||
};
|
||
},
|
||
|
||
routes({ config, log, shell }) {
|
||
// Route table, mounted under /api/modules/<id>/
|
||
return {
|
||
'GET /state': (req, res) => { … },
|
||
'GET /stream': (req, res) => { … }, // SSE — see notes
|
||
'POST /command': (req, res) => { … },
|
||
'GET /proxy/*': (req, res) => { … }, // trailing /* = prefix route
|
||
'* /anything/*': (req, res) => { … }, // * method = any method
|
||
};
|
||
},
|
||
};
|
||
```
|
||
|
||
- `config` is your admin config: `configSchema` defaults overlaid with what
|
||
the admin page saved — **including password fields**.
|
||
- `log(...)` prefixes output with your module id.
|
||
- `shell.port` is the port ProdDash is serving on — what you need to read
|
||
another module's routes over loopback (`http://127.0.0.1:${shell.port}/api/
|
||
modules/<id>/…`). Never guess it.
|
||
- `shell.alert(opts)` flashes every dashboard listening to an alert channel
|
||
and `shell.clearAlert(channel)` ends one early — see **Raising alerts**.
|
||
Alerts your module raised end when it stops.
|
||
- Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is
|
||
the matched remainder (leading `/` included) and `req.search` is the query
|
||
string (with `?`, or `''`) — so a proxy rebuilds the upstream path as
|
||
`req.wildcard + req.search`.
|
||
- Exact routes match the path exactly; prefix routes (`/x/*`) match `/x` and
|
||
everything under it. First declared match wins.
|
||
- **Re-init instead of restart:** when the admin page saves your config, the
|
||
shell calls your handle's `stop()`, then `init()` and `routes()` again
|
||
with the new config. Keep module-level state inside `init()`'s closure (or
|
||
reset it there) so a re-init starts clean. Disabling a module unmounts its
|
||
routes entirely (clients get 404).
|
||
- `health()` feeds the admin page's status line — report your upstream's
|
||
reachability (`status: 'ok' | 'connecting' | 'error'`), not just "running".
|
||
- A throw from `init()`/`routes()` marks the module as failed: the error
|
||
shows in `/admin`, and API calls answer 502 until a config fix re-inits
|
||
it. Validate config early so bad input fails loudly there.
|
||
|
||
### Presenting multiple tiles
|
||
|
||
By default a module is one entry in the + Add-tile picker. A module can
|
||
instead present a **list of tiles** — one picker entry each, grouped under
|
||
the module's name. This is how a module whose content is naturally plural
|
||
works: one tile per ProPresenter timer, one tile per admin-configured page.
|
||
|
||
Export `tiles` from your server entry (sync or async):
|
||
|
||
```js
|
||
module.exports = {
|
||
init({ config, log }) { … },
|
||
routes({ config, log }) { … },
|
||
|
||
tiles({ config, log }) {
|
||
// e.g. derived from admin config, or discovered from your upstream
|
||
return config.pages.map((page) => ({
|
||
id: page.name, // stable id, stored with the tile
|
||
name: page.name, // picker label AND the tile's title
|
||
description: page.url, // optional; the picker's tooltip
|
||
settings: { page: page.name }, // preset instance settings
|
||
defaultSize: { w: 4, h: 3 }, // optional per-entry size overrides
|
||
minSize: { w: 2, h: 2 },
|
||
}));
|
||
},
|
||
};
|
||
```
|
||
|
||
- `tiles()` runs on **every picker load**, so the list may be live data —
|
||
keep it fast (answer from state you already hold; don't fetch upstream on
|
||
demand). A throw or a hang (>2 s) falls back to the classic single entry,
|
||
so a dead upstream can't break the picker.
|
||
- `instanceSchema` / `instanceGroups` on an entry replace the module's for
|
||
tiles added from it — settings that fit that tile, nothing else.
|
||
- `aliases: ['old-id', …]` on an entry names earlier variant ids it stands
|
||
for: a tile added under one of them (a layout saved by an older version of
|
||
your module) gets this entry's settings too. A tile whose variant matches
|
||
no entry and no alias falls back to the module's full `instanceSchema`.
|
||
- Entry `description`s are tooltips in the + picker now, not visible lines —
|
||
one short sentence.
|
||
- `single: true` on an entry allows at most one such tile per dashboard: the
|
||
picker greys the entry out once it is placed. Use it for a control surface
|
||
that would only confuse in duplicate — a send box, a master switch.
|
||
- When the user adds an entry, its `settings` overlay your `instanceSchema`
|
||
defaults in that tile's instance settings, the entry's `name` becomes the
|
||
tile's title, and `moduleApi.variant` carries its `id`.
|
||
- **A tile is shown only while its entry is offered.** The shell mounts a
|
||
tile added from an entry only when that entry (or one of its `aliases`) is
|
||
in your list right now; otherwise the tile keeps its place in the layout,
|
||
draws nothing, and appears the moment your list offers it again — a
|
||
Service timer waits for PCO Plan, a transcript for its channel. So your
|
||
client is never asked to render an entry you did not offer at mount time.
|
||
One case is still yours: an entry that vanishes *after* the tile mounted
|
||
(a timer deleted in ProPresenter mid-service). Render nothing for it — no
|
||
placeholder, no error — and the shell folds the tile away at its next
|
||
registry refresh. Never fall back to your default view in its place: five
|
||
different timers turning into five "All timers" is worse than five gaps.
|
||
- A client-only module can declare a fixed list as a `tiles` array in
|
||
`module.json` instead (same entry shape).
|
||
- An empty list (or no `tiles` at all) keeps today's behavior: one picker
|
||
entry, no preset.
|
||
|
||
### Server-Sent Events from a module
|
||
|
||
For live data, expose a `/stream` route and let clients use
|
||
`moduleApi.sse('/stream', …)`:
|
||
|
||
```js
|
||
'GET /stream': (req, res) => {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||
'Cache-Control': 'no-cache, no-transform',
|
||
Connection: 'keep-alive',
|
||
});
|
||
res.write('retry: 3000\n\n');
|
||
res.write(`event: state\ndata: ${JSON.stringify(latest)}\n\n`); // instant paint
|
||
streams.add(res);
|
||
req.on('close', () => streams.delete(res));
|
||
}
|
||
```
|
||
|
||
Broadcast only when the payload actually changed, send a `: ping\n\n`
|
||
comment every ~15 s so idle connections survive sleepy Wi-Fi, and wrap every
|
||
per-client `res.write` in try/catch (drop the client on failure). The
|
||
`propresenter-now-next` module is the reference implementation of this
|
||
pattern.
|
||
|
||
## Raising alerts
|
||
|
||
An alert is for the thing a tech must act on *now*, whichever tile they
|
||
happen to be looking at — a timer that has overrun, a meter past its limit,
|
||
timecode that has gone quiet mid-service. Every dashboard that listens to
|
||
the alert's channel flashes: its menu bar (brought out for the duration,
|
||
even if it was hidden) and a frame round the whole screen alternate between
|
||
the level's two colours, with your text bold across the middle of the bar.
|
||
The shell owns all of that — the colours (Admin → Alerts), the cadence,
|
||
which screens listen (each dashboard's Layouts menu → Alerts) — so a module
|
||
only has to say what happened. [docs/ALERTS.md](ALERTS.md) has the
|
||
operator's side.
|
||
|
||
Raise one from your server entry, where the condition is usually noticed:
|
||
|
||
```js
|
||
init({ config, log, shell }) {
|
||
// …when timecode drops:
|
||
shell.alert({ channel: config.alertChannel, level: 'critical', text: 'LTC lost', duration: 0 });
|
||
// …and when it is back:
|
||
shell.clearAlert(config.alertChannel);
|
||
}
|
||
```
|
||
|
||
or from a tile, through the shell's server:
|
||
|
||
```js
|
||
await moduleApi.alert({ channel: 2, level: 'medium', text: 'Service over by 5:00', duration: 20 });
|
||
```
|
||
|
||
| Field | Meaning |
|
||
| --- | --- |
|
||
| `channel` | `1`–`6`. Which dashboards flash: each listens to the channels its operator chose in the Layouts menu. A channel carries no meaning of its own — the booth decides that "1 is the stage, 2 is video" — so **make yours an admin setting** (`configSchema`, `type: "number"`, `min: 1`, `max: 6`) rather than hard-coding one. |
|
||
| `level` | `low` — a slow fade between the two colours, for something to know about; `medium` — a quicker pulse, for something to deal with soon; `critical` — no fade, the colours snap, and the board behind the tiles floods with a muted cut of them too, for something to deal with now. |
|
||
| `text` | What happened, in a few words — 80 characters at most (more is cut, whitespace is collapsed). It is shown as given, so write it the way it should read on a wall: `SPL 102 dB — over limit`, not `spl_over_limit`. |
|
||
| `duration` | Seconds, 1 to 600; `15` when omitted. `0` holds the alert until you clear it — for a condition you will *notice ending* (timecode lost, a feed down) and clear; a one-off event takes a duration. |
|
||
|
||
Both return the alert as raised (`{ id, channel, level, text, source,
|
||
startedAt, duration, endsAt, remainingMs }`) and throw — reject, on the
|
||
client — with a one-line reason when the input is refused. That is a bug
|
||
in your module, not something to show the operator.
|
||
|
||
What to know:
|
||
|
||
- **One alert per channel.** A new alert on a channel replaces whatever was
|
||
on it, whoever raised it; `clearAlert(channel)` ends whatever is on it.
|
||
Two modules on one channel talk over each other — one more reason the
|
||
channel is admin config.
|
||
- **Your alerts end when you stop.** A disable, an uninstall or a config
|
||
save that re-inits you clears every alert your module raised, held ones
|
||
included. If the condition is still true, raise it again from `init()`.
|
||
- **Raise on the transition, not the state.** `if (over && !wasOver)
|
||
shell.alert(…)` — an alert re-raised on every poll restarts its timer and
|
||
flashes for ever. Clear it, or let it expire, when the condition ends.
|
||
- **Make alerting a setting**, and the threshold too: a `switch` in
|
||
`configSchema`, off by default unless alerting is the module's reason to
|
||
exist. A screen that flashes for nothing teaches a room to ignore it.
|
||
- **It is not a status.** The tile's dot (`setStatus`) says how *you* are;
|
||
an alert says what the *room* must do. An unreachable upstream turns the
|
||
dot red; it raises an alert only if that is something a tech has to act on
|
||
during the service, and the operator switched that on.
|
||
- Alerts reach the server over `POST /api/alerts` — what `moduleApi.alert`
|
||
calls, with your module id as `source` — which anything on the network may
|
||
use too; `node tools/alert.js -c 1 -l critical "LTC lost"` raises one from
|
||
the command line while you build (`--clear`, `--list`).
|
||
|
||
## Working with other modules
|
||
|
||
Modules don't import each other. When one needs what another knows, it reads
|
||
that module's **own server routes over loopback**, exactly as a browser would:
|
||
`http://127.0.0.1:${shell.port}/api/modules/<id>/…`. The PCO Plan module
|
||
follows ProPresenter this way — it reads `propresenter-now-next`'s `/stream`
|
||
rather than talking to ProPresenter a second time. A consumer must survive the
|
||
provider being disabled, uninstalled, or restarting mid-service (404s and
|
||
dropped connections are normal; retry with backoff and say so in `health()`).
|
||
|
||
### Offering timers to the Timers module
|
||
|
||
The Timers module shows timers from anywhere: ProPresenter, LTC, its own
|
||
clock, and any module that offers some. A module offers timers by:
|
||
|
||
1. Declaring it — `"provides": ["timers"]` in `module.json`. The shell
|
||
forwards `provides` in `/api/modules`, which is how the Timers module finds
|
||
providers without a list anyone maintains.
|
||
2. Serving `GET /timers` from its server routes, returning a snapshot:
|
||
|
||
```json
|
||
{
|
||
"source": "pco-plan",
|
||
"label": "PCO Plan",
|
||
"updatedAt": 1757800000000,
|
||
"timers": [
|
||
{
|
||
"id": "service",
|
||
"label": "Service",
|
||
"kind": "elapsed",
|
||
"state": "running",
|
||
"startedAt": 1757799000000,
|
||
"elapsedMs": 1000000,
|
||
"remainingMs": null,
|
||
"targetMs": 4500000,
|
||
"status": { "text": "Welcome & Announcements", "tone": "ok" },
|
||
"detail": "Item 3 of 12"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
| Field | Meaning |
|
||
| --- | --- |
|
||
| `source`, `label` | Your module id, and the heading the Timers module puts over your timers. |
|
||
| `updatedAt` | ms since epoch when this snapshot was taken. Every duration below is *as of this instant*. |
|
||
| `id` | Stable within your module — the Timers module keys tile settings (shown/hidden, colours) on `source` + `id`. |
|
||
| `kind` | `elapsed` (counts up), `countdown` (counts down to zero), `clock` (a time of day; put the ISO time in `status.text`). |
|
||
| `state` | `running`, `paused`, `stopped` (finished; value frozen), `idle` (exists, hasn't started — a timer that will start at 11:00). |
|
||
| `startedAt` | ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll. |
|
||
| `elapsedMs`, `remainingMs` | The values at `updatedAt`; `null` when not meaningful for the kind. |
|
||
| `asOf` | Optional, per timer: ms epoch this timer's durations were measured, when that differs from the snapshot's `updatedAt` (e.g. a value phase-locked to an upstream's second flips). Consumers extrapolate from it instead. |
|
||
| `targetMs` | Planned length (elapsed) or full length (countdown), or `null`. Lets the consumer colour overruns. |
|
||
| `status` | Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". `tone`: `ok`, `warn`, `danger`, `muted`. |
|
||
| `detail` | Optional second line. |
|
||
|
||
The consumer derives the displayed time locally from `startedAt`/`elapsedMs`
|
||
and its own clock, so a snapshot every couple of seconds is plenty — `/timers`
|
||
must be cheap to answer (serve from state you already hold; never call
|
||
upstream to answer it). Optionally also serve `GET /timers/stream` as SSE
|
||
(`event: timers`, `data:` the same object, on every change plus a heartbeat
|
||
comment every 15 s); the consumer uses the stream when it exists and polls
|
||
`/timers` otherwise. Only offer timers a production tech would want on a
|
||
wall — the Timers module lets them hide any of them per tile, but every entry
|
||
you add is a decision someone has to make.
|
||
|
||
### Offering messaging to the Send Message module
|
||
|
||
The Send Message module is the one place a dashboard sends from — a
|
||
PIN-gated compose box and a grid of quick replies — whatever the platform.
|
||
It owns nothing upstream: a module that *can* deliver a message (Slack posts
|
||
to a channel, ProdCom puts typed text on a comms channel) offers that over
|
||
its own routes, keeps its one upstream connection, and the Send Message
|
||
module hands messages to it over loopback. A provider has no PIN, no send
|
||
tile and no quick replies of its own; the shell's Add-tile picker shows the
|
||
Send message and Quick replies tiles once, under Send Message.
|
||
|
||
A module offers messaging by:
|
||
|
||
1. Declaring it — `"provides": ["messaging"]` in `module.json`. The Send
|
||
Message module re-reads `/api/modules` every 30 s, so a provider installed
|
||
mid-session shows up without a restart.
|
||
2. Serving `GET /messaging` — what it can send to, from state it already
|
||
holds (it is polled every few seconds while a send tile is open):
|
||
|
||
```json
|
||
{
|
||
"label": "Slack",
|
||
"identity": "Apollo Beach",
|
||
"destinationRole": "recipient",
|
||
"status": { "state": "ok", "message": "Apollo Beach · #production, #ops" },
|
||
"destinations": [
|
||
{ "id": "C01ABC", "label": "#production" },
|
||
{ "id": "C02DEF", "label": "#ops", "color": "#5c9de0" }
|
||
],
|
||
"emptyMessage": "",
|
||
"maxChars": 4000
|
||
}
|
||
```
|
||
|
||
| Field | Meaning |
|
||
| --- | --- |
|
||
| `label` | The platform's name, as the sending line and the Send as menu show it. |
|
||
| `identity` | Who a message appears to come from when that is fixed by the connection, or `''`. Shown as "as Apollo Beach". |
|
||
| `destinationRole` | What a destination *is*. `recipient` (the default): a message goes **to** it — a Slack channel — and the operator picks one by clicking its transcript tile, so it never appears in the Send as menu. `identity`: a message goes out **as** it — a ProdCom channel — and the Send as menu lists them; the tiles then say "Send message to ProdCom as FOH" and "Sent to ProdCom as FOH". The menu exists only while some installed platform offers identities. |
|
||
| `status` | `ok`, `connecting` or `error` plus one line — the same thing your `health()` says. A provider in error is still listed, so the operator sees why a send will fail before trying. |
|
||
| `destinations` | Where a message can go, or who it can go out as: a stable `id` (what `/messaging/send` receives), a `label`, and an optional `color`. Offer only what will work — ProdCom lists just the channels that accept typed text. Order is the order the menu shows. |
|
||
| `emptyMessage` | Optional: why the list is empty, when you know ("No ProdCom channel accepts typed text — turn on typing for one in ProdCom"). The tiles show it in place of a destination. |
|
||
| `maxChars` | The longest text you accept; the Send Message module refuses longer ones before asking you. |
|
||
|
||
3. Serving `POST /messaging/send` — `{ "destination": "<id>", "text": "…" }`
|
||
with the browser's unlock token as `Authorization: Bearer …`. **Check the
|
||
token before touching your upstream**: the Send Message module owns the
|
||
PIN, so ask it — `GET http://127.0.0.1:${shell.port}/api/modules/send-message/verify`
|
||
with the same `Authorization` header answers `200 { ok: true, expiresAt }`
|
||
or `401`. A `404` means no Send Message module is installed or enabled:
|
||
refuse with a sentence that says so. Without this check, any browser on
|
||
the network could post as the location by calling your route directly.
|
||
Answer `200 { "ok": true, "label": "#ops" }` when it landed, otherwise
|
||
`{ "error": "<one sentence a tech can act on>", "code": "…" }` with `400`
|
||
(bad destination or text), `401` (the token was refused), `429` (your
|
||
upstream is rate-limiting) or `502` (your upstream failed). Validate the
|
||
destination against your own list — never pass an arbitrary id upstream.
|
||
|
||
The same verification gates any other outbound action a module offers from
|
||
its tiles — the Slack transcript's emoji reactions ride on it — so a module
|
||
never needs a PIN of its own: one unlock serves every messaging tile on the
|
||
page. The `slack` and `prodcom-transcript` modules are the reference
|
||
implementations (`verifyUnlock()` in either server entry is the whole check).
|
||
|
||
### Messaging on the page
|
||
|
||
Where the next message goes is a fact about the whole dashboard, not one
|
||
tile: the Send message tile, the Quick replies tile and every transcript on
|
||
the page must agree, and they belong to different modules. Modules don't
|
||
import each other, so the agreement lives on `window` — one plain object plus
|
||
`CustomEvent`s on `window`, created by whichever messaging-aware tile mounts
|
||
first:
|
||
|
||
```js
|
||
window.proddashMessaging = {
|
||
version: 1,
|
||
target: { provider: '', destination: '', providerLabel: '', destinationLabel: '', identity: '' },
|
||
unlock: { token: '', expiresAt: 0, reason: '' },
|
||
quickEmojis: [], // the Send Message module's admin list, plain strings
|
||
transcripts: new Map(), // instanceId → { provider, destination }
|
||
senders: new Set(), // instanceIds of Send message / Quick replies tiles
|
||
};
|
||
```
|
||
|
||
Events, all named `proddash:messaging:<name>` with the payload in `detail`:
|
||
|
||
| Event | Who sends it | Meaning |
|
||
| --- | --- | --- |
|
||
| `select` | a transcript tile, or a Send as menu | `{ provider, destination }` — the operator pointed the senders here. A tile bound to one recipient (a Slack channel transcript) names it; one that shows a whole platform (ProdCom) sends `destination: ''` and the Send Message tile keeps the identity it has chosen for that platform. |
|
||
| `target` | the Send Message tiles | `window.proddashMessaging.target` changed: where the next message goes, resolved. A transcript compares `provider` (and `destination`, when it has one) and lights its bottom edge. |
|
||
| `unlock` | the Send Message tiles; a provider's tile on a `401` | `window.proddashMessaging.unlock` changed. Read the token from the object, never cache it; `''` means locked, with `reason` saying why. |
|
||
| `transcripts` | transcript tiles | the `transcripts` map changed (a tile mounted, unmounted or learned its channel). Register once you know your destination; unregister in `stop()`. This is how a dashboard with one transcript sends there by default. |
|
||
| `senders` | the Send Message tiles | the `senders` set changed — a transcript's locked note can say "add a Send message tile" rather than "enter the PIN". |
|
||
|
||
A transcript tile needs only three things: emit `select` on a click in its
|
||
body, register in `transcripts`, and toggle a target class on `target`. A
|
||
tile that performs a gated action itself (a reaction) reads
|
||
`window.proddashMessaging.unlock.token` and sends it as the Bearer token to
|
||
its own module's route. The Send Message tiles save the chosen platform and
|
||
per-platform destination in their instance settings, so a named layout
|
||
remembers them; the page object is the live copy.
|
||
|
||
## Complete minimal example
|
||
|
||
A working clock — copy-paste this as your starter. (A slightly fancier
|
||
version ships in `modules/clock/`.)
|
||
|
||
`modules/hello-clock/module.json`
|
||
|
||
```json
|
||
{
|
||
"id": "hello-clock",
|
||
"name": "Hello Clock",
|
||
"version": "1.0.0",
|
||
"description": "Minimal example module",
|
||
"client": "client.js",
|
||
"style": "style.css",
|
||
"minSize": { "w": 2, "h": 1 },
|
||
"defaultSize": { "w": 3, "h": 2 },
|
||
"instanceSchema": {
|
||
"showSeconds": { "type": "boolean", "label": "Show seconds", "default": true }
|
||
}
|
||
}
|
||
```
|
||
|
||
`modules/hello-clock/client.js`
|
||
|
||
```js
|
||
export default function create({ root, moduleApi }) {
|
||
let timer = null;
|
||
|
||
function tick() {
|
||
const { showSeconds } = moduleApi.instanceSettings;
|
||
root.querySelector('.hc-time').textContent = new Date().toLocaleTimeString(
|
||
[], { hour: '2-digit', minute: '2-digit', ...(showSeconds ? { second: '2-digit' } : {}) }
|
||
);
|
||
}
|
||
|
||
return {
|
||
start() {
|
||
root.innerHTML = '<div class="hc-time"></div>';
|
||
tick();
|
||
timer = setInterval(tick, 250);
|
||
moduleApi.setStatus('ok', 'Ticking');
|
||
},
|
||
stop() {
|
||
clearInterval(timer);
|
||
root.innerHTML = '';
|
||
},
|
||
};
|
||
}
|
||
```
|
||
|
||
`modules/hello-clock/style.css`
|
||
|
||
```css
|
||
[data-module="hello-clock"] .hc-time {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 2.4em;
|
||
font-weight: 700;
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--text);
|
||
}
|
||
```
|
||
|
||
Drop the folder in `modules/`, restart ProdDash, and “Hello Clock” appears
|
||
in the Add-tile picker (new modules are enabled by default; `/admin` can
|
||
disable them).
|
||
|
||
## Testing your module
|
||
|
||
**Run it:**
|
||
|
||
```bash
|
||
node server.js # from the repo root — prints the LAN URL
|
||
```
|
||
|
||
The server logs each module it mounts (or why it refused to). `/admin`
|
||
shows mount errors and your `health()` line; the browser console shows
|
||
client-side failures (a tile that fails to start says so in the tile).
|
||
|
||
**Poke the pieces directly:**
|
||
|
||
- `GET /api/modules` — what the shell sees (your manifest + non-secret config).
|
||
- `GET /api/modules/<id>/...` — your routes, `curl`-able. For SSE:
|
||
`curl -N http://localhost:24500/api/modules/<id>/stream`.
|
||
- `GET /api/admin/state` — enabled flags, health, mount errors.
|
||
- `GET /api/alerts` — what is flashing, and the colours. `node tools/alert.js
|
||
-c 1 -l critical "LTC lost"` raises one without a module (`--clear`,
|
||
`--list`); a dashboard shows it once channel 1 is chosen in its Layouts
|
||
menu → Alerts.
|
||
|
||
**Mock upstreams** (so you can develop without the real gear):
|
||
|
||
- ProdCom: `node tools/prodcom-mock.js` (port 24480) — channels, groups,
|
||
history, a live stream with in-progress speech, and
|
||
`POST /api/v1/channels/{id}/send`, which lands typed text in the channel
|
||
as a `source: "typed"` entry (so a send from the Send Message module can
|
||
be watched arriving; a text containing `!429` answers RATE_LIMITED, and
|
||
`PRODCOM_MOCK_KEY=…` makes it demand that API key). Point the transcript
|
||
module's ProdCom endpoint at `127.0.0.1`, port `24480`.
|
||
- Send Message: no mock — it talks to the installed modules. Run the Slack
|
||
and ProdCom mocks, set a PIN under Send Message in `/admin` (or switch the
|
||
PIN off), add a transcript or two plus Send message and Quick replies from
|
||
+, and watch a send arrive in the transcript you clicked.
|
||
- ProPresenter: `node band-lineup-display/tmp/pp-mock.js 1599` — playlists
|
||
and an advancing slide index (including the arrangement-repeat case).
|
||
Point the Now/Next module at host `127.0.0.1`, port `1599`.
|
||
- ProPresenter timers: `node tools/pp-timers-mock.js 1600` — running,
|
||
overrunning, stopped and elapsed timers plus an LTC timecode that
|
||
periodically drops to no-signal (`--no-ltc` serves a ProPresenter without
|
||
a timecode API). Also serves the stage-display websocket with an
|
||
LTC-labeled layout field (`--stage-pwd`, `--no-ltc-field` exercise its
|
||
failure paths). Point the Timers module at host `127.0.0.1`, port `1600`.
|
||
- Planning Center: `node tools/pco-mock.js` (port 24700) — folders, service
|
||
types, plans (last week / today / next week), plan times and items with
|
||
songs, keys and item notes, behind a Basic/Bearer auth check. Planning
|
||
Center is a cloud API, so the module reads its base URL from the
|
||
environment instead of admin config: run ProdDash with
|
||
`PCO_PLAN_API_BASE=http://127.0.0.1:24700 node server.js`, enter any
|
||
Application ID + Secret under PCO Plan in `/admin`, save, then pick
|
||
"Sunday Service" from the Service type list. Its item titles match
|
||
`pp-mock.js`, so running both mocks exercises the ProPresenter matching.
|
||
- AbleSet: `node tools/ableset-mock.js 39071` — a setlist playing through
|
||
over AbleSet's OSC API (UDP): sections, a tempo and meter per song, a
|
||
section that loops twice, a song queued out of order, heartbeats.
|
||
`--stopped`, `--no-live` and `--speed 4` exercise the other states. Point
|
||
the AbleSet module at host `127.0.0.1`, port `39071`.
|
||
|
||
**The resilience checklist** — every module must pass this before it's done:
|
||
|
||
1. Add your tile, then **kill the upstream** (or the mock). The status dot
|
||
must go red (with a useful hover message) and the tile must show a clear
|
||
degraded state, not a freeze.
|
||
2. **Restart the upstream.** The tile must recover by itself — no reload,
|
||
no clicking.
|
||
3. **Restart ProdDash itself** with the tile open. Same expectation.
|
||
4. Add **two tiles of your module** with different per-tile settings — both
|
||
must work independently.
|
||
5. **Remove your tile**, then check the browser console and server log: no
|
||
errors, no timers still firing, no streams still open.
|
||
6. Change your admin config in `/admin` while the tile is open — the tile
|
||
must pick it up (via `onConfigChange` or the automatic remount).
|
||
7. Reload the page — the tile comes back with its settings intact.
|