feat: close the browser boundary and add the generic UI host loader
Two gaps stood between the module UI contract and a host that could use
it. ADR 0023 recorded the first honestly but never diagnosed it: a module
UI file compiled with `"types": []` could still typecheck `process.env`.
The second was simply missing — nothing imported a compiled artifact,
checked its identity, mounted it, or tore it down.
The leak was one type-only import, bisected against the real type graph
rather than guessed. `@ekklesia/module-ui-sdk` imported
`type { EkklesiaPrincipal } from "@ekklesia/contracts"`. Importing
contracts/identity alone is clean and importing Zod alone is clean, but
contracts exposes a single barrel entry, so that one import reached
module-database, whose Drizzle types re-introduce Node's globals. The
runtime was never affected — the import was erased and host-context.js is
`export {}` — but a type-graph leak is still a real boundary failure,
because it silently permits code that cannot run in its target
environment.
The fix is not a narrower import path. The SDK now has no dependencies at
all, so there is no graph to leak through and no future contracts change
can reopen this. It owns `ModuleUiPrincipal`: the same shape ADR 0022
chose, with the Zod brand dropped. That brand encodes a server-side
parsing guarantee a browser cannot produce, and it is the same reason
`permissions.has` already took a plain string. An Ekklesia host still
passes its own `EkklesiaPrincipal` straight through — a branded string is
assignable to `string` — while a standalone host builds the literal.
Proven the way an author experiences it: two fixtures compiled under the
exact project a module uses, one supported and one deliberately reaching
for `process`, with the second required to fail on that specific name.
`@ekklesia/module-ui-host` is the generic loader, in its own package
because the SDK is bundled into every module artifact and host-only
loader code has no business shipping there. The boundary checker enforces
both directions: modules may not depend on the loader, and the loader may
depend only on the SDK plus dev-time tooling.
The distinction that shaped the design: Core resolves `entrypoints.ui` to
an absolute path on the server's disk, and a browser needs a URL. A
loader that accepted the path would test green on a server and fail in
every real host, and would quietly own asset publication. So the loader
takes a browser-loadable URL and knows nothing about how one is obtained.
Ekklesia has no route that publishes installed artifacts, so this is
precisely: loader complete, publication deferred. Nothing can mount an
installed module UI yet, and the docs say so rather than implying
otherwise.
Dynamic import is `(url) => import(url)` with a non-analyzable specifier,
kept as a runtime import so a host does not statically bundle every
installed module or need a hardcoded registry. It is injectable for
bundlers needing their own escape hatch, and deliberately not `eval` or
`new Function`, which would cost a host `unsafe-eval` in its CSP.
Identity is validated through the SDK's existing parser before `mount`
runs, so a stale or mismatched bundle never renders. Contract violations
propagate unwrapped; the loader adds only import and mount failure codes,
sanitized, with the cause attached. Unmount is idempotent, safe when a
module returned no cleanup, and marks an instance unmounted before
running cleanup so a throwing cleanup surfaces once and never re-runs. A
failed mount registers no cleanup. Each instance owns its own state, so
two mounts coexist. The loader never mutates the host's container — not
on success, failure, or unmount.
Tests prove all of it against real module URLs (`data:`, which both
browsers and Node import natively) rather than filesystem paths,
including one artifact built by the canonical Pass 2 pipeline and run
under both an Ekklesia-shaped and a standalone-shaped context. Core,
apps/api, and the contracts package are untouched; discovery remains
inert.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5d75d910fd
commit
44e676f0df
@@ -0,0 +1 @@
|
||||
export * from "./load-module-ui.js";
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
type ModuleUiDefinition,
|
||||
type ModuleUiHostContext,
|
||||
type ModuleUiUnmount,
|
||||
parseModuleUiExport,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
|
||||
/**
|
||||
* How a host obtains a module's UI artifact.
|
||||
*
|
||||
* `url` is a **browser-loadable module URL**, not a filesystem path. That distinction is the whole
|
||||
* reason this type exists. Core's module discovery resolves `entrypoints.ui` to an absolute path on
|
||||
* the server's disk (`ResolvedModuleEntrypoints.ui`); a browser cannot import that, and a host that
|
||||
* passed one here would fail at run time in a way no test on a server would catch. Turning an
|
||||
* installed artifact into a URL a browser may fetch is **asset publication**, a separate concern
|
||||
* this package deliberately does not own and Ekklesia does not implement yet (ADR 0024).
|
||||
*
|
||||
* `moduleId` and `version` are the identity the host already read from the module's validated
|
||||
* manifest. They are checked against the artifact before anything is mounted.
|
||||
*/
|
||||
export interface ModuleUiSource {
|
||||
readonly moduleId: string;
|
||||
readonly version: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How the artifact is actually imported.
|
||||
*
|
||||
* Injectable because bundlers disagree about dynamic `import()`. The default is a plain
|
||||
* `import(url)` with a non-analyzable specifier, which esbuild and Rollup leave as a run-time import
|
||||
* rather than trying to resolve at build time — the behaviour a host needs, since installed modules
|
||||
* are not known when the host is built. A host whose bundler needs a specific escape hatch (Vite's
|
||||
* `/* @vite-ignore *\/`, webpack's `webpackIgnore`) supplies its own one-line importer instead of
|
||||
* this package guessing.
|
||||
*
|
||||
* Deliberately not implemented with `new Function` or `eval`: those defeat static analysis reliably
|
||||
* but require `unsafe-eval` in a Content-Security-Policy, and a host should not have to weaken its
|
||||
* CSP to load a module.
|
||||
*/
|
||||
export type ModuleUiImporter = (url: string) => Promise<unknown>;
|
||||
|
||||
const defaultImporter: ModuleUiImporter = (url) => import(url);
|
||||
|
||||
export interface LoadModuleUiOptions {
|
||||
readonly source: ModuleUiSource;
|
||||
/** The element the module renders into. The host owns it; the module owns its contents. */
|
||||
readonly container: Element;
|
||||
/** Supplied whole by the host. This package forwards it and never adds to it. */
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly importModule?: ModuleUiImporter;
|
||||
}
|
||||
|
||||
/** One mounted instance. Each `loadModuleUi` call returns its own; nothing is shared or cached
|
||||
* here, so a module may be mounted into several containers at once if its own implementation
|
||||
* supports it. */
|
||||
export interface MountedModuleUi {
|
||||
readonly moduleId: string;
|
||||
readonly version: string;
|
||||
/** Whether cleanup is still pending. Becomes `false` after the first {@link unmount}. */
|
||||
readonly mounted: boolean;
|
||||
/**
|
||||
* Runs the module's cleanup, if it returned one. Idempotent: the first call cleans up, later
|
||||
* calls do nothing. Safe when the module returned no cleanup function.
|
||||
*/
|
||||
unmount(): Promise<void>;
|
||||
}
|
||||
|
||||
export type ModuleUiHostErrorCode = "MODULE_UI_IMPORT_FAILED" | "MODULE_UI_MOUNT_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_MOUNT_FAILED: "A module UI artifact failed while mounting.",
|
||||
});
|
||||
|
||||
/**
|
||||
* Carries a stable code and a fixed message, never the artifact URL or a caught cause, matching
|
||||
* every other Ekklesia error boundary. The originating error is attached as `cause` for a host's
|
||||
* own diagnostics, and is never part of the message.
|
||||
*/
|
||||
export class ModuleUiHostError extends Error {
|
||||
readonly code: ModuleUiHostErrorCode;
|
||||
|
||||
constructor(code: ModuleUiHostErrorCode, options?: { readonly cause?: unknown }) {
|
||||
super(errorMessages[code], options);
|
||||
this.name = "ModuleUiHostError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a published module UI artifact, verifies it is the module the host expected, and mounts
|
||||
* it into the host's container.
|
||||
*
|
||||
* **This executes trusted first-party code in the host's own page.** A module UI artifact is
|
||||
* ordinary same-realm JavaScript once imported: it can reach the DOM, globals, and anything else the
|
||||
* page can. Nothing here is a sandbox, and publishing an artifact must never be read as evidence
|
||||
* that it is safe — only installed, reviewed modules should ever be published or mounted. Server
|
||||
* APIs remain the sole authority for authorization; a module UI that is mounted has gained no
|
||||
* privilege it did not already have (ADR 0022, ADR 0024).
|
||||
*
|
||||
* Nothing is mounted until the artifact's declared identity matches the manifest identity the host
|
||||
* passed in, so a stale or mismatched bundle fails before it can render or run `mount`.
|
||||
*/
|
||||
export async function loadModuleUi(options: LoadModuleUiOptions): Promise<MountedModuleUi> {
|
||||
const { container, context, source } = options;
|
||||
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 and that its id/version match what the host expected, and throws `ModuleUiContractError`
|
||||
// with a specific code when either fails. That error propagates unwrapped — a malformed artifact
|
||||
// is a contract violation, not a host failure.
|
||||
const definition: ModuleUiDefinition = parseModuleUiExport(namespace, {
|
||||
id: source.moduleId,
|
||||
version: source.version,
|
||||
});
|
||||
|
||||
let cleanup: ModuleUiUnmount | undefined;
|
||||
try {
|
||||
const result = await definition.mount(container, context);
|
||||
if (typeof result === "function") {
|
||||
cleanup = result;
|
||||
}
|
||||
} catch (error) {
|
||||
// No cleanup is registered for a mount that never completed, so the returned handle can never
|
||||
// claim a failed module is mounted. The container is deliberately left untouched: the host owns
|
||||
// it and decides what a failed mount should look like.
|
||||
throw new ModuleUiHostError("MODULE_UI_MOUNT_FAILED", { cause: error });
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
return {
|
||||
moduleId: definition.id,
|
||||
version: definition.version,
|
||||
get mounted() {
|
||||
return mounted;
|
||||
},
|
||||
async unmount() {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
// Marked unmounted before running cleanup, so a throwing cleanup surfaces to the host once
|
||||
// and a second call never re-runs it.
|
||||
mounted = false;
|
||||
await cleanup?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user