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:
ekklesia
2026-08-27 07:47:10 -05:00
co-authored by Claude Opus 5
parent 07835b7b56
commit c444ff570b
79 changed files with 3317 additions and 137 deletions
+1
View File
@@ -2,6 +2,7 @@ export * from "./authorization.js";
export * from "./identifiers.js";
export * from "./identity.js";
export * from "./module-configuration.js";
export * from "./module-contribution.js";
export * from "./module-database.js";
export * from "./module-http.js";
export * from "./module-lifecycle.js";
@@ -0,0 +1,66 @@
import { z } from "zod";
import { capabilityIdSchema, packageRelativePathSchema } from "./identifiers.js";
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
/**
* A **browser contribution**: one module's compiled renderer, declared for a capability another
* module provides, and mounted by that other module inside a container it owns (ADR 0041).
*
* This is deliberately not a third UI role. `entrypoints.ui` and `entrypoints.display` are artifacts
* *the platform* mounts, in surfaces the platform owns. A contribution is mounted by a **consumer
* module**, at a moment and in a place only that module understands — a slot in its own layout —
* and the platform never renders one on its own.
*
* ## What the platform knows, and the complete list of it
*
* Identity, version, target capability, compatible range, and one compiled artifact. That is all,
* and the omissions are the point: nothing here says what the renderer draws, what shape its input
* has, when it should be mounted, or what any of it means. Core could not learn what a "slide" or a
* "timeline slot" is from this declaration, because the declaration cannot express it — the
* domain contract belongs entirely to the consumer's own versioned input.
*
* Inert, like every other manifest declaration: reading one causes no import, no evaluation, and no
* network or database access.
*/
/** `<module-id>.<local-name>` — exactly two segments. The prefix is checked against the declaring
* module in `moduleManifestSchema`, which is what makes a contribution id globally unique without a
* registry: module ids are already unique, so two modules cannot produce the same contribution id. */
export const contributionIdSchema = z
.string()
.min(3)
.max(160)
.regex(
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u,
"Must be <module-id>.<local-name>, both lowercase kebab-case.",
);
export const moduleContributionDeclarationSchema = z
.object({
id: contributionIdSchema,
/** This contribution's own version, independent of the module's. A consumer sees it and a host
* verifies the artifact claims it, so a stale bundle is caught exactly as it is for a module
* UI artifact. */
version: semanticVersionSchema,
/** The capability this renderer plugs into — owned by whichever module provides it, never by
* the contributing module. */
capability: capabilityIdSchema,
/** The provider capability versions this renderer is compatible with. A provider outside this
* range makes the contribution unpublishable rather than mounted and broken. */
capabilityRange: semanticVersionRangeSchema,
/** The compiled browser artifact, resolved and validated during discovery exactly like every
* other declared entrypoint: package-relative, inside the package before and after symlink
* resolution, and a real `.js`/`.mjs` file. */
artifact: packageRelativePathSchema,
description: z.string().trim().min(1).max(240).optional(),
})
.strict();
export type ModuleContributionDeclaration = z.output<typeof moduleContributionDeclarationSchema>;
/** The local segment of a contribution id — the part after the declaring module's own id. Used by
* the canonical builder to locate the fixed source directory, so a module author configures no
* paths and cannot point the build somewhere else. */
export function contributionLocalName(contributionId: string): string {
return contributionId.slice(contributionId.indexOf(".") + 1);
}
+73 -10
View File
@@ -11,6 +11,10 @@ import {
type ModuleConfigurationDeclaration,
moduleConfigurationDeclarationSchema,
} from "./module-configuration.js";
import {
type ModuleContributionDeclaration,
moduleContributionDeclarationSchema,
} from "./module-contribution.js";
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
@@ -118,9 +122,9 @@ function reportDuplicates(
}
/**
* The one manifest shape Core accepts. Version 5's strict `entrypoints` object rejects `display`, so
* the static display declaration is an incompatible shape change and the canonical manifest moves
* to `manifestVersion: 6` rather than silently reinterpreting version 5.
* The one manifest shape Core accepts. Version 6's strict top-level object rejects `contributions`,
* so the static browser-contribution declaration is an incompatible shape change and the canonical
* manifest moves to `manifestVersion: 7` rather than silently reinterpreting version 6.
*
* Configuration is declared **statically, in this inert document**, rather than registered by
* executable module code, for one decisive reason: the platform validates a module's configuration
@@ -129,13 +133,14 @@ function reportDuplicates(
* inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without
* importing a single line of module code, exactly as they always have.
*
* Version 5, and every other version, fails validation: there is no compatibility adapter, no
* Version 6, and every other version, fails validation: there is no compatibility adapter, no
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
* (ADR 0006), version 2 by version 3 (ADR 0014), and version 3 by version 4 (ADR 0021). One
* manifest document can therefore never be read as two different versions. Upgrading a version 5
* manifest is a one-character edit for a module that declares no display: the new field is optional.
* (ADR 0006), version 2 by version 3 (ADR 0014), version 3 by version 4 (ADR 0021), and version 5
* by version 6 (ADR 0033). One manifest document can therefore never be read as two different
* versions. Upgrading a version 6 manifest is a one-character edit for a module that contributes
* nothing: the new field defaults to empty.
*/
export const MODULE_MANIFEST_VERSION = 6;
export const MODULE_MANIFEST_VERSION = 7;
export const moduleManifestSchema = z
.object({
@@ -158,6 +163,10 @@ export const moduleManifestSchema = z
* module code during discovery. A module that needs nothing configured omits the field. */
configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []),
storage: moduleStorageSchema,
/** Compiled browser renderers this module offers to capabilities **other** modules provide
* (ADR 0041). Inert data: Core validates identity, compatibility, and the artifact path, and
* never learns what a contribution renders. A module that contributes nothing omits the field. */
contributions: z.array(moduleContributionDeclarationSchema).default(() => []),
entrypoints: entrypointsSchema,
})
.strict()
@@ -200,6 +209,58 @@ export const moduleManifestSchema = z
}
});
reportDuplicates(
manifest.contributions.map((contribution) => contribution.id),
["contributions"],
context,
"id",
);
const ownedCapabilityIds = new Set(manifest.capabilities.map((capability) => capability.id));
const contributionArtifacts = new Set<string>();
manifest.contributions.forEach((contribution, index) => {
// Namespacing is what makes a contribution id globally unique without a registry: module ids
// are already unique, so two modules can never produce the same contribution id.
if (!contribution.id.startsWith(`${manifest.id}.`)) {
context.addIssue({
code: "custom",
message: `Contribution identifiers owned by this module must begin with ${manifest.id}.`,
path: ["contributions", index, "id"],
});
}
// A contribution extends *another* module's capability. Targeting one this module already
// provides would be a module contributing to itself, which needs no platform mechanism at all
// and would make the compatibility check meaningless.
if (ownedCapabilityIds.has(contribution.capability)) {
context.addIssue({
code: "custom",
message: "A browser contribution must target a capability another module provides.",
path: ["contributions", index, "capability"],
});
}
if (
Object.entries(manifest.entrypoints).some(([entrypointName, configured]) =>
entrypointName === "display"
? (configured as { artifact?: string } | undefined)?.artifact === contribution.artifact
: configured === contribution.artifact,
)
) {
context.addIssue({
code: "custom",
message: "A contribution artifact must use a different path from every entrypoint.",
path: ["contributions", index, "artifact"],
});
}
if (contributionArtifacts.has(contribution.artifact)) {
context.addIssue({
code: "custom",
message: "Two contributions must not declare the same artifact path.",
path: ["contributions", index, "artifact"],
});
}
contributionArtifacts.add(contribution.artifact);
});
manifest.permissions.forEach((permission, index) => {
if (!permission.id.startsWith(`${manifest.id}.`)) {
context.addIssue({
@@ -263,10 +324,9 @@ export const moduleManifestSchema = z
}
}
const ownedCapabilities = new Set(manifest.capabilities.map((capability) => capability.id));
for (const dependencyKind of ["required", "optional"] as const) {
for (const capability of Object.keys(manifest.dependencies.capabilities[dependencyKind])) {
if (ownedCapabilities.has(capability)) {
if (ownedCapabilityIds.has(capability)) {
context.addIssue({
code: "custom",
message: "A module does not need to depend on a capability it owns.",
@@ -297,6 +357,9 @@ export function safeParseModuleManifest(input: unknown) {
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
/** A module manifest's `contributions` array entry — the same `ModuleContributionDeclaration` shape
* Core's publication registry consumes (`packages/contracts/src/module-contribution.ts`). */
export type ModuleManifestContributionDeclaration = ModuleContributionDeclaration;
export type ModuleDisplayEntrypoint = z.output<typeof moduleDisplayEntrypointSchema>;
/** A module manifest's `configuration` array entry — the same `ModuleConfigurationDeclaration` shape
* Core's configuration resolver consumes (`packages/contracts/src/module-configuration.ts`), aliased
@@ -16,7 +16,7 @@ import {
function validManifest(): Record<string, unknown> {
return {
manifestVersion: 6,
manifestVersion: 7,
id: "calendar",
name: "Calendar",
version: "0.1.0",
@@ -226,15 +226,21 @@ describe("module manifest contract", () => {
expect(safeParseModuleManifest(version4Manifest).success).toBe(false);
});
it("rejects a version 5 manifest rather than silently interpreting it as version 6", () => {
it("rejects a version 5 manifest rather than silently interpreting it as a later version", () => {
const version5Manifest = { ...validManifest(), manifestVersion: 5 };
expect(safeParseModuleManifest(version5Manifest).success).toBe(false);
});
it("rejects a version 6 manifest rather than silently interpreting it as version 7", () => {
const version6Manifest = { ...validManifest(), manifestVersion: 6 };
expect(safeParseModuleManifest(version6Manifest).success).toBe(false);
});
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
expect(MODULE_MANIFEST_VERSION).toBe(6);
for (const manifestVersion of [1, 2, 3, 4, 5, 7, "6", 6.5, null]) {
expect(MODULE_MANIFEST_VERSION).toBe(7);
for (const manifestVersion of [1, 2, 3, 4, 5, 6, 8, "7", 7.5, null]) {
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
}
expect(
@@ -243,6 +249,124 @@ describe("module manifest contract", () => {
).toBe(true);
});
it("defaults contributions to an empty list for a module that contributes nothing", () => {
const parsed = safeParseModuleManifest(validManifest());
expect(parsed.success).toBe(true);
expect(parsed.success && parsed.data.contributions).toEqual([]);
});
it("accepts a well-formed browser contribution", () => {
const parsed = safeParseModuleManifest({
...validManifest(),
contributions: [
{
id: "calendar.signage-slide",
version: "1.0.0",
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
artifact: "./dist/contributions/signage-slide/index.js",
description: "Renders an upcoming-events slide.",
},
],
});
expect(parsed.success).toBe(true);
expect(parsed.success && parsed.data.contributions[0]?.capability).toBe(
"signage.slide-renderer",
);
});
it.each<[string, Record<string, unknown>]>([
["an id outside this module's namespace", { id: "signage.slide" }],
["an id with no local segment", { id: "calendar" }],
["an id with more than one local segment", { id: "calendar.signage.slide" }],
["an unknown property", { slot: "primary" }],
["a non-semver version", { version: "1" }],
["an invalid capability range", { capabilityRange: "not a range" }],
["an artifact escaping the package", { artifact: "../elsewhere/index.js" }],
["an absolute artifact path", { artifact: "/etc/passwd" }],
])("rejects a contribution with %s", (_label, override) => {
const contribution = {
id: "calendar.signage-slide",
version: "1.0.0",
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
artifact: "./dist/contributions/signage-slide/index.js",
...override,
};
expect(
safeParseModuleManifest({ ...validManifest(), contributions: [contribution] }).success,
).toBe(false);
});
it("rejects a contribution targeting a capability this module already provides", () => {
// A module contributing to itself needs no platform mechanism, and would make the compatibility
// check meaningless: it would always be comparing a module against itself.
expect(
safeParseModuleManifest({
...validManifest(),
contributions: [
{
id: "calendar.self",
version: "1.0.0",
capability: "calendar.events",
capabilityRange: "^2.0.0",
artifact: "./dist/contributions/self/index.js",
},
],
}).success,
).toBe(false);
});
it("rejects two contributions sharing an id or an artifact path", () => {
const base = {
version: "1.0.0",
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
};
expect(
safeParseModuleManifest({
...validManifest(),
contributions: [
{ ...base, id: "calendar.a", artifact: "./dist/contributions/a/index.js" },
{ ...base, id: "calendar.a", artifact: "./dist/contributions/b/index.js" },
],
}).success,
).toBe(false);
expect(
safeParseModuleManifest({
...validManifest(),
contributions: [
{ ...base, id: "calendar.a", artifact: "./dist/contributions/a/index.js" },
{ ...base, id: "calendar.b", artifact: "./dist/contributions/a/index.js" },
],
}).success,
).toBe(false);
});
it("rejects a contribution artifact that collides with a declared entrypoint", () => {
const manifest = validManifest();
const entrypoints = manifest.entrypoints as Record<string, unknown>;
expect(
safeParseModuleManifest({
...manifest,
contributions: [
{
id: "calendar.signage-slide",
version: "1.0.0",
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
artifact: entrypoints.server,
},
],
}).success,
).toBe(false);
});
it("rejects a bare permission-id string array instead of complete declarations", () => {
const manifest = { ...validManifest(), permissions: ["calendar.events.read"] };
+56 -14
View File
@@ -1,6 +1,11 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { moduleDisplayEntrypointSchema, packageRelativePathSchema } from "@ekklesia/contracts";
import {
contributionLocalName,
moduleContributionDeclarationSchema,
moduleDisplayEntrypointSchema,
packageRelativePathSchema,
} from "@ekklesia/contracts";
import { build, type Plugin } from "esbuild";
/**
@@ -13,6 +18,14 @@ import { build, type Plugin } from "esbuild";
*/
export const MODULE_UI_SOURCE_ENTRYPOINT = "src/ui/index.tsx";
export const MODULE_DISPLAY_SOURCE_ENTRYPOINT = "src/display/index.tsx";
/** Where a declared browser contribution's source lives: `src/contributions/<local-name>/index.tsx`,
* where the local name is the part of the contribution id after the module's own id. Derived rather
* than configured, exactly like the two role entrypoints, so an author points the build nowhere. */
export const MODULE_CONTRIBUTION_SOURCE_DIRECTORY = "src/contributions";
export function moduleContributionSourceEntrypoint(contributionId: string): string {
return `${MODULE_CONTRIBUTION_SOURCE_DIRECTORY}/${contributionLocalName(contributionId)}/index.tsx`;
}
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
/** Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree. */
@@ -27,7 +40,7 @@ export type ModuleUiBuildErrorCode =
| "MODULE_UI_STYLESHEET_UNDECLARED"
| "MODULE_UI_BUILD_FAILED";
export type ModuleUiArtifactKind = "ui" | "display";
export type ModuleUiArtifactKind = "ui" | "display" | "contribution";
const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.freeze({
MODULE_UI_MANIFEST_UNREADABLE: `${MODULE_MANIFEST_FILENAME} could not be read or parsed.`,
@@ -165,13 +178,17 @@ export interface BuildModuleUiOptions {
}
export interface ModuleUiBuildResult {
/** Built artifacts in stable role order: primary UI first, then display. Empty for backend-only
* modules, which makes the canonical command usable unchanged by every module package. */
/** Built artifacts in stable role order: primary UI, then display, then each declared contribution
* in manifest order. Empty for backend-only modules, which makes the canonical command usable
* unchanged by every module package. */
readonly artifacts: readonly ModuleUiBuildArtifactResult[];
}
export interface ModuleUiBuildArtifactResult {
readonly kind: ModuleUiArtifactKind;
/** For a contribution, the declared contribution id — so a multi-contribution build reports which
* artifact it produced. Absent for the two role entrypoints. */
readonly contributionId?: string;
/** Absolute path of the artifact actually written, exactly as the manifest declares. */
readonly outputFile: string;
/** Absolute path of the fixed source entrypoint that produced it. */
@@ -203,7 +220,15 @@ async function resolveDeclaredArtifacts(
}
const display = entrypoints.display;
const manifestContributions = isRecord(manifest) ? manifest.contributions : undefined;
const declaredContributions = Array.isArray(manifestContributions) ? manifestContributions : [];
for (const contribution of declaredContributions) {
if (!moduleContributionDeclarationSchema.safeParse(contribution).success) {
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "contribution");
}
}
const declarations: readonly {
readonly contributionId?: string;
readonly declared: unknown;
readonly kind: ModuleUiArtifactKind;
readonly present: boolean;
@@ -221,6 +246,13 @@ async function resolveDeclaredArtifacts(
present: display !== undefined,
source: MODULE_DISPLAY_SOURCE_ENTRYPOINT,
},
...(declaredContributions as { artifact: string; id: string }[]).map((contribution) => ({
contributionId: contribution.id,
declared: contribution.artifact,
kind: "contribution" as const,
present: true,
source: moduleContributionSourceEntrypoint(contribution.id),
})),
];
const artifacts: DeclaredModuleUiArtifact[] = [];
@@ -239,6 +271,9 @@ async function resolveDeclaredArtifacts(
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE", declaration.kind);
}
artifacts.push({
...(declaration.contributionId === undefined
? {}
: { contributionId: declaration.contributionId }),
kind: declaration.kind,
outputFile: path.resolve(moduleDirectory, declared),
sourceFile: path.join(moduleDirectory, declaration.source),
@@ -249,17 +284,24 @@ async function resolveDeclaredArtifacts(
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "display");
}
const displayArtifact = artifacts.find((artifact) => artifact.kind === "display");
if (displayArtifact !== undefined) {
for (const entrypointName of ["server", "schema", "migrations", "jobs", "ui"] as const) {
const configured = entrypoints[entrypointName];
if (
typeof configured === "string" &&
path.resolve(moduleDirectory, configured) === displayArtifact.outputFile
) {
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_COLLISION", "display");
}
// No declared artifact may overwrite another declared file — an entrypoint this build does not
// produce (`server`, `schema`, ...), or another artifact it does. Checked before esbuild runs, so
// a collision can never destroy output that was already correct.
const backendEntrypointPaths = new Set(
(["server", "schema", "migrations", "jobs"] as const)
.map((entrypointName) => entrypoints[entrypointName])
.filter((configured): configured is string => typeof configured === "string")
.map((configured) => path.resolve(moduleDirectory, configured)),
);
const claimedOutputs = new Set<string>();
for (const artifact of artifacts) {
if (
backendEntrypointPaths.has(artifact.outputFile) ||
claimedOutputs.has(artifact.outputFile)
) {
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_COLLISION", artifact.kind);
}
claimedOutputs.add(artifact.outputFile);
}
// Complete preflight before esbuild starts: a missing second source must not leave the first role's
+1 -1
View File
@@ -26,7 +26,7 @@ async function main(): Promise<number> {
}
for (const artifact of result.artifacts) {
process.stdout.write(
`Built module UI (${artifact.kind}): ${path.relative(moduleDirectory, artifact.outputFile)}\n`,
`Built module UI (${artifact.contributionId ?? artifact.kind}): ${path.relative(moduleDirectory, artifact.outputFile)}\n`,
);
}
return 0;
@@ -34,7 +34,7 @@ async function createModule(options: ModuleFixtureOptions = {}): Promise<string>
const manifest =
options.manifest ??
JSON.stringify({
manifestVersion: 6,
manifestVersion: 7,
id: "example",
name: "Example",
version: "0.1.0",
+1
View File
@@ -1,2 +1,3 @@
export * from "./isolated-root.js";
export * from "./load-module-contribution.js";
export * from "./load-module-ui.js";
@@ -0,0 +1,142 @@
import {
isModuleContributionInput,
type ModuleContributionDefinition,
ModuleContributionError,
type ModuleContributionInput,
type ModuleContributionInstance,
type MountedModuleContribution,
parseModuleContributionExport,
} from "@ekklesia/module-ui-sdk";
import { createIsolatedArtifactRoot, type IsolatedArtifactRoot } from "./isolated-root.js";
import { type ModuleUiImporter, ModuleUiHostError } from "./load-module-ui.js";
/**
* How a host obtains one contribution artifact.
*
* `url` is a **browser-loadable module URL** the host produced, never a path a module composed. The
* consuming module names a contribution id; turning that into a URL is asset publication, which this
* generic package deliberately does not own — exactly as it does not own it for a module UI artifact
* (ADR 0024, ADR 0025). Ekklesia implements it at its API edge (ADR 0041).
*
* `contributionId` and `version` are the identity the host already read from the contributing
* module's validated manifest, and are checked against the artifact before anything is mounted.
*/
export interface ModuleContributionSource {
readonly contributionId: string;
readonly version: string;
readonly url: string;
}
export interface LoadModuleContributionOptions {
readonly source: ModuleContributionSource;
/** A container the **consuming module** owns. The loader builds the contribution's isolated root
* inside it and never reaches outside it. */
readonly container: Element;
/** The consumer's own versioned input, validated as JSON before it crosses the boundary. */
readonly input: ModuleContributionInput;
readonly importModule?: ModuleUiImporter;
}
const defaultImporter: ModuleUiImporter = (url) => import(url);
/**
* Imports a published contribution artifact, verifies it is the contribution the host expected, and
* mounts it into an isolated root inside the consumer's container (ADR 0040, ADR 0041).
*
* **This executes trusted first-party code in the host's own page**, exactly like a module UI
* artifact. The isolated root keeps the renderer's *styles* to itself; it is not a sandbox, and
* mounting a contribution grants its module no authority whatsoever. Server-side authorization is
* untouched: the consumer was already authorized to obtain the data it passes as input, and the
* contribution receives nothing else.
*
* The consumer's `input` is validated as genuinely JSON-serializable **before** the artifact is even
* imported. A `Date`, a function, a cyclic object, or `undefined` would either vanish or throw
* somewhere far from the mistake, so it fails here, at the boundary, with a sanitized code that
* never echoes the domain data itself.
*/
export async function loadModuleContribution(
options: LoadModuleContributionOptions,
): Promise<MountedModuleContribution> {
const { container, input, source } = options;
if (!isModuleContributionInput(input)) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_INPUT_INVALID");
}
const importModule = options.importModule ?? defaultImporter;
let namespace: unknown;
try {
namespace = await importModule(source.url);
} catch (error) {
throw new ModuleUiHostError("MODULE_UI_IMPORT_FAILED", { cause: error });
}
// The one contract parser, reused rather than reimplemented: it validates the default export's
// shape, its ABI marker, and that its id/version match what the host expected. A mismatched or
// stale artifact fails before it can render.
const definition: ModuleContributionDefinition = parseModuleContributionExport(namespace, {
id: source.contributionId,
version: source.version,
});
let isolated: IsolatedArtifactRoot;
try {
isolated = createIsolatedArtifactRoot(container, definition.styles);
} catch (error) {
throw new ModuleUiHostError("MODULE_UI_ISOLATION_UNAVAILABLE", { cause: error });
}
let instance: ModuleContributionInstance | undefined;
try {
const result = await definition.mount(isolated.mountTarget, {
contributionId: definition.id,
input,
});
if (result && typeof result === "object") {
instance = result;
}
} catch (error) {
isolated.dispose();
throw new ModuleUiHostError("MODULE_UI_MOUNT_FAILED", { cause: error });
}
let mounted = true;
return {
contributionId: definition.id,
version: definition.version,
get mounted() {
return mounted;
},
async update(next: ModuleContributionInput): Promise<void> {
if (!mounted) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_UNKNOWN");
}
if (!isModuleContributionInput(next)) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_INPUT_INVALID");
}
// A renderer that cannot update in place says so by returning no `update`, and the consumer is
// told to remount rather than left rendering stale content behind a silently ignored call.
if (!instance?.update) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_NOT_UPDATABLE");
}
try {
await instance.update(next);
} catch (error) {
throw new ModuleUiHostError("MODULE_CONTRIBUTION_UPDATE_FAILED", { cause: error });
}
},
async unmount(): Promise<void> {
if (!mounted) {
return;
}
// Marked unmounted before cleanup runs, so a throwing cleanup surfaces once and a second call
// never re-runs it. The isolated root goes in a `finally`, so a renderer that fails to clean
// up still has its styles taken out of the consumer's page.
mounted = false;
try {
await instance?.unmount?.();
} finally {
isolated.dispose();
}
},
};
}
@@ -82,13 +82,15 @@ export interface MountedModuleUi {
export type ModuleUiHostErrorCode =
| "MODULE_UI_IMPORT_FAILED"
| "MODULE_UI_ISOLATION_UNAVAILABLE"
| "MODULE_UI_MOUNT_FAILED";
| "MODULE_UI_MOUNT_FAILED"
| "MODULE_CONTRIBUTION_UPDATE_FAILED";
const errorMessages: Readonly<Record<ModuleUiHostErrorCode, string>> = Object.freeze({
MODULE_UI_IMPORT_FAILED: "A module UI artifact could not be imported from its published URL.",
MODULE_UI_ISOLATION_UNAVAILABLE:
"A module UI artifact could not be given an isolated root in this environment.",
MODULE_UI_MOUNT_FAILED: "A module UI artifact failed while mounting.",
MODULE_CONTRIBUTION_UPDATE_FAILED: "A contributed renderer failed while applying new input.",
});
/**
@@ -1,5 +1,5 @@
{
"manifestVersion": 6,
"manifestVersion": 7,
"id": "example",
"name": "Example",
"version": "0.1.0",
@@ -0,0 +1,350 @@
// @vitest-environment happy-dom
import { MODULE_CONTRIBUTION_ABI_VERSION, ModuleContributionError } from "@ekklesia/module-ui-sdk";
import { describe, expect, it } from "vitest";
import {
loadModuleContribution,
MODULE_UI_ISOLATION_ATTRIBUTE,
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
MODULE_UI_ISOLATION_STYLE_ATTRIBUTE,
ModuleUiHostError,
} from "../src/index.js";
function moduleUrl(source: string): string {
return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`;
}
const contributionArtifact = (
options: {
id?: string;
version?: string;
styles?: string;
updatable?: boolean;
cleanup?: boolean;
mountThrows?: boolean;
updateThrows?: boolean;
} = {},
) => `
export default {
abi: ${MODULE_CONTRIBUTION_ABI_VERSION},
id: ${JSON.stringify(options.id ?? "announcements.signage-slide")},
version: ${JSON.stringify(options.version ?? "1.0.0")},
${options.styles === undefined ? "" : `styles: ${JSON.stringify(options.styles)},`}
mount(container, context) {
${options.mountThrows ? 'throw new Error("mount exploded");' : ""}
globalThis.__contributionMounts ??= [];
globalThis.__contributionMounts.push({ container, contributionId: context.contributionId });
container.textContent = "slide:" + JSON.stringify(context.input);
return {
${
options.updatable === false
? ""
: `update(input) {
${options.updateThrows ? 'throw new Error("update exploded");' : ""}
container.textContent = "slide:" + JSON.stringify(input);
},`
}
${
options.cleanup === false
? ""
: `unmount() {
globalThis.__contributionUnmounts = (globalThis.__contributionUnmounts ?? 0) + 1;
container.textContent = "";
},`
}
};
},
};
`;
function container(): Element {
const element = document.createElement("div");
document.body.append(element);
return element;
}
function renderedText(element: Element): string {
const shadowHost = element.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
return (
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
""
);
}
const source = { contributionId: "announcements.signage-slide", version: "1.0.0" };
describe("generic contribution loader", () => {
it("mounts a contribution with the consumer's input, in its own isolated root", async () => {
const element = container();
const mounted = await loadModuleContribution({
container: element,
input: { slide: 1 },
source: { ...source, url: moduleUrl(contributionArtifact({ styles: ".slide{color:red}" })) },
});
expect(mounted.contributionId).toBe("announcements.signage-slide");
expect(mounted.version).toBe("1.0.0");
expect(mounted.mounted).toBe(true);
expect(renderedText(element)).toBe('slide:{"slide":1}');
// Same isolation as every other browser artifact (ADR 0040): the renderer's styles cannot reach
// the consuming module's own screen around it.
const shadowRoot = element.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`)?.shadowRoot;
expect(
shadowRoot?.querySelector(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`)?.textContent,
).toContain(".slide{color:red}");
expect(element.textContent).toBe("");
});
it("gives the renderer its own id and nothing else from the platform", async () => {
const element = container();
await loadModuleContribution({
container: element,
input: null,
source: { ...source, url: moduleUrl(contributionArtifact()) },
});
const mounts = (
globalThis as { __contributionMounts?: { contributionId: string; container: Element }[] }
).__contributionMounts;
expect(mounts?.at(-1)?.contributionId).toBe("announcements.signage-slide");
// The isolated root, never the consumer's own element.
expect(mounts?.at(-1)?.container).not.toBe(element);
});
it("updates in place without remounting", async () => {
const element = container();
const before = (globalThis as { __contributionMounts?: unknown[] }).__contributionMounts
?.length;
const mounted = await loadModuleContribution({
container: element,
input: { slide: 1 },
source: { ...source, url: moduleUrl(contributionArtifact()) },
});
await mounted.update({ slide: 2 });
expect(renderedText(element)).toBe('slide:{"slide":2}');
// One mount, two renders: exactly the property a long-running screen needs.
expect(
((globalThis as { __contributionMounts?: unknown[] }).__contributionMounts?.length ?? 0) -
(before ?? 0),
).toBe(1);
});
it("tells a consumer to remount when the renderer cannot update in place", async () => {
const element = container();
const mounted = await loadModuleContribution({
container: element,
input: { slide: 1 },
source: { ...source, url: moduleUrl(contributionArtifact({ updatable: false })) },
});
await expect(mounted.update({ slide: 2 })).rejects.toMatchObject({
code: "MODULE_CONTRIBUTION_NOT_UPDATABLE",
});
});
it("reports a throwing update as a sanitized failure", async () => {
const element = container();
const mounted = await loadModuleContribution({
container: element,
input: { slide: 1 },
source: { ...source, url: moduleUrl(contributionArtifact({ updateThrows: true })) },
});
try {
await mounted.update({ slide: 2 });
expect.unreachable("expected the update to fail");
} catch (error) {
expect(error).toBeInstanceOf(ModuleUiHostError);
expect((error as ModuleUiHostError).code).toBe("MODULE_CONTRIBUTION_UPDATE_FAILED");
expect((error as ModuleUiHostError).message).not.toContain("exploded");
expect((error as { cause?: Error }).cause?.message).toBe("update exploded");
}
});
it("unmounts deterministically, idempotently, and removes the isolated root", async () => {
const element = container();
const before = (globalThis as { __contributionUnmounts?: number }).__contributionUnmounts ?? 0;
const mounted = await loadModuleContribution({
container: element,
input: { slide: 1 },
source: { ...source, url: moduleUrl(contributionArtifact({ styles: ".slide{}" })) },
});
await mounted.unmount();
await mounted.unmount();
expect(mounted.mounted).toBe(false);
expect(element.children).toHaveLength(0);
expect(
((globalThis as { __contributionUnmounts?: number }).__contributionUnmounts ?? 0) - before,
).toBe(1);
await expect(mounted.update({ slide: 2 })).rejects.toMatchObject({
code: "MODULE_CONTRIBUTION_UNKNOWN",
});
});
it("unmounts safely when the renderer registered no cleanup", async () => {
const element = container();
const mounted = await loadModuleContribution({
container: element,
input: null,
source: { ...source, url: moduleUrl(contributionArtifact({ cleanup: false })) },
});
await expect(mounted.unmount()).resolves.toBeUndefined();
expect(element.children).toHaveLength(0);
});
it("refuses input that is not genuinely JSON, before importing anything", async () => {
const element = container();
let imported = false;
for (const input of [
new Date() as unknown as null,
(() => undefined) as unknown as null,
Number.NaN as unknown as null,
{ nested: undefined } as unknown as null,
]) {
await expect(
loadModuleContribution({
container: element,
input,
importModule: async () => {
imported = true;
return {};
},
source: { ...source, url: "unused" },
}),
).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_INPUT_INVALID" });
}
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
await expect(
loadModuleContribution({
container: element,
input: cyclic as never,
source: { ...source, url: "unused" },
}),
).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_INPUT_INVALID" });
expect(imported).toBe(false);
expect(element.children).toHaveLength(0);
});
it("never echoes the consumer's domain data in a failure", async () => {
const element = container();
try {
await loadModuleContribution({
container: element,
input: { secretHeading: new Date() } as unknown as null,
source: { ...source, url: "unused" },
});
expect.unreachable("expected the input to be refused");
} catch (error) {
expect(error).toBeInstanceOf(ModuleContributionError);
expect(String((error as Error).message)).not.toContain("secretHeading");
expect(String((error as Error).stack)).not.toContain("secretHeading");
}
});
it.each([
[
"a different contribution id",
contributionArtifact({ id: "calendar.week" }),
"MODULE_CONTRIBUTION_IDENTITY_MISMATCH",
],
[
"a different version",
contributionArtifact({ version: "2.0.0" }),
"MODULE_CONTRIBUTION_IDENTITY_MISMATCH",
],
[
"a different contribution ABI",
'export default { abi: 99, id: "announcements.signage-slide", version: "1.0.0", mount() {} };',
"MODULE_CONTRIBUTION_ABI_UNSUPPORTED",
],
["no default export", "export const contribution = {};", "MODULE_CONTRIBUTION_EXPORT_MISSING"],
[
"a malformed definition",
`export default { abi: ${MODULE_CONTRIBUTION_ABI_VERSION}, id: "announcements.signage-slide" };`,
"MODULE_CONTRIBUTION_DEFINITION_INVALID",
],
])("refuses %s before mounting", async (_label, artifact, code) => {
const element = container();
await expect(
loadModuleContribution({
container: element,
input: null,
source: { ...source, url: moduleUrl(artifact) },
}),
).rejects.toMatchObject({ code });
expect(element.children).toHaveLength(0);
});
it("reports a throwing mount as a sanitized failure and leaves nothing behind", async () => {
const element = container();
try {
await loadModuleContribution({
container: element,
input: null,
source: { ...source, url: moduleUrl(contributionArtifact({ mountThrows: true })) },
});
expect.unreachable("expected the mount to fail");
} catch (error) {
expect(error).toBeInstanceOf(ModuleUiHostError);
expect((error as ModuleUiHostError).code).toBe("MODULE_UI_MOUNT_FAILED");
expect((error as ModuleUiHostError).message).not.toContain("exploded");
}
expect(element.children).toHaveLength(0);
});
it("reports an unreachable artifact as an import failure with no URL in the message", async () => {
const element = container();
try {
await loadModuleContribution({
container: element,
input: null,
source: { ...source, url: "data:text/javascript,this is not(valid javascript" },
});
expect.unreachable("expected the import to fail");
} catch (error) {
expect((error as ModuleUiHostError).code).toBe("MODULE_UI_IMPORT_FAILED");
expect((error as ModuleUiHostError).message).not.toContain("data:");
}
});
it("keeps two mounted contributions independent", async () => {
const first = container();
const second = container();
const url = moduleUrl(contributionArtifact());
const a = await loadModuleContribution({
container: first,
input: { slide: "a" },
source: { ...source, url },
});
const b = await loadModuleContribution({
container: second,
input: { slide: "b" },
source: { ...source, url },
});
await a.unmount();
expect(a.mounted).toBe(false);
expect(b.mounted).toBe(true);
expect(first.children).toHaveLength(0);
expect(renderedText(second)).toBe('slide:{"slide":"b"}');
});
});
@@ -3,6 +3,7 @@ import {
MODULE_UI_ABI_VERSION,
ModuleUiContractError,
type ModuleUiHostContext,
NO_MODULE_UI_CONTRIBUTIONS,
} from "@ekklesia/module-ui-sdk";
import { describe, expect, it } from "vitest";
import {
@@ -50,6 +51,7 @@ export default {
function hostContext(requested: string[], moduleId = "announcements"): ModuleUiHostContext {
return {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
moduleId,
api: {
async request(path) {
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildModuleUi } from "@ekklesia/module-ui-build";
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { type ModuleUiHostContext, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
import { beforeAll, describe, expect, it } from "vitest";
import {
loadModuleUi,
@@ -32,6 +32,7 @@ let artifactSource: string;
/** An Ekklesia-shaped host: module routes live under a Core-owned prefix. */
function ekklesiaContext(requested: string[]): ModuleUiHostContext {
return {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
moduleId: "example",
api: {
async request(pathname) {
@@ -48,6 +49,7 @@ function ekklesiaContext(requested: string[]): ModuleUiHostContext {
/** A standalone host: one module, a different origin, a completely different URL shape, no Core. */
function standaloneContext(requested: string[]): ModuleUiHostContext {
return {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
moduleId: "example",
api: {
async request(pathname) {
+9
View File
@@ -0,0 +1,9 @@
/**
* A value or a promise of one.
*
* In its own file rather than beside either contract that uses it: `module-ui.ts` and
* `contribution.ts` both need it, and `module-ui.ts` reaches `host-context.ts`, which reaches
* `contribution.ts` — so declaring it in either would close a source cycle that
* `scripts/check-boundaries.ts` rejects, including one formed only by `import type`.
*/
export type Awaitable<Value> = Value | PromiseLike<Value>;
+339
View File
@@ -0,0 +1,339 @@
import type { Awaitable } from "./awaitable.js";
import type { ModuleUiJsonValue } from "./json.js";
/**
* The browser **contribution** contract: what a module's contributed renderer exports, and what a
* consuming module can do with one (ADR 0041).
*
* A contribution is not a third UI role. `entrypoints.ui` and `entrypoints.display` are artifacts a
* *host* mounts, in surfaces the host owns. A contribution is mounted by a **consumer module**,
* inside a container that consumer owns, at a moment only that consumer understands — a slot in its
* own layout. The platform loads the code and validates its identity; it never decides that a
* contribution should be on screen.
*
* ## The consumer owns the domain contract, entirely
*
* `input` is opaque JSON. The platform validates that it is JSON and nothing else: not its shape,
* not its meaning, not its version. A consumer that provides a "slide renderer" capability publishes
* *its own* input contract, versions it through its own capability version, and every contributor
* writes against that. Core cannot learn what a slide is from this file, which is the point.
*
* ## What a contribution does not receive
*
* No database, no object storage, no credential, no session, no API client, and no reach into any
* module — including the one that contributed it. Loading a contribution grants its module nothing:
* server-side authorization is unchanged and remains the only authority. Everything a contributed
* renderer needs arrives through the consumer's input, because the consumer is the party that was
* authorized to obtain it.
*
* ## Still trusted code
*
* A contribution artifact is installed first-party JavaScript running in the host's realm, exactly
* like every other module artifact (ADR 0022, ADR 0024). Its isolated root (ADR 0040) is a
* correctness boundary for styles, not a sandbox, and this contract must never be described as one.
*/
/**
* The version of the contribution ABI this package defines, and the only one a host mounts.
*
* Separate from `MODULE_UI_ABI_VERSION` on purpose: a contributed renderer and a mounted module UI
* are different contracts with different lifecycles — one is handed a host context, the other is
* handed a consumer's input and can be updated in place — and versioning them together would force
* every module to rebuild whenever either moved.
*
* `defineModuleContribution` stamps it. An author never writes it.
*/
export const MODULE_CONTRIBUTION_ABI_VERSION = 1;
/** The consumer's own versioned input. Opaque to the platform. */
export type ModuleContributionInput = ModuleUiJsonValue;
/**
* Everything a contributed renderer is given, and the complete list of it.
*
* Deliberately two members. Anything else — a principal, an API client, a realtime subscription,
* configuration — would either be authority the contribution has not earned or state the consumer
* should be passing in its own input, where it can version it.
*/
export interface ModuleContributionContext {
/** This contribution's own id, so it never re-derives its identity from anything. */
readonly contributionId: string;
/** The consumer's input for this mount. */
readonly input: ModuleContributionInput;
}
/**
* What a contribution's `mount` returns: how to give it new input, and how to release it.
*
* `update` exists so a consumer can drive a renderer through changing state — a new slide, a new
* selection — **without** remounting. Remounting for every change would destroy DOM, restart
* animations, and reload media on every tick, which is exactly wrong for the long-running screens
* this contract is for. A contribution that genuinely cannot update in place omits it, and the
* consumer remounts.
*
* Returning nothing at all declares that the mount acquired nothing and cannot be updated.
*/
export interface ModuleContributionInstance {
readonly update?: (input: ModuleContributionInput) => Awaitable<void>;
readonly unmount?: () => Awaitable<void>;
}
/** What a module author passes to {@link defineModuleContribution}. */
export interface ModuleContributionDefinitionInput {
/** `<module-id>.<local-name>`, matching the manifest declaration exactly. */
readonly id: string;
/** This contribution's own version, matching the manifest declaration exactly. */
readonly version: string;
/** The artifact's compiled CSS text, installed by the host into this artifact's isolated root
* (ADR 0040). Exactly the same mechanism a module UI artifact uses. */
readonly styles?: string;
/**
* Asynchronous by contract. A renderer that has to decode media, measure a layout, or wait for a
* font before it is honestly ready returns a promise, and the consumer's `mount` resolves only
* once it has settled — so a consumer can sequence a transition instead of guessing.
*/
readonly mount: (
container: Element,
context: ModuleContributionContext,
) => Awaitable<ModuleContributionInstance | void>;
}
/** What a compiled contribution artifact default-exports: the author's declaration plus the ABI
* marker `defineModuleContribution` stamped on it. */
export interface ModuleContributionDefinition extends ModuleContributionDefinitionInput {
readonly abi: number;
}
export type ModuleContributionErrorCode =
| "MODULE_CONTRIBUTION_EXPORT_MISSING"
| "MODULE_CONTRIBUTION_DEFINITION_INVALID"
| "MODULE_CONTRIBUTION_ABI_UNSUPPORTED"
| "MODULE_CONTRIBUTION_STYLES_INVALID"
| "MODULE_CONTRIBUTION_IDENTITY_MISMATCH"
| "MODULE_CONTRIBUTION_INPUT_INVALID"
| "MODULE_CONTRIBUTION_UNKNOWN"
| "MODULE_CONTRIBUTION_NOT_UPDATABLE";
const errorMessages: Readonly<Record<ModuleContributionErrorCode, string>> = Object.freeze({
MODULE_CONTRIBUTION_EXPORT_MISSING:
"A module contribution artifact must have a default export produced by defineModuleContribution.",
MODULE_CONTRIBUTION_DEFINITION_INVALID:
"A module contribution definition must declare a non-empty id and version and a mount function, and nothing else.",
MODULE_CONTRIBUTION_ABI_UNSUPPORTED:
"This contribution artifact was built for a different contribution ABI. Rebuild the module against the current module UI SDK.",
MODULE_CONTRIBUTION_STYLES_INVALID:
"A module contribution's styles must be the artifact's compiled CSS text, as a non-empty string.",
MODULE_CONTRIBUTION_IDENTITY_MISMATCH:
"The contribution artifact's id and version do not match the contribution it was loaded for.",
MODULE_CONTRIBUTION_INPUT_INVALID:
"A contribution's input must be a JSON-serializable value supplied by the consuming module.",
MODULE_CONTRIBUTION_UNKNOWN: "No contribution with that id is available to this module.",
MODULE_CONTRIBUTION_NOT_UPDATABLE:
"This contribution does not support updating in place. Unmount it and mount it again.",
});
/** Carries a stable code and a fixed message, never the offending value, the artifact URL, or the
* consumer's input — which is domain data and never belongs in a platform error. */
export class ModuleContributionError extends Error {
readonly code: ModuleContributionErrorCode;
constructor(code: ModuleContributionErrorCode) {
super(errorMessages[code]);
this.name = "ModuleContributionError";
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;
}
/**
* Whether a value is genuinely JSON-serializable, checked structurally rather than by attempting
* `JSON.stringify` and hoping.
*
* A consumer's input crosses from one module's code to another's. `undefined`, a function, a
* `Symbol`, a `Date`, a cyclic object, and a class instance all either vanish or throw when
* serialized, and a contribution that received one would fail somewhere far from the mistake. This
* is a bounded structural walk, so a cycle is caught rather than followed.
*/
export function isModuleContributionInput(value: unknown, seen = new Set<object>()): boolean {
if (value === null || typeof value === "boolean" || typeof value === "string") {
return true;
}
if (typeof value === "number") {
return Number.isFinite(value);
}
if (typeof value !== "object") {
return false;
}
if (seen.has(value)) {
return false;
}
seen.add(value);
const entries = Array.isArray(value) ? value : Object.values(value);
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype) {
return false;
}
const valid = entries.every((entry) => isModuleContributionInput(entry, seen));
seen.delete(value);
return valid;
}
export function isModuleContributionDefinition(
value: unknown,
): value is ModuleContributionDefinition {
if (!isRecord(value) || value.abi !== MODULE_CONTRIBUTION_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 a contribution artifact's default export. The ABI marker is checked first and on its own,
* so an artifact built against a different contribution ABI is reported as exactly that. */
export function parseModuleContributionDefinition(value: unknown): ModuleContributionDefinition {
if (!isRecord(value)) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_DEFINITION_INVALID");
}
if (value.abi !== MODULE_CONTRIBUTION_ABI_VERSION) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_ABI_UNSUPPORTED");
}
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_STYLES_INVALID");
}
if (!isModuleContributionDefinition(value)) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_DEFINITION_INVALID");
}
return Object.freeze({
abi: MODULE_CONTRIBUTION_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 contributing module's validated manifest. */
export interface ModuleContributionIdentity {
readonly id: string;
readonly version: string;
}
/** The one entry point a host uses after importing a contribution artifact. Imports nothing itself,
* exactly like `parseModuleUiExport`, so this package stays free of any loader or URL assumption. */
export function parseModuleContributionExport(
namespace: unknown,
expected: ModuleContributionIdentity,
): ModuleContributionDefinition {
if (!isRecord(namespace) || !("default" in namespace)) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_EXPORT_MISSING");
}
const definition = parseModuleContributionDefinition(namespace.default);
if (definition.id !== expected.id || definition.version !== expected.version) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_IDENTITY_MISMATCH");
}
return definition;
}
/**
* The contributing module author's entry point, mirroring `defineModuleUi`: validate at authoring
* time, freeze, and stamp the contribution ABI this artifact was built against.
*/
export function defineModuleContribution(
definition: ModuleContributionDefinitionInput,
): ModuleContributionDefinition {
return parseModuleContributionDefinition({
...definition,
abi: MODULE_CONTRIBUTION_ABI_VERSION,
});
}
/**
* What a **consumer** module sees of the contributions aimed at a capability it provides.
*
* Identity and version only. There is deliberately no artifact URL: a consumer names a contribution
* and the host resolves it, exactly as a module names a relative API path and the host resolves that
* (ADR 0022). A module that built `/api/v1/module-ui/<id>/<version>/contributions/<id>` itself would
* be welded to Ekklesia's private deployment detail and unhostable anywhere else.
*/
export interface ModuleContributionDescriptor {
readonly id: string;
readonly version: string;
/** The capability this renderer was contributed to — one the consuming module provides. */
readonly capability: string;
}
/** One mounted contribution, owned by the consumer that mounted it. */
export interface MountedModuleContribution {
readonly contributionId: string;
readonly version: string;
/** Whether cleanup is still pending. Becomes `false` after the first {@link unmount}. */
readonly mounted: boolean;
/**
* Hands the renderer new input without remounting it.
*
* Rejects if the contribution declared no `update`, so a consumer learns that it must remount
* rather than silently rendering stale content.
*/
update(input: ModuleContributionInput): Promise<void>;
/** Runs the renderer's cleanup and removes its isolated root. Idempotent. */
unmount(): Promise<void>;
}
export interface MountModuleContributionOptions {
/** The exact contribution id from a {@link ModuleContributionDescriptor}. */
readonly contributionId: string;
/** A container the **consumer** owns — its own slot. The host builds the renderer's isolated root
* inside it and never reaches outside it. */
readonly container: Element;
/** The consumer's own versioned input. */
readonly input: ModuleContributionInput;
}
/**
* A consuming module's access to the renderers contributed to its own capabilities.
*
* The host supplies this; a module never constructs one. It can only ever see contributions aimed at
* a capability this module actually provides, because that is the only thing the server put in its
* descriptor — a module cannot enumerate the platform's contributions, only its own extension
* points.
*/
export interface ModuleUiContributions {
/** Contributions declared for one capability this module provides, in a stable order. Returns an
* empty list for a capability this module does not provide, rather than throwing: a consumer
* asking about an extension point nobody filled is the ordinary case. */
list(capabilityId: string): readonly ModuleContributionDescriptor[];
/** Loads, verifies, and mounts one contribution into a consumer-owned container. */
mount(options: MountModuleContributionOptions): Promise<MountedModuleContribution>;
}
/**
* What a host with no contribution mechanism supplies.
*
* A standalone host running exactly one module has no other module to contribute anything, so an
* empty implementation is the *correct* one rather than a stub — which is precisely why this member
* is always present on the context: a module writes the same code either way, and finds nothing.
*/
export const NO_MODULE_UI_CONTRIBUTIONS: ModuleUiContributions = Object.freeze({
list: (): readonly ModuleContributionDescriptor[] => Object.freeze([]),
mount: (): Promise<MountedModuleContribution> =>
Promise.reject(new ModuleContributionError("MODULE_CONTRIBUTION_UNKNOWN")),
});
@@ -1,3 +1,4 @@
import type { ModuleUiContributions } from "./contribution.js";
import type { ModuleUiRealtimeClient } from "./realtime.js";
/**
@@ -83,6 +84,16 @@ export interface ModuleUiHostContext {
readonly permissions: ModuleUiPermissions;
/** Host-owned delivery of this module's own runtime-published state. */
readonly realtime: ModuleUiRealtimeClient;
/**
* The renderers other modules contributed to capabilities **this** module provides (ADR 0041).
*
* Present on every context, and empty for the overwhelming majority of modules — a module that
* provides no extension point simply finds nothing in it. It is the one member that lets one
* module's UI extend another's without either importing the other, and it deliberately exposes
* identity and a mount operation rather than a URL: the host resolves publication, exactly as it
* resolves `api.request`.
*/
readonly contributions: ModuleUiContributions;
}
/**
+2
View File
@@ -1,4 +1,6 @@
export * from "./contribution.js";
export * from "./define-module-ui.js";
export * from "./host-context.js";
export * from "./json.js";
export * from "./module-ui.js";
export * from "./realtime.js";
+15
View File
@@ -0,0 +1,15 @@
/**
* A JSON value crossing a host-owned boundary — a realtime snapshot, a contribution's input.
*
* Data, not vocabulary. Everything the platform carries between two pieces of module code is
* serializable and opaque to the host: the host validates that it *is* JSON and never what it
* means. That is what keeps a domain contract (a slide, a timeline, an announcement) entirely
* between the two modules that share it, with nothing for Core to learn or version.
*/
export type ModuleUiJsonValue =
| boolean
| number
| string
| null
| readonly ModuleUiJsonValue[]
| { readonly [key: string]: ModuleUiJsonValue };
+2 -1
View File
@@ -1,6 +1,7 @@
import type { ModuleUiHostContext } from "./host-context.js";
export type Awaitable<Value> = Value | PromiseLike<Value>;
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.
+6 -7
View File
@@ -1,17 +1,16 @@
import type { ModuleUiJsonValue } from "./json.js";
/**
* A JSON value carried from a module's server runtime to its browser UI.
*
* Realtime state is deliberately data, not transport vocabulary. A module does not open a socket,
* choose an origin, attach credentials, or know how its host keeps a subscription alive. The host
* owns all of that and gives the module only typed snapshots and updates.
*
* An alias of the SDK's one {@link ModuleUiJsonValue}, kept under its original name because it is
* the published realtime contract. There is one JSON shape in this package, not one per feature.
*/
export type ModuleUiRealtimeJsonValue =
| boolean
| number
| string
| null
| readonly ModuleUiRealtimeJsonValue[]
| { readonly [key: string]: ModuleUiRealtimeJsonValue };
export type ModuleUiRealtimeJsonValue = ModuleUiJsonValue;
/** Connection state useful for rendering a small, honest status indicator. */
export type ModuleUiRealtimeStatus = "connecting" | "connected" | "reconnecting" | "closed";
@@ -4,6 +4,7 @@ import {
isModuleUiDefinition,
MODULE_UI_ABI_VERSION,
ModuleUiContractError,
NO_MODULE_UI_CONTRIBUTIONS,
type ModuleUiApiClient,
type ModuleUiDefinition,
type ModuleUiDefinitionInput,
@@ -184,6 +185,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
name: "ekklesia",
requestedUrls,
context: {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
moduleId,
api,
principal: { kind: "anonymous" },
@@ -208,6 +210,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
name: "standalone",
requestedUrls,
context: {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
moduleId,
api,
principal: { kind: "anonymous" },