2 Commits
Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 d01ffb13e6 fix(display): retry every failed request with backoff, and never give up
A display tried each of its network paths once:
- A registration that failed left it continuing from its persisted
  state, behind the offline banner, with no realtime, heartbeat or
  retry, until someone reloaded it. This is what the production kiosk
  showed after a restart.
- A failed state refresh scheduled no further refresh, so a display
  whose realtime was also down never fetched state again.
- A realtime stream the host reported closed was never subscribed
  again.

Each path now retries on its own backoff: about 1 s, doubling to a
one-minute ceiling, with jitter so a building's screens do not return
in lockstep. There is no attempt limit, because a television has
nobody to press Retry. Each resets when its request works again.
- Registration retries until it works, then brings the display live
  exactly as a first-time success does.
- A failed refresh schedules its own retry, with no realtime needed.
- A closed realtime subscription is replaced.
- Stopping clears every retry.

The recovery test's virtual window shrinks from an hour to five
minutes: eight failures back off for at most about 183 s, and an hour
of offline-continuation timers made the test slow without proving more.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 18:35:46 -05:00
ekklesiaandClaude Opus 5.5 c41b3d02f1 test(display): a display recovers from every failed request without a reload
Pins what a kiosk needs from each network path, on a virtual clock: a
registration that fails once is retried with backoff until it works, a
state refresh that fails is retried without a realtime nudge, and a
realtime stream the host reports closed is subscribed again. Nothing is
scheduled after stop.

