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 { 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 { 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 { 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(); } }, }; }