Files
Ekklesia/packages/module-ui-sdk/src/module-ui.ts
T
ekklesiaandClaude Opus 5 c444ff570b 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]>
2026-08-27 07:47:10 -05:00

202 lines
8.5 KiB
TypeScript

import type { ModuleUiHostContext } from "./host-context.js";
export type { Awaitable } from "./awaitable.js";
import type { Awaitable } from "./awaitable.js";
/**
* The version of the browser mount ABI this package defines, and the only one a host accepts.
*
* Version 1 was `{ id, version, mount }` mounted straight into the host's own container. Version 2
* adds a declared `styles` stylesheet and makes every artifact mount inside an **isolated root** the
* host creates (ADR 0040). That is a behavioural change an artifact can observe — its DOM is no
* longer a direct child of the host's element — so it is versioned explicitly rather than
* introduced silently.
*
* There is deliberately no dual acceptance, exactly as there is none for a manifest version
* (ADR 0006, ADR 0014, ADR 0021, ADR 0033): an artifact built against version 1 fails to mount with
* `MODULE_UI_ABI_UNSUPPORTED` rather than being mounted under guessed semantics. Migration is to
* rebuild the module, which is one `pnpm build` — the canonical builder owns every option, and a
* module that declares no stylesheet needs no source change at all.
*
* `defineModuleUi` stamps this for the author. A module never writes it.
*/
export const MODULE_UI_ABI_VERSION = 2;
/**
* Released by a host when the module UI should be torn down. A module that acquires anything during
* `mount` — a framework root, a subscription, a timer, an event listener — must return one of these
* and release that work here, exactly as a backend module releases resources in its `stop` hook
* (ADR 0006). Returning nothing declares that the mount acquired nothing to release.
*
* A module never has to remove its declared `styles`: the host installed them into the isolated root
* it owns, and it removes that whole root after this runs.
*/
export type ModuleUiUnmount = () => Awaitable<void>;
/**
* What a module author passes to {@link defineModuleUi}.
*
* `id` and `version` are build-artifact identity markers, mirroring `ModuleRuntimeDefinition`
* exactly (ADR 0006): they let a host prove the UI bundle it imported was built from the module it
* thinks it is, and catch a stale artifact. They do not duplicate anything else from the manifest.
*
* `mount` is intentionally the whole behavioral contract. The host supplies a container element and
* the host context; what happens inside is the module's business. React is the supported way to
* write that (ADR 0022), but React appears nowhere in this type — the contract is a mount function,
* so a module's internal framework choice, and a host's, can each change without renegotiating the
* platform boundary.
*
* `styles` is the artifact's own compiled CSS **text**, not a URL, a filename, or a sidecar
* (ADR 0040). A module imports its local `.css` files and the canonical builder compiles them into
* this string, so the declared JavaScript artifact stays exactly as self-contained as it was before
* stylesheets existed: there is no second file to publish, nothing for a host to fetch, and no way
* for a stylesheet to become an asset nothing serves. The host installs it into the artifact's
* isolated root at mount and removes it at unmount.
*/
export interface ModuleUiDefinitionInput {
readonly id: string;
readonly version: string;
readonly styles?: string;
readonly mount: (
container: Element,
context: ModuleUiHostContext,
) => Awaitable<ModuleUiUnmount | void>;
}
/**
* What either compiled browser artifact (`entrypoints.ui` or `entrypoints.display.artifact`)
* default-exports (ADR 0021, ADR 0022, ADR 0033, ADR 0040): the author's declaration plus the ABI
* marker `defineModuleUi` stamped on it.
*/
export interface ModuleUiDefinition extends ModuleUiDefinitionInput {
readonly abi: number;
}
export type ModuleUiContractErrorCode =
| "MODULE_UI_EXPORT_MISSING"
| "MODULE_UI_DEFINITION_INVALID"
| "MODULE_UI_ABI_UNSUPPORTED"
| "MODULE_UI_STYLES_INVALID"
| "MODULE_UI_IDENTITY_MISMATCH";
const errorMessages: Readonly<Record<ModuleUiContractErrorCode, string>> = Object.freeze({
MODULE_UI_EXPORT_MISSING:
"A module UI entrypoint must have a default export produced by defineModuleUi.",
MODULE_UI_DEFINITION_INVALID:
"A module UI definition must declare a non-empty id and version and a mount function, and nothing else.",
MODULE_UI_ABI_UNSUPPORTED:
"This module UI artifact was built for a different browser mount ABI. Rebuild the module against the current module UI SDK.",
MODULE_UI_STYLES_INVALID:
"A module UI definition's styles must be the artifact's compiled CSS text, as a non-empty string.",
MODULE_UI_IDENTITY_MISMATCH:
"The module UI artifact's id and version do not match the module it was loaded for.",
});
/**
* Carries a stable code and a fixed message, never the offending value, the artifact URL, a
* filesystem path, or a caught cause — matching the sanitization rule every other Ekklesia error
* boundary follows.
*/
export class ModuleUiContractError extends Error {
readonly code: ModuleUiContractErrorCode;
constructor(code: ModuleUiContractErrorCode) {
super(errorMessages[code]);
this.name = "ModuleUiContractError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
const definitionKeys = new Set(["abi", "id", "mount", "styles", "version"]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
/**
* Structural check only. The identity a definition claims is verified against the manifest by
* {@link parseModuleUiExport}, so this never needs a copy of Core's module-id or semantic-version
* grammar — there is exactly one place each of those is defined, and it is not here.
*/
export function isModuleUiDefinition(value: unknown): value is ModuleUiDefinition {
if (!isRecord(value) || value.abi !== MODULE_UI_ABI_VERSION) {
return false;
}
if (Object.keys(value).some((key) => !definitionKeys.has(key))) {
return false;
}
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
return false;
}
return (
isNonEmptyString(value.id) &&
isNonEmptyString(value.version) &&
typeof value.mount === "function"
);
}
/**
* Parses an artifact's default export.
*
* The ABI marker is checked **first and on its own**, so an artifact built against the previous
* mount ABI is reported as exactly that rather than as a malformed definition — the difference
* between "rebuild your module" and a developer hunting for a typo.
*/
export function parseModuleUiDefinition(value: unknown): ModuleUiDefinition {
if (!isRecord(value)) {
throw new ModuleUiContractError("MODULE_UI_DEFINITION_INVALID");
}
if (value.abi !== MODULE_UI_ABI_VERSION) {
throw new ModuleUiContractError("MODULE_UI_ABI_UNSUPPORTED");
}
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
throw new ModuleUiContractError("MODULE_UI_STYLES_INVALID");
}
if (!isModuleUiDefinition(value)) {
throw new ModuleUiContractError("MODULE_UI_DEFINITION_INVALID");
}
return Object.freeze({
abi: MODULE_UI_ABI_VERSION,
id: value.id,
mount: value.mount,
...(value.styles === undefined ? {} : { styles: value.styles }),
version: value.version,
});
}
/** The identity a host already knows from the module's validated manifest. */
export interface ModuleUiIdentity {
readonly id: string;
readonly version: string;
}
/**
* The one entry point a host uses after importing a module's UI artifact.
*
* Deliberately does **not** import anything itself. Each host performs its own dynamic import — a
* browser host from a served URL, a test host from a file URL — and passes the resulting module
* namespace here. That keeps this package free of any loader, bundler, or URL assumption, and keeps
* Core out of the business of evaluating browser code entirely: module discovery only resolves
* and validates each declared browser artifact path and never executes either role (ADR 0021,
* ADR 0033).
*/
export function parseModuleUiExport(
namespace: unknown,
expected: ModuleUiIdentity,
): ModuleUiDefinition {
if (!isRecord(namespace) || !("default" in namespace)) {
throw new ModuleUiContractError("MODULE_UI_EXPORT_MISSING");
}
const definition = parseModuleUiDefinition(namespace.default);
if (definition.id !== expected.id || definition.version !== expected.version) {
throw new ModuleUiContractError("MODULE_UI_IDENTITY_MISMATCH");
}
return definition;
}