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; /** * 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; } /** * 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> = 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 { 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; }