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]>
13 KiB
0023: Module UI build pipeline
- Status: Accepted
- Date: 2026-08-21
- Extends: 0022
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 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.
tscalone. 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
uientrypoint, and everything else still works. - A
binpointing directly at compiled output. Rejected: it cannot link on a fresh clone, because installation precedes the build.
Consequences
Cross-reference — see ADR 0024. 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/contractsin 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 useprocess. 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.