Fails today on all three paths: each is tried once. The first matches
the production screen exactly: mounted, showing "Offline — continuing
from the last update", with no heartbeat and no retry until someone
reloads it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 18:34:25 -05:00
28 changed files with 218 additions and 5782 deletions
-217
View File
@@ -1,217 +0,0 @@
name: CI
# Signage builds, tests, and runs only inside an Ekklesia workspace, so every job first assembles it
# into the platform commit pinned in ci/platform.env (ci/assemble.sh) and installs that workspace
# with --frozen-lockfile from ci/pnpm-lock.yaml.
#
# The same trust rules as the platform's own CI (docs/development/security.md there):
# * every action is pinned by a full commit SHA;
# * every checkout sets `persist-credentials: false`, and nothing in this workflow pushes;
# * no repository, organization, or instance secret is referenced;
# * there is no `pull_request_target`;
# * the self-hosted runner executes trusted, reviewed code only.
#
# The server is Gitea 1.24, which ignores `permissions`, `concurrency`, and `timeout-minutes`
# (https://docs.gitea.com/1.24/usage/actions/comparison). They are declared so the workflow is already
# correct on a release that honours them; on 1.24 they bound nothing, and `permissions` does not
# restrict the job token.
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: lint
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Nothing here pushes, so the job token is never needed after checkout and must not be left
# in .git/config for a later step to read.
persist-credentials: false
- name: Assemble Signage into the pinned platform
run: bash ci/assemble.sh
- name: Set up Node.js at the platform's version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: build/workspace/.node-version
- name: Install the assembled workspace (frozen lockfile)
working-directory: build/workspace
run: |
corepack enable
corepack install
pnpm install --frozen-lockfile
- name: Lint
working-directory: build/workspace
run: pnpm --filter @ekklesia/signage lint
- name: Check that CI cleans up every stack it starts
run: bash ci/check-cleanup.sh
typecheck:
name: typecheck
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Nothing here pushes, so the job token is never needed after checkout and must not be left
# in .git/config for a later step to read.
persist-credentials: false
- name: Assemble Signage into the pinned platform
run: bash ci/assemble.sh
- name: Set up Node.js at the platform's version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: build/workspace/.node-version
- name: Install the assembled workspace (frozen lockfile)
working-directory: build/workspace
run: |
corepack enable
corepack install
pnpm install --frozen-lockfile
- name: Build the platform packages Signage depends on
working-directory: build/workspace
run: pnpm exec turbo run build --filter='@ekklesia/signage^...'
- name: Typecheck the server, browser, and browser-test projects
working-directory: build/workspace
run: pnpm --filter @ekklesia/signage typecheck
test:
name: test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Nothing here pushes, so the job token is never needed after checkout and must not be left
# in .git/config for a later step to read.
persist-credentials: false
- name: Assemble Signage into the pinned platform
run: bash ci/assemble.sh
- name: Set up Node.js at the platform's version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: build/workspace/.node-version
- name: Install the assembled workspace (frozen lockfile)
working-directory: build/workspace
run: |
corepack enable
corepack install
pnpm install --frozen-lockfile
- name: Build the platform packages Signage depends on
working-directory: build/workspace
run: pnpm exec turbo run build --filter='@ekklesia/signage^...'
- name: Unit, browser, and display contract tests
working-directory: build/workspace
run: pnpm --filter @ekklesia/signage test
build:
name: build
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Nothing here pushes, so the job token is never needed after checkout and must not be left
# in .git/config for a later step to read.
persist-credentials: false
- name: Assemble Signage into the pinned platform
run: bash ci/assemble.sh
- name: Set up Node.js at the platform's version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: build/workspace/.node-version
- name: Install the assembled workspace (frozen lockfile)
working-directory: build/workspace
run: |
corepack enable
corepack install
pnpm install --frozen-lockfile
# The same gates the platform's image build runs against exactly this module set: its boundary
# rules (which apply to modules), the manifest, and the migrations.
- name: Platform gates (boundaries, module manifest, migrations)
working-directory: build/workspace
run: |
pnpm boundaries
pnpm modules:validate
pnpm migrations:validate
- name: Build Signage and the platform it depends on
working-directory: build/workspace
run: pnpm exec turbo run build --filter='@ekklesia/signage...'
# Signage inside Ekklesia, end to end, in a real browser: the platform's own Dockerfile builds the
# deployment image with Signage in it, and the platform's split-topology deployment smoke runs
# against it, pairing a kiosk display and running the scenarios in ci/display-scenarios. A
# disposable database and object store with generated secrets; nothing is pushed or logged in to.
integration:
name: integration
needs:
- test
- build
runs-on: ubuntu-latest
timeout-minutes: 45
env:
DOCKER_BUILDKIT: "1"
# Names this job's stacks, so its final step removes exactly these (the platform's
# scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: signage-integration
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Assemble Signage into the pinned platform
run: bash ci/assemble.sh
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
- name: Sweep stale CI stacks from the runner
run: bash build/workspace/scripts/ci-sweep.sh --stale
- name: Build the image and run the display scenarios
run: bash ci/integration.sh
# Whatever happened above, this job's own stacks do not outlive it. If assembly failed, nothing
# was started.
- name: Remove this job's CI stacks
if: always()
run: |
if [ -f build/workspace/scripts/ci-sweep.sh ]; then
bash build/workspace/scripts/ci-sweep.sh --job
fi
-1
View File
@@ -4,4 +4,3 @@ dist/
coverage/ coverage/
*.log *.log
.DS_Store .DS_Store
build/
+5 -6
View File
@@ -16,8 +16,7 @@ Ekklesia and vendors none of its SDKs.
## Status ## Status
Version 0.1.2 is feature-complete for its declared scope, and Signage has run in production since Version 0.1.1 is feature-complete for its declared scope and unreleased. What exists today:
0.1.1. What exists today:
- The module package, manifest (v7), four capabilities, six permissions, ten operator settings, and - The module package, manifest (v7), four capabilities, six permissions, ten operator settings, and
one logical storage bucket. one logical storage bucket.
@@ -35,10 +34,10 @@ Version 0.1.2 is feature-complete for its declared scope, and Signage has run in
renderers mounted as browser contributions, the built-in "Content unavailable" slate, deliberate renderers mounted as browser contributions, the built-in "Content unavailable" slate, deliberate
blackout, and offline continuation from this display's own persisted state. blackout, and offline continuation from this display's own persisted state.
No cutover from the legacy Announcements container has happened or is planned by this repository. Nothing here is deployed, and no cutover from the legacy Announcements container has happened or is
Video, audio, MQTT, Home Assistant, Companion, and a notification outbox are out of scope; so is planned by this repository. Video, audio, MQTT, Home Assistant, Companion, and a notification outbox
any content module — Signage ships with no announcements, scripture, or trivia, and cannot render are out of scope; so is any content module — Signage ships with no announcements, scripture, or
any until one is installed. trivia, and cannot render any until one is installed.
### Offline cold start is not yet possible ### Offline cold start is not yet possible
-72
View File
@@ -1,72 +0,0 @@
#!/usr/bin/env bash
# Assembles Signage into the Ekklesia platform it is tested against: the platform at the commit pinned
# in ci/platform.env, this checkout at modules/signage, and ci/pnpm-lock.yaml as the workspace
# lockfile. Signage cannot build or run outside an Ekklesia workspace, so every CI job starts here.
#
# bash ci/assemble.sh [--allow-missing-lock] [output directory]
#
# The output directory (default build/workspace) is deleted and recreated. The platform is fetched
# by its `main` branch and the pinned commit must be on it, so CI only ever runs reviewed platform
# code; the checkout is then by commit, so nothing after it depends on a ref the network can move.
# Every .git directory is removed afterwards.
#
# --allow-missing-lock keeps the platform's own lockfile. Only ci/update-lock.sh uses it.
set -Eeuo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
allow_missing_lock=0
if [[ "${1:-}" == "--allow-missing-lock" ]]; then
allow_missing_lock=1
shift
fi
out="${1:-$root/build/workspace}"
fail() {
echo "assemble: $*" >&2
exit 1
}
for command in git tar; do
command -v "$command" >/dev/null || fail "$command is required."
done
# Read the two pins without sourcing the file: it is data, not a script.
pin() {
local value
value="$(sed -n "s/^$1=//p" "$root/ci/platform.env")"
[[ -n "$value" ]] || fail "ci/platform.env has no $1."
printf '%s' "$value"
}
repository="$(pin EKKLESIA_PLATFORM_REPOSITORY)"
commit="$(pin EKKLESIA_PLATFORM_COMMIT)"
[[ "$commit" =~ ^[0-9a-f]{40}$ ]] || fail "EKKLESIA_PLATFORM_COMMIT must be a full commit SHA."
rm -rf "$out"
mkdir -p "$(dirname -- "$out")"
git init --quiet "$out"
git -C "$out" remote add origin "$repository"
git -C "$out" fetch --quiet --no-tags origin "+refs/heads/main:refs/remotes/origin/main" ||
fail "cannot fetch the platform's main from ${repository}."
git -C "$out" cat-file -e "${commit}^{commit}" 2>/dev/null ||
fail "the pinned platform commit ${commit} is not on the platform's main."
git -C "$out" merge-base --is-ancestor "$commit" refs/remotes/origin/main ||
fail "the pinned platform commit ${commit} is not on the platform's main."
git -C "$out" -c advice.detachedHead=false checkout --quiet --detach "$commit"
[[ "$(git -C "$out" rev-parse HEAD)" == "$commit" ]] || fail "the platform did not check out at ${commit}."
rm -rf "$out/.git"
echo "assemble: platform ${commit}"
[[ ! -e "$out/modules/signage" ]] || fail "the platform checkout already has modules/signage."
mkdir -p "$out/modules/signage"
# Exactly the files this checkout tracks, as committed or as changed in the working tree. Nothing
# installed, built, or ignored, and not the CI scaffolding, which is not part of the module.
git -C "$root" ls-files -z --cached --others --exclude-standard -- . ':!ci' ':!.gitea' |
tar -C "$root" --null --files-from=- --create --file=- |
tar -C "$out/modules/signage" --extract --file=-
echo "assemble: signage $(git -C "$root" rev-parse HEAD 2>/dev/null || echo "(working tree)")"
if [[ -f "$root/ci/pnpm-lock.yaml" ]]; then
cp "$root/ci/pnpm-lock.yaml" "$out/pnpm-lock.yaml"
elif ((allow_missing_lock == 0)); then
fail "ci/pnpm-lock.yaml is missing; run bash ci/update-lock.sh."
fi
-45
View File
@@ -1,45 +0,0 @@
#!/usr/bin/env bash
# Checks that Signage's CI cleans up every stack it starts, even when the runner kills a job (the
# platform's rule: docs/development/security.md, "Disposable stacks never outlive their job").
#
# bash ci/check-cleanup.sh
#
# The `integration` job is the one that starts Docker stacks. It must have its own
# EKKLESIA_CI_JOB_KEY, sweep stale CI stacks before it starts anything, and remove its own in a final
# `if: always()` step; and the image ci/integration.sh builds must carry the CI labels the sweep finds.
set -Eeuo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
workflow="$root/.gitea/workflows/ci.yml"
failures=0
check() {
if eval "$1"; then
echo "ok $2"
else
echo "FAIL $2"
failures=$((failures + 1))
fi
}
# The integration job's lines, from its key to the next job (or the end of the file).
job="$(awk '/^ integration:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$workflow")"
line_of() { grep -n -F -- "$1" <<<"$job" | head -1 | cut -d: -f1 || true; }
stale="$(line_of 'ci-sweep.sh --stale')"
run="$(line_of 'bash ci/integration.sh')"
own="$(line_of 'ci-sweep.sh --job')"
last_step="$(grep -n -- " - name:" <<<"$job" | tail -1 | cut -d: -f1 || true)"
check '[[ -n "$job" ]]' "the workflow has an integration job"
check 'grep -q "EKKLESIA_CI_JOB_KEY:" <<<"$job"' "the integration job names its stacks with its own key"
check '[[ -n "$stale" && -n "$run" ]] && ((stale < run))' "it sweeps stale CI stacks before it starts anything"
check '[[ -n "$own" && -n "$run" ]] && ((own > run)) && ((own > last_step))' \
"its last step removes its own stacks"
check 'sed -n "${last_step},\$p" <<<"$job" | grep -q "if: always()"' "that last step always runs"
check 'grep -q "EKKLESIA_CI_LABEL" "$root/ci/integration.sh" && grep -q "EKKLESIA_CI_CREATED_LABEL" "$root/ci/integration.sh"' \
"the image ci/integration.sh builds carries the CI labels"
((failures == 0)) || {
echo "check-cleanup: ${failures} check(s) failed." >&2
exit 1
}
-7
View File
@@ -1,7 +0,0 @@
# The platform's display-device scenarios this repository's CI runs against Signage, one per line
# (scripts/lib/deploy-smoke-display.mjs in the pinned platform). Pairing a kiosk and restarting it on
# the same profile always run first. A change that makes another scenario pass adds it here.
two-pages
session-blip
stream-failure
stream-recycle
-44
View File
@@ -1,44 +0,0 @@
#!/usr/bin/env bash
# Signage inside Ekklesia, end to end: builds the deployment image from the assembled workspace with
# the platform's own Dockerfile, then runs the platform's split-topology deployment smoke against it
# with the display-device scenarios listed in ci/display-scenarios. A disposable PostgreSQL and object
# store, generated secrets, and an explicit migration apply against that fresh database only; nothing
# is pushed, published, or logged in to. The image is removed afterwards.
#
# bash ci/integration.sh (after bash ci/assemble.sh)
set -Eeuo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
workspace="$root/build/workspace"
[[ -f "$workspace/deploy/Dockerfile" ]] || {
echo "integration: run bash ci/assemble.sh first." >&2
exit 1
}
# Named and labeled as a CI stack of this run and job (the platform's scripts/lib/ci-project.sh), so
# the workflow's final `ci-sweep.sh --job` removes it even if this script is killed before its trap.
# shellcheck source=/dev/null
source "$workspace/scripts/lib/ci-project.sh"
project="$(ci_project_name signage-image)"
image="${project}:latest"
trap 'docker image rm --force "$image" >/dev/null 2>&1 || true' EXIT
scenarios="$(sed -e 's/#.*//' -e '/^[[:space:]]*$/d' "$root/ci/display-scenarios" | tr '\n' ' ')"
[[ -n "${scenarios// /}" ]] || {
echo "integration: ci/display-scenarios lists no scenario." >&2
exit 1
}
DOCKER_BUILDKIT=1 docker build --quiet --file "$workspace/deploy/Dockerfile" --tag "$image" \
--label "${EKKLESIA_CI_LABEL}=${project}" --label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
"$workspace" >/dev/null
echo "integration: built the deployment image with Signage; scenarios: ${scenarios}"
cd "$workspace"
EKKLESIA_SMOKE_TOPOLOGY=split \
EKKLESIA_SMOKE_IMAGE="$image" \
EKKLESIA_SMOKE_DISPLAY_DEVICE=1 \
EKKLESIA_SMOKE_DISPLAY_SCENARIOS="$scenarios" \
EKKLESIA_SMOKE_WATCH_MS=120000 \
EKKLESIA_SMOKE_RECYCLE_WATCH_MS=120000 \
bash scripts/smoke-deploy.sh
-6
View File
@@ -1,6 +0,0 @@
# The Ekklesia platform Signage's CI builds and tests against: one reviewed commit on the platform's
# `main`, never a moving ref. ci/assemble.sh refuses a commit that is not on `main`.
#
# Move it deliberately: change the commit, run `bash ci/update-lock.sh`, and commit both.
EKKLESIA_PLATFORM_REPOSITORY=https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia.git
EKKLESIA_PLATFORM_COMMIT=874deabea3468eb5c3e907f0821718b077a682c7
-4403
View File
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env bash
# Regenerates ci/pnpm-lock.yaml: the lockfile of Signage assembled into the pinned platform. Run it
# after changing Signage's dependencies or ci/platform.env, and commit the result. CI installs with
# --frozen-lockfile, so a stale lockfile fails there rather than resolving anything new.
#
# bash ci/update-lock.sh
#
# Uses the platform's own pinned pnpm (its packageManager field) through Corepack.
set -Eeuo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
bash "$root/ci/assemble.sh" --allow-missing-lock "$work/workspace"
cd "$work/workspace"
corepack enable >/dev/null 2>&1 || true
corepack install >/dev/null
pnpm install --lockfile-only
cp pnpm-lock.yaml "$root/ci/pnpm-lock.yaml"
echo "update-lock: wrote ci/pnpm-lock.yaml"
+1 -1
View File
@@ -3,7 +3,7 @@
"id": "signage", "id": "signage",
"name": "Signage", "name": "Signage",
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.", "description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
"version": "0.1.2", "version": "0.1.1",
"compatibility": { "compatibility": {
"platform": "^0.1.0", "platform": "^0.1.0",
"moduleSdk": "^0.1.0" "moduleSdk": "^0.1.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/signage", "name": "@ekklesia/signage",
"version": "0.1.2", "version": "0.1.1",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Ekklesia's visual programming, timeline, synchronization, and display-delivery module.", "description": "Ekklesia's visual programming, timeline, synchronization, and display-delivery module.",
+3 -49
View File
@@ -36,9 +36,6 @@ export interface SignageMediaCache {
* can never leave a screen with less than it had. */ * can never leave a screen with less than it had. */
prune(keep: readonly string[]): Promise<void>; prune(keep: readonly string[]): Promise<void>;
count(): Promise<number>; count(): Promise<number>;
/** Gives back every local URL this cache handed out. Called when a display stops; a URL handed
* out before this is no longer valid afterwards. */
release(): void;
} }
/** The stable key a native image's bytes are cached under. Deliberately built from identity, never /** The stable key a native image's bytes are cached under. Deliberately built from identity, never
@@ -89,9 +86,6 @@ export function createMemoryMediaCache(): SignageMediaCache {
} }
return Promise.resolve(); return Promise.resolve();
}, },
release(): void {
// Holds plain URLs, nothing the browser has to be given back.
},
store(key: string, url: string): Promise<string | undefined> { store(key: string, url: string): Promise<string | undefined> {
stored.set(key, url); stored.set(key, url);
return Promise.resolve(url); return Promise.resolve(url);
@@ -185,45 +179,14 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis):
return `${origin}/${key}`; return `${origin}/${key}`;
} }
/** async function localUrl(cache: Cache, url: string): Promise<string | undefined> {
* The one object URL each cached slide is rendered from, by cache entry. const response = await cache.match(url);
*
* An object URL pins its blob in the browser until it is revoked. Every state refresh looks every
* cached slide up again, so minting a URL per lookup leaked a blob per slide per refresh, and
* within minutes the browser refused the display's own requests for want of resources. So each
* entry gets exactly one URL, revoked when the entry is pruned or replaced, and all of them when
* the display stops.
*/
const objectUrls = new Map<string, string>();
function forget(request: string): void {
const url = objectUrls.get(request);
if (url !== undefined) {
objectUrls.delete(request);
URL.revokeObjectURL(url);
}
}
async function localUrl(cache: Cache, request: string): Promise<string | undefined> {
const existing = objectUrls.get(request);
if (existing !== undefined) {
return existing;
}
const response = await cache.match(request);
if (!response) { if (!response) {
return undefined; return undefined;
} }
try { try {
const blob = await response.blob(); const blob = await response.blob();
// Two lookups of one slide can be in flight at once; the first to finish wins, and the other return URL.createObjectURL(blob);
// gives its URL straight back rather than leaving it pinned.
const raced = objectUrls.get(request);
if (raced !== undefined) {
return raced;
}
const url = URL.createObjectURL(blob);
objectUrls.set(request, url);
return url;
} catch { } catch {
return undefined; return undefined;
} }
@@ -257,7 +220,6 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis):
for (const request of await cache.keys()) { for (const request of await cache.keys()) {
if (!retained.has(request.url)) { if (!retained.has(request.url)) {
await cache.delete(request); await cache.delete(request);
forget(request.url);
} }
} }
} catch { } catch {
@@ -265,12 +227,6 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis):
} }
}, },
release(): void {
for (const request of [...objectUrls.keys()]) {
forget(request);
}
},
async store(key: string, url: string): Promise<string | undefined> { async store(key: string, url: string): Promise<string | undefined> {
const cache = await open(); const cache = await open();
if (!cache) { if (!cache) {
@@ -286,8 +242,6 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis):
// Stored under the *stable* key, so the next refresh's freshly signed URL finds these // Stored under the *stable* key, so the next refresh's freshly signed URL finds these
// bytes instead of downloading them again. // bytes instead of downloading them again.
await cache.put(cacheRequest(key), response.clone()); await cache.put(cacheRequest(key), response.clone());
// New bytes under this key: the URL for the old ones is given back before one is made.
forget(cacheRequest(key));
return localUrl(cache, cacheRequest(key)); return localUrl(cache, cacheRequest(key));
} catch { } catch {
return undefined; return undefined;
+36 -27
View File
@@ -19,13 +19,6 @@ import {
type SignageMediaCache, type SignageMediaCache,
signageMediaKey, signageMediaKey,
} from "../browser/storage.js"; } from "../browser/storage.js";
import {
type DisplayChannelState,
type DisplayHeartbeat,
type DisplayRealtimeGroup,
parseViewerSessionResponse,
type ViewerSessionResponse,
} from "../shared/display-protocol.js";
/** /**
* The display: what it fetches, what it shows, and what it reports. * The display: what it fetches, what it shows, and what it reports.
@@ -46,11 +39,36 @@ const SNAPSHOT_STORAGE = "last-known-good";
* must not sit on a stale frame until the next; and a delivery URL has a finite life. */ * must not sit on a stale frame until the next; and a delivery URL has a finite life. */
const REFRESH_INTERVAL_MS = 4 * 60 * 1000; const REFRESH_INTERVAL_MS = 4 * 60 * 1000;
/** The channel state a display renders, exactly as the server sends it (`src/shared`). */ export interface DisplayChannelStateView {
export type DisplayChannelStateView = DisplayChannelState; readonly serverNowMs: number;
readonly channel: { readonly id: string; readonly key: string; readonly groupId: string };
readonly crossfadeMs: number;
readonly playback: {
readonly revision: number;
readonly transport: string;
readonly blackout: boolean;
readonly remainingMs: number | null;
readonly selectionRevision: number | null;
readonly currentSelectionId: string | null;
readonly currentSlotId: string | null;
readonly activeTimelineId: string | null;
readonly pendingTimelineId: string | null;
readonly startedAtMs: number | null;
readonly deadlineAtMs: number | null;
};
readonly timeline: OfflineSnapshot["timeline"];
readonly current: SceneSelection | null;
readonly staticHold: SceneSelection | null;
readonly selections: readonly SceneSelection[];
}
/** The display's registration, exactly as the server answers it (`src/shared`). */ export interface DisplaySessionView {
export type DisplaySessionView = ViewerSessionResponse; readonly viewerId: string;
readonly channelId: string;
readonly channelKey: string;
readonly groupId: string;
readonly heartbeatIntervalMs: number;
}
/** What an operator can see about this screen, and what a test asserts on. Sanitized by /** What an operator can see about this screen, and what a test asserts on. Sanitized by
* construction: identity, counts, and a code. */ * construction: identity, counts, and a code. */
@@ -556,10 +574,7 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
method: "POST", method: "POST",
}); });
if (response.ok) { if (response.ok) {
// Read, not cast: a registration the display cannot understand is no registration, and is registered = (await response.json()) as DisplaySessionView;
// tried again like any other failure.
const parsed = parseViewerSessionResponse(await response.json());
registered = parsed.ok ? parsed.value : undefined;
} }
} catch { } catch {
registered = undefined; registered = undefined;
@@ -625,10 +640,11 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
return; return;
} }
try { try {
// Built as the shared message type, which is the same type the route parses with. await options.context.api.request(`viewers/${session.viewerId}/heartbeat`, {
const report: DisplayHeartbeat = { body: JSON.stringify({
cachedAssetCount, cachedAssetCount,
error: lastError === undefined ? null : { code: lastError.code, detail: lastError.detail }, error:
lastError === undefined ? null : { code: lastError.code, detail: lastError.detail },
offlineReady: snapshot !== undefined && snapshot.selections.length > 0, offlineReady: snapshot !== undefined && snapshot.selections.length > 0,
reportedContributionId: reportedContributionId:
lastState?.current?.renderer.kind === "contribution" lastState?.current?.renderer.kind === "contribution"
@@ -638,9 +654,7 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
reportedProviderId: lastState?.current?.providerId ?? null, reportedProviderId: lastState?.current?.providerId ?? null,
reportedSelectionId: renderedSelectionId ?? null, reportedSelectionId: renderedSelectionId ?? null,
reportedTimelineId: lastState?.playback.activeTimelineId ?? null, reportedTimelineId: lastState?.playback.activeTimelineId ?? null,
}; }),
await options.context.api.request(`viewers/${session.viewerId}/heartbeat`, {
body: JSON.stringify(report),
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
method: "POST", method: "POST",
}); });
@@ -715,8 +729,6 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
exitOffline(); exitOffline();
await stage?.destroy(); await stage?.destroy();
stage = undefined; stage = undefined;
// After the stage is gone, so nothing on screen still points at a released URL.
media.release();
}, },
}; };
@@ -729,10 +741,7 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
if (stopped || !session) { if (stopped || !session) {
return; return;
} }
// Checked at run time as well as typed: realtime state is the platform's transport, and a frame const groups = (payload as { groups?: readonly Record<string, unknown>[] } | null)?.groups;
// this display cannot read is simply not one it acts on.
const groups = (payload as { groups?: readonly Partial<DisplayRealtimeGroup>[] } | null)
?.groups;
if (!Array.isArray(groups)) { if (!Array.isArray(groups)) {
return; return;
} }
+1 -1
View File
@@ -59,7 +59,7 @@ function SignageDisplay({ context }: { readonly context: ModuleUiHostContext }):
*/ */
export default defineModuleUi({ export default defineModuleUi({
id: "signage", id: "signage",
version: "0.1.2", version: "0.1.1",
styles: displayStyles, styles: displayStyles,
mount(container, context) { mount(container, context) {
const root = createRoot(container); const root = createRoot(container);
+70 -18
View File
@@ -1,9 +1,4 @@
import type { ModuleStorage } from "@ekklesia/module-sdk"; import type { ModuleStorage } from "@ekklesia/module-sdk";
import type {
DisplayChannelState as DisplayChannelStateWire,
DisplaySelection,
DisplayTimeline,
} from "../../shared/display-protocol.js";
import { import {
resolveNativeScene, resolveNativeScene,
SIGNAGE_NATIVE_PROVIDER_ID, SIGNAGE_NATIVE_PROVIDER_ID,
@@ -40,21 +35,78 @@ import type {
* and a display refreshes state long before one lapses. * and a display refreshes state long before one lapses.
*/ */
export type { export interface DisplayMedia {
DisplayMedia, readonly url: string;
DisplaySelection, readonly expiresAtMs: number;
DisplayTimeline, readonly width: number;
DisplayTimelineSlot, readonly height: number;
} from "../../shared/display-protocol.js"; }
/** export interface DisplaySelection {
* The channel state the server builds: the wire shape a display reads (`src/shared`), plus the readonly id: string;
* channel's name and the group's whole playback snapshot. Declared as an extension so that a field readonly revision: number;
* the display relies on cannot be renamed or retyped here without failing to compile. readonly slotId: string | null;
*/ readonly providerId: string;
export interface DisplayChannelState extends DisplayChannelStateWire { readonly itemId: string;
readonly channel: DisplayChannelStateWire["channel"] & { readonly name: string }; readonly itemRevision: string;
readonly startedAtMs: number | null;
readonly deadlineAtMs: number | null;
readonly durationMs: number;
readonly availableUntilMs: number | null;
readonly withdrawn: boolean;
readonly stale: boolean;
/** `native-image` is drawn by Signage's own display code; `contribution` is mounted through the
* platform's contribution ABI by id. A display never receives a URL for either. */
readonly renderer:
| { readonly kind: "native-image" }
| {
readonly kind: "contribution";
readonly contributionId: string;
readonly contributionVersion: string;
}
| { readonly kind: "unavailable" };
/** The provider's own payload for this channel's audience, or its default. */
readonly payload: unknown;
readonly presentation: unknown;
/** Native images only: fresh delivery URLs, and the already-resolved scene. */
readonly media?: { readonly display: DisplayMedia; readonly thumbnail: DisplayMedia };
readonly scene?: ReturnType<typeof resolveNativeScene>;
}
export interface DisplayTimelineSlot {
readonly id: string;
readonly position: number;
readonly enabled: boolean;
readonly eligibleFromMs: number | null;
readonly eligibleUntilMs: number | null;
readonly estimatedDurationMs: number;
}
export interface DisplayTimeline {
readonly id: string;
readonly revision: number;
readonly eligibleFromMs: number | null;
readonly eligibleUntilMs: number | null;
readonly slots: readonly DisplayTimelineSlot[];
}
export interface DisplayChannelState {
readonly serverNowMs: number;
readonly channel: {
readonly id: string;
readonly key: string;
readonly name: string;
readonly groupId: string;
};
readonly crossfadeMs: number;
readonly playback: PlaybackSnapshot; readonly playback: PlaybackSnapshot;
readonly timeline: DisplayTimeline | null;
readonly current: DisplaySelection | null;
/** The held image, already resolved as a scene. Separate from `current` because a static hold sits
* *over* a preserved cursor rather than replacing it. */
readonly staticHold: DisplaySelection | null;
/** Bounded, newest first. Material for offline continuation, never a substitute for authority. */
readonly selections: readonly DisplaySelection[];
} }
/** How many resolved selections travel to a display. Enough to cover a pattern and then some, small /** How many resolved selections travel to a display. Enough to cover a pattern and then some, small
+75 -33
View File
@@ -1,14 +1,13 @@
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk"; import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
import {
type DisplayHeartbeatResponse,
type ProtocolResult,
parseDisplayHeartbeat,
parseViewerSessionRequest,
type ViewerSessionResponse,
} from "../../shared/display-protocol.js";
import { buildDisplayChannelState } from "../display/state.js"; import { buildDisplayChannelState } from "../display/state.js";
import { SignageError, signageBoundary } from "../errors.js"; import { SignageError, signageBoundary } from "../errors.js";
import { optionalString, readJsonObject, requireUuidParam } from "../http.js"; import {
optionalBoolean,
optionalInteger,
optionalString,
readJsonObject,
requireUuidParam,
} from "../http.js";
import type { SignagePlaybackService } from "../playback/service.js"; import type { SignagePlaybackService } from "../playback/service.js";
import type { SignageProviderRegistry } from "../providers/registry.js"; import type { SignageProviderRegistry } from "../providers/registry.js";
import { readSignageSettings } from "../settings.js"; import { readSignageSettings } from "../settings.js";
@@ -22,24 +21,12 @@ import {
updateViewerAssignment, updateViewerAssignment,
} from "../viewers/service.js"; } from "../viewers/service.js";
/** How a route reaches Signage's tables. Production uses the module database; the display contract const OPAQUE_ID = /^[\w][\w.:-]{0,199}$/u;
* test supplies the in-memory store so the real handlers can answer the real display controller. */
/** How often a display checks in. Sent to the display rather than built into it, so the server can
* change its own expectations without a display deployment. */
export const SIGNAGE_HEARTBEAT_INTERVAL_MS = 12_000;
/** A display message the shared protocol refused, as Signage's own sanitized code naming the field. */ function store(context: ModuleRouteHandlerContext): SignageStore {
function accepted<Value>(result: ProtocolResult<Value>): Value { return createSignageStore(context.services.database);
if (!result.ok) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: result.field });
}
return result.value;
} }
export type ViewerRouteStore = (context: ModuleRouteHandlerContext) => SignageStore;
const databaseStore: ViewerRouteStore = (context) => createSignageStore(context.services.database);
/** /**
* The safe Core device identifier, when the caller is a paired display. * The safe Core device identifier, when the caller is a paired display.
* *
@@ -70,7 +57,6 @@ export function registerViewerRoutes(
http: ModuleHttpRouteRegistrar, http: ModuleHttpRouteRegistrar,
playback: () => SignagePlaybackService, playback: () => SignagePlaybackService,
registry: () => SignageProviderRegistry, registry: () => SignageProviderRegistry,
store: ViewerRouteStore = databaseStore,
): void { ): void {
http.route({ http.route({
method: "POST", method: "POST",
@@ -79,7 +65,11 @@ export function registerViewerRoutes(
maxBodyBytes: 2048, maxBodyBytes: 2048,
handler(request, context) { handler(request, context) {
return signageBoundary(async () => { return signageBoundary(async () => {
const { viewerKey } = accepted(parseViewerSessionRequest(await readJsonObject(request))); const body = await readJsonObject(request);
const viewerKey = body.viewerKey;
if (typeof viewerKey !== "string") {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "viewerKey" });
}
// A display registers with the identity it keeps in its own IndexedDB and lands on the // A display registers with the identity it keeps in its own IndexedDB and lands on the
// default channel. It never chooses one: a public screen that could would let anyone who // default channel. It never chooses one: a public screen that could would let anyone who
// reaches the page decide what a wall shows (ADR 0004). // reaches the page decide what a wall shows (ADR 0004).
@@ -89,15 +79,14 @@ export function registerViewerRoutes(
store: store(context), store: store(context),
viewerKey, viewerKey,
}); });
const response: ViewerSessionResponse = { return Response.json({
channelId: session.channel.id, channelId: session.channel.id,
channelKey: session.channel.key, channelKey: session.channel.key,
groupId: session.groupId, groupId: session.groupId,
heartbeatIntervalMs: SIGNAGE_HEARTBEAT_INTERVAL_MS, heartbeatIntervalMs: 12_000,
viewerId: session.viewer.id, viewerId: session.viewer.id,
viewerName: session.viewer.name, viewerName: session.viewer.name,
}; });
return Response.json(response);
}); });
}, },
}); });
@@ -110,21 +99,52 @@ export function registerViewerRoutes(
handler(request, context) { handler(request, context) {
return signageBoundary(async () => { return signageBoundary(async () => {
const viewerId = requireUuidParam(context, "viewerId"); const viewerId = requireUuidParam(context, "viewerId");
const heartbeat = accepted(parseDisplayHeartbeat(await readJsonObject(request))); const body = await readJsonObject(request);
const settings = readSignageSettings(context.config); const settings = readSignageSettings(context.config);
const rawError = body.error;
// Telemetry only. Nothing a display reports here changes authoritative playback, and a // Telemetry only. Nothing a display reports here changes authoritative playback, and a
// local cursor is never accepted as truth. // local cursor is never accepted as truth.
const result = await recordViewerHeartbeat({ const result = await recordViewerHeartbeat({
at: new Date(), at: new Date(),
heartbeat, heartbeat: {
cachedAssetCount:
optionalInteger(body, "cachedAssetCount", {
maximum: 100_000,
minimum: 0,
}) ?? undefined,
offlineReady: optionalBoolean(body, "offlineReady") ?? undefined,
reportedContributionId:
optionalString(body, "reportedContributionId", { maxLength: 200 }) ?? null,
reportedPlaybackRevision:
optionalInteger(body, "reportedPlaybackRevision", {
maximum: Number.MAX_SAFE_INTEGER,
minimum: 0,
}) ?? null,
reportedProviderId:
optionalString(body, "reportedProviderId", { maxLength: 200 }) ?? null,
reportedSelectionId:
optionalString(body, "reportedSelectionId", { maxLength: 64 }) ?? null,
reportedTimelineId:
optionalString(body, "reportedTimelineId", { maxLength: 64 }) ?? null,
...(rawError === undefined
? {}
: {
error:
rawError === null
? null
: {
code: readErrorCode(rawError),
detail: readErrorDetail(rawError),
},
}),
},
minimumWriteIntervalMs: settings.viewerHeartbeatWriteMs, minimumWriteIntervalMs: settings.viewerHeartbeatWriteMs,
store: store(context), store: store(context),
viewerId, viewerId,
}); });
// The response says what a display needs to keep itself honest, and nothing about other // The response says what a display needs to keep itself honest, and nothing about other
// displays or the installation. // displays or the installation.
const response: DisplayHeartbeatResponse = { acknowledged: true, recorded: result.written }; return Response.json({ acknowledged: true, recorded: result.written });
return Response.json(response);
}); });
}, },
}); });
@@ -215,3 +235,25 @@ export function registerViewerRoutes(
}, },
}); });
} }
function readErrorCode(value: unknown): string {
if (typeof value !== "object" || value === null) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error" });
}
const code = (value as { code?: unknown }).code;
if (typeof code !== "string" || !OPAQUE_ID.test(code)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.code" });
}
return code;
}
function readErrorDetail(value: unknown): string | null {
const detail = (value as { detail?: unknown }).detail;
if (detail === undefined || detail === null) {
return null;
}
if (typeof detail !== "string") {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.detail" });
}
return detail;
}
+6 -7
View File
@@ -3,7 +3,6 @@ import {
type ModuleRuntimeContext, type ModuleRuntimeContext,
type ModuleRuntimeDefinition, type ModuleRuntimeDefinition,
} from "@ekklesia/module-sdk"; } from "@ekklesia/module-sdk";
import type { DisplayRealtimePayload } from "../shared/display-protocol.js";
import { SIGNAGE_SLIDE_RENDERER_INPUT_VERSION } from "../shared/renderer-input.js"; import { SIGNAGE_SLIDE_RENDERER_INPUT_VERSION } from "../shared/renderer-input.js";
import { import {
SIGNAGE_IMAGE_MAX_BYTES, SIGNAGE_IMAGE_MAX_BYTES,
@@ -99,10 +98,10 @@ export function createSignageModuleRuntime(
left.groupId.localeCompare(right.groupId), left.groupId.localeCompare(right.groupId),
); );
try { try {
// The shape the display reads (`src/shared/display-protocol.ts`): each group's whole snapshot context.realtime.publish(SIGNAGE_DISPLAY_CHANNEL, {
// carries the revisions it compares. groups,
const display: DisplayRealtimePayload = { groups, serverNowMs: now().getTime() }; serverNowMs: now().getTime(),
context.realtime.publish(SIGNAGE_DISPLAY_CHANNEL, display); });
context.realtime.publish(SIGNAGE_MANAGEMENT_CHANNEL, { context.realtime.publish(SIGNAGE_MANAGEMENT_CHANNEL, {
// Revisions only. The dashboard refetches what changed over authorized HTTP. // Revisions only. The dashboard refetches what changed over authorized HTTP.
groups: groups.map((snapshot) => ({ groups: groups.map((snapshot) => ({
@@ -127,7 +126,7 @@ export function createSignageModuleRuntime(
const definition = defineModuleRuntime({ const definition = defineModuleRuntime({
id: "signage", id: "signage",
version: "0.1.2", version: "0.1.1",
register({ capabilities, config, http, realtime }) { register({ capabilities, config, http, realtime }) {
const settings = readSignageSettings(config); const settings = readSignageSettings(config);
@@ -189,7 +188,7 @@ export function createSignageModuleRuntime(
// server enforces — a UI believing a stale limit would offer an upload the server refuses. // server enforces — a UI believing a stale limit would offer an upload the server refuses.
return Response.json({ return Response.json({
crossfadeMs: current.crossfadeMs, crossfadeMs: current.crossfadeMs,
moduleVersion: "0.1.2", moduleVersion: "0.1.1",
nativeImageDurationMs: current.nativeImageDurationMs, nativeImageDurationMs: current.nativeImageDurationMs,
nativePresentation: current.nativePresentation, nativePresentation: current.nativePresentation,
rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION, rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
-318
View File
@@ -1,318 +0,0 @@
/**
* What a display and Signage's server say to each other, defined once.
*
* The display controller builds these messages and the viewer routes read them, from this one file.
* They used to be described twice — once in the controller's own interfaces and once in each
* route's field-by-field parsing — and the two descriptions disagreed about one thing: the display
* sent `null` for "nothing to report" and the heartbeat route accepted only a *missing* field. Every
* heartbeat from a screen showing a native image was refused with a 400, silently, for as long as it
* ran. Nothing here can drift that way again, because there is nothing to drift between.
*
* Three rules hold for every message:
*
* - **A nullable field accepts `null` explicitly.** `null` means "I have nothing", and is a value.
* - **An absent field means "not reported".** A receiver keeps whatever it already had.
* - **An unknown field is ignored, never refused.** A newer display reporting something an older
* server does not know yet must still be heard; refusing it would be this same bug in reverse.
*
* Like the rest of `src/shared`, it imports nothing from outside `src/shared`: it is compiled into the browser
* artifact as well as the server, and must pull neither the module SDK nor Node into a display.
*/
import type { SignageNativeScene } from "./native-images.js";
// --- Parsing ---------------------------------------------------------------------------------------
/** The outcome of reading one message: the value, or the one field that was wrong. Never a parser's
* own message, which is not something a client or an operator should see. */
export type ProtocolResult<Value> =
| { readonly ok: true; readonly value: Value }
| { readonly ok: false; readonly field: string };
type Reader<Value> = (raw: unknown) => { readonly ok: true; readonly value: Value } | undefined;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function text(options: { readonly maxLength: number; readonly pattern?: RegExp }): Reader<string> {
return (raw) => {
if (typeof raw !== "string") {
return undefined;
}
const value = raw.trim();
if (value.length === 0 || value.length > options.maxLength) {
return undefined;
}
if (options.pattern && !options.pattern.test(value)) {
return undefined;
}
return { ok: true, value };
};
}
function integer(options: { readonly minimum: number; readonly maximum: number }): Reader<number> {
return (raw) =>
typeof raw === "number" &&
Number.isSafeInteger(raw) &&
raw >= options.minimum &&
raw <= options.maximum
? { ok: true, value: raw }
: undefined;
}
const flag: Reader<boolean> = (raw) =>
typeof raw === "boolean" ? { ok: true, value: raw } : undefined;
function nullable<Value>(read: Reader<Value>): Reader<Value | null> {
return (raw) => (raw === null ? { ok: true, value: null } : read(raw));
}
/** Reads the named fields of a message. Absent fields are left out of the result; any other field
* of the object is ignored. */
function readFields<Shape extends object>(
raw: unknown,
readers: { readonly [Key in keyof Shape]-?: Reader<Exclude<Shape[Key], undefined>> },
): ProtocolResult<Shape> {
if (!isRecord(raw)) {
return { field: "body", ok: false };
}
const value: Record<string, unknown> = {};
for (const key of Object.keys(readers) as (keyof Shape & string)[]) {
if (raw[key] === undefined) {
continue;
}
const read = readers[key](raw[key]);
if (!read) {
return { field: key, ok: false };
}
value[key] = read.value;
}
return { ok: true, value: value as Shape };
}
function requireFields<Shape>(
result: ProtocolResult<Partial<Shape>>,
required: readonly (keyof Shape & string)[],
): ProtocolResult<Shape> {
if (!result.ok) {
return result;
}
for (const key of required) {
if (result.value[key] === undefined) {
return { field: key, ok: false };
}
}
return result as ProtocolResult<Shape>;
}
// --- Limits ----------------------------------------------------------------------------------------
/** A display's own identity, kept in its IndexedDB. */
export const DISPLAY_VIEWER_KEY = /^[\w-]{8,128}$/u;
/** The longest error detail a display may report. Operator-facing text a browser supplied, so it is
* bounded, and because an unbounded one is a way to fill a table. */
export const DISPLAY_ERROR_DETAIL_MAX = 200;
export const DISPLAY_ERROR_CODE = /^[\w][\w.:-]{0,63}$/u;
export const DISPLAY_CACHED_ASSET_MAX = 100_000;
// --- Viewer session: POST viewers/session ----------------------------------------------------------
export interface ViewerSessionRequest {
readonly viewerKey: string;
}
export interface ViewerSessionResponse {
readonly viewerId: string;
readonly viewerName: string;
readonly channelId: string;
readonly channelKey: string;
readonly groupId: string;
readonly heartbeatIntervalMs: number;
}
const identifier = text({ maxLength: 200 });
export function parseViewerSessionRequest(raw: unknown): ProtocolResult<ViewerSessionRequest> {
return requireFields<ViewerSessionRequest>(
readFields<Partial<ViewerSessionRequest>>(raw, {
viewerKey: text({ maxLength: 128, pattern: DISPLAY_VIEWER_KEY }),
}),
["viewerKey"],
);
}
export function parseViewerSessionResponse(raw: unknown): ProtocolResult<ViewerSessionResponse> {
return requireFields<ViewerSessionResponse>(
readFields<Partial<ViewerSessionResponse>>(raw, {
channelId: identifier,
channelKey: identifier,
groupId: identifier,
heartbeatIntervalMs: integer({ maximum: 600_000, minimum: 1_000 }),
viewerId: identifier,
viewerName: text({ maxLength: 200 }),
}),
["viewerId", "viewerName", "channelId", "channelKey", "groupId", "heartbeatIntervalMs"],
);
}
// --- Heartbeat: POST viewers/:viewerId/heartbeat ---------------------------------------------------
export interface DisplayReportedError {
readonly code: string;
readonly detail: string | null;
}
/**
* What a display reports about itself. Telemetry, never authority: nothing here changes playback.
*
* Every field is optional on the wire (absent = not reported). The display sends all of them.
*/
export interface DisplayHeartbeat {
readonly cachedAssetCount?: number;
readonly offlineReady?: boolean;
readonly reportedTimelineId?: string | null;
readonly reportedSelectionId?: string | null;
readonly reportedProviderId?: string | null;
readonly reportedContributionId?: string | null;
readonly reportedPlaybackRevision?: number | null;
readonly error?: DisplayReportedError | null;
}
const reportedError: Reader<DisplayReportedError> = (raw) => {
const read = readFields<{ code?: string; detail?: string | null }>(raw, {
code: text({ maxLength: 64, pattern: DISPLAY_ERROR_CODE }),
detail: nullable(text({ maxLength: DISPLAY_ERROR_DETAIL_MAX })),
});
if (!read.ok || read.value.code === undefined) {
return undefined;
}
return { ok: true, value: { code: read.value.code, detail: read.value.detail ?? null } };
};
export function parseDisplayHeartbeat(raw: unknown): ProtocolResult<DisplayHeartbeat> {
return readFields<DisplayHeartbeat>(raw, {
cachedAssetCount: integer({ maximum: DISPLAY_CACHED_ASSET_MAX, minimum: 0 }),
error: nullable(reportedError),
offlineReady: flag,
reportedContributionId: nullable(text({ maxLength: 200 })),
reportedPlaybackRevision: nullable(integer({ maximum: Number.MAX_SAFE_INTEGER, minimum: 0 })),
reportedProviderId: nullable(text({ maxLength: 200 })),
reportedSelectionId: nullable(text({ maxLength: 64 })),
reportedTimelineId: nullable(text({ maxLength: 64 })),
});
}
export interface DisplayHeartbeatResponse {
readonly acknowledged: true;
readonly recorded: boolean;
}
// --- Channel state: GET channels/:channelId/display-state -----------------------------------------
/** A fresh, signed delivery URL. A bearer capability with an expiry: never persisted by a display. */
export interface DisplayMedia {
readonly url: string;
readonly expiresAtMs: number;
readonly width: number;
readonly height: number;
}
export type DisplayRenderer =
| { readonly kind: "native-image" }
| {
readonly kind: "contribution";
readonly contributionId: string;
readonly contributionVersion: string;
}
| { readonly kind: "unavailable" };
export interface DisplaySelection {
readonly id: string;
readonly revision: number;
readonly slotId: string | null;
readonly providerId: string;
readonly itemId: string;
readonly itemRevision: string;
readonly startedAtMs: number | null;
readonly deadlineAtMs: number | null;
readonly durationMs: number;
readonly availableUntilMs: number | null;
readonly withdrawn: boolean;
readonly stale: boolean;
/** `native-image` is drawn by Signage's own display code; `contribution` is mounted through the
* platform's contribution ABI by id. A display never receives a URL for either. */
readonly renderer: DisplayRenderer;
/** The provider's own payload for this channel's audience, or its default. */
readonly payload: unknown;
readonly presentation: unknown;
/** Native images only: fresh delivery URLs, and the already-resolved scene. */
readonly media?: { readonly display: DisplayMedia; readonly thumbnail: DisplayMedia };
readonly scene?: SignageNativeScene;
}
export interface DisplayTimelineSlot {
readonly id: string;
readonly position: number;
readonly enabled: boolean;
readonly eligibleFromMs: number | null;
readonly eligibleUntilMs: number | null;
readonly estimatedDurationMs: number;
}
export interface DisplayTimeline {
readonly id: string;
readonly revision: number;
readonly eligibleFromMs: number | null;
readonly eligibleUntilMs: number | null;
readonly slots: readonly DisplayTimelineSlot[];
}
/** The part of the group's playback a display acts on. The server sends its whole snapshot; this is
* what a display may rely on being there. */
export interface DisplayPlayback {
readonly revision: number;
readonly transport: string;
readonly blackout: boolean;
readonly remainingMs: number | null;
readonly selectionRevision: number | null;
readonly currentSelectionId: string | null;
readonly currentSlotId: string | null;
readonly activeTimelineId: string | null;
readonly pendingTimelineId: string | null;
readonly startedAtMs: number | null;
readonly deadlineAtMs: number | null;
}
export interface DisplayChannelState {
readonly serverNowMs: number;
readonly channel: {
readonly id: string;
readonly key: string;
readonly groupId: string;
};
readonly crossfadeMs: number;
readonly playback: DisplayPlayback;
readonly timeline: DisplayTimeline | null;
readonly current: DisplaySelection | null;
/** The held image, already resolved as a scene. Separate from `current` because a static hold sits
* *over* a preserved cursor rather than replacing it. */
readonly staticHold: DisplaySelection | null;
/** Bounded, newest first. Material for offline continuation, never a substitute for authority. */
readonly selections: readonly DisplaySelection[];
}
// --- Realtime: the display-state channel ----------------------------------------------------------
/** One group's revisions on the realtime channel. Compact on purpose: a revision this display has
* not applied means "fetch the state you are authorized for", never content. */
export interface DisplayRealtimeGroup {
readonly groupId: string;
readonly revision: number;
readonly selectionRevision: number | null;
}
export interface DisplayRealtimePayload {
readonly groups: readonly DisplayRealtimeGroup[];
readonly serverNowMs: number;
}
+1 -1
View File
@@ -15,7 +15,7 @@ import ownStyles from "./styles.css";
*/ */
export default defineModuleUi({ export default defineModuleUi({
id: "signage", id: "signage",
version: "0.1.2", version: "0.1.1",
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`, styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
mount(container, context) { mount(container, context) {
const root = createRoot(container); const root = createRoot(container);
@@ -43,7 +43,6 @@ const SELECTION = {
}, },
revision: 1, revision: 1,
slotId: "slot-trivia", slotId: "slot-trivia",
stale: false,
startedAtMs: 0, startedAtMs: 0,
withdrawn: false, withdrawn: false,
}; };
-223
View File
@@ -1,223 +0,0 @@
// @vitest-environment happy-dom
import type { ModuleRouteDefinitionInput, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
import { beforeEach, describe, expect, it } from "vitest";
import { createMemoryMediaCache, createMemoryStore } from "../../src/browser/storage.js";
import { createDisplayController } from "../../src/display/controller.js";
import { ingestNativeImage } from "../../src/server/images/service.js";
import {
createPlaybackService,
type SignagePlaybackService,
} from "../../src/server/playback/service.js";
import { createNativeImageProvider } from "../../src/server/providers/native-provider.js";
import {
createProviderRegistry,
type SignageProviderRegistry,
} from "../../src/server/providers/registry.js";
import { registerViewerRoutes } from "../../src/server/routes/viewers.js";
import { addSlot, createTimeline } from "../../src/server/timelines/service.js";
import { SIGNAGE_NATIVE_PROVIDER_ID } from "../../src/shared/native-images.js";
import { SIGNAGE_DEFAULT_SETTINGS } from "../../src/shared/settings.js";
import { createFakeStorage, type FakeStorage } from "../support/fake-storage.js";
import { makeImage } from "../support/images.js";
import { createMemorySignageStore, type MemorySignageStore } from "../support/memory-store.js";
import { fakeConfiguration, fakeHandlerContext } from "../support/module-harness.js";
import { createFakeHost, type FakeHost } from "./support/fake-host.js";
/**
* The display and the server, speaking to each other for real.
*
* Every other display test answers the controller with a hand-written stub, and every server test
* calls the service with a hand-written object. Each side was therefore only ever tested against its
* own idea of the other, and in production every heartbeat a display sent was refused with a 400:
* the display sent `null` for an absent value and the route accepted only a missing field.
*
* Here the **real** display controller's requests are answered by the **real** viewer routes, over
* Signage's in-memory store. A request one side builds and the other refuses fails this suite.
*/
const START = Date.parse("2026-10-05T15:00:00.000Z");
const DEVICE_ID = "6f1c2d3e-4b5a-4c6d-8e7f-901a2b3c4d5e";
interface RouteCall {
readonly method: string;
readonly path: string;
readonly status: number;
readonly code: string | undefined;
}
/** Matches a relative module path against the routes the module registered, and runs the handler
* exactly as the platform would: one Request, one handler context. */
function routeTable(routes: readonly ModuleRouteDefinitionInput[]) {
return routes.map((route) => {
const names: string[] = [];
const pattern = new RegExp(
`^${route.path.replace(/:(\w+)/gu, (_match, name: string) => {
names.push(name);
return "([^/]+)";
})}$`,
"u",
);
return { names, pattern, route };
});
}
describe("display ↔ server contract", () => {
let clock: number;
let counter: number;
let store: MemorySignageStore;
let storage: FakeStorage;
let registry: SignageProviderRegistry;
let playback: SignagePlaybackService;
let calls: RouteCall[];
let host: FakeHost;
let root: HTMLElement;
const newId = () => {
counter += 1;
return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`;
};
beforeEach(async () => {
clock = START;
counter = 0;
calls = [];
store = createMemorySignageStore({ now: () => new Date(clock) });
storage = createFakeStorage({ now: () => new Date(clock) });
registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 });
registry.capability.register(
createNativeImageProvider({ defaultDurationMs: 7000, now: () => new Date(clock), store })
.registration,
);
playback = createPlaybackService({
newId,
now: () => clock,
registry,
setTimer: () => undefined,
settings: SIGNAGE_DEFAULT_SETTINGS,
store,
});
await playback.ensureDefaultGroup();
// One native image on a pinned slot: the most ordinary thing a wall shows, and exactly the case
// whose heartbeat carries no contribution id.
const image = await ingestNativeImage({
bytes: await makeImage({ format: "png", height: 1080, width: 1920 }),
displayName: "Welcome",
expiresAt: null,
filename: "welcome.png",
storage,
store,
});
const timeline = await createTimeline({ name: "Lobby", newId, store });
await addSlot({
newId,
pinned: { itemId: image.image.id, providerId: SIGNAGE_NATIVE_PROVIDER_ID },
store,
timelineId: timeline.timeline.id,
});
const groups = await store.listChannelGroups();
await playback.activate(groups[0]?.group.id as string, timeline.timeline.id, "now");
const registered: ModuleRouteDefinitionInput[] = [];
registerViewerRoutes(
{ route: (definition) => registered.push(definition) },
() => playback,
() => registry,
() => store,
);
const table = routeTable(registered);
host = createFakeHost({
permissions: ["signage.display.view"],
principal: { deviceId: DEVICE_ID, kind: "device" } as never,
routes: [],
});
Object.assign(host.context.api, {
async request(path: string, init?: RequestInit): Promise<Response> {
const method = (init?.method ?? "GET").toUpperCase();
const relative = `/${path.replace(/^\//u, "").split("?")[0]}`;
for (const { names, pattern, route } of table) {
const match = pattern.exec(relative);
if (route.method !== method || !match) {
continue;
}
const params = Object.fromEntries(names.map((name, index) => [name, match[index + 1]]));
const context: ModuleRouteHandlerContext = fakeHandlerContext({
config: fakeConfiguration({}),
params: Object.freeze(params) as never,
principal: { deviceId: DEVICE_ID, kind: "device" } as never,
storage,
});
const request = new Request(
`https://api.example.test/api/v1/modules/signage${relative}`,
{
...(init?.body === undefined ? {} : { body: init.body }),
headers: init?.headers ?? {},
method,
},
);
const response = await route.handler(request, context);
const body = (await response
.clone()
.json()
.catch(() => null)) as { code?: string } | null;
calls.push({ code: body?.code, method, path: route.path, status: response.status });
return response;
}
throw new Error(
`The display asked for ${method} ${relative}, which Signage does not serve.`,
);
},
});
root = document.createElement("div");
document.body.append(root);
});
async function settle(): Promise<void> {
for (let index = 0; index < 20; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
function controller() {
return createDisplayController({
context: host.context,
decodeImage: () => Promise.resolve(),
media: createMemoryMediaCache(),
newViewerKey: () => "kiosk-contract-0001",
now: () => clock,
root,
store: createMemoryStore(),
});
}
it("the server accepts every request the display sends", async () => {
const display = controller();
await display.start();
await settle();
await display.stop();
const sent = calls.map((call) => `${call.method} ${call.path}`);
expect(sent).toContain("POST /viewers/session");
expect(sent).toContain("GET /channels/:channelId/display-state");
expect(sent).toContain("POST /viewers/:viewerId/heartbeat");
// Not one refusal, of any kind, for anything the display built itself.
expect(calls.filter((call) => call.status >= 400)).toEqual([]);
});
it("a heartbeat from a screen showing a native image is recorded", async () => {
const display = controller();
await display.start();
await settle();
await display.stop();
const [viewer] = await store.listViewers();
expect(viewer).toBeDefined();
// What the display reported is what the roster now holds — which is only true if the server
// took the heartbeat, rather than refusing it and leaving the session's values in place.
expect(viewer?.reportedSelectionId).not.toBeNull();
expect(viewer?.reportedProviderId).toBe(SIGNAGE_NATIVE_PROVIDER_ID);
expect(viewer?.reportedContributionId).toBeNull();
});
});
+1 -4
View File
@@ -6,7 +6,6 @@ import {
type DisplayChannelStateView, type DisplayChannelStateView,
type DisplayController, type DisplayController,
} from "../../src/display/controller.js"; } from "../../src/display/controller.js";
import type { ViewerSessionResponse } from "../../src/shared/display-protocol.js";
import { createFakeHost, jsonResponse } from "./support/fake-host.js"; import { createFakeHost, jsonResponse } from "./support/fake-host.js";
/** /**
@@ -115,14 +114,12 @@ function state(overrides: Partial<DisplayChannelStateView> = {}): DisplayChannel
}; };
} }
/** Typed as the shared protocol message, so a stub cannot drift from what the server sends. */ const SESSION = {
const SESSION: ViewerSessionResponse = {
channelId: "chan-1", channelId: "chan-1",
channelKey: "default", channelKey: "default",
groupId: "group-1", groupId: "group-1",
heartbeatIntervalMs: 12_000, heartbeatIntervalMs: 12_000,
viewerId: "11111111-1111-4111-8111-111111111111", viewerId: "11111111-1111-4111-8111-111111111111",
viewerName: "Foyer",
}; };
describe("display controller", () => { describe("display controller", () => {
-119
View File
@@ -1,119 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createBrowserMediaCache, type SignageMediaCache } from "../../src/browser/storage.js";
/**
* The Cache Storage media cache hands out object URLs, and every live one pins its blob in the
* browser until it is revoked.
*
* Every state refresh looks up every cached slide, so a cache that minted a fresh URL per lookup
* leaked one blob per slide per refresh. In a real browser that exhausted blob storage within
* minutes: the display's own state requests then failed with `net::ERR_INSUFFICIENT_RESOURCES` and
* it fell back to its offline banner on a perfectly good network.
*/
/** Just enough Cache Storage: one named cache, keyed by request URL. */
function fakeCacheStorage() {
const entries = new Map<string, { body: ArrayBuffer; type: string }>();
const cache = {
async delete(request: Request | string): Promise<boolean> {
return entries.delete(typeof request === "string" ? request : request.url);
},
async keys(): Promise<Request[]> {
return [...entries.keys()].map((url) => new Request(url));
},
async match(request: Request | string): Promise<Response | undefined> {
const entry = entries.get(typeof request === "string" ? request : request.url);
return entry
? new Response(entry.body, { headers: { "content-type": entry.type } })
: undefined;
},
async put(request: Request | string, response: Response): Promise<void> {
entries.set(typeof request === "string" ? request : request.url, {
body: await response.arrayBuffer(),
type: response.headers.get("content-type") ?? "application/octet-stream",
});
},
};
return { open: async () => cache } as unknown as CacheStorage;
}
describe("Signage's media cache and object URLs", () => {
const realCreate = URL.createObjectURL;
const realRevoke = URL.revokeObjectURL;
let live: Set<string>;
let media: SignageMediaCache;
beforeEach(() => {
live = new Set();
let next = 0;
URL.createObjectURL = () => {
next += 1;
const url = `blob:test/${next}`;
live.add(url);
return url;
};
URL.revokeObjectURL = (url: string) => {
live.delete(url);
};
media = createBrowserMediaCache({
caches: fakeCacheStorage(),
fetch: async () =>
new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/webp" } }),
location: { origin: "https://api.example.test" },
} as unknown as typeof globalThis);
});
afterEach(() => {
URL.createObjectURL = realCreate;
URL.revokeObjectURL = realRevoke;
});
const keys = ["slide/a", "slide/b", "slide/c"];
it("keeps one live object URL per cached slide, however often it is looked up", async () => {
for (const key of keys) {
await media.store(key, `https://files.example.test/${key}?sig=1`);
}
// A state refresh looks every slide up again; a long-running display does this for months.
for (let refresh = 0; refresh < 100; refresh += 1) {
for (const key of keys) {
await media.find(key);
}
}
expect(live.size).toBe(keys.length);
});
it("hands out the same URL for the same bytes", async () => {
await media.store("slide/a", "https://files.example.test/a?sig=1");
const first = await media.find("slide/a");
const second = await media.find("slide/a");
expect(first).toBeDefined();
expect(second).toBe(first);
});
it("revokes a pruned slide's URL", async () => {
for (const key of keys) {
await media.store(key, `https://files.example.test/${key}?sig=1`);
await media.find(key);
}
await media.prune(["slide/a"]);
expect(live.size).toBe(1);
expect(await media.find("slide/b")).toBeUndefined();
});
it("revokes the URL it replaces when a slide is stored again", async () => {
await media.store("slide/a", "https://files.example.test/a?sig=1");
await media.store("slide/a", "https://files.example.test/a?sig=2");
await media.find("slide/a");
expect(live.size).toBe(1);
});
it("releases every URL it handed out when the display stops", async () => {
for (const key of keys) {
await media.store(key, `https://files.example.test/${key}?sig=1`);
await media.find(key);
}
media.release();
expect(live.size).toBe(0);
});
});
+5 -19
View File
@@ -16,7 +16,6 @@ import {
createDisplayController, createDisplayController,
type DisplayChannelStateView, type DisplayChannelStateView,
} from "../../src/display/controller.js"; } from "../../src/display/controller.js";
import type { DisplaySelection, ViewerSessionResponse } from "../../src/shared/display-protocol.js";
import { createFakeHost, jsonResponse } from "./support/fake-host.js"; import { createFakeHost, jsonResponse } from "./support/fake-host.js";
/** /**
@@ -310,26 +309,14 @@ describe("a display that loses its network", () => {
} }
} }
/** A native selection exactly as the server sends one in channel state. */ function nativeSelection(id: string, slotId: string): SceneSelection {
function nativeSelection(id: string, slotId: string): DisplaySelection {
return { return {
...selection({ id, slotId }), ...selection({ id, slotId }),
media: { media: {
display: { display: { url: `https://cdn.invalid/${id}?sig=a` },
expiresAtMs: 900_000, thumbnail: { url: `https://cdn.invalid/${id}-t?sig=a` },
height: 1080,
url: `https://cdn.invalid/${id}?sig=a`,
width: 1920,
}, },
thumbnail: { } as SceneSelection;
expiresAtMs: 900_000,
height: 270,
url: `https://cdn.invalid/${id}-t?sig=a`,
width: 480,
},
},
stale: false,
};
} }
function state(currentId: string): DisplayChannelStateView { function state(currentId: string): DisplayChannelStateView {
@@ -386,8 +373,7 @@ describe("a display that loses its network", () => {
groupId: "group-1", groupId: "group-1",
heartbeatIntervalMs: 12_000, heartbeatIntervalMs: 12_000,
viewerId: "11111111-1111-4111-8111-111111111111", viewerId: "11111111-1111-4111-8111-111111111111",
viewerName: "Foyer", }),
} satisfies ViewerSessionResponse),
}, },
{ {
method: "GET", method: "GET",
+1 -1
View File
@@ -127,7 +127,7 @@ export function createFakeHost(options: FakeHostOptions): FakeHost {
/** The settings payload every screen loads first. */ /** The settings payload every screen loads first. */
export const FAKE_SETTINGS = Object.freeze({ export const FAKE_SETTINGS = Object.freeze({
crossfadeMs: 1000, crossfadeMs: 1000,
moduleVersion: "0.1.2", moduleVersion: "0.1.1",
nativeImageDurationMs: 7000, nativeImageDurationMs: 7000,
nativePresentation: { nativePresentation: {
backgroundColor: "#000000", backgroundColor: "#000000",
-125
View File
@@ -1,125 +0,0 @@
import { describe, expect, it } from "vitest";
import {
DISPLAY_ERROR_DETAIL_MAX,
parseDisplayHeartbeat,
parseViewerSessionRequest,
parseViewerSessionResponse,
} from "../src/shared/display-protocol.js";
/**
* The rules every display message follows, pinned one by one, because the production failure was
* exactly a disagreement about one of them.
*/
const FULL_HEARTBEAT = {
cachedAssetCount: 3,
error: null,
offlineReady: true,
reportedContributionId: null,
reportedPlaybackRevision: 4,
reportedProviderId: "signage.native-images",
reportedSelectionId: "sel-1",
reportedTimelineId: "timeline-1",
};
describe("display heartbeat", () => {
it("accepts null for every nullable field: null is a value, not an omission", () => {
const result = parseDisplayHeartbeat({
...FULL_HEARTBEAT,
reportedContributionId: null,
reportedPlaybackRevision: null,
reportedProviderId: null,
reportedSelectionId: null,
reportedTimelineId: null,
});
expect(result).toEqual({
ok: true,
value: {
...FULL_HEARTBEAT,
reportedContributionId: null,
reportedPlaybackRevision: null,
reportedProviderId: null,
reportedSelectionId: null,
reportedTimelineId: null,
},
});
});
it("treats an absent field as not reported, so the receiver keeps what it had", () => {
const result = parseDisplayHeartbeat({ offlineReady: false });
expect(result).toEqual({ ok: true, value: { offlineReady: false } });
});
it("ignores a field it does not know, so a newer display is never refused by an older server", () => {
const result = parseDisplayHeartbeat({ ...FULL_HEARTBEAT, reportedSomethingNew: { a: 1 } });
expect(result).toEqual({ ok: true, value: FULL_HEARTBEAT });
});
it.each([
["cachedAssetCount", null],
["cachedAssetCount", -1],
["cachedAssetCount", 1.5],
["offlineReady", null],
["offlineReady", "yes"],
["reportedSelectionId", ""],
["reportedSelectionId", "x".repeat(65)],
["reportedProviderId", 7],
["reportedPlaybackRevision", -1],
["error", { code: "" }],
["error", { code: "BAD CODE" }],
["error", { code: "OK", detail: "y".repeat(DISPLAY_ERROR_DETAIL_MAX + 1) }],
["error", "SLIDE_UNRENDERABLE"],
])("refuses %s = %j, naming that field and nothing else", (field, value) => {
expect(parseDisplayHeartbeat({ ...FULL_HEARTBEAT, [field]: value })).toEqual({
field,
ok: false,
});
});
it("accepts a reported error with or without detail", () => {
expect(parseDisplayHeartbeat({ error: { code: "SLIDE_UNRENDERABLE", detail: null } })).toEqual({
ok: true,
value: { error: { code: "SLIDE_UNRENDERABLE", detail: null } },
});
expect(parseDisplayHeartbeat({ error: { code: "SLIDE_UNRENDERABLE" } })).toEqual({
ok: true,
value: { error: { code: "SLIDE_UNRENDERABLE", detail: null } },
});
});
it("refuses something that is not an object at all", () => {
for (const body of [null, [], "heartbeat", 3]) {
expect(parseDisplayHeartbeat(body)).toEqual({ field: "body", ok: false });
}
});
});
describe("viewer session", () => {
it("requires a well-formed viewer key", () => {
expect(parseViewerSessionRequest({ viewerKey: "kiosk-lobby-0001" })).toEqual({
ok: true,
value: { viewerKey: "kiosk-lobby-0001" },
});
for (const viewerKey of [undefined, null, "short", "has spaces in it", "x".repeat(129)]) {
expect(parseViewerSessionRequest({ viewerKey })).toEqual({ field: "viewerKey", ok: false });
}
});
it("a display accepts only a complete registration", () => {
const complete = {
channelId: "chan-1",
channelKey: "default",
groupId: "group-1",
heartbeatIntervalMs: 12_000,
viewerId: "11111111-1111-4111-8111-111111111111",
viewerName: "New display",
};
expect(parseViewerSessionResponse(complete)).toEqual({ ok: true, value: complete });
const { viewerId: _omitted, ...missing } = complete;
expect(parseViewerSessionResponse(missing)).toEqual({ field: "viewerId", ok: false });
expect(parseViewerSessionResponse({ ...complete, heartbeatIntervalMs: 0 })).toEqual({
field: "heartbeatIntervalMs",
ok: false,
});
});
});
+3 -3
View File
@@ -66,7 +66,7 @@ describe("Signage module manifest", () => {
expect(manifest.manifestVersion).toBe(7); expect(manifest.manifestVersion).toBe(7);
expect(manifest.id).toBe("signage"); expect(manifest.id).toBe("signage");
expect(manifest.name).toBe("Signage"); expect(manifest.name).toBe("Signage");
expect(manifest.version).toBe("0.1.2"); expect(manifest.version).toBe("0.1.1");
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.1.0", platform: "^0.1.0" }); expect(manifest.compatibility).toEqual({ moduleSdk: "^0.1.0", platform: "^0.1.0" });
}); });
@@ -142,7 +142,7 @@ describe("Signage module manifest", () => {
describe("Signage server runtime", () => { describe("Signage server runtime", () => {
it("exports the typed runtime definition the compiled server entrypoint publishes", async () => { it("exports the typed runtime definition the compiled server entrypoint publishes", async () => {
expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.2" }); expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.1" });
}); });
it("registers its routes declaratively, with no database, storage, or realtime work", async () => { it("registers its routes declaratively, with no database, storage, or realtime work", async () => {
@@ -241,7 +241,7 @@ describe("Signage server runtime", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(await response.json()).toEqual({ expect(await response.json()).toEqual({
crossfadeMs: 400, crossfadeMs: 400,
moduleVersion: "0.1.2", moduleVersion: "0.1.1",
nativeImageDurationMs: 12_000, nativeImageDurationMs: 12_000,
nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation, nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation,
rendererInputVersion: "1.0.0", rendererInputVersion: "1.0.0",