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; readonly unmount?: () => Awaitable; } /** What a module author passes to {@link defineModuleContribution}. */ export interface ModuleContributionDefinitionInput { /** `.`, 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; } /** 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> = 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 { 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()): 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///contributions/` 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; /** Runs the renderer's cleanup and removes its isolated root. Idempotent. */ unmount(): Promise; } 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; } /** * 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 => Promise.reject(new ModuleContributionError("MODULE_CONTRIBUTION_UNKNOWN")), });