2 Commits
Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 776540ff78 fix(display): define the display-server messages once, in src/shared
The display controller and the viewer routes each described the
heartbeat their own way and disagreed about one thing: the display sent
null for "nothing to report", and the route accepted only a missing
field. Every heartbeat from a screen showing a native image was refused
with a 400. The roster therefore marked working screens Offline 90 s
after they opened, and never recorded offlineReady or cachedAssetCount.

src/shared/display-protocol.ts now defines every message between a
display and the server:
- viewer session request and response;
- heartbeat request and response;
- channel state;
- the realtime display payload.

Both halves import it. Its rules: a nullable field accepts null
explicitly, an absent field means "not reported", and an unknown field
is ignored, so a newer display is never refused by an older server.

- The routes parse with the shared parsers, and a refusal names the
  field with Signage's sanitized code.
- The server's channel state extends the wire type, so a field the
  display relies on cannot drift without a compile error.
- The display builds its heartbeat as the shared type, and reads its
  registration with the shared parser instead of casting it.
- Test stubs that described server responses more loosely than the
  server sends them (no viewerName, no stale, partial media) now match
  the real responses and are typed as the protocol.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 18:32:34 -05:00
ekklesiaandClaude Opus 5.5 7483d7cbbe test(display): run the real display controller against the real viewer routes
Every display test answered the controller with a hand-written stub, and
every viewer test called the service with a hand-written object, so
neither side was ever tested against the other. In production every
heartbeat was refused with a 400.

This suite answers the real controller's requests with the real viewer
route handlers over the in-memory store. The routes gain an optional
store factory for that; production still uses the module database.

