feat: build module UI into its declared browser artifact
CI / verify (pull_request) Successful in 32s
CI / infrastructure-smoke (pull_request) Successful in 26s
CI / migration-smoke (pull_request) Successful in 50s

ADR 0022 defined what a module UI is and selected React and TypeScript
as the authoring model, but closed by recording that it was not buildable
end to end: `browser.json` preserves JSX, bundler plugins are denied, and
nothing turned a module's `.tsx` into the artifact its manifest declares.
This closes exactly that gap.

esbuild produces the artifact. It was already the right answer before it
was chosen: this workspace resolves esbuild 0.28.1 through Vite/Vitest
today and `pnpm-workspace.yaml` already carries `allowBuilds: esbuild`,
so the pinned dependency adds no second copy and no new trust decision.
It is also the smallest tool that covers TSX, ESM, browser platform,
bundling, source maps, and a define-time constant with no plugin
ecosystem. Vite library mode would drag in `@vitejs/plugin-react`, which
the module boundary denies on purpose; `tsc` alone cannot bundle, so its
output would carry bare `react/jsx-runtime` imports no browser resolves.

React is bundled, not externalized. That follows from ADR 0022 rather
than from convenience: `mount()` was made the ABI precisely so React
would not be part of the host contract, and externalizing it would
reverse that through the build system — every host would have to supply
a compatible React, and a standalone host might not use React at all.
The cost is real and accepted: duplicate runtimes and a ~570 KB
unminified artifact. Sharing one runtime later is a build optimization
that cannot change the contract, because the contract is a mount
function.

Output is browser ESM at `es2022`, unminified so an operator can review
what they install, with `process.env.NODE_ENV` substituted — React reads
it at module scope and would otherwise touch a Node global that does not
exist in a browser. The output path is read from the module's own
manifest rather than a build convention, so declared and produced
artifacts cannot drift, and it is validated with the same
`packageRelativePathSchema` discovery enforces, so a build can never
write where discovery would later reject.

`@ekklesia/module-ui-build` owns every option; modules own none. It uses
the existing `tooling` role that modules could already depend on, so this
pass required no boundary relaxation at all. Its `bin` points at a
committed launcher rather than compiled output because pnpm links bins
during install, before Turborepo builds anything — a bin targeting
`dist/cli.js` fails to link on a fresh clone and breaks every module's UI
build in CI. A module with no `entrypoints.ui` is not an error; the CLI
reports nothing to build and exits zero, so one script serves both kinds
of module.

Styling is React inline style objects. A stylesheet import fails the
build rather than emitting a sidecar `.css` that no manifest declares and
no host loads — an undeclared asset would appear to work in tests and
silently do nothing in a real host.

`templates/module` is the canonical example and now ships optional UI.
Its tests build the artifact from source, validate it with
`parseModuleUiExport`, mount it into a real DOM, assert the rendered
output, and unmount it; the artifact is rebuilt per run so the proof can
never pass against stale output, and `dist/` stays gitignored. Because
verify runs both `pnpm test` and `pnpm build`, a UI that cannot be
produced fails CI through two independent paths.

One correction worth stating plainly: the UI TypeScript project declares
`"types": []`, and a UI file referencing `process` does fail in
isolation, but TypeScript still pulls Node globals in transitively — a
file importing `@ekklesia/module-ui-sdk` reaches `@ekklesia/contracts`
and through its Zod and Drizzle types `process.env` typechecks again.
The docs and ADR describe this as the convention it is rather than the
guard it is not.

Core is untouched. Building is an authoring step; discovery still only
validates the artifact a module shipped and never executes or produces
one.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-21 21:24:18 -05:00
co-authored by Claude Opus 5
parent f9c1108527
commit 1e5866504e
34 changed files with 1388 additions and 45 deletions
@@ -0,0 +1,193 @@
# 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
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.