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
@@ -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" }));
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user