Fails today: POST /viewers/:viewerId/heartbeat -> 400
SIGNAGE_INVALID_REQUEST, because the display sends
reportedContributionId: null and the route accepts only a missing field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 18:28:59 -05:00
23 changed files with 58 additions and 5488 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/
*.log
.DS_Store
build/
+5 -6
View File
@@ -16,8 +16,7 @@ Ekklesia and vendors none of its SDKs.
## Status
Version 0.1.2 is feature-complete for its declared scope, and Signage has run in production since
0.1.1. What exists today:
Version 0.1.1 is feature-complete for its declared scope and unreleased. What exists today:
- The module package, manifest (v7), four capabilities, six permissions, ten operator settings, and
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
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.
Video, audio, MQTT, Home Assistant, Companion, and a notification outbox are out of scope; so is
any content module — Signage ships with no announcements, scripture, or trivia, and cannot render
any until one is installed.
Nothing here is deployed, and no cutover from the legacy Announcements container has happened or is
planned by this repository. Video, audio, MQTT, Home Assistant, Companion, and a notification outbox
are out of scope; so is any content module — Signage ships with no announcements, scripture, or
trivia, and cannot render any until one is installed.
### 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",
"name": "Signage",
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
"version": "0.1.2",
"version": "0.1.1",
"compatibility": {
"platform": "^0.1.0",
"moduleSdk": "^0.1.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/signage",
"version": "0.1.2",
"version": "0.1.1",
"private": true,
"type": "module",
"description": "Ekklesia's visual programming, timeline, synchronization, and display-delivery module.",
-21
View File
@@ -1,21 +0,0 @@
/**
* How long a display waits before trying a failed request again.
*
* Exponential from one second to a one-minute ceiling, so a screen whose server is restarting is
* back within seconds, and one whose server is down for an evening asks once a minute rather than
* hammering it. The wait is jittered between half and all of that step, so a building's worth of
* screens that lost the server at the same moment do not all come back at the same moment.
*
* There is no attempt limit. A television on a wall has nobody to press Retry, so giving up is the
* one thing it must never do.
*/
export const DISPLAY_RETRY_BASE_MS = 1_000;
export const DISPLAY_RETRY_MAX_MS = 60_000;
export function displayRetryDelayMs(attempt: number, random: () => number = Math.random): number {
const step = Math.min(
DISPLAY_RETRY_MAX_MS,
DISPLAY_RETRY_BASE_MS * 2 ** Math.min(Math.max(0, attempt), 16),
);
return Math.round(step / 2 + (step / 2) * Math.min(1, Math.max(0, random())));
}
+3 -49
View File
@@ -36,9 +36,6 @@ export interface SignageMediaCache {
* can never leave a screen with less than it had. */
prune(keep: readonly string[]): Promise<void>;
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
@@ -89,9 +86,6 @@ export function createMemoryMediaCache(): SignageMediaCache {
}
return Promise.resolve();
},
release(): void {
// Holds plain URLs, nothing the browser has to be given back.
},
store(key: string, url: string): Promise<string | undefined> {
stored.set(key, url);
return Promise.resolve(url);
@@ -185,45 +179,14 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis):
return `${origin}/${key}`;
}
/**
* The one object URL each cached slide is rendered from, by cache entry.
*
* 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);
async function localUrl(cache: Cache, url: string): Promise<string | undefined> {
const response = await cache.match(url);
if (!response) {
return undefined;
}
try {
const blob = await response.blob();
// Two lookups of one slide can be in flight at once; the first to finish wins, and the other
// 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;
return URL.createObjectURL(blob);
} catch {
return undefined;
}
@@ -257,7 +220,6 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis):
for (const request of await cache.keys()) {
if (!retained.has(request.url)) {
await cache.delete(request);
forget(request.url);
}
}
} 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> {
const cache = await open();
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
// bytes instead of downloading them again.
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));
} catch {
return undefined;
+40 -126
View File
@@ -1,5 +1,4 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { displayRetryDelayMs } from "../browser/backoff.js";
import {
type OfflineSnapshot,
resolveOffline,
@@ -123,13 +122,6 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
let snapshot: OfflineSnapshot | undefined;
let offlineActive = false;
let offlineHandle: unknown;
/** Retries, each on its own backoff. A display has nobody to press Retry, so none of them give up;
* each resets once its request works again. */
let sessionHandle: unknown;
let sessionAttempt = 0;
let refreshAttempt = 0;
let realtimeHandle: unknown;
let realtimeAttempt = 0;
/** Selections this display has tried and failed to render. A broken item is skipped rather than
* retried in a tight loop, which is what keeps one bad slide from becoming a flicker. */
const unusable = new Set<string>();
@@ -505,119 +497,17 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
`channels/${session.channelId}/display-state`,
);
if (!response.ok) {
await refreshFailed();
online = false;
await runOffline();
return;
}
const state = (await response.json()) as DisplayChannelStateView;
refreshAttempt = 0;
await apply(state);
await apply((await response.json()) as DisplayChannelStateView);
} catch {
await refreshFailed();
}
}
/**
* The server is unreachable, or answered with an error. The frame stays and local continuation
* takes over — nothing about a network failure belongs on a lobby wall — and the refresh is tried
* again on its own, because a realtime message that would otherwise prompt it may never come.
*/
async function refreshFailed(): Promise<void> {
online = false;
await runOffline();
if (stopped) {
return;
}
if (refreshHandle !== undefined) {
clearTimer(refreshHandle);
}
refreshHandle = setTimer(() => {
refreshHandle = undefined;
void refresh();
}, displayRetryDelayMs(refreshAttempt));
refreshAttempt += 1;
}
/**
* Registers this display, and keeps trying until it can.
*
* Until it has registered, a display continues from its own persisted state, honestly marked as
* offline. The first registration that works brings it live — realtime, heartbeats, and fresh
* state — exactly as if it had worked the first time.
*/
async function register(): Promise<void> {
if (stopped || session) {
return;
}
let registered: DisplaySessionView | undefined;
try {
const response = await options.context.api.request("viewers/session", {
body: JSON.stringify({ viewerKey: await viewerKey() }),
headers: { "content-type": "application/json" },
method: "POST",
});
if (response.ok) {
// Read, not cast: a registration the display cannot understand is no registration, and is
// tried again like any other failure.
const parsed = parseViewerSessionResponse(await response.json());
registered = parsed.ok ? parsed.value : undefined;
}
} catch {
registered = undefined;
}
if (stopped) {
return;
}
if (!registered) {
// The server is unreachable. The frame stays and local continuation takes over; nothing about
// a network failure belongs on a lobby wall.
online = false;
report();
sessionHandle = setTimer(() => {
sessionHandle = undefined;
void register();
}, displayRetryDelayMs(sessionAttempt));
sessionAttempt += 1;
return;
await runOffline();
}
sessionAttempt = 0;
session = registered;
online = true;
subscribe();
heartbeatHandle = setInterval(
() => void heartbeat(),
registered.heartbeatIntervalMs,
) as unknown;
await refresh();
void heartbeat();
report();
}
/**
* Subscribes to the display's realtime channel, and again whenever the host reports that a
* subscription has ended for good. The host retries ordinary disconnects itself; a stream that
* is refused outright ends, and until this resubscribes the display would only learn of changes
* at its next slot deadline.
*/
function subscribe(): void {
if (stopped || !session) {
return;
}
subscription?.close();
subscription = options.context.realtime.subscribe("display-state", {
onSnapshot: (state) => onRealtime(state),
onStatus: (status) => {
online = status === "connected";
if (status === "connected") {
realtimeAttempt = 0;
} else if (status === "closed" && !stopped && realtimeHandle === undefined) {
realtimeHandle = setTimer(() => {
realtimeHandle = undefined;
subscribe();
}, displayRetryDelayMs(realtimeAttempt));
realtimeAttempt += 1;
}
report();
},
onUpdate: (state) => onRealtime(state),
});
}
async function heartbeat(): Promise<void> {
@@ -688,7 +578,40 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
}
if (telemetry) {
await register();
try {
const response = await options.context.api.request("viewers/session", {
body: JSON.stringify({ viewerKey: await viewerKey() }),
headers: { "content-type": "application/json" },
method: "POST",
});
if (response.ok) {
// Read, not cast: a registration the display cannot understand is no registration.
const parsed = parseViewerSessionResponse(await response.json());
if (parsed.ok) {
session = parsed.value;
online = true;
}
}
} catch {
online = false;
}
}
if (session) {
subscription = options.context.realtime.subscribe("display-state", {
onSnapshot: (state) => onRealtime(state),
onStatus: (status) => {
online = status === "connected";
report();
},
onUpdate: (state) => onRealtime(state),
});
heartbeatHandle = setInterval(
() => void heartbeat(),
session.heartbeatIntervalMs,
) as unknown;
await refresh();
void heartbeat();
}
report();
},
@@ -705,18 +628,9 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
clearTimer(refreshHandle);
refreshHandle = undefined;
}
for (const handle of [sessionHandle, realtimeHandle]) {
if (handle !== undefined) {
clearTimer(handle);
}
}
sessionHandle = undefined;
realtimeHandle = undefined;
exitOffline();
await stage?.destroy();
stage = undefined;
// After the stage is gone, so nothing on screen still points at a released URL.
media.release();
},
};
+1 -1
View File
@@ -59,7 +59,7 @@ function SignageDisplay({ context }: { readonly context: ModuleUiHostContext }):
*/
export default defineModuleUi({
id: "signage",
version: "0.1.2",
version: "0.1.1",
styles: displayStyles,
mount(container, context) {
const root = createRoot(container);
+2 -2
View File
@@ -127,7 +127,7 @@ export function createSignageModuleRuntime(
const definition = defineModuleRuntime({
id: "signage",
version: "0.1.2",
version: "0.1.1",
register({ capabilities, config, http, realtime }) {
const settings = readSignageSettings(config);
@@ -189,7 +189,7 @@ export function createSignageModuleRuntime(
// server enforces — a UI believing a stale limit would offer an upload the server refuses.
return Response.json({
crossfadeMs: current.crossfadeMs,
moduleVersion: "0.1.2",
moduleVersion: "0.1.1",
nativeImageDurationMs: current.nativeImageDurationMs,
nativePresentation: current.nativePresentation,
rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
+1 -1
View File
@@ -15,7 +15,7 @@ import ownStyles from "./styles.css";
*/
export default defineModuleUi({
id: "signage",
version: "0.1.2",
version: "0.1.1",
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
mount(container, context) {
const root = createRoot(container);
-34
View File
@@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest";
import {
DISPLAY_RETRY_BASE_MS,
DISPLAY_RETRY_MAX_MS,
displayRetryDelayMs,
} from "../../src/browser/backoff.js";
describe("display retry backoff", () => {
it("doubles from one second and stops at one minute", () => {
const longest = (attempt: number) => displayRetryDelayMs(attempt, () => 1);
expect([0, 1, 2, 3, 4, 5, 6, 7, 50].map(longest)).toEqual([
1_000, 2_000, 4_000, 8_000, 16_000, 32_000, 60_000, 60_000, 60_000,
]);
});
it("jitters between half and all of each step, and never waits zero", () => {
for (const attempt of [0, 3, 6, 40]) {
const step = Math.min(DISPLAY_RETRY_MAX_MS, DISPLAY_RETRY_BASE_MS * 2 ** attempt);
expect(displayRetryDelayMs(attempt, () => 0)).toBe(step / 2);
expect(displayRetryDelayMs(attempt, () => 1)).toBe(step);
for (let index = 0; index < 50; index += 1) {
const delay = displayRetryDelayMs(attempt);
expect(delay).toBeGreaterThanOrEqual(step / 2);
expect(delay).toBeLessThanOrEqual(step);
}
}
});
it("treats a nonsensical attempt or random value as the nearest sensible one", () => {
expect(displayRetryDelayMs(-3, () => 1)).toBe(1_000);
expect(displayRetryDelayMs(2, () => 7)).toBe(4_000);
expect(displayRetryDelayMs(2, () => -1)).toBe(2_000);
});
});
-307
View File
@@ -1,307 +0,0 @@
// @vitest-environment happy-dom
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { beforeEach, describe, expect, it } from "vitest";
import {
createMemoryMediaCache,
createMemoryStore,
type SignageLocalStore,
} from "../../src/browser/storage.js";
import {
createDisplayController,
type DisplayChannelStateView,
} from "../../src/display/controller.js";
import { createFakeHost, type FakeHost, jsonResponse } from "./support/fake-host.js";
/**
* A display never reaches a dead end.
*
* Every network path a display depends on — registering, fetching state, realtime — can fail once,
* as a kiosk that boots before its network settles or an API that restarts will make it. Each must
* be tried again, with backoff, until it works, and the display must then be exactly as live as if
* nothing had happened. None of it may need a person to reload a television.
*
* Time is virtual: every timer the controller schedules goes through the queue below.
*/
const SESSION = {
channelId: "chan-1",
channelKey: "default",
groupId: "group-1",
heartbeatIntervalMs: 12_000,
viewerId: "11111111-1111-4111-8111-111111111111",
viewerName: "Foyer",
};
function selection(id: string) {
return {
availableUntilMs: null,
deadlineAtMs: 5000,
durationMs: 5000,
id,
itemId: `image-${id}`,
itemRevision: "rev-1",
media: {
display: {
expiresAtMs: 900_000,
height: 1080,
url: `https://cdn.invalid/${id}`,
width: 1920,
},
thumbnail: {
expiresAtMs: 900_000,
height: 270,
url: `https://cdn.invalid/${id}-t`,
width: 480,
},
},
payload: { kind: "native-image" },
presentation: null,
providerId: "signage.native-images",
renderer: { kind: "native-image" as const },
revision: 1,
scene: {
backgroundColor: "#000000",
backgroundImage: false,
backgroundMode: "none" as const,
blurPixels: 0,
darkenOpacity: 0,
fit: "fill" as const,
shadow: false,
},
slotId: "slot-1",
stale: false,
startedAtMs: 0,
withdrawn: false,
};
}
function channelState(revision = 3): DisplayChannelStateView {
const current = selection("sel-1");
return {
channel: { groupId: "group-1", id: "chan-1", key: "default" },
crossfadeMs: 0,
current,
playback: {
activeTimelineId: "timeline-1",
blackout: false,
currentSelectionId: current.id,
currentSlotId: current.slotId,
deadlineAtMs: 5000,
pendingTimelineId: null,
remainingMs: 5000,
revision,
selectionRevision: 1,
startedAtMs: 0,
transport: "playing",
},
selections: [current],
serverNowMs: 0,
staticHold: null,
timeline: null,
};
}
/** Server behaviour that a test can change mid-run. */
interface Server {
sessionFailures: number;
stateFailing: boolean;
sessionRequests: number;
stateRequests: number;
}
describe("a display recovers from every failed request without a reload", () => {
let clock: number;
let timers: Map<number, { at: number; run: () => void }>;
let nextTimer: number;
let root: HTMLElement;
let local: SignageLocalStore;
let server: Server;
let host: FakeHost;
let subscriptions: { onStatus?: (status: string) => void }[];
beforeEach(() => {
clock = 0;
timers = new Map();
nextTimer = 1;
root = document.createElement("div");
document.body.append(root);
local = createMemoryStore();
server = { sessionFailures: 0, sessionRequests: 0, stateFailing: false, stateRequests: 0 };
subscriptions = [];
host = createFakeHost({
permissions: ["signage.display.view"],
routes: [
{
method: "POST",
path: "viewers/session",
respond: () => {
server.sessionRequests += 1;
if (server.sessionFailures > 0) {
server.sessionFailures -= 1;
throw new TypeError("Failed to fetch");
}
return jsonResponse(SESSION);
},
},
{
method: "GET",
path: "channels/chan-1/display-state",
respond: () => {
server.stateRequests += 1;
if (server.stateFailing) {
throw new TypeError("Failed to fetch");
}
return jsonResponse(channelState());
},
},
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
],
});
Object.assign(host.context, {
realtime: {
subscribe: (_channel: string, handlers: { onStatus?: (status: string) => void }) => {
subscriptions.push(handlers);
return { close: () => undefined };
},
} satisfies Pick<ModuleUiHostContext["realtime"], "subscribe"> as never,
});
});
async function settle(): Promise<void> {
for (let index = 0; index < 20; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
/** Moves virtual time forward, firing every timer that falls due, in order. */
async function advance(ms: number): Promise<void> {
const end = clock + ms;
for (;;) {
const due = [...timers]
.filter(([, timer]) => timer.at <= end)
.sort((left, right) => left[1].at - right[1].at)[0];
if (!due) {
break;
}
timers.delete(due[0]);
clock = due[1].at;
due[1].run();
await settle();
}
clock = end;
}
function build() {
return createDisplayController({
clearTimer: (handle) => timers.delete(handle as number),
context: host.context,
decodeImage: () => Promise.resolve(),
media: createMemoryMediaCache(),
newViewerKey: () => "viewer-fixed-key-0001",
now: () => clock,
root,
setTimer: (run, delayMs) => {
const id = nextTimer;
nextTimer += 1;
timers.set(id, { at: clock + delayMs, run });
return id;
},
store: local,
});
}
/** One ordinary live run, so the display has a persisted snapshot to continue from, exactly as a
* screen that has been on the wall for a while does. */
async function runOnceLive(): Promise<void> {
const display = build();
await display.start();
await settle();
expect(root.dataset.offline).toBe("false");
await display.stop();
timers.clear();
}
it("registers once the server answers, after its first registration failed", async () => {
await runOnceLive();
server.sessionFailures = 1;
const sessionsBefore = server.sessionRequests;
const display = build();
await display.start();
await settle();
// Continuing from its own state while it cannot register: the honest banner.
expect(root.dataset.offline).toBe("true");
await advance(60_000);
expect(server.sessionRequests - sessionsBefore).toBeGreaterThanOrEqual(2);
expect(root.dataset.offline).toBe("false");
expect(subscriptions.length).toBeGreaterThanOrEqual(2);
await display.stop();
});
it("keeps retrying registration with backoff, and never gives up", async () => {
await runOnceLive();
server.sessionFailures = 8;
const sessionsBefore = server.sessionRequests;
const display = build();
await display.start();
await settle();
// Eight failures back off for at most 1 + 2 + 4 + 8 + 16 + 32 + 60 + 60 seconds, the last two at
// the one-minute ceiling. Five virtual minutes is enough for all of them and the success after.
await advance(5 * 60_000);
expect(server.sessionRequests - sessionsBefore).toBe(9);
expect(root.dataset.offline).toBe("false");
await display.stop();
});
it("retries a failed state refresh until the server answers, with no realtime nudge", async () => {
const display = build();
await display.start();
await settle();
expect(root.dataset.offline).toBe("false");
// The slot's deadline refresh fails, as it would mid-restart. Realtime says nothing.
server.stateFailing = true;
await advance(6_000);
expect(root.dataset.offline).toBe("true");
const failedRequests = server.stateRequests;
server.stateFailing = false;
await advance(120_000);
expect(server.stateRequests).toBeGreaterThan(failedRequests);
expect(root.dataset.offline).toBe("false");
await display.stop();
});
it("subscribes again when the host reports its realtime stream closed for good", async () => {
const display = build();
await display.start();
await settle();
expect(subscriptions).toHaveLength(1);
subscriptions[0]?.onStatus?.("closed");
await settle();
await advance(60_000);
expect(subscriptions.length).toBeGreaterThanOrEqual(2);
await display.stop();
});
it("schedules nothing after it is stopped", async () => {
await runOnceLive();
server.sessionFailures = 3;
const display = build();
await display.start();
await settle();
await display.stop();
const sessionsAtStop = server.sessionRequests;
await advance(5 * 60_000);
expect(server.sessionRequests).toBe(sessionsAtStop);
});
});
-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);
});
});
+1 -1
View File
@@ -127,7 +127,7 @@ export function createFakeHost(options: FakeHostOptions): FakeHost {
/** The settings payload every screen loads first. */
export const FAKE_SETTINGS = Object.freeze({
crossfadeMs: 1000,
moduleVersion: "0.1.2",
moduleVersion: "0.1.1",
nativeImageDurationMs: 7000,
nativePresentation: {
backgroundColor: "#000000",
+3 -3
View File
@@ -66,7 +66,7 @@ describe("Signage module manifest", () => {
expect(manifest.manifestVersion).toBe(7);
expect(manifest.id).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" });
});
@@ -142,7 +142,7 @@ describe("Signage module manifest", () => {
describe("Signage server runtime", () => {
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 () => {
@@ -241,7 +241,7 @@ describe("Signage server runtime", () => {
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
crossfadeMs: 400,
moduleVersion: "0.1.2",
moduleVersion: "0.1.1",
nativeImageDurationMs: 12_000,
nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation,
rendererInputVersion: "1.0.0",