0.1.2 fixes what the first production Signage deployment found: - #4: the display and server share one message schema; every heartbeat from an image slide used to be refused with a 400; - #5: every display request retries with backoff and never gives up, so a display that failed one request no longer stays offline until reloaded; - #6: one object URL per cached slide, released when done; the leak exhausted the browser within minutes; - CI (#7, #8): every change runs inside a pinned Ekklesia, end to end in a real browser, and cleans up even when killed. compatibility.platform stays ^0.1.0: every fix is module-side, and CI tested it against platform v0.1.1 plus test-only scripts. The README no longer says Signage is unreleased or undeployed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
147 lines
3.9 KiB
TypeScript
147 lines
3.9 KiB
TypeScript
import type {
|
|
ModuleContributionDescriptor,
|
|
ModuleUiApiClient,
|
|
ModuleUiContributions,
|
|
ModuleUiHostContext,
|
|
MountedModuleContribution,
|
|
MountModuleContributionOptions,
|
|
} from "@ekklesia/module-ui-sdk";
|
|
|
|
/**
|
|
* A host, as a module's artifact actually experiences one.
|
|
*
|
|
* Requests are matched on the **relative** path a module asks for, which is what proves the artifact
|
|
* never builds `/api/v1/modules/signage/...`: a route table keyed on Ekklesia's private prefix would
|
|
* simply never match.
|
|
*/
|
|
export interface FakeHostRoute {
|
|
readonly method: string;
|
|
readonly path: string | RegExp;
|
|
readonly respond: (request: {
|
|
readonly path: string;
|
|
readonly init: RequestInit | undefined;
|
|
}) => Response | Promise<Response>;
|
|
}
|
|
|
|
export interface FakeHost {
|
|
readonly context: ModuleUiHostContext;
|
|
readonly requests: { method: string; path: string }[];
|
|
/** Contributions this host published against Signage's renderer capability. */
|
|
readonly mounted: { contributionId: string; input: unknown }[];
|
|
}
|
|
|
|
export function jsonResponse(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
headers: { "content-type": "application/json" },
|
|
status,
|
|
});
|
|
}
|
|
|
|
export function errorResponse(
|
|
code: string,
|
|
message: string,
|
|
status = 400,
|
|
): Response {
|
|
return jsonResponse({ code, message }, status);
|
|
}
|
|
|
|
export interface FakeHostOptions {
|
|
readonly permissions: readonly string[];
|
|
readonly routes: readonly FakeHostRoute[];
|
|
readonly contributions?: readonly ModuleContributionDescriptor[];
|
|
readonly displayUrl?: string;
|
|
readonly principal?: ModuleUiHostContext["principal"];
|
|
readonly mount?: (
|
|
options: MountModuleContributionOptions,
|
|
) => Promise<MountedModuleContribution> | MountedModuleContribution;
|
|
}
|
|
|
|
export function createFakeHost(options: FakeHostOptions): FakeHost {
|
|
const requests: { method: string; path: string }[] = [];
|
|
const mounted: { contributionId: string; input: unknown }[] = [];
|
|
|
|
const api: ModuleUiApiClient = {
|
|
async request(path, init) {
|
|
const method = (init?.method ?? "GET").toUpperCase();
|
|
const normalized = path.replace(/^\//u, "");
|
|
requests.push({ method, path: normalized });
|
|
for (const route of options.routes) {
|
|
const matches =
|
|
typeof route.path === "string"
|
|
? route.path === normalized.split("?")[0]
|
|
: route.path.test(normalized);
|
|
if (route.method === method && matches) {
|
|
return route.respond({ init, path: normalized });
|
|
}
|
|
}
|
|
return errorResponse(
|
|
"SIGNAGE_NOT_FOUND",
|
|
"The requested resource was not found.",
|
|
404,
|
|
);
|
|
},
|
|
};
|
|
|
|
const contributions: ModuleUiContributions = {
|
|
list: (capabilityId) =>
|
|
capabilityId === "signage.slide-renderer"
|
|
? (options.contributions ?? [])
|
|
: [],
|
|
async mount(mountOptions) {
|
|
mounted.push({
|
|
contributionId: mountOptions.contributionId,
|
|
input: mountOptions.input,
|
|
});
|
|
if (options.mount) {
|
|
return options.mount(mountOptions);
|
|
}
|
|
return {
|
|
contributionId: mountOptions.contributionId,
|
|
mounted: true,
|
|
unmount: () => Promise.resolve(),
|
|
update: () => Promise.resolve(),
|
|
version: "1.0.0",
|
|
};
|
|
},
|
|
};
|
|
|
|
return {
|
|
context: {
|
|
api,
|
|
contributions,
|
|
display: {
|
|
url: options.displayUrl ?? "https://api.example.test/display/signage",
|
|
},
|
|
moduleId: "signage",
|
|
permissions: {
|
|
has: (permission) => options.permissions.includes(permission),
|
|
},
|
|
principal: options.principal ?? { kind: "anonymous" },
|
|
realtime: { subscribe: () => ({ close: () => undefined }) },
|
|
},
|
|
mounted,
|
|
requests,
|
|
};
|
|
}
|
|
|
|
/** The settings payload every screen loads first. */
|
|
export const FAKE_SETTINGS = Object.freeze({
|
|
crossfadeMs: 1000,
|
|
moduleVersion: "0.1.2",
|
|
nativeImageDurationMs: 7000,
|
|
nativePresentation: {
|
|
backgroundColor: "#000000",
|
|
backgroundDarkened: false,
|
|
backgroundMode: "blurred",
|
|
blurPixels: 20,
|
|
},
|
|
rendererInputVersion: "1.0.0",
|
|
timezone: "America/Chicago",
|
|
uploads: {
|
|
batchMaxFiles: 20,
|
|
concurrency: 3,
|
|
maxBytes: 26_214_400,
|
|
maxPixels: 40_000_000,
|
|
},
|
|
});
|