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
+2
-1
@@ -40,7 +40,8 @@ pnpm infrastructure:config
|
||||
artifact the manifest declares, via `@ekklesia/module-ui-build` (ADR 0023) — module authors write
|
||||
no bundler configuration. Styling is React inline style objects only for now, and no host mounts a
|
||||
UI entrypoint yet.
|
||||
- Module UI reaches its host only through `@ekklesia/module-ui-sdk`, and asks the host for its own
|
||||
- Module UI reaches its host only through `@ekklesia/module-ui-sdk` — never
|
||||
`@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own
|
||||
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
|
||||
private deployment detail and would break standalone hosting.
|
||||
- The containing application shell, navigation, global theme, and platform policy belong to Core and
|
||||
|
||||
@@ -287,9 +287,24 @@ from source, validate it with `parseModuleUiExport`, mount it into a real DOM, a
|
||||
output, and unmount it — so a UI that cannot be produced fails CI, and no generated file is ever
|
||||
committed. See [ADR 0023](docs/architecture/decisions/0023-module-ui-build-pipeline.md).
|
||||
|
||||
**Still no host.** A module UI can be authored, built, and tested; **nothing serves one to a user
|
||||
yet.** The Dashboard shell, navigation, a design system, a stylesheet loading contract, and the
|
||||
standalone host do not exist.
|
||||
Phase 6B pass 3 closes the last two foundational gaps. **The browser boundary now actually holds**:
|
||||
`@ekklesia/module-ui-sdk` had one type-only import of `@ekklesia/contracts`, whose barrel pulled
|
||||
Drizzle's types — and with them Node's globals — into every module UI program, so `process.env`
|
||||
typechecked inside browser-only code. The SDK now has **no dependencies at all**, and owns a plain
|
||||
browser `ModuleUiPrincipal` instead of Core's Zod-branded server type; a compile test proves that
|
||||
supported UI source compiles and that `process` does not. **And there is now a generic host loader**:
|
||||
`@ekklesia/module-ui-host` imports a published artifact from a browser URL, validates its id and
|
||||
version against the manifest *before* mounting, mounts it into a host-owned container, and returns an
|
||||
idempotent `unmount()`. A module may never depend on it — it is host infrastructure, and the boundary
|
||||
checker enforces that. See
|
||||
[ADR 0024](docs/architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md).
|
||||
|
||||
**Still no host, and one specific reason why.** Core resolves `entrypoints.ui` to a path on the
|
||||
*server's filesystem*; a browser cannot import that. **Ekklesia has no route that serves an installed
|
||||
module's artifact at a browser URL**, so nothing can mount an installed module UI yet — the loader is
|
||||
complete, asset publication is not, and that is the next pass's first job. The Dashboard shell,
|
||||
navigation, a design system, a stylesheet loading contract, and the standalone host also do not
|
||||
exist.
|
||||
|
||||
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
|
||||
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
|
||||
|
||||
@@ -87,6 +87,14 @@ components, no layout, no design system, no router.
|
||||
|
||||
### The host context is deliberately four members
|
||||
|
||||
> **Partial correction — see [ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md).**
|
||||
> `principal` is no longer typed as Core's `EkklesiaPrincipal`. That import made this package depend
|
||||
> on `@ekklesia/contracts`, whose barrel pulled Drizzle's types — and Node's globals — into every
|
||||
> browser program that imported the SDK. The context now uses a browser-owned `ModuleUiPrincipal`
|
||||
> with the same shape and an unbranded `userId`, and the SDK has no dependencies at all. The four
|
||||
> members and everything else in this section are unchanged.
|
||||
|
||||
|
||||
```ts
|
||||
interface ModuleUiHostContext {
|
||||
readonly moduleId: string;
|
||||
|
||||
@@ -170,6 +170,13 @@ through two independent paths.
|
||||
|
||||
## Consequences
|
||||
|
||||
> **Cross-reference — see [ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md).**
|
||||
> The Node-globals leak this ADR recorded under "Browser TypeScript stays scoped to UI source" is
|
||||
> now diagnosed and closed: it was a single type-only import of `@ekklesia/contracts` in the UI SDK,
|
||||
> whose barrel reached Drizzle's types. The SDK now has no dependencies, and a compile test proves a
|
||||
> module UI file importing it cannot use `process`. Everything else in this ADR is unchanged.
|
||||
|
||||
|
||||
A module author can now write React and TypeScript, run their normal build, and get exactly the
|
||||
artifact their manifest declares — with no bundler configuration of their own. The end-to-end path
|
||||
from ADR 0021's static field through ADR 0022's contract to a mounted, unmountable UI is closed and
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# 0024: Browser-safe module UI SDK and the generic host loader
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-21
|
||||
- Extends: [0022](0022-module-ui-authoring-and-host-contract.md),
|
||||
[0023](0023-module-ui-build-pipeline.md)
|
||||
- Supersedes (partial): [0022](0022-module-ui-authoring-and-host-contract.md) — only its decision to
|
||||
type `ModuleUiHostContext.principal` as Core's `EkklesiaPrincipal`. Every other part of that ADR —
|
||||
the mount-function ABI, the four-member context, module-relative API access, and the trust model —
|
||||
remains accepted and unchanged.
|
||||
|
||||
## Context
|
||||
|
||||
Two gaps stand between the module UI contract and a host that could actually use it.
|
||||
|
||||
**The browser boundary had a hole.** ADR 0023 recorded it honestly: a module UI file compiled with
|
||||
`"types": []` should not see Node globals, yet `process.env` typechecked. The cause was never
|
||||
diagnosed beyond "transitively, through dependency type graphs."
|
||||
|
||||
**Nothing loads a module UI.** ADR 0022 defined `parseModuleUiExport` for a host to call, and ADR
|
||||
0023 made the artifact buildable, but no code imports an artifact, checks its identity, mounts it, or
|
||||
tears it down. Every future host — the Ekklesia Dashboard and a standalone host alike — needs that
|
||||
lifecycle, and it must not be invented twice.
|
||||
|
||||
## Decision
|
||||
|
||||
### The leak was one type-only import, and it is removed by deleting the dependency
|
||||
|
||||
Bisected against the real type graph rather than inferred: `@ekklesia/module-ui-sdk` imported
|
||||
`type { EkklesiaPrincipal } from "@ekklesia/contracts"`. Importing `contracts/identity` alone is
|
||||
clean, and importing Zod alone is clean — but `@ekklesia/contracts` exposes a single barrel entry,
|
||||
so that one import pulled in `module-database`, whose Drizzle types re-introduce Node's global
|
||||
declarations. A browser file importing the SDK therefore inherited `process`.
|
||||
|
||||
The runtime was never affected: the import was type-only and the emitted `host-context.js` is
|
||||
`export {};`. This was purely a type-graph leak — and a type-graph leak is still a real boundary
|
||||
failure, because it silently permits code that cannot run in the target environment.
|
||||
|
||||
The fix is not a narrower import path. `@ekklesia/module-ui-sdk` now has **no dependencies at all**,
|
||||
so there is no graph to leak through and no future contracts change can reopen this. That is the
|
||||
property worth having: the boundary holds because the package is empty of external references, not
|
||||
because today's import happens to resolve to something clean.
|
||||
|
||||
### A browser principal is not a server principal
|
||||
|
||||
`ModuleUiPrincipal` is now defined by the SDK: `{ kind: "anonymous" }` or
|
||||
`{ kind: "authenticated", userId: string }`. The shape ADR 0022 chose is preserved; only the brand is
|
||||
dropped.
|
||||
|
||||
That is a deliberate contract change, not a convenience. Core's `EkklesiaUserId` is a Zod-branded
|
||||
string, and the brand encodes a *server-side parsing* guarantee that a browser cannot produce without
|
||||
running Zod — which ADR 0022 already identified as the reason `permissions.has` takes a plain string.
|
||||
A browser-facing identity should be plain and serializable; a server type that drags an ORM's types
|
||||
behind it is not a browser contract merely because it is convenient to reuse.
|
||||
|
||||
The two remain structurally compatible: a branded string is assignable to `string`, so an Ekklesia
|
||||
host passes its own `EkklesiaPrincipal` straight into the context with no conversion, while a
|
||||
standalone host builds the same object literal from whatever identity system it has. No field was
|
||||
added. Nothing server-internal is exposed to gain type reuse.
|
||||
|
||||
### The generic loader lives in its own package
|
||||
|
||||
`@ekklesia/module-ui-host` (new shared role `module-ui-host`) owns the load-and-mount lifecycle. It
|
||||
is not part of `@ekklesia/module-ui-sdk`, because the two have opposite audiences: the SDK is bundled
|
||||
into **every module artifact**, so putting host-only loader code there would ship it inside every
|
||||
module that never runs it and blur the author/host split. `pnpm boundaries` enforces the separation
|
||||
in both directions — the `module-ui-host` role is deliberately absent from the roles a module may
|
||||
depend on, and the loader itself may depend only on the SDK (plus dev-time `tooling`), never on a
|
||||
module, an app, a surface, or the contracts barrel.
|
||||
|
||||
Dependency direction is therefore: module UI → SDK; host → loader → SDK. Never module → loader, and
|
||||
never SDK → anything.
|
||||
|
||||
### A browser module URL is not a filesystem path, and this is the pass's central distinction
|
||||
|
||||
Core's discovery resolves `entrypoints.ui` to an **absolute path on the server's disk**
|
||||
(`ResolvedModuleEntrypoints.ui`). A browser cannot import that. A loader that accepted one would look
|
||||
correct in a server-side test and fail in every real host.
|
||||
|
||||
So `loadModuleUi` takes a `url` — a browser-loadable module URL — and this package deliberately does
|
||||
not know how one is obtained. Turning an installed artifact into a URL a browser may fetch is **asset
|
||||
publication**: a separate, host-specific concern. Ekklesia will need a trusted route that serves an
|
||||
installed module's compiled artifact; a standalone host may publish the same file from a CDN, a
|
||||
static directory, or its own bundle. Making the loader own publication would weld it to one host and
|
||||
recreate exactly the coupling ADR 0022 exists to prevent.
|
||||
|
||||
**Ekklesia has no such route today.** The loader is complete; publication is deferred and is the
|
||||
first thing the next pass must build. Nothing here should be read as "a host can mount an installed
|
||||
module now" — it cannot, because it has no way to obtain a URL.
|
||||
|
||||
### Dynamic import is injectable, and never `eval`
|
||||
|
||||
The default importer is `(url) => import(url)` with a non-analyzable specifier, which esbuild and
|
||||
Rollup leave as a run-time import rather than resolving at build time. That is required, not
|
||||
incidental: installed modules are unknown when a host is built, and a statically-resolved import
|
||||
would either fail or force a hardcoded registry of module imports — defeating independently
|
||||
installable modules entirely.
|
||||
|
||||
`importModule` is injectable so a host whose bundler needs a specific escape hatch (Vite's
|
||||
`@vite-ignore`, webpack's `webpackIgnore`) supplies a one-line importer instead of this package
|
||||
guessing. It is deliberately **not** implemented with `new Function` or `eval`: those defeat static
|
||||
analysis reliably but require `unsafe-eval` in a Content-Security-Policy, and loading a module should
|
||||
never cost a host its CSP.
|
||||
|
||||
### Identity is verified before anything mounts
|
||||
|
||||
The loader calls the SDK's existing `parseModuleUiExport(namespace, { id, version })` — it does not
|
||||
reimplement shape or identity checking. A missing default export, a malformed definition, a wrong
|
||||
module id, or a wrong version throws `ModuleUiContractError` with its specific code, **before**
|
||||
`mount` is called, so a stale or mismatched bundle can never render or execute module logic.
|
||||
|
||||
Contract violations propagate unwrapped; the loader adds only two codes of its own,
|
||||
`MODULE_UI_IMPORT_FAILED` and `MODULE_UI_MOUNT_FAILED`, whose messages carry no URL or caught text.
|
||||
The originating error is attached as `cause` for host diagnostics.
|
||||
|
||||
### Lifecycle: idempotent unmount, no hidden state, host owns the container
|
||||
|
||||
`loadModuleUi` returns a `MountedModuleUi` that owns its own cleanup. There is no cache, registry, or
|
||||
singleton, so a module may be mounted into several containers at once and unmounting one leaves the
|
||||
others untouched.
|
||||
|
||||
`unmount()` is idempotent: the first call runs the module's cleanup, later calls do nothing, and it
|
||||
is safe when the module returned no cleanup at all. An instance is marked unmounted *before* cleanup
|
||||
runs, so a throwing cleanup surfaces to the host exactly once and never re-runs.
|
||||
|
||||
A failed `mount` registers no cleanup, so no handle can claim a failed module is mounted. The loader
|
||||
never mutates the container — not on success, not on failure, not on unmount. The host owns the
|
||||
container; the module owns its contents; deciding what a half-rendered failure should look like is
|
||||
the host's call, not the loader's.
|
||||
|
||||
### Mounting executes trusted code in the host's page
|
||||
|
||||
Importing a module UI artifact runs ordinary same-realm JavaScript: it can reach the DOM, globals,
|
||||
and anything else the page can. **The loader is not a sandbox and does not become one by validating
|
||||
identity.** Identity validation prevents mounting the *wrong* module; it says nothing about whether a
|
||||
module is safe.
|
||||
|
||||
Publishing an artifact must never be read as evidence that it is safe. Only installed, reviewed
|
||||
modules may be published or mounted under the current trust model, and asset publication must be
|
||||
restricted accordingly when it is built. Server APIs remain the sole authority for authorization: a
|
||||
mounted module UI has gained no privilege it did not already have.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Narrowing the import to a `@ekklesia/contracts` subpath.** Rejected. It would work today —
|
||||
`identity` alone is clean — but it makes the browser boundary depend on which types a backend
|
||||
package happens to co-locate, and any future edit could reopen the hole silently.
|
||||
- **A new `@ekklesia/client-contracts` package.** Rejected: only one package originates these types,
|
||||
and the loader receives them transitively through the SDK. A package for two interfaces would be
|
||||
structure without a second consumer to justify it.
|
||||
- **Keeping `EkklesiaPrincipal` and adding Node types to the UI tsconfig.** Rejected outright: that
|
||||
hides the error instead of fixing the graph, and hands browser code a `process` that does not
|
||||
exist at run time.
|
||||
- **Putting the loader in `@ekklesia/module-ui-sdk`.** Rejected: it would ship host-only code inside
|
||||
every module artifact and erase the author/host distinction the roles exist to express.
|
||||
- **A loader that accepts Core's resolved filesystem path.** Rejected as the most tempting wrong
|
||||
answer: it would test green on a server and fail in every browser, and it would quietly make the
|
||||
loader responsible for asset publication.
|
||||
- **Implementing publication here to make the loader "complete".** Rejected: it is host-specific, it
|
||||
needs an authorization decision about who may fetch an artifact, and bundling it in would couple
|
||||
the generic loader to Ekklesia. Deferred explicitly instead of half-built.
|
||||
- **`new Function("return import(u)")` to defeat bundler analysis.** Rejected: requires `unsafe-eval`
|
||||
in a CSP. An injectable importer achieves the same result without that cost.
|
||||
- **Throwing on a second `unmount()`.** Rejected: teardown races are ordinary in UI hosts, and an
|
||||
idempotent unmount lets a host clean up without tracking whether it already did.
|
||||
- **Clearing the container on mount failure.** Rejected: the loader promises never to mutate the
|
||||
host's container, and a single unconditional rule is easier to reason about than one with an
|
||||
exception.
|
||||
|
||||
## Consequences
|
||||
|
||||
A module author importing the supported SDK now provably cannot reach Node globals — proven by
|
||||
compiling both a supported fixture and a deliberately-failing one under the exact project a module
|
||||
uses. And a host implementer has one narrow, tested lifecycle to call instead of writing dynamic
|
||||
import, identity checking, and teardown themselves.
|
||||
|
||||
The cost is that this is still infrastructure without a consumer. **Ekklesia cannot mount an
|
||||
installed module UI**, because nothing serves an installed artifact at a browser URL. The loader is
|
||||
proven against `data:` URLs built from real artifacts, which is a genuine module URL rather than a
|
||||
filesystem path — but a real host needs a real route.
|
||||
|
||||
`ModuleUiPrincipal` is also a public type change. Nothing outside this repository consumes it yet, so
|
||||
the migration cost is zero today, and an Ekklesia host's `EkklesiaPrincipal` remains assignable to it
|
||||
without conversion.
|
||||
|
||||
## Deferred
|
||||
|
||||
Not implemented or promised here: **module UI asset publication** — the trusted route that turns an
|
||||
installed module's compiled artifact into a browser URL, including who may fetch it — which is the
|
||||
prerequisite for any real host; the Dashboard shell, navigation, or grouping; a design system, theme
|
||||
tokens, or component library; stylesheet and asset loading; React runtime sharing; a standalone
|
||||
application host; module installation, marketplace, or hot loading; and every real feature module.
|
||||
|
||||
`entrypoints.jobs` still accepts source TypeScript where other executable entrypoints require
|
||||
compiled output. This pass did not centralize compiled-entrypoint validation, so that inconsistency
|
||||
is unchanged and still open. The permission-id typing gap (`permissions.has` takes a plain string, so
|
||||
a typo silently hides a control) is likewise unchanged: the browser-safe split did not produce a
|
||||
clean answer for it, and inventing one would have meant shipping generated constants this phase does
|
||||
not need.
|
||||
@@ -32,3 +32,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0021: Modules as independently hostable products](0021-modules-as-hostable-products.md) — Accepted
|
||||
- [0022: Module UI authoring model and host contract](0022-module-ui-authoring-and-host-contract.md) — Accepted
|
||||
- [0023: Module UI build pipeline](0023-module-ui-build-pipeline.md) — Accepted
|
||||
- [0024: Browser-safe module UI SDK and the generic host loader](0024-browser-safe-ui-sdk-and-host-loader.md) — Accepted
|
||||
|
||||
@@ -206,6 +206,20 @@ styling is inline style objects only, and a stylesheet import fails the build ra
|
||||
asset nothing declares. Building remains an authoring step — Core never builds, and discovery still
|
||||
only validates the artifact a module shipped. The Dashboard shell, a UI runtime, a design system, a
|
||||
stylesheet loading contract, the standalone host, and every feature module remain future work.
|
||||
|
||||
**Phase 6B pass 3 makes the browser boundary real and adds the generic host loader.**
|
||||
[ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md) removes the UI SDK's last
|
||||
dependency — one type-only import of the contracts barrel that pulled Drizzle's types, and Node's
|
||||
globals, into browser programs — so the SDK now depends on nothing and defines its own plain
|
||||
`ModuleUiPrincipal`. `@ekklesia/module-ui-host` is the loader both Ekklesia and a future standalone
|
||||
host will use: dynamic import from a browser URL, identity validated against the manifest before
|
||||
mounting, a host-owned container the loader never mutates, and an idempotent unmount. Modules may not
|
||||
depend on it.
|
||||
|
||||
The distinction that shapes the next pass: Core resolves `entrypoints.ui` to a **server filesystem
|
||||
path**, and a browser needs a **URL**. Turning an installed artifact into a browser URL is asset
|
||||
publication — host-specific, deliberately outside the loader, and not implemented. Until it exists,
|
||||
no host can mount an installed module UI.
|
||||
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
|
||||
are all unchanged.
|
||||
|
||||
|
||||
@@ -28,7 +28,12 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
([ADR 0023](decisions/0023-module-ui-build-pipeline.md)). An unsupported framework aliased behind an allowed name is resolved and rejected by its
|
||||
real target.
|
||||
- Module UI source is subject to the identical layer rules as module backend source: it may not
|
||||
import Core, an app, a surface, a builder, or another module. Core still owns the containing
|
||||
import Core, an app, a surface, a builder, or another module. It also may not import
|
||||
`@ekklesia/module-ui-host`: that package is host infrastructure, and a module bundling the loader
|
||||
would blur the author/host split ([ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md)).
|
||||
- `@ekklesia/module-ui-sdk` has no dependencies at all, and `@ekklesia/module-ui-host` depends only
|
||||
on it. Both are browser-safe by construction: neither can pull a backend type graph — and with it
|
||||
Node's global declarations — into browser code. Core still owns the containing
|
||||
application shell and all platform policy, and module UI reaches the host only through
|
||||
`@ekklesia/module-ui-sdk`.
|
||||
- A surface never imports Core or a feature module. It uses public API/client contracts.
|
||||
|
||||
@@ -76,6 +76,23 @@ The contract does leave room for a future sandboxed tier without a redesign: `mo
|
||||
context)` passes an element and a plain-data context rather than shared object graphs, so a host
|
||||
could implement it against an isolated realm. Nothing does today.
|
||||
|
||||
### Loading a module UI executes trusted code in the host page
|
||||
|
||||
`@ekklesia/module-ui-host` dynamically imports a module's compiled artifact from a URL and mounts it
|
||||
([ADR 0024](../architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md)). Once imported,
|
||||
that artifact is ordinary same-realm JavaScript: it can reach the DOM, `window`, and anything else
|
||||
the page can. **The loader is not a sandbox.** It validates that the artifact is the module the host
|
||||
expected — the right id, the right version, a well-formed default export — and refuses to mount
|
||||
anything else, but that is an integrity check against a *stale or mismatched* bundle. It is not a
|
||||
judgement about whether a module is safe, and it must never be described as one.
|
||||
|
||||
The publication side carries the real obligation, and it is not built yet. When Ekklesia gains a
|
||||
route that serves installed module artifacts as browser URLs, **the existence of an artifact on disk
|
||||
must not be what makes it servable.** Only installed, reviewed modules may be published or mounted
|
||||
under this trust model. A module UI that mounts has gained no privilege it did not already have —
|
||||
its server half already runs in the API process with the API's database authority — and server APIs
|
||||
remain the sole authority for authorization regardless of what a browser loaded.
|
||||
|
||||
### Client-side permission checks are not a security boundary
|
||||
|
||||
A host may tell a module UI what permissions it believes the viewer holds
|
||||
|
||||
@@ -231,7 +231,7 @@ independent.
|
||||
| --- | --- |
|
||||
| `moduleId` | Your module's id, so you never re-derive it from a URL |
|
||||
| `api` | `request(path, init)` against **your own** module API — see below |
|
||||
| `principal` | Who is viewing (`anonymous` or `authenticated`), for presentation |
|
||||
| `principal` | Who is viewing: `ModuleUiPrincipal`, either `{ kind: "anonymous" }` or `{ kind: "authenticated", userId }` |
|
||||
| `permissions` | `has(permissionId)` — a presentation hint, never a security check |
|
||||
|
||||
Navigation, host theme and design tokens, module configuration, organization context, and
|
||||
@@ -332,12 +332,13 @@ not match `.tsx`, and a UI project covers the browser half:
|
||||
}
|
||||
```
|
||||
|
||||
`"types": []` is deliberate: UI source should not reach for Node globals. Be aware this is a
|
||||
convention rather than an airtight guard — TypeScript still pulls global declarations in
|
||||
transitively through dependency type graphs, so `process.env` currently typechecks in a UI file that
|
||||
imports `@ekklesia/module-ui-sdk`. Nothing catches it at build time either: only
|
||||
`process.env.NODE_ENV` is substituted, so any other Node global reaches the browser and fails there.
|
||||
Treat "no Node globals in UI source" as a rule you keep, not one the compiler keeps for you.
|
||||
`"types": []` is deliberate, and as of
|
||||
[ADR 0024](../architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md) it actually holds:
|
||||
`process.env` in a UI file fails to compile even though the file imports `@ekklesia/module-ui-sdk`.
|
||||
It did not hold previously — the SDK imported `@ekklesia/contracts`, whose barrel pulled Drizzle's
|
||||
types and with them Node's globals into every module UI program. The SDK now has **no dependencies at
|
||||
all**, so importing it cannot drag a backend type graph into your browser code, and a compile test in
|
||||
that package proves both directions.
|
||||
|
||||
`noEmit` is also deliberate — TypeScript only checks types here; esbuild produces the artifact.
|
||||
|
||||
@@ -382,9 +383,11 @@ Layer rules are unchanged: module UI source may not import Core, an app, a surfa
|
||||
another module. An unsupported framework aliased behind an allowed name — `"react": "npm:vue@3"` —
|
||||
is resolved and rejected by its real target.
|
||||
|
||||
> **Still no host.** You can now author, build, and test a module UI end to end. **Nothing serves it
|
||||
> to a user yet** — the Dashboard shell does not exist, and neither does the standalone host. Your
|
||||
> artifact is proven by your own tests, not mounted in production.
|
||||
> **Still no host.** You can author, build, and test a module UI end to end, and
|
||||
> `@ekklesia/module-ui-host` can load and mount a built artifact from a browser URL. **Nothing serves
|
||||
> your artifact at such a URL yet** — Ekklesia has no module-asset publication route, and the
|
||||
> Dashboard shell and standalone host do not exist. Your artifact is proven by tests, not mounted in
|
||||
> production. See [How a host loads your UI](#how-a-host-loads-your-ui).
|
||||
|
||||
### What this does and does not do today
|
||||
|
||||
@@ -398,6 +401,49 @@ Core also never *builds* anything. Building is an authoring and packaging step y
|
||||
discovers the compiled artifact you shipped and validates it. Installing a module does not trigger a
|
||||
build, and neither does starting the API.
|
||||
|
||||
### How a host loads your UI
|
||||
|
||||
You do not write this — a host does — but knowing the shape explains what your artifact must satisfy.
|
||||
`@ekklesia/module-ui-host` is the generic loader both Ekklesia and a future standalone host use
|
||||
([ADR 0024](../architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md)):
|
||||
|
||||
```text
|
||||
compiled artifact (dist/ui/index.js)
|
||||
↓ asset publication — host-specific, NOT YET IMPLEMENTED for Ekklesia
|
||||
browser-loadable module URL
|
||||
↓ loadModuleUi({ source, container, context })
|
||||
dynamic import → parseModuleUiExport → identity checked against the manifest
|
||||
↓
|
||||
mount(container, context)
|
||||
↓
|
||||
MountedModuleUi.unmount() // idempotent
|
||||
```
|
||||
|
||||
```ts
|
||||
const mounted = await loadModuleUi({
|
||||
source: { moduleId: "example", version: "0.1.0", url: publishedUrl },
|
||||
container,
|
||||
context,
|
||||
});
|
||||
await mounted.unmount();
|
||||
```
|
||||
|
||||
What that means for you as a module author:
|
||||
|
||||
- **Your `id` and `version` must match your manifest.** They are checked *before* `mount` runs, so a
|
||||
stale artifact is rejected rather than rendered.
|
||||
- **Return an unmount function if you acquire anything.** The host calls it once; calling it again is
|
||||
a no-op. Returning nothing is fine if you acquired nothing.
|
||||
- **If your `mount` throws, nothing is registered as mounted** and the host is told the mount failed.
|
||||
The loader does not clear your container — the host owns it and decides what a failure looks like.
|
||||
- **Your UI may be mounted more than once**, into different containers, if your implementation
|
||||
supports it. Each mount gets its own cleanup.
|
||||
|
||||
> **Asset publication is the missing piece.** Core resolves `entrypoints.ui` to a path on the
|
||||
> *server's filesystem*; a browser cannot import that. Something must serve your compiled artifact at
|
||||
> a URL first, and Ekklesia has no such route yet. Until it does, no host can mount an installed
|
||||
> module's UI — the loader is ready, the plumbing to reach it is not.
|
||||
|
||||
### What a module UI will never own
|
||||
|
||||
A module UI consumes platform policy and never redefines it.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@ekklesia/module-ui-host",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"lint": "biome lint .",
|
||||
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-ui-host",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ekklesia/module-ui-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@ekklesia/module-ui-build": "workspace:*",
|
||||
"@types/node": "24.13.3",
|
||||
"happy-dom": "20.0.11",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"ekklesia": {
|
||||
"layer": "module-ui-host"
|
||||
}
|
||||
}
|
||||
@@ -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?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
/**
|
||||
* The host loader runs in a browser too, so it inherits the same rule as the authoring SDK: it must
|
||||
* not reach a backend type graph. Its only dependency is the module-author contract (ADR 0024).
|
||||
*/
|
||||
describe("module UI host loader stays browser-safe", () => {
|
||||
it("declares exactly one runtime dependency: the module UI SDK", async () => {
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
|
||||
) as { dependencies?: Record<string, string>; peerDependencies?: Record<string, string> };
|
||||
|
||||
expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/module-ui-sdk"]);
|
||||
expect(Object.keys(manifest.peerDependencies ?? {})).toEqual([]);
|
||||
});
|
||||
|
||||
it("imports nothing beyond the module UI SDK in its source", async () => {
|
||||
const source = await readFile(path.join(packageDirectory, "src", "load-module-ui.ts"), "utf8");
|
||||
const external = [...source.matchAll(/from\s+"([^"]+)"/gmu)]
|
||||
.map(([, specifier]) => specifier)
|
||||
.filter((specifier) => specifier !== undefined && !specifier.startsWith("."));
|
||||
|
||||
expect([...new Set(external)]).toEqual(["@ekklesia/module-ui-sdk"]);
|
||||
});
|
||||
|
||||
it("never names a Node built-in or an Ekklesia URL prefix", async () => {
|
||||
const source = await readFile(path.join(packageDirectory, "src", "load-module-ui.ts"), "utf8");
|
||||
|
||||
expect(source).not.toContain('"node:');
|
||||
// The loader forwards a host-supplied URL; it must never construct one.
|
||||
expect(source).not.toMatch(/["'`]\/api\/v1\//u);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"manifestVersion": 4,
|
||||
"id": "example",
|
||||
"name": "Example",
|
||||
"version": "0.1.0",
|
||||
"compatibility": {
|
||||
"platform": "^0.1.0",
|
||||
"moduleSdk": "^0.1.0"
|
||||
},
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"ui": "./dist/ui/index.js"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
||||
|
||||
/**
|
||||
* A real module UI compiled by the canonical pipeline. It imports the public SDK so the built
|
||||
* artifact proves the whole loop closes: SDK source -> esbuild bundle -> browser URL -> host loader.
|
||||
*
|
||||
* React is deliberately absent — the module template already proves the React path end to end, and
|
||||
* omitting it keeps this artifact small enough to serve as a `data:` URL.
|
||||
*/
|
||||
export default defineModuleUi({
|
||||
id: "example",
|
||||
version: "0.1.0",
|
||||
async mount(container, context) {
|
||||
const response = await context.api.request("records");
|
||||
const label = context.principal.kind === "authenticated" ? context.principal.userId : "guest";
|
||||
container.textContent = `${context.moduleId}:${response.status}:${label}`;
|
||||
return () => {
|
||||
container.textContent = "";
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { ModuleUiContractError, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadModuleUi, ModuleUiHostError, type ModuleUiImporter } from "../src/index.js";
|
||||
|
||||
/**
|
||||
* A browser-loadable URL for a module built from source text.
|
||||
*
|
||||
* `data:` URLs are genuine module URLs that both browsers and Node import natively, so these tests
|
||||
* exercise the loader's **default** `import(url)` against a real URL rather than a filesystem path.
|
||||
* That matters: Core resolves `entrypoints.ui` to a server filesystem path, and a loader proven only
|
||||
* against such a path would not prove anything a browser host could rely on (ADR 0024).
|
||||
*/
|
||||
function moduleUrl(source: string): string {
|
||||
return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`;
|
||||
}
|
||||
|
||||
const validArtifact = (options: { id?: string; version?: string; cleanup?: boolean } = {}) => `
|
||||
export default {
|
||||
id: ${JSON.stringify(options.id ?? "announcements")},
|
||||
version: ${JSON.stringify(options.version ?? "1.2.0")},
|
||||
mount(container, context) {
|
||||
globalThis.__mounts ??= [];
|
||||
globalThis.__mounts.push({ moduleId: context.moduleId, container });
|
||||
container.textContent = "mounted:" + context.moduleId;
|
||||
void context.api.request("posts");
|
||||
${
|
||||
options.cleanup === false
|
||||
? ""
|
||||
: `return () => { globalThis.__unmounts ??= []; globalThis.__unmounts.push(context.moduleId); container.textContent = ""; };`
|
||||
}
|
||||
},
|
||||
};
|
||||
`;
|
||||
|
||||
function hostContext(requested: string[], moduleId = "announcements"): ModuleUiHostContext {
|
||||
return {
|
||||
moduleId,
|
||||
api: {
|
||||
async request(path) {
|
||||
requested.push(path);
|
||||
return new Response(null, { status: 200 });
|
||||
},
|
||||
},
|
||||
principal: { kind: "anonymous" },
|
||||
permissions: { has: () => true },
|
||||
};
|
||||
}
|
||||
|
||||
function container(): Element {
|
||||
const element = document.createElement("div");
|
||||
document.body.append(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
const source = { moduleId: "announcements", version: "1.2.0" };
|
||||
|
||||
describe("generic module UI host loader", () => {
|
||||
it("imports a browser URL with the default importer, mounts, and reports identity", async () => {
|
||||
const element = container();
|
||||
const requested: string[] = [];
|
||||
|
||||
const mounted = await loadModuleUi({
|
||||
source: { ...source, url: moduleUrl(validArtifact()) },
|
||||
container: element,
|
||||
context: hostContext(requested),
|
||||
});
|
||||
|
||||
expect(mounted.moduleId).toBe("announcements");
|
||||
expect(mounted.version).toBe("1.2.0");
|
||||
expect(mounted.mounted).toBe(true);
|
||||
expect(element.textContent).toBe("mounted:announcements");
|
||||
});
|
||||
|
||||
it("passes the host's own container and context straight through", async () => {
|
||||
const element = container();
|
||||
const requested: string[] = [];
|
||||
const context = hostContext(requested);
|
||||
|
||||
await loadModuleUi({
|
||||
source: { ...source, url: moduleUrl(validArtifact()) },
|
||||
container: element,
|
||||
context,
|
||||
});
|
||||
|
||||
const mounts = (globalThis as { __mounts?: { moduleId: string; container: Element }[] })
|
||||
.__mounts;
|
||||
expect(mounts?.at(-1)?.container).toBe(element);
|
||||
expect(mounts?.at(-1)?.moduleId).toBe("announcements");
|
||||
});
|
||||
|
||||
it("keeps the module's API calls relative — the loader adds no URL knowledge", async () => {
|
||||
const element = container();
|
||||
const requested: string[] = [];
|
||||
|
||||
await loadModuleUi({
|
||||
source: { ...source, url: moduleUrl(validArtifact()) },
|
||||
container: element,
|
||||
context: hostContext(requested),
|
||||
});
|
||||
|
||||
expect(requested).toEqual(["posts"]);
|
||||
expect(requested.join(" ")).not.toContain("/api/v1/modules");
|
||||
});
|
||||
|
||||
it("runs the module's cleanup on unmount without touching the host container", async () => {
|
||||
const element = container();
|
||||
const parent = element.parentElement;
|
||||
|
||||
const mounted = await loadModuleUi({
|
||||
source: { ...source, url: moduleUrl(validArtifact()) },
|
||||
container: element,
|
||||
context: hostContext([]),
|
||||
});
|
||||
await mounted.unmount();
|
||||
|
||||
expect(mounted.mounted).toBe(false);
|
||||
expect(element.textContent).toBe("");
|
||||
// The host owns the container; unmounting a module never removes it.
|
||||
expect(element.parentElement).toBe(parent);
|
||||
expect(document.body.contains(element)).toBe(true);
|
||||
});
|
||||
|
||||
it("is idempotent: a second unmount does nothing", async () => {
|
||||
const element = container();
|
||||
const before = ((globalThis as { __unmounts?: string[] }).__unmounts ?? []).length;
|
||||
|
||||
const mounted = await loadModuleUi({
|
||||
source: { ...source, url: moduleUrl(validArtifact()) },
|
||||
container: element,
|
||||
context: hostContext([]),
|
||||
});
|
||||
await mounted.unmount();
|
||||
await mounted.unmount();
|
||||
await mounted.unmount();
|
||||
|
||||
const after = ((globalThis as { __unmounts?: string[] }).__unmounts ?? []).length;
|
||||
expect(after - before).toBe(1);
|
||||
expect(mounted.mounted).toBe(false);
|
||||
});
|
||||
|
||||
it("unmounts safely when the module returned no cleanup function", async () => {
|
||||
const element = container();
|
||||
|
||||
const mounted = await loadModuleUi({
|
||||
source: { ...source, url: moduleUrl(validArtifact({ cleanup: false })) },
|
||||
container: element,
|
||||
context: hostContext([]),
|
||||
});
|
||||
|
||||
await expect(mounted.unmount()).resolves.toBeUndefined();
|
||||
expect(mounted.mounted).toBe(false);
|
||||
});
|
||||
|
||||
it("marks an instance unmounted even when its cleanup throws, so it never re-runs", async () => {
|
||||
const element = container();
|
||||
const artifact = `
|
||||
export default {
|
||||
id: "announcements", version: "1.2.0",
|
||||
mount() { return () => { globalThis.__cleanupCalls = (globalThis.__cleanupCalls ?? 0) + 1; throw new Error("cleanup exploded"); }; },
|
||||
};`;
|
||||
|
||||
const mounted = await loadModuleUi({
|
||||
source: { ...source, url: moduleUrl(artifact) },
|
||||
container: element,
|
||||
context: hostContext([]),
|
||||
});
|
||||
|
||||
await expect(mounted.unmount()).rejects.toThrow("cleanup exploded");
|
||||
expect(mounted.mounted).toBe(false);
|
||||
await expect(mounted.unmount()).resolves.toBeUndefined();
|
||||
expect((globalThis as { __cleanupCalls?: number }).__cleanupCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("supports two independent instances in two containers", async () => {
|
||||
const first = container();
|
||||
const second = container();
|
||||
const url = moduleUrl(validArtifact());
|
||||
|
||||
const a = await loadModuleUi({
|
||||
source: { ...source, url },
|
||||
container: first,
|
||||
context: hostContext([]),
|
||||
});
|
||||
const b = await loadModuleUi({
|
||||
source: { ...source, url },
|
||||
container: second,
|
||||
context: hostContext([]),
|
||||
});
|
||||
|
||||
expect(first.textContent).toBe("mounted:announcements");
|
||||
expect(second.textContent).toBe("mounted:announcements");
|
||||
|
||||
await a.unmount();
|
||||
|
||||
// Each handle owns its own cleanup state; unmounting one leaves the other alone.
|
||||
expect(a.mounted).toBe(false);
|
||||
expect(b.mounted).toBe(true);
|
||||
expect(first.textContent).toBe("");
|
||||
expect(second.textContent).toBe("mounted:announcements");
|
||||
});
|
||||
|
||||
describe("rejects an artifact before mounting it", () => {
|
||||
it("when the module id does not match the manifest", async () => {
|
||||
const element = container();
|
||||
const url = moduleUrl(validArtifact({ id: "calendar" }));
|
||||
|
||||
await expect(
|
||||
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
|
||||
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
||||
expect(element.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("when the version does not match the manifest", async () => {
|
||||
const element = container();
|
||||
const url = moduleUrl(validArtifact({ version: "1.1.0" }));
|
||||
|
||||
await expect(
|
||||
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
|
||||
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
||||
expect(element.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("when there is no default export", async () => {
|
||||
const element = container();
|
||||
const url = moduleUrl("export const ui = {};");
|
||||
|
||||
await expect(
|
||||
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
|
||||
).rejects.toMatchObject({ code: "MODULE_UI_EXPORT_MISSING" });
|
||||
});
|
||||
|
||||
it("when the default export is not a module UI definition", async () => {
|
||||
const element = container();
|
||||
const url = moduleUrl('export default { id: "announcements", version: "1.2.0" };');
|
||||
|
||||
const failure = loadModuleUi({
|
||||
source: { ...source, url },
|
||||
container: element,
|
||||
context: hostContext([]),
|
||||
});
|
||||
await expect(failure).rejects.toBeInstanceOf(ModuleUiContractError);
|
||||
await expect(failure).rejects.toMatchObject({ code: "MODULE_UI_DEFINITION_INVALID" });
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an unreachable artifact as an import failure, with the cause attached", async () => {
|
||||
const element = container();
|
||||
|
||||
try {
|
||||
await loadModuleUi({
|
||||
source: { ...source, url: "data:text/javascript,this is not(valid javascript" },
|
||||
container: element,
|
||||
context: hostContext([]),
|
||||
});
|
||||
expect.unreachable("expected the import to fail");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ModuleUiHostError);
|
||||
expect((error as ModuleUiHostError).code).toBe("MODULE_UI_IMPORT_FAILED");
|
||||
expect((error as ModuleUiHostError).message).not.toContain("data:");
|
||||
expect((error as ModuleUiHostError).cause).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a throwing mount as a mount failure and registers no cleanup", async () => {
|
||||
const element = container();
|
||||
const url = moduleUrl(`
|
||||
export default {
|
||||
id: "announcements", version: "1.2.0",
|
||||
mount(container) { container.textContent = "partial"; throw new Error("mount exploded"); },
|
||||
};`);
|
||||
|
||||
try {
|
||||
await loadModuleUi({
|
||||
source: { ...source, url },
|
||||
container: element,
|
||||
context: hostContext([]),
|
||||
});
|
||||
expect.unreachable("expected 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((error as { cause?: Error }).cause?.message).toBe("mount exploded");
|
||||
}
|
||||
// No handle exists, so nothing can claim the module is mounted. The container is left exactly
|
||||
// as the module left it: the host owns it and decides what a failed mount looks like.
|
||||
expect(element.textContent).toBe("partial");
|
||||
});
|
||||
|
||||
it("accepts a host-supplied importer for bundlers that need their own escape hatch", async () => {
|
||||
const element = container();
|
||||
const seen: string[] = [];
|
||||
const importModule: ModuleUiImporter = async (url) => {
|
||||
seen.push(url);
|
||||
return { default: { id: "announcements", version: "1.2.0", mount: () => undefined } };
|
||||
};
|
||||
|
||||
const mounted = await loadModuleUi({
|
||||
source: { ...source, url: "https://assets.example.test/modules/announcements/ui.js" },
|
||||
container: element,
|
||||
context: hostContext([]),
|
||||
importModule,
|
||||
});
|
||||
|
||||
expect(seen).toEqual(["https://assets.example.test/modules/announcements/ui.js"]);
|
||||
expect(mounted.mounted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
// @vitest-environment happy-dom
|
||||
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 { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadModuleUi } from "../src/index.js";
|
||||
|
||||
const fixtureDirectory = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"fixtures",
|
||||
"artifact-module",
|
||||
);
|
||||
|
||||
let artifactUrl: string;
|
||||
let artifactSource: string;
|
||||
|
||||
/** An Ekklesia-shaped host: module routes live under a Core-owned prefix. */
|
||||
function ekklesiaContext(requested: string[]): ModuleUiHostContext {
|
||||
return {
|
||||
moduleId: "example",
|
||||
api: {
|
||||
async request(pathname) {
|
||||
requested.push(`/api/v1/modules/example/${pathname.replace(/^\//u, "")}`);
|
||||
return new Response(null, { status: 200 });
|
||||
},
|
||||
},
|
||||
principal: { kind: "authenticated", userId: "user-123" },
|
||||
permissions: { has: () => true },
|
||||
};
|
||||
}
|
||||
|
||||
/** A standalone host: one module, a different origin, a completely different URL shape, no Core. */
|
||||
function standaloneContext(requested: string[]): ModuleUiHostContext {
|
||||
return {
|
||||
moduleId: "example",
|
||||
api: {
|
||||
async request(pathname) {
|
||||
requested.push(`https://example.test/api/${pathname.replace(/^\//u, "")}`);
|
||||
return new Response(null, { status: 200 });
|
||||
},
|
||||
},
|
||||
principal: { kind: "anonymous" },
|
||||
permissions: { has: () => false },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Proves the loader consumes exactly the browser ESM artifact `@ekklesia/module-ui-build` produces
|
||||
* (ADR 0023) — no second output format, no checked-in build output. The artifact is rebuilt from
|
||||
* source each run and served through a `data:` URL, a real module URL rather than a filesystem path.
|
||||
*/
|
||||
describe("loading a real compiled artifact", () => {
|
||||
beforeAll(async () => {
|
||||
const result = await buildModuleUi({ moduleDirectory: fixtureDirectory });
|
||||
artifactSource = await readFile(result.outputFile, "utf8");
|
||||
artifactUrl = `data:text/javascript;base64,${Buffer.from(artifactSource, "utf8").toString("base64")}`;
|
||||
}, 60_000);
|
||||
|
||||
it("produces a self-contained artifact with no bare imports for a host to resolve", () => {
|
||||
expect(artifactSource).not.toMatch(/^import\s.*from\s*["'][^.]/mu);
|
||||
expect(artifactSource).toContain("export {");
|
||||
expect(artifactSource).not.toContain("/api/v1/modules");
|
||||
});
|
||||
|
||||
it("mounts the real artifact through the generic loader and unmounts it", async () => {
|
||||
const container = document.createElement("div");
|
||||
const requested: string[] = [];
|
||||
|
||||
const mounted = await loadModuleUi({
|
||||
source: { moduleId: "example", version: "0.1.0", url: artifactUrl },
|
||||
container,
|
||||
context: ekklesiaContext(requested),
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe("example:200:user-123");
|
||||
await mounted.unmount();
|
||||
expect(container.textContent).toBe("");
|
||||
expect(mounted.mounted).toBe(false);
|
||||
});
|
||||
|
||||
it("still rejects the real artifact when the host expects a different version", async () => {
|
||||
await expect(
|
||||
loadModuleUi({
|
||||
source: { moduleId: "example", version: "0.2.0", url: artifactUrl },
|
||||
container: document.createElement("div"),
|
||||
context: ekklesiaContext([]),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
||||
});
|
||||
|
||||
it("runs the same artifact under an Ekklesia host and a standalone host", async () => {
|
||||
const ekklesiaRequests: string[] = [];
|
||||
const standaloneRequests: string[] = [];
|
||||
const ekklesiaContainer = document.createElement("div");
|
||||
const standaloneContainer = document.createElement("div");
|
||||
const source = { moduleId: "example", version: "0.1.0", url: artifactUrl };
|
||||
|
||||
const inEkklesia = await loadModuleUi({
|
||||
source,
|
||||
container: ekklesiaContainer,
|
||||
context: ekklesiaContext(ekklesiaRequests),
|
||||
});
|
||||
const inStandalone = await loadModuleUi({
|
||||
source,
|
||||
container: standaloneContainer,
|
||||
context: standaloneContext(standaloneRequests),
|
||||
});
|
||||
|
||||
// One artifact, one loader, two hosts. Every difference lives in the context the host supplied:
|
||||
// the module asked for "records" both times and resolved nothing itself.
|
||||
expect(ekklesiaRequests).toEqual(["/api/v1/modules/example/records"]);
|
||||
expect(standaloneRequests).toEqual(["https://example.test/api/records"]);
|
||||
expect(ekklesiaContainer.textContent).toBe("example:200:user-123");
|
||||
expect(standaloneContainer.textContent).toBe("example:200:guest");
|
||||
|
||||
await inEkklesia.unmount();
|
||||
await inStandalone.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../typescript-config/browser.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../typescript-config/browser.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["tests/**/*.ts"]
|
||||
}
|
||||
@@ -16,13 +16,11 @@
|
||||
"lint": "biome lint .",
|
||||
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-ui-sdk",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ekklesia/contracts": "workspace:*"
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,33 @@
|
||||
import type { EkklesiaPrincipal } from "@ekklesia/contracts";
|
||||
/**
|
||||
* Who is viewing, as a browser sees it.
|
||||
*
|
||||
* This is deliberately **not** Core's `EkklesiaPrincipal`, and the difference is the point. That
|
||||
* type's `userId` is a Zod-branded string whose brand only means something to server-side parsing,
|
||||
* and reaching for it made this package import `@ekklesia/contracts` — a backend barrel whose type
|
||||
* graph pulls Drizzle, and with it Node's global declarations, back into every module UI program
|
||||
* (measured: a UI file importing this package could typecheck `process.env`). A browser contract
|
||||
* that drags a database ORM's types behind it is not a browser contract.
|
||||
*
|
||||
* So the shape is preserved and the brand is dropped: plain, serializable, and exactly what a UI
|
||||
* needs to decide what to render. An Ekklesia host can pass its own `EkklesiaPrincipal` straight in
|
||||
* — a branded string is assignable to `string`, so the two remain structurally compatible without
|
||||
* this package knowing anything about the server's representation. A standalone host builds the
|
||||
* same object literal from whatever identity system it has.
|
||||
*
|
||||
* Nothing server-internal belongs here. A field is added only when module UI provably needs it to
|
||||
* render something.
|
||||
*/
|
||||
export interface ModuleUiAnonymousPrincipal {
|
||||
readonly kind: "anonymous";
|
||||
}
|
||||
|
||||
export interface ModuleUiAuthenticatedPrincipal {
|
||||
readonly kind: "authenticated";
|
||||
/** Opaque to module UI: an identifier to correlate with, never to parse or display verbatim. */
|
||||
readonly userId: string;
|
||||
}
|
||||
|
||||
export type ModuleUiPrincipal = ModuleUiAnonymousPrincipal | ModuleUiAuthenticatedPrincipal;
|
||||
|
||||
/**
|
||||
* Everything a host hands a module's UI, and the complete list of what module UI may assume exists.
|
||||
@@ -10,7 +39,8 @@ import type { EkklesiaPrincipal } from "@ekklesia/contracts";
|
||||
* than to add. See ADR 0022.
|
||||
*
|
||||
* Nothing in this object is an Ekklesia implementation. Every member is satisfiable by a standalone
|
||||
* host that never runs Core, which is what makes one module UI hostable by both.
|
||||
* host that never runs Core, which is what makes one module UI hostable by both. This package has
|
||||
* no dependencies at all, so importing it cannot pull a backend type graph into a browser program.
|
||||
*/
|
||||
export interface ModuleUiHostContext {
|
||||
/** The module this UI belongs to, as the host knows it. Supplied so a module never has to hardcode
|
||||
@@ -18,9 +48,9 @@ export interface ModuleUiHostContext {
|
||||
readonly moduleId: string;
|
||||
/** The only supported way for module UI to reach its own backend. See {@link ModuleUiApiClient}. */
|
||||
readonly api: ModuleUiApiClient;
|
||||
/** Who is viewing, for presentation decisions only. Reuses Core's own principal type so a host and
|
||||
* a module UI cannot disagree about its shape. */
|
||||
readonly principal: EkklesiaPrincipal;
|
||||
/** Who is viewing, for presentation decisions only. See {@link ModuleUiPrincipal} for why this is
|
||||
* a browser-owned shape rather than Core's server principal type. */
|
||||
readonly principal: ModuleUiPrincipal;
|
||||
/** Presentation-only permission hints. See {@link ModuleUiPermissions}. */
|
||||
readonly permissions: ModuleUiPermissions;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const run = promisify(execFile);
|
||||
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
const fixtureDirectory = path.join(packageDirectory, "tests", "browser-boundary");
|
||||
const typescript = path.join(packageDirectory, "node_modules", ".bin", "tsc");
|
||||
|
||||
async function typecheck(project: string): Promise<{ code: number; output: string }> {
|
||||
try {
|
||||
await run(typescript, ["--noEmit", "--project", path.join(fixtureDirectory, project)]);
|
||||
return { code: 0, output: "" };
|
||||
} catch (error) {
|
||||
const failure = error as { code?: number; stdout?: string; stderr?: string };
|
||||
return { code: failure.code ?? 1, output: `${failure.stdout ?? ""}${failure.stderr ?? ""}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser boundary, tested the way a module author experiences it rather than by inspecting the
|
||||
* dependency graph.
|
||||
*
|
||||
* Both fixtures compile under the *exact* project a module uses for UI source —
|
||||
* `browser-react.json` with `"types": []`. Before this package dropped its `@ekklesia/contracts`
|
||||
* dependency, the negative fixture below compiled successfully: that import reached a backend
|
||||
* barrel whose Drizzle types re-introduced Node's global declarations, so `process.env` typechecked
|
||||
* inside browser-only code (ADR 0024).
|
||||
*/
|
||||
describe("module UI SDK browser boundary", () => {
|
||||
it("compiles supported UI source that imports the public SDK", async () => {
|
||||
const result = await typecheck("tsconfig.supported.json");
|
||||
|
||||
expect(result.output).toBe("");
|
||||
expect(result.code).toBe(0);
|
||||
}, 60_000);
|
||||
|
||||
it("refuses Node globals in UI source that imports the public SDK", async () => {
|
||||
const result = await typecheck("tsconfig.node-global.json");
|
||||
|
||||
expect(result.code).not.toBe(0);
|
||||
// Specifically because `process` does not exist — not because of an unrelated error.
|
||||
expect(result.output).toContain("Cannot find name 'process'");
|
||||
expect(result.output).toContain("node-global.tsx");
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
// The negative case. Importing the public SDK must NOT make Node globals visible. If this file
|
||||
// ever typechecks, the browser boundary has regressed and `browser-boundary.test.ts` fails.
|
||||
import type { ModuleUiHostContext } from "../../src/index.js";
|
||||
|
||||
export type Context = ModuleUiHostContext;
|
||||
export const leaked: string | undefined = process.env.NODE_ENV;
|
||||
@@ -0,0 +1,24 @@
|
||||
// A module author's supported UI source: it imports the public SDK and must typecheck cleanly
|
||||
// under the exact browser project a module uses (`browser-react.json`, `"types": []`).
|
||||
import {
|
||||
defineModuleUi,
|
||||
type ModuleUiHostContext,
|
||||
type ModuleUiPrincipal,
|
||||
} from "../../src/index.js";
|
||||
|
||||
function describePrincipal(principal: ModuleUiPrincipal): string {
|
||||
return principal.kind === "authenticated" ? principal.userId : "anonymous";
|
||||
}
|
||||
|
||||
export default defineModuleUi({
|
||||
id: "example",
|
||||
version: "0.1.0",
|
||||
mount(container: Element, context: ModuleUiHostContext) {
|
||||
container.textContent = describePrincipal(context.principal);
|
||||
void context.api.request("records");
|
||||
void context.permissions.has("example.records.read");
|
||||
return () => {
|
||||
container.textContent = "";
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../../typescript-config/browser-react.json",
|
||||
"compilerOptions": { "noEmit": true, "types": [] },
|
||||
"include": ["node-global.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../../typescript-config/browser-react.json",
|
||||
"compilerOptions": { "noEmit": true, "types": [] },
|
||||
"include": ["supported.tsx"]
|
||||
}
|
||||
@@ -18,29 +18,30 @@ async function sourceFiles(): Promise<string[]> {
|
||||
* way; `import type` is erased at build time, a plain `import` is not.
|
||||
*/
|
||||
describe("module UI SDK stays browser-safe", () => {
|
||||
it("imports nothing from another workspace package at runtime", async () => {
|
||||
it("imports nothing from another package at all, at type level or runtime", async () => {
|
||||
// Stronger than "type-only imports are fine". A type-only import is erased at runtime but still
|
||||
// drags the imported package's *type graph* into every consuming program — which is exactly how
|
||||
// Node globals reached browser-only module UI before ADR 0024. This package now imports nothing
|
||||
// external, so there is no graph to drag.
|
||||
for (const file of await sourceFiles()) {
|
||||
const source = await readFile(path.join(sourceDirectory, file), "utf8");
|
||||
const workspaceImports = [
|
||||
...source.matchAll(/^import\s+(.*?)\s+from\s+"(@ekklesia\/[^"]+)"/gmu),
|
||||
];
|
||||
const externalImports = [...source.matchAll(/^(?:import|export)\s[^;]*?from\s+"([^"]+)"/gmu)]
|
||||
.map(([, specifier]) => specifier)
|
||||
.filter((specifier) => specifier !== undefined && !specifier.startsWith("."));
|
||||
|
||||
for (const [, clause, specifier] of workspaceImports) {
|
||||
expect(
|
||||
clause?.startsWith("type "),
|
||||
`${file} imports ${specifier} at runtime; it must be a type-only import`,
|
||||
).toBe(true);
|
||||
}
|
||||
expect(externalImports, `${file} imports outside this package`).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares no runtime dependency other than the type-only contracts package", async () => {
|
||||
it("declares no dependencies whatsoever", async () => {
|
||||
const manifestPath = path.join(sourceDirectory, "..", "package.json");
|
||||
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/contracts"]);
|
||||
expect(Object.keys(manifest.dependencies ?? {})).toEqual([]);
|
||||
expect(Object.keys(manifest.peerDependencies ?? {})).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not depend on a UI framework, so the contract stays framework-neutral", async () => {
|
||||
@@ -61,5 +62,9 @@ describe("module UI SDK stays browser-safe", () => {
|
||||
for (const framework of ["react", "react-dom", "vue", "svelte", "preact"]) {
|
||||
expect(declared).not.toContain(framework);
|
||||
}
|
||||
// Backend graphs must not reappear through a development dependency either.
|
||||
for (const backend of ["drizzle-orm", "zod", "semver", "@ekklesia/contracts"]) {
|
||||
expect(declared).not.toContain(backend);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"sourceMap": true,
|
||||
"types": []
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "tests"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../typescript-config/browser.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["vitest/globals"]
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../typescript-config/browser.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["tests/**/*.ts"]
|
||||
}
|
||||
Generated
+27
-3
@@ -183,15 +183,39 @@ importers:
|
||||
specifier: 4.1.10
|
||||
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
|
||||
packages/module-ui-sdk:
|
||||
packages/module-ui-host:
|
||||
dependencies:
|
||||
'@ekklesia/contracts':
|
||||
'@ekklesia/module-ui-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../contracts
|
||||
version: link:../module-ui-sdk
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.5.3
|
||||
version: 2.5.3
|
||||
'@ekklesia/module-ui-build':
|
||||
specifier: workspace:*
|
||||
version: link:../module-ui-build
|
||||
'@types/node':
|
||||
specifier: 24.13.3
|
||||
version: 24.13.3
|
||||
happy-dom:
|
||||
specifier: 20.0.11
|
||||
version: 20.0.11
|
||||
typescript:
|
||||
specifier: 7.0.2
|
||||
version: 7.0.2
|
||||
vitest:
|
||||
specifier: 4.1.10
|
||||
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
|
||||
packages/module-ui-sdk:
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.5.3
|
||||
version: 2.5.3
|
||||
'@types/node':
|
||||
specifier: 24.13.3
|
||||
version: 24.13.3
|
||||
typescript:
|
||||
specifier: 7.0.2
|
||||
version: 7.0.2
|
||||
|
||||
@@ -152,6 +152,7 @@ const SHARED_PACKAGE_ROLES = new Set([
|
||||
"contracts",
|
||||
"design-tokens",
|
||||
"module-sdk",
|
||||
"module-ui-host",
|
||||
"module-ui-sdk",
|
||||
"shared",
|
||||
"surface-sdk",
|
||||
@@ -254,7 +255,9 @@ function boundaryRule(from: Location, to: Location): string | undefined {
|
||||
|
||||
if (
|
||||
from.layer === "shared" &&
|
||||
["builder-sdk", "module-sdk", "module-ui-sdk", "surface-sdk"].includes(from.role ?? "") &&
|
||||
["builder-sdk", "module-sdk", "module-ui-host", "module-ui-sdk", "surface-sdk"].includes(
|
||||
from.role ?? "",
|
||||
) &&
|
||||
["app", "builder", "core", "module", "surface"].includes(to.layer)
|
||||
) {
|
||||
return "sdks-do-not-depend-on-implementations";
|
||||
@@ -270,6 +273,13 @@ function boundaryRule(from: Location, to: Location): string | undefined {
|
||||
) {
|
||||
return "sdks-do-not-depend-on-implementations";
|
||||
}
|
||||
// The host loader consumes the module-author contract — that is its whole job — plus dev-time
|
||||
// `tooling` (it builds a real artifact in its own tests through the canonical pipeline rather
|
||||
// than reimplementing one). It may not reach further: never a module, an app, a surface, or the
|
||||
// backend contracts barrel, which is what keeps it browser-safe (ADR 0024).
|
||||
if (from.role === "module-ui-host" && !["module-ui-sdk", "tooling"].includes(to.role ?? "")) {
|
||||
return "sdks-do-not-depend-on-implementations";
|
||||
}
|
||||
if (from.role === "builder-sdk" && !["contracts", "surface-sdk"].includes(to.role ?? "")) {
|
||||
return "sdks-do-not-depend-on-implementations";
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ describe("repository dependency boundaries", () => {
|
||||
["module-ui-aliased-framework", "modules-do-not-own-ui"],
|
||||
["module-ui-imports-core", "modules-use-public-platform-contracts"],
|
||||
["module-ui-bundler-plugin", "modules-do-not-own-ui"],
|
||||
["module-imports-ui-host", "modules-use-public-platform-contracts"],
|
||||
["module-imports-ui-package", "modules-use-public-platform-contracts"],
|
||||
["cross-package-relative", "workspace-imports-use-public-exports"],
|
||||
["cycle", "no-circular-source-dependencies"],
|
||||
@@ -121,6 +122,18 @@ describe("repository dependency boundaries", () => {
|
||||
expect(result.violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the host loader out of module packages", async () => {
|
||||
// `@ekklesia/module-ui-host` is host infrastructure. A module that depended on it would bundle
|
||||
// loader code into its own artifact and blur the author/host split (ADR 0024), so the
|
||||
// `module-ui-host` role is deliberately absent from the roles a module may depend on.
|
||||
const result = await checkBoundaries(path.join(fixtures, "module-imports-ui-host"));
|
||||
|
||||
expect(result.violations.map((violation) => violation.rule)).toEqual([
|
||||
"modules-use-public-platform-contracts",
|
||||
"modules-use-public-platform-contracts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("still rejects a module reaching for a bundler plugin ecosystem", async () => {
|
||||
// Module authors do not configure a bundler; `@ekklesia/module-ui-build` owns that entirely.
|
||||
const result = await checkBoundaries(path.join(fixtures, "module-ui-bundler-plugin"));
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@ekklesia/module-calendar",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@ekklesia/module-ui-host": "workspace:*"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"ekklesia": {
|
||||
"layer": "module"
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const moduleId = "calendar";
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
||||
|
||||
export const misuse = loadModuleUi;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@ekklesia/module-ui-host",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"ekklesia": {
|
||||
"layer": "module-ui-host"
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export function loadModuleUi(): void {}
|
||||
Reference in New Issue
Block a user