feat: let one module contribute a browser renderer to another's capability
The platform has had a complete story for one module using another's
*backend* since ADR 0006 — capabilities, versioned ranges, provider
registration. It has had none at all for one module using another's *browser*
code, and every workaround is wrong in a way that matters: a consumer cannot
import a peer module, it cannot re-implement a renderer for a domain it does
not own, and a second `entrypoints` role would put the platform in charge of
deciding when a slot inside someone else's screen should be filled.
Add a generic browser contribution. `manifestVersion: 7` declares one:
namespaced id, its own version, a target capability, a compatible range, and
one artifact. That is the complete list, and the omissions are the decision —
no payload schema, slot, ordering, or rendering hint exists anywhere, so Core
cannot learn what a slide is because the declaration cannot express it.
Discovery resolves a contribution artifact through the *identical* loop every
other compiled entrypoint uses, inheriting the path, containment, symlink,
extension, and file-kind checks rather than getting a weaker copy of them. The
registry publishes one only when another eligible module provides the target
capability within the declared range; otherwise it is absent rather than
mounted and broken. `apps/api` serves it as a sibling of the artifact and
display routes, with the same policy: three lookup keys, one exact match, one
already-validated path, and one indistinguishable 404 for unknown, undeclared,
stale, and incompatible alike.
A consumer discovers through the host, never through a URL. Its descriptor
carries identity, version, and capability; the URL stays server-side and is
resolved exactly as `api.request("records")` is. A module can only ever see
contributions aimed at a capability it itself provides, and cannot name its
way to anything else — a mount for a withheld id fails without fetching.
The consumer owns the domain contract entirely. Input is opaque JSON, checked
structurally so a Date, a function, a cycle, or `undefined` fails at the
boundary rather than vanishing somewhere far from the mistake, and checked for
nothing else. A contributed renderer receives its own id and that input: no
API client, realtime, principal, configuration, database, storage, or
credential. Loading one grants its module nothing.
The contribution ABI is versioned separately from the module UI ABI, because
the two have different shapes and lifecycles and coupling them would force
every module to rebuild whenever either moved. It supports asynchronous
readiness, update-without-remount (a screen that remounted per tick would
restart animations and reload media), deterministic cleanup in a `finally`,
and sanitized failure that never carries the consumer's domain data. Each
renderer mounts in its own isolated root with its own stylesheet.
The template now declares a contribution, so `pnpm build` exercises the whole
authoring path, and the shell suite drives the full relationship — one module
discovering, mounting, updating, and unmounting another's React renderer with
neither importing the other.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
07835b7b56
commit
c444ff570b
@@ -79,6 +79,7 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
|
||||
config,
|
||||
descriptor,
|
||||
principal,
|
||||
...(importModule ? { importModule } : {}),
|
||||
...(onAuthorizationResponse
|
||||
? { onAuthorizationResponse: reportAuthorizationResponse }
|
||||
: {}),
|
||||
|
||||
@@ -159,7 +159,12 @@ function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
|
||||
try {
|
||||
const instance = await loadModuleUi({
|
||||
container,
|
||||
context: createModuleUiContext({ config, descriptor, principal }),
|
||||
context: createModuleUiContext({
|
||||
config,
|
||||
descriptor,
|
||||
principal,
|
||||
...(importModule ? { importModule } : {}),
|
||||
}),
|
||||
...(importModule ? { importModule } : {}),
|
||||
source: {
|
||||
moduleId: descriptor.id,
|
||||
|
||||
@@ -111,6 +111,21 @@ export interface HostModuleUiDescriptor {
|
||||
readonly realtimeBaseUrl: string;
|
||||
/** Granted ids only, already evaluated server-side. Presentation hints, never authority. */
|
||||
readonly permissions: readonly string[];
|
||||
/** Renderers other modules contributed to capabilities this one provides (ADR 0041). The shell
|
||||
* hands identity to the module and resolves the URL itself; the module never sees this URL. */
|
||||
readonly contributions: readonly HostModuleContributionDescriptor[];
|
||||
}
|
||||
|
||||
/** One contributed renderer this module may mount, exactly as the server described it. Mirrors the
|
||||
* server's `HostModuleContributionDescriptor` rather than importing it, for the same reason the
|
||||
* module descriptor does. */
|
||||
export interface HostModuleContributionDescriptor {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
readonly capability: string;
|
||||
/** Server-produced. The shell resolves it against `apiBaseUrl` and hands it to the generic
|
||||
* contribution loader unchanged. */
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -213,6 +228,7 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
|
||||
}
|
||||
return Object.freeze({
|
||||
apiBaseUrl,
|
||||
contributions: parseContributions(isRecord(entry) ? entry.contributions : undefined),
|
||||
id,
|
||||
permissions: Object.freeze([...(permissions as string[])]),
|
||||
realtimeBaseUrl,
|
||||
@@ -221,6 +237,44 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict, total validation of the contribution list.
|
||||
*
|
||||
* Every entry must carry an exact key set, a well-formed identity, and a root-relative URL that
|
||||
* passes the same containment checks every other server-produced URL does — a contribution URL is a
|
||||
* thing the browser will `import()`, so it gets no weaker treatment than the module artifact URL.
|
||||
* Anything unexpected fails the whole parse rather than yielding a half-built list, because a
|
||||
* descriptor with a missing URL surfaces much later as an inexplicable mount failure.
|
||||
*/
|
||||
function parseContributions(value: unknown): readonly HostModuleContributionDescriptor[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
const descriptors = value.map((entry) => {
|
||||
if (!hasExactKeys(entry, ["capability", "id", "url", "version"])) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
const id = nonEmptyString(entry.id);
|
||||
const version = nonEmptyString(entry.version);
|
||||
const capability = nonEmptyString(entry.capability);
|
||||
const url = rootRelativePath({ url: entry.url }, "url");
|
||||
if (
|
||||
id === undefined ||
|
||||
version === undefined ||
|
||||
!isSemanticVersion(version) ||
|
||||
capability === undefined ||
|
||||
url === undefined
|
||||
) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
return Object.freeze({ capability, id, url, version });
|
||||
});
|
||||
if (new Set(descriptors.map((descriptor) => descriptor.id)).size !== descriptors.length) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
return Object.freeze(descriptors);
|
||||
}
|
||||
|
||||
/**
|
||||
* The display host's one-module response.
|
||||
*
|
||||
@@ -264,7 +318,15 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
|
||||
const viewer = parseDisplayViewer(payload.viewer);
|
||||
const entry = payload.module;
|
||||
if (
|
||||
!hasExactKeys(entry, ["api", "display", "id", "permissions", "realtime", "version"]) ||
|
||||
!hasExactKeys(entry, [
|
||||
"api",
|
||||
"contributions",
|
||||
"display",
|
||||
"id",
|
||||
"permissions",
|
||||
"realtime",
|
||||
"version",
|
||||
]) ||
|
||||
!hasExactKeys(entry.api, ["baseUrl"]) ||
|
||||
!hasExactKeys(entry.display, ["url"]) ||
|
||||
!hasExactKeys(entry.realtime, ["baseUrl"])
|
||||
@@ -296,6 +358,7 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
|
||||
return Object.freeze({
|
||||
module: Object.freeze({
|
||||
apiBaseUrl,
|
||||
contributions: parseContributions(entry.contributions),
|
||||
id,
|
||||
permissions: Object.freeze([...(permissions as string[])]),
|
||||
realtimeBaseUrl,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import type {
|
||||
ModuleUiApiClient,
|
||||
ModuleUiHostContext,
|
||||
ModuleUiPrincipal,
|
||||
ModuleUiRealtimeClient,
|
||||
ModuleUiRealtimeError,
|
||||
ModuleUiRealtimeErrorCode,
|
||||
ModuleUiRealtimeHandlers,
|
||||
ModuleUiRealtimeStatus,
|
||||
import { loadModuleContribution } from "@ekklesia/module-ui-host";
|
||||
import {
|
||||
ModuleContributionError,
|
||||
type ModuleContributionDescriptor,
|
||||
type ModuleUiApiClient,
|
||||
type ModuleUiContributions,
|
||||
type ModuleUiHostContext,
|
||||
type ModuleUiPrincipal,
|
||||
type ModuleUiRealtimeClient,
|
||||
type ModuleUiRealtimeError,
|
||||
type ModuleUiRealtimeErrorCode,
|
||||
type ModuleUiRealtimeHandlers,
|
||||
type ModuleUiRealtimeStatus,
|
||||
type MountedModuleContribution,
|
||||
type MountModuleContributionOptions,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
import {
|
||||
type EkklesiaHostConfig,
|
||||
@@ -263,13 +269,70 @@ export interface CreateModuleUiContextOptions {
|
||||
/** Host-only signal used by the Dashboard to revalidate an apparently expired session. It is not
|
||||
* included in the returned ModuleUiHostContext and is intentionally absent from the UI SDK. */
|
||||
readonly onAuthorizationResponse?: (status: 401 | 403) => void;
|
||||
/** Test-only seam, forwarded to the generic contribution loader so a test can drive a contributed
|
||||
* renderer without a real network. Production composition leaves it unset. */
|
||||
readonly importModule?: (url: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The consumer-facing half of the contribution contract (ADR 0041).
|
||||
*
|
||||
* A module names a contribution id; this resolves it against the **server-produced** descriptor and
|
||||
* hands the generic loader a URL. That indirection is the whole point: a module that built
|
||||
* `/api/v1/module-ui/<id>/<version>/contributions/<id>` itself would be welded to Ekklesia's private
|
||||
* deployment detail, exactly as one building its own API prefix would be. It also means a module can
|
||||
* only ever mount a contribution the *server* decided it may — one aimed at a capability this module
|
||||
* provides, whose provider satisfies the declared compatibility range. A consumer cannot name its
|
||||
* way to anything else, because nothing else is in the descriptor.
|
||||
*/
|
||||
function createModuleContributions(
|
||||
config: EkklesiaHostConfig,
|
||||
descriptor: HostModuleUiDescriptor,
|
||||
importModule?: (url: string) => Promise<unknown>,
|
||||
): ModuleUiContributions {
|
||||
const byId = new Map(
|
||||
descriptor.contributions.map((contribution) => [contribution.id, contribution]),
|
||||
);
|
||||
const descriptors: readonly ModuleContributionDescriptor[] = Object.freeze(
|
||||
descriptor.contributions.map((contribution) =>
|
||||
Object.freeze({
|
||||
capability: contribution.capability,
|
||||
id: contribution.id,
|
||||
version: contribution.version,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
list(capabilityId: string): readonly ModuleContributionDescriptor[] {
|
||||
return Object.freeze(
|
||||
descriptors.filter((contribution) => contribution.capability === capabilityId),
|
||||
);
|
||||
},
|
||||
async mount(mountOptions: MountModuleContributionOptions): Promise<MountedModuleContribution> {
|
||||
const contribution = byId.get(mountOptions.contributionId);
|
||||
if (!contribution) {
|
||||
throw new ModuleContributionError("MODULE_CONTRIBUTION_UNKNOWN");
|
||||
}
|
||||
return loadModuleContribution({
|
||||
container: mountOptions.container,
|
||||
...(importModule ? { importModule } : {}),
|
||||
input: mountOptions.input,
|
||||
source: {
|
||||
contributionId: contribution.id,
|
||||
url: resolveApiUrl(config, contribution.url),
|
||||
version: contribution.version,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `ModuleUiHostContext` (ADR 0022) Ekklesia hands a module UI at mount time.
|
||||
*
|
||||
* All five members come from host-derived data: the module id, API root, and realtime root from the
|
||||
* inventory descriptor; the principal from `/api/v1/principal`; and permission hints from the
|
||||
* Every member comes from host-derived data: the module id, API root, realtime root, and contribution
|
||||
* list from the inventory descriptor; the principal from `/api/v1/principal`; and permission hints from the
|
||||
* server's evaluation of that module's declarations. Nothing is guessed, defaulted to `true`, or
|
||||
* inferred from a client-side role name.
|
||||
*
|
||||
@@ -282,6 +345,11 @@ export function createModuleUiContext(options: CreateModuleUiContextOptions): Mo
|
||||
const granted = new Set(options.descriptor.permissions);
|
||||
return Object.freeze({
|
||||
api: createModuleApiClient(options.config, options.descriptor, options.onAuthorizationResponse),
|
||||
contributions: createModuleContributions(
|
||||
options.config,
|
||||
options.descriptor,
|
||||
options.importModule,
|
||||
),
|
||||
moduleId: options.descriptor.id,
|
||||
permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }),
|
||||
principal: options.principal,
|
||||
|
||||
@@ -47,6 +47,7 @@ function descriptorPayload(
|
||||
return {
|
||||
viewer,
|
||||
module: {
|
||||
contributions: [],
|
||||
api: { baseUrl: `/api/v1/modules/${id}` },
|
||||
display: { url: `/api/v1/module-ui/${id}/${version}/display` },
|
||||
id,
|
||||
@@ -235,6 +236,7 @@ describe("display host — full-viewport boundary", () => {
|
||||
|
||||
expect(Object.keys(seenContext ?? {}).sort()).toEqual([
|
||||
"api",
|
||||
"contributions",
|
||||
"moduleId",
|
||||
"permissions",
|
||||
"principal",
|
||||
@@ -327,6 +329,7 @@ describe("display host — full-viewport boundary", () => {
|
||||
ungrouped: [
|
||||
{
|
||||
module: {
|
||||
contributions: [],
|
||||
api: { baseUrl: "/api/v1/modules/announcements" },
|
||||
id: "announcements",
|
||||
permissions: ["announcements.slideshow.view"],
|
||||
|
||||
@@ -18,6 +18,7 @@ function displayPayload(overrides: Record<string, unknown> = {}): {
|
||||
// human session (ADR 0036).
|
||||
viewer: { kind: "authenticated", userId: "user-1" },
|
||||
module: {
|
||||
contributions: [],
|
||||
api: { baseUrl: "/api/v1/modules/announcements" },
|
||||
display: { url: "/api/v1/module-ui/announcements/1.2.0/display" },
|
||||
id: "announcements",
|
||||
@@ -43,6 +44,7 @@ describe("display host protocol — strict descriptor", () => {
|
||||
expect(parsed).toEqual({
|
||||
module: {
|
||||
apiBaseUrl: "/api/v1/modules/announcements",
|
||||
contributions: [],
|
||||
id: "announcements",
|
||||
permissions: ["announcements.slideshow.view"],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/announcements",
|
||||
|
||||
@@ -41,6 +41,7 @@ afterEach(async () => {
|
||||
function entry(id: string, name: string, version = "0.1.0", permissions: readonly string[] = []) {
|
||||
return {
|
||||
module: {
|
||||
contributions: [],
|
||||
api: { baseUrl: `/api/v1/modules/${id}` },
|
||||
id,
|
||||
permissions,
|
||||
|
||||
@@ -37,6 +37,7 @@ function stubFetch(routes: Readonly<Record<string, () => Response>>) {
|
||||
const inventoryPayload = {
|
||||
modules: [
|
||||
{
|
||||
contributions: [],
|
||||
api: { baseUrl: "/api/v1/modules/example" },
|
||||
id: "example",
|
||||
permissions: ["example.records.read"],
|
||||
@@ -48,6 +49,7 @@ const inventoryPayload = {
|
||||
};
|
||||
|
||||
const calendarDescriptor: HostModuleUiDescriptor = {
|
||||
contributions: [],
|
||||
apiBaseUrl: "/api/v1/modules/calendar",
|
||||
id: "calendar",
|
||||
permissions: [],
|
||||
@@ -68,6 +70,7 @@ const navigationPayload = {
|
||||
ungrouped: [
|
||||
{
|
||||
module: {
|
||||
contributions: [],
|
||||
api: { baseUrl: "/api/v1/modules/calendar" },
|
||||
id: "calendar",
|
||||
permissions: [],
|
||||
@@ -81,6 +84,7 @@ const navigationPayload = {
|
||||
};
|
||||
|
||||
const exampleDescriptor: HostModuleUiDescriptor = {
|
||||
contributions: [],
|
||||
apiBaseUrl: "/api/v1/modules/example",
|
||||
id: "example",
|
||||
permissions: ["example.records.read"],
|
||||
@@ -403,6 +407,7 @@ describe("module UI host context", () => {
|
||||
it("gives each module only its own API root", async () => {
|
||||
const { calls, implementation } = recordingFetch();
|
||||
const calendar: HostModuleUiDescriptor = {
|
||||
contributions: [],
|
||||
apiBaseUrl: "/api/v1/modules/calendar",
|
||||
id: "calendar",
|
||||
permissions: [],
|
||||
@@ -433,13 +438,14 @@ describe("module UI host context", () => {
|
||||
expect(none.has("core.authorization.grants.manage")).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes exactly the five contract members and the browser principal", () => {
|
||||
it("exposes exactly the six contract members and the browser principal", () => {
|
||||
const { implementation } = recordingFetch();
|
||||
|
||||
const context = contextFor(implementation);
|
||||
|
||||
expect(Object.keys(context).sort()).toEqual([
|
||||
"api",
|
||||
"contributions",
|
||||
"moduleId",
|
||||
"permissions",
|
||||
"principal",
|
||||
|
||||
@@ -135,14 +135,15 @@ describe("the shell builds no module knowledge into its bundle", () => {
|
||||
bundle = result.outputFiles.map((file) => file.text).join("\n");
|
||||
}, 60_000);
|
||||
|
||||
it("leaves exactly one dynamic import — the loader's — unresolved for run time", () => {
|
||||
it("leaves exactly the generic loaders' dynamic imports unresolved for run time", () => {
|
||||
const dynamicImports = [...bundle.matchAll(/\bimport\s*\(/gu)];
|
||||
|
||||
// Exactly one: `@ekklesia/module-ui-host`'s `(url) => import(url)`. More would mean the shell
|
||||
// acquired its own loader; fewer would mean the bundler resolved it away and installed modules
|
||||
// could never be fetched at run time.
|
||||
expect(dynamicImports).toHaveLength(1);
|
||||
expect(bundle).toContain("(url) => import(url)");
|
||||
// Exactly two, both `@ekklesia/module-ui-host`'s `(url) => import(url)`: one for a module UI
|
||||
// artifact and one for a contributed renderer (ADR 0041). More would mean the shell acquired a
|
||||
// loader of its own; fewer would mean the bundler resolved one away and installed code could
|
||||
// never be fetched at run time.
|
||||
expect(dynamicImports).toHaveLength(2);
|
||||
expect(bundle.split("(url) => import(url)")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("contains no module identity and no module API prefix", () => {
|
||||
@@ -214,9 +215,10 @@ describe("the display host is a separate generic browser bundle", () => {
|
||||
bundle = result.outputFiles.map((file) => file.text).join("\n");
|
||||
}, 60_000);
|
||||
|
||||
it("contains the generic loader's one dynamic import and no statically known module", () => {
|
||||
expect([...bundle.matchAll(/\bimport\s*\(/gu)]).toHaveLength(1);
|
||||
expect(bundle).toContain("(url) => import(url)");
|
||||
it("contains the generic loaders' dynamic imports and no statically known module", () => {
|
||||
// Two, both the generic loader's: a display artifact and a contributed renderer (ADR 0041).
|
||||
expect([...bundle.matchAll(/\bimport\s*\(/gu)]).toHaveLength(2);
|
||||
expect(bundle.split("(url) => import(url)")).toHaveLength(3);
|
||||
expect(bundle).not.toContain("ekklesia.module.json");
|
||||
expect(bundle).not.toContain("/api/v1/modules");
|
||||
for (const moduleId of ["announcements", "calendar", "livestream", "signage"]) {
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
// @vitest-environment happy-dom
|
||||
import {
|
||||
MODULE_CONTRIBUTION_ABI_VERSION,
|
||||
MODULE_UI_ABI_VERSION,
|
||||
type ModuleContributionContext,
|
||||
type ModuleContributionInput,
|
||||
type ModuleUiHostContext,
|
||||
type MountedModuleContribution,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
import { act, type ReactElement } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { EkklesiaHostConfig, HostModuleUiDescriptor } from "../src/host-protocol.js";
|
||||
import { parseDashboardNavigation } from "../src/host-protocol.js";
|
||||
import { createModuleUiContext } from "../src/module-context.js";
|
||||
import { isolatedText } from "./support/isolated-dom.js";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
||||
}
|
||||
|
||||
const apiBaseUrl = "https://ekklesia.example.test";
|
||||
|
||||
/**
|
||||
* The generic browser-contribution mechanism (ADR 0041), driven through exactly the relationship it
|
||||
* exists for:
|
||||
*
|
||||
* ```text
|
||||
* signage provides the capability signage.slide-renderer, and owns the input contract
|
||||
* announcements contributes a React renderer for it
|
||||
* signage mounts that renderer in a slot it owns, updates it, and unmounts it
|
||||
* ```
|
||||
*
|
||||
* Neither module imports the other, and neither imports the platform's publication routing. The
|
||||
* consumer asks the **host** what was contributed and asks the host to mount it; the URL is the
|
||||
* server's business and never reaches module code. Nothing in this file teaches Ekklesia what a
|
||||
* "slide" is: the payload is opaque JSON that travels between the two modules.
|
||||
*/
|
||||
|
||||
/** The consuming module's own published input contract — its business, never the platform's. */
|
||||
interface SlideInput extends Record<string, ModuleContributionInput> {
|
||||
readonly headline: string;
|
||||
readonly seconds: number;
|
||||
}
|
||||
|
||||
interface Recorder {
|
||||
/** Everything the consumer's artifact was told about the contribution. */
|
||||
readonly seen: unknown[];
|
||||
/** Every URL the *host* imported. The consumer never sees these. */
|
||||
readonly imported: string[];
|
||||
readonly renders: SlideInput[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The contributed artifact: a real React renderer, exactly as `defineModuleContribution` produces
|
||||
* (the compiled path through the canonical builder is proven in the module template's own suite).
|
||||
*/
|
||||
function contributedArtifact(recorder: Recorder, options: { readonly version?: string } = {}) {
|
||||
function Slide({ input }: { readonly input: SlideInput }): ReactElement {
|
||||
return (
|
||||
<article className="slide" data-testid="contributed-slide">
|
||||
<h2>{input.headline}</h2>
|
||||
<p>{`${input.seconds}s`}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
default: {
|
||||
abi: MODULE_CONTRIBUTION_ABI_VERSION,
|
||||
id: "announcements.signage-slide",
|
||||
version: options.version ?? "1.0.0",
|
||||
styles: ".slide{color:var(--ekklesia-text-primary)}",
|
||||
mount(container: Element, context: ModuleContributionContext) {
|
||||
const root: Root = createRoot(container);
|
||||
const render = (input: ModuleContributionInput): void => {
|
||||
recorder.renders.push(input as SlideInput);
|
||||
root.render(<Slide input={input as SlideInput} />);
|
||||
};
|
||||
render(context.input);
|
||||
return {
|
||||
update(input: ModuleContributionInput) {
|
||||
render(input);
|
||||
},
|
||||
unmount() {
|
||||
root.unmount();
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The consuming module's own UI: it discovers, mounts, updates, and unmounts through the host. */
|
||||
function consumerArtifact(recorder: Recorder) {
|
||||
return {
|
||||
default: {
|
||||
abi: MODULE_UI_ABI_VERSION,
|
||||
id: "signage",
|
||||
version: "2.0.0",
|
||||
async mount(container: Element, context: ModuleUiHostContext) {
|
||||
const slot = container.ownerDocument.createElement("div");
|
||||
slot.dataset.testid = "signage-slot";
|
||||
container.append(slot);
|
||||
|
||||
// Discovery is a question to the host about this module's *own* capability. There is no
|
||||
// URL, no registry query, and no other module's name involved.
|
||||
const available = context.contributions.list("signage.slide-renderer");
|
||||
recorder.seen.push(...available.map((entry) => ({ ...entry })));
|
||||
const first = available[0];
|
||||
if (!first) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mounted: MountedModuleContribution = await context.contributions.mount({
|
||||
container: slot,
|
||||
contributionId: first.id,
|
||||
input: { headline: "Welcome", seconds: 7 } satisfies SlideInput,
|
||||
});
|
||||
(globalThis as { __mountedSlide?: MountedModuleContribution }).__mountedSlide = mounted;
|
||||
return () => {
|
||||
void mounted.unmount();
|
||||
slot.remove();
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function descriptor(overrides: Partial<HostModuleUiDescriptor> = {}): HostModuleUiDescriptor {
|
||||
return {
|
||||
apiBaseUrl: "/api/v1/modules/signage",
|
||||
contributions: [
|
||||
{
|
||||
capability: "signage.slide-renderer",
|
||||
id: "announcements.signage-slide",
|
||||
url: "/api/v1/module-ui/announcements/0.4.0/contributions/announcements.signage-slide",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
id: "signage",
|
||||
permissions: [],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/signage",
|
||||
uiUrl: "/api/v1/module-ui/signage/2.0.0",
|
||||
version: "2.0.0",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let recorder: Recorder;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
recorder = { imported: [], renders: [], seen: [] };
|
||||
});
|
||||
|
||||
function hostConfig(): EkklesiaHostConfig {
|
||||
return { apiBaseUrl };
|
||||
}
|
||||
|
||||
/** The host's importer, standing in for a browser `import()` of a published URL. */
|
||||
function importer(): (url: string) => Promise<unknown> {
|
||||
return async (url: string) => {
|
||||
recorder.imported.push(url);
|
||||
return contributedArtifact(recorder);
|
||||
};
|
||||
}
|
||||
|
||||
async function mountConsumer(
|
||||
contributionsOverride?: HostModuleUiDescriptor["contributions"],
|
||||
): Promise<{ unmount: () => void | Promise<void> }> {
|
||||
const context = createModuleUiContext({
|
||||
config: hostConfig(),
|
||||
descriptor:
|
||||
contributionsOverride === undefined
|
||||
? descriptor()
|
||||
: descriptor({ contributions: contributionsOverride }),
|
||||
importModule: importer(),
|
||||
principal: { kind: "device", deviceId: "screen-1" },
|
||||
});
|
||||
const consumer = consumerArtifact(recorder).default;
|
||||
let cleanup: (() => void | Promise<void>) | undefined;
|
||||
await act(async () => {
|
||||
cleanup = (await consumer.mount(container, context)) ?? undefined;
|
||||
});
|
||||
return { unmount: () => cleanup?.() };
|
||||
}
|
||||
|
||||
function mountedSlide(): MountedModuleContribution {
|
||||
const mounted = (globalThis as { __mountedSlide?: MountedModuleContribution }).__mountedSlide;
|
||||
if (!mounted) {
|
||||
throw new Error("the consumer did not mount a contribution");
|
||||
}
|
||||
return mounted;
|
||||
}
|
||||
|
||||
describe("a consuming module mounts another module's contributed renderer", () => {
|
||||
it("discovers, loads, and mounts it inside the consumer's own slot", async () => {
|
||||
const handle = await mountConsumer();
|
||||
|
||||
// Discovery gave the consumer identity only — no URL, no filesystem path, no module internals.
|
||||
expect(recorder.seen).toEqual([
|
||||
{
|
||||
capability: "signage.slide-renderer",
|
||||
id: "announcements.signage-slide",
|
||||
version: "1.0.0",
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(recorder.seen)).not.toContain("/api/");
|
||||
|
||||
const slot = container.querySelector('[data-testid="signage-slot"]');
|
||||
expect(isolatedText(slot)).toContain("Welcome");
|
||||
expect(isolatedText(slot)).toContain("7s");
|
||||
await act(async () => handle.unmount());
|
||||
});
|
||||
|
||||
it("resolves the publication URL in the host, never in module code", async () => {
|
||||
const handle = await mountConsumer();
|
||||
|
||||
// The host turned the server-produced descriptor path into an absolute URL. A module that built
|
||||
// this itself would be welded to Ekklesia's private deployment detail.
|
||||
expect(recorder.imported).toEqual([
|
||||
`${apiBaseUrl}/api/v1/module-ui/announcements/0.4.0/contributions/announcements.signage-slide`,
|
||||
]);
|
||||
await act(async () => handle.unmount());
|
||||
});
|
||||
|
||||
it("updates the renderer in place, without remounting it", async () => {
|
||||
const handle = await mountConsumer();
|
||||
const slot = container.querySelector('[data-testid="signage-slot"]');
|
||||
const beforeNode = slot?.querySelector?.('[data-testid="contributed-slide"]');
|
||||
|
||||
await act(async () => {
|
||||
await mountedSlide().update({ headline: "Second slide", seconds: 12 });
|
||||
});
|
||||
|
||||
expect(isolatedText(slot)).toContain("Second slide");
|
||||
expect(isolatedText(slot)).toContain("12s");
|
||||
// One mount, two renders — the property a long-running screen depends on.
|
||||
expect(recorder.renders).toHaveLength(2);
|
||||
expect(recorder.imported).toHaveLength(1);
|
||||
expect(slot?.querySelector?.('[data-testid="contributed-slide"]')).toBe(beforeNode);
|
||||
await act(async () => handle.unmount());
|
||||
});
|
||||
|
||||
it("isolates the contributed renderer's styles from the consumer's own screen", async () => {
|
||||
const handle = await mountConsumer();
|
||||
const slot = container.querySelector('[data-testid="signage-slot"]') as Element;
|
||||
const shadowRoot = slot.querySelector("[data-ekklesia-module-ui]")?.shadowRoot;
|
||||
|
||||
expect(shadowRoot?.querySelector("style")?.textContent).toContain(".slide{");
|
||||
// Neither the consumer's own subtree nor the host document received the renderer's rules.
|
||||
expect(container.textContent).not.toContain(".slide{");
|
||||
expect(document.head.textContent ?? "").not.toContain(".slide{");
|
||||
await act(async () => handle.unmount());
|
||||
});
|
||||
|
||||
it("tears the renderer down when the consumer unmounts, leaving its slot empty", async () => {
|
||||
const handle = await mountConsumer();
|
||||
const slot = container.querySelector('[data-testid="signage-slot"]') as Element;
|
||||
|
||||
await act(async () => handle.unmount());
|
||||
|
||||
expect(mountedSlide().mounted).toBe(false);
|
||||
expect(slot.children).toHaveLength(0);
|
||||
expect(container.querySelector('[data-testid="signage-slot"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a contribution id the server did not offer this module", async () => {
|
||||
const context = createModuleUiContext({
|
||||
config: hostConfig(),
|
||||
descriptor: descriptor(),
|
||||
importModule: importer(),
|
||||
principal: { kind: "device", deviceId: "screen-1" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
context.contributions.mount({
|
||||
container,
|
||||
contributionId: "calendar.week-view",
|
||||
input: null,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_UNKNOWN" });
|
||||
// Nothing was fetched: a consumer cannot name its way to an artifact the server withheld.
|
||||
expect(recorder.imported).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds nothing for a capability nothing was contributed to", async () => {
|
||||
const context = createModuleUiContext({
|
||||
config: hostConfig(),
|
||||
descriptor: descriptor(),
|
||||
importModule: importer(),
|
||||
principal: { kind: "device", deviceId: "screen-1" },
|
||||
});
|
||||
|
||||
expect(context.contributions.list("signage.ticker")).toEqual([]);
|
||||
});
|
||||
|
||||
it("mounts nothing when the platform published no compatible contribution", async () => {
|
||||
// Exactly what a consumer sees when no module contributes, or when every contributor is outside
|
||||
// the declared compatible range: an empty list, and a screen that renders its own fallback.
|
||||
const handle = await mountConsumer([]);
|
||||
|
||||
expect(recorder.seen).toEqual([]);
|
||||
expect(recorder.imported).toEqual([]);
|
||||
await act(async () => handle.unmount());
|
||||
});
|
||||
|
||||
it("refuses an artifact whose declared identity does not match the descriptor", async () => {
|
||||
const context = createModuleUiContext({
|
||||
config: hostConfig(),
|
||||
descriptor: descriptor(),
|
||||
importModule: async () => contributedArtifact(recorder, { version: "9.9.9" }),
|
||||
principal: { kind: "device", deviceId: "screen-1" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
context.contributions.mount({
|
||||
container,
|
||||
contributionId: "announcements.signage-slide",
|
||||
input: { headline: "x", seconds: 1 },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_IDENTITY_MISMATCH" });
|
||||
expect(container.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps a contribution out of a descriptor the shell refuses to parse", () => {
|
||||
// The wire is validated on arrival, exactly like every other server-produced URL: a contribution
|
||||
// URL is something the browser will `import()`, so it gets no weaker treatment.
|
||||
for (const url of [
|
||||
"https://evil.example.test/x.js",
|
||||
"//evil.example.test/x.js",
|
||||
"../../x.js",
|
||||
]) {
|
||||
expect(() =>
|
||||
parseDashboardNavigation({
|
||||
groups: [],
|
||||
ungrouped: [
|
||||
{
|
||||
descriptor: {
|
||||
...descriptor({
|
||||
contributions: [
|
||||
{
|
||||
capability: "signage.slide-renderer",
|
||||
id: "announcements.signage-slide",
|
||||
url,
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
}),
|
||||
api: { baseUrl: "/api/v1/modules/signage" },
|
||||
realtime: { baseUrl: "/api/v1/module-realtime/signage" },
|
||||
ui: { url: "/api/v1/module-ui/signage/2.0.0" },
|
||||
},
|
||||
name: "Signage",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrowError(expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { createModuleUiContext } from "../src/module-context.js";
|
||||
const apiBaseUrl = "https://ekklesia.example.test";
|
||||
|
||||
const exampleDescriptor: HostModuleUiDescriptor = {
|
||||
contributions: [],
|
||||
apiBaseUrl: "/api/v1/modules/example",
|
||||
id: "example",
|
||||
permissions: ["example.state.read"],
|
||||
@@ -102,6 +103,7 @@ describe("module UI realtime host adapter", () => {
|
||||
const harness = eventSourceHarness();
|
||||
const calendar: HostModuleUiDescriptor = {
|
||||
...exampleDescriptor,
|
||||
contributions: [],
|
||||
apiBaseUrl: "/api/v1/modules/calendar",
|
||||
id: "calendar",
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/calendar",
|
||||
|
||||
Reference in New Issue
Block a user