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
+28 -5
View File
@@ -262,11 +262,34 @@ hints are presentation only** — server-side `requirePermission` remains the so
backend trust model rather than pretending to improve on it. See
[ADR 0022](docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md).
**This is a contract, not a running feature.** No Dashboard mounts a module UI, no module ships one,
and the pipeline that compiles a module's `.tsx` into the compiled artifact is not designed —
`.css` and bundler plugins stay rejected for that reason. Core still never loads browser code:
discovery validates the `entrypoints.ui` path and never evaluates it, and hosts do their own dynamic
import.
Core still never loads browser code: discovery validates the `entrypoints.ui` path and never
evaluates it, and hosts do their own dynamic import.
Phase 6B pass 2 makes that contract **buildable end to end**. A module author writes React and
TypeScript at `src/ui/index.tsx` and runs its normal `pnpm build`; **esbuild** compiles it to the
browser ESM artifact the manifest declares. Authors write no bundler configuration — the new
`@ekklesia/module-ui-build` package owns all of it, and because it uses the existing `tooling` role
that modules could already depend on, **this pass required no boundary relaxation at all**. The
output path is read from the manifest rather than a build convention, so the declared and produced
artifacts cannot drift.
**React is bundled into each artifact rather than shared from the host.** That is the decision ADR
0022 implies: `mount()` is the ABI precisely so React is *not* part of the host contract, and
externalizing it would make every host supply a compatible React — defeating standalone hosting. The
accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template);
sharing one runtime later is a build optimization that does not change the contract. Styling is React
inline style objects only: a stylesheet import **fails the build** rather than emitting a sidecar
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
and installing a module or starting the API never triggers one.
`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 — 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.
**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