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]>
201 lines
13 KiB
Markdown
201 lines
13 KiB
Markdown
# 0023: Module UI build pipeline
|
|
|
|
- Status: Accepted
|
|
- Date: 2026-08-21
|
|
- Extends: [0022](0022-module-ui-authoring-and-host-contract.md)
|
|
|
|
## Context
|
|
|
|
ADR 0022 defined what a module UI is — a compiled artifact default-exporting
|
|
`defineModuleUi({ id, version, mount })` — and selected React and TypeScript as the supported
|
|
authoring model. It closed by recording that the contract was not yet buildable end to end:
|
|
`browser.json` sets `"jsx": "preserve"`, which needs a bundler that no module had, `.css` and
|
|
bundler plugins were denied, and nothing turned a module's `.tsx` into the artifact its manifest
|
|
declares.
|
|
|
|
So a module author could read the contract but not satisfy it. This ADR closes exactly that gap and
|
|
nothing else.
|
|
|
|
## Decision
|
|
|
|
### esbuild is the module UI bundler
|
|
|
|
esbuild produces the compiled artifact. It was already the right answer before it was chosen: the
|
|
repository resolves esbuild 0.28.1 today through Vite/Vitest, and `pnpm-workspace.yaml` already
|
|
carries `allowBuilds: esbuild: true`, so its native binary is an accepted, reviewed part of this
|
|
workspace. It is pinned to that same 0.28.1 rather than a floating range, so no second copy enters
|
|
the store.
|
|
|
|
It is also the smallest tool that satisfies every requirement: TSX transformation, ESM output,
|
|
browser platform, bundling, source maps, and a define-time constant, with no plugin ecosystem and no
|
|
application framework. Vite library mode was rejected because it would pull a Rollup build plus
|
|
`@vitejs/plugin-react` — a package the module boundary deliberately denies — for output this does
|
|
not need. tsup was rejected as an esbuild wrapper that adds a dependency and no capability. Plain
|
|
`tsc` cannot do it at all: it transpiles TSX but cannot bundle, so its output would carry bare
|
|
`react/jsx-runtime` and `react-dom/client` imports that no browser can resolve without an import map
|
|
the platform does not define.
|
|
|
|
### React and ReactDOM are bundled, never externalized
|
|
|
|
Every dependency, React included, is bundled into the artifact. Nothing is external, so a host
|
|
provides no modules, no globals, and no import map.
|
|
|
|
This follows directly from ADR 0022. That ADR made `mount()` the ABI *specifically* so React would
|
|
not be part of the host contract. Externalizing React would reverse that decision through the build
|
|
system: the host would have to supply a React runtime, module and host would be locked to compatible
|
|
majors, and a standalone host — or any host not written in React — could not load a module at all.
|
|
Host independence is the property this platform is trying to keep; bundle size is not.
|
|
|
|
The accepted cost is duplication: a page hosting several module UIs may load several React copies,
|
|
and the template's artifact is roughly 570 KB unminified. That is a real cost and it is deliberate.
|
|
Sharing one runtime later through peer dependencies, bundler externals, or an import map is a
|
|
build-pipeline optimization that **does not change the public contract**, because the contract is a
|
|
mount function rather than a component. It can be revisited when a real host exists to measure.
|
|
|
|
### Output is unminified browser ESM at the path the manifest declares
|
|
|
|
`format: "esm"`, `platform: "browser"`, `target: "es2022"` (matching `browser.json`), with source
|
|
maps, and `process.env.NODE_ENV` replaced at build time — React reads that at module scope, and
|
|
without substitution the artifact would reference a Node global that does not exist in a browser.
|
|
|
|
Output is **not** minified. Nothing else in this repository minifies, and
|
|
[the security model](../../development/security.md) asks an operator to review the artifact they
|
|
install; a minified bundle is materially harder to review.
|
|
|
|
The artifact path is not a build convention — it is read from the module's own manifest
|
|
`entrypoints.ui`, and the source is `src/ui/index.tsx` by convention. Deriving the output from the
|
|
manifest means the declared artifact and the produced artifact cannot drift, and the build validates
|
|
that path with `packageRelativePathSchema` from `@ekklesia/contracts`, the same grammar module
|
|
discovery enforces, so a build can never write somewhere discovery would later reject.
|
|
|
|
### `@ekklesia/module-ui-build` owns the configuration; modules own none
|
|
|
|
The bundler configuration lives in one shared package with the existing `tooling` role — a role
|
|
modules were already permitted to depend on, so **this pass required no boundary relaxation at
|
|
all**. A module author writes no bundler configuration; they place UI at `src/ui/index.tsx`, declare
|
|
`entrypoints.ui`, and run their normal build.
|
|
|
|
The package ships a committed launcher at `bin/ekklesia-build-module-ui.mjs` rather than pointing its
|
|
`bin` straight at compiled output. pnpm creates `.bin` links during `pnpm install`, which runs before
|
|
Turborepo builds anything, so a `bin` targeting `dist/cli.js` fails to link on a fresh clone and
|
|
every module's UI build breaks in CI. The committed launcher always exists, so the link always
|
|
succeeds, and it defers to the compiled CLI at run time.
|
|
|
|
A module with no `entrypoints.ui` is not an error: the CLI reports that there is nothing to build and
|
|
exits successfully, so one build script serves backend-only and UI-bearing modules alike.
|
|
|
|
### Styling is inline style objects only, for now
|
|
|
|
Module UI styles with React inline `style` objects. Stylesheet imports remain unsupported, and the
|
|
build **fails** on one rather than quietly emitting a sidecar.
|
|
|
|
esbuild would happily bundle `import "./styles.css"` into an `index.css` next to the artifact. No
|
|
manifest field declares that file, no host loads it, and nothing in the runtime contract mentions it
|
|
— so it would appear to work in a local test and silently do nothing in a real host. Emitting an
|
|
undeclared asset is worse than refusing one. `scripts/check-boundaries.ts` already rejects a `.css`
|
|
file authored inside a module; the build additionally rejects a stylesheet reached through a
|
|
dependency, with an explanatory error.
|
|
|
|
This is deliberately the smallest viable styling story. A real stylesheet contract needs a manifest
|
|
field, a host loading mechanism, and a style-isolation decision, none of which exist — and inventing
|
|
them here would expand the manifest and runtime contract before a host can validate them.
|
|
|
|
### Browser TypeScript stays scoped to UI source
|
|
|
|
`@ekklesia/typescript-config/browser-react.json` extends the existing `browser.json` and sets
|
|
`"jsx": "react-jsx"`, matching esbuild's `jsx: "automatic"` so typing and emission agree.
|
|
|
|
A module keeps separate TypeScript projects: its Node project continues to include `src/**/*.ts`,
|
|
which does not match `.tsx`, and a UI project covers `src/ui/**` with `"types": []`. Backend module
|
|
code therefore never inherits DOM globals, and UI source is never compiled with Node settings.
|
|
|
|
The reverse direction is weaker than it looks, and is recorded here rather than overstated.
|
|
`"types": []` does keep `@types/node` out of the UI program — a file that references `process` in
|
|
isolation fails with TS2591 — but TypeScript still pulls global declarations in transitively through
|
|
a dependency's type graph. Measured on this branch: a UI file that imports
|
|
`@ekklesia/module-ui-sdk` reaches `@ekklesia/contracts`, and through its Zod and Drizzle types the
|
|
Node globals become visible again, so `process.env` typechecks. The separation is therefore a strong
|
|
convention, not an airtight guard, and nothing at build time closes it either: esbuild substitutes
|
|
only `process.env.NODE_ENV`, so any other Node global that slips through fails in the browser rather
|
|
than in CI. Closing this properly means giving the UI SDK a type surface that does not reach the
|
|
backend contracts barrel, which is an ADR 0022 contract question and is deferred.
|
|
|
|
### The build is an authoring step; discovery still only validates
|
|
|
|
Nothing here runs during discovery, startup, or install. The lifecycle is unchanged: an author
|
|
builds, the compiled artifact is packaged and installed, and Core discovery validates that artifact
|
|
without executing it (ADR 0021). Core does not build, does not import browser code, and gains no new
|
|
responsibility in this ADR.
|
|
|
|
### The pipeline is proven by the module template, in the repository's own verification
|
|
|
|
`templates/module` is the canonical example and now ships optional UI: `src/ui/index.tsx` and one
|
|
small React component. Its test suite builds the artifact from source, imports it, validates it with
|
|
`parseModuleUiExport`, mounts it into a real DOM, asserts the rendered output, and unmounts it.
|
|
|
|
The artifact is rebuilt in the test rather than read from a checked-in file, so the proof can never
|
|
pass against stale output — and `dist/` is gitignored, so nothing generated is committed. Because
|
|
`pnpm verify` runs both `pnpm test` and `pnpm build`, a module UI that cannot be produced fails CI
|
|
through two independent paths.
|
|
|
|
## Alternatives
|
|
|
|
- **Vite library mode.** Rejected: heavier than the job requires, and its React support routes
|
|
through `@vitejs/plugin-react`, which the module boundary denies precisely so module authors do not
|
|
acquire a plugin ecosystem.
|
|
- **tsup.** Rejected: a wrapper around the esbuild this repository already resolves, adding a
|
|
dependency and no capability.
|
|
- **`tsc` alone.** Rejected: it cannot bundle. Its output would carry unresolvable bare imports and
|
|
would require the platform to define an import map — which would make React part of the host ABI
|
|
by another route.
|
|
- **Externalizing React.** Rejected as the default, in detail above: it undoes ADR 0022's central
|
|
decision and makes standalone hosting impractical. Revisit as an optimization when a real host can
|
|
measure the cost.
|
|
- **Minified output.** Rejected: nothing else here minifies, and the trusted-module model asks
|
|
operators to review what they install.
|
|
- **A build convention instead of reading the manifest.** Rejected: two independent statements of
|
|
where the artifact lives can disagree, and the failure would surface as a discovery error long
|
|
after the build "succeeded".
|
|
- **Emitting a CSS sidecar.** Rejected: an asset nothing declares and no host loads is a silent
|
|
failure, and adding the manifest and runtime contract to support one belongs with a host that can
|
|
validate it.
|
|
- **CSS Modules or a CSS-in-JS library.** Rejected for this pass: both are larger commitments than
|
|
the smallest viable styling story, and one would add a runtime dependency to every module.
|
|
- **A separate UI fixture instead of the template.** Rejected: the template is what an author copies,
|
|
so proving the pipeline anywhere else would leave the canonical example unproven. Optionality is
|
|
preserved instead — a backend-only module deletes the UI directory, its two UI TypeScript projects,
|
|
and the `ui` entrypoint, and everything else still works.
|
|
- **A `bin` pointing directly at compiled output.** Rejected: it cannot link on a fresh clone,
|
|
because installation precedes the build.
|
|
|
|
## 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
|
|
tested.
|
|
|
|
The costs are explicit: bundled React means duplicate runtimes and large artifacts until a sharing
|
|
strategy is measured against a real host; inline styles are the only styling available; and a module
|
|
that wants a stylesheet, a router, or a component library has none of them yet. Module UI also still
|
|
has no production host — the Dashboard does not exist, so a built artifact is proven by tests rather
|
|
than served to anyone.
|
|
|
|
## Deferred
|
|
|
|
Not implemented or promised here: the Dashboard application shell, navigation, or grouping; a design
|
|
system, component library, or theme/design-token contract; a stylesheet or asset loading contract and
|
|
the manifest field it would need; React runtime sharing between host and modules; minified or
|
|
production-optimized output; 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 reused the *path* grammar from
|
|
`@ekklesia/contracts` but did not centralize the *extension* rule, so that inconsistency is
|
|
unchanged and still open.
|