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
+106 -7
View File
@@ -271,25 +271,120 @@ exposes. This matches the backend model: installing a module already means runni
API process. Write module UI as trusted first-party code, because that is what it is. See
[Security model and deployment trust boundaries](../development/security.md).
### Building your UI
Put your UI source at `src/ui/index.tsx`, declare the artifact in your manifest, and build:
```text
src/ui/index.tsx # the compiled entrypoint's source
src/ui/ExampleApp.tsx # your components
↓ pnpm build
dist/ui/index.js # the artifact your manifest declares
```
You write no bundler configuration. `@ekklesia/module-ui-build` owns all of it
([ADR 0023](../architecture/decisions/0023-module-ui-build-pipeline.md)), and your package's build
script runs both halves of the module:
```json
{
"scripts": {
"build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui"
},
"dependencies": {
"@ekklesia/module-ui-sdk": "workspace:*",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@ekklesia/module-ui-build": "workspace:*",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4"
}
}
```
The output path is **read from your manifest**, not from a build convention, so the artifact you
declare and the artifact you produce cannot drift. Declare it exactly as you would any other
entrypoint:
```json
{ "entrypoints": { "ui": "./dist/ui/index.js" } }
```
A module with no `entrypoints.ui` is not an error — the build reports that there is nothing to build
and exits successfully, so the same script works for a backend-only module.
The build fails, loudly and with esbuild's own file-and-line diagnostics, when your TSX does not
compile, an import cannot be resolved, the declared path is unsafe or is not a `.js`/`.mjs` artifact,
or `src/ui/index.tsx` is missing.
#### TypeScript projects
Keep UI and backend TypeScript separate. Your Node project already includes `src/**/*.ts`, which does
not match `.tsx`, and a UI project covers the browser half:
```json
{
"extends": "@ekklesia/typescript-config/browser-react.json",
"compilerOptions": { "noEmit": true, "types": [] },
"include": ["src/ui/**/*.tsx", "src/ui/**/*.ts"]
}
```
`"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.
`noEmit` is also deliberate — TypeScript only checks types here; esbuild produces the artifact.
#### What is in your artifact
**React is bundled into it.** Your artifact is self-contained: it resolves nothing at run time and
needs no React from its host, which is what lets the same file run under Ekklesia and under a future
standalone host that may not use React at all. The cost is that a page hosting several module UIs
loads several React copies, and artifacts are large (the template's is roughly 570 KB, unminified so
an operator can review it). That is accepted for now and can be optimized later **without changing
the contract**, because the contract is a mount function rather than a component.
### Styling: inline style objects only, for now
Style with React's `style` prop:
```tsx
<section style={{ display: "grid", gap: "0.5rem" }}>…</section>
```
**Stylesheet imports are not supported, and the build fails on one.** That is deliberate rather than
an oversight: esbuild would emit a `dist/ui/index.css` beside your artifact, but no manifest field
declares it and no host loads it — so it would work in your tests and silently do nothing in a real
host. A stylesheet contract needs a manifest field, a host loading mechanism, and a style-isolation
decision, none of which exist yet. Inline styles are the whole supported story for this phase.
### What you may and may not use today
`pnpm boundaries` still enforces `modules-do-not-own-ui`, narrowed precisely (ADR 0022):
`pnpm boundaries` enforces `modules-do-not-own-ui`, narrowed precisely (ADR 0022) and **unchanged by
the build pipeline** — adding the pipeline required no boundary relaxation at all:
| Allowed in a module | Still rejected |
| --- | --- |
| `.tsx` source | `.jsx` (strict TypeScript only), `.css`, `.vue`, `.svelte`, `.astro`, `.mdx`, `.svg`, `.html` |
| `react`, `react-dom` | Every other framework, plus `react-router-dom` (routing is the host's), `react-native`, `@vitejs/plugin-react` |
| `@ekklesia/module-ui-sdk` | `@ekklesia/ui` and other shared UI-role packages |
| `@ekklesia/module-ui-sdk`, `@ekklesia/module-ui-build` | `@ekklesia/ui` and other shared UI-role packages |
You do not need a bundler plugin — `@vitejs/plugin-react` and friends stay rejected because
`@ekklesia/module-ui-build` owns the bundler entirely.
Layer rules are unchanged: module UI source may not import Core, an app, a surface, a builder, or
another module. An unsupported framework aliased behind an allowed name — `"react": "npm:vue@3"` —
is resolved and rejected by its real target.
> **Not yet buildable end to end.** The pipeline that compiles a module's `.tsx` into the compiled
> `entrypoints.ui` artifact is not designed: `browser.json` uses `"jsx": "preserve"`, which needs a
> bundler, and `@vitejs/plugin-react` is still denied. Module styling is unresolved for the same
> reason, which is why `.css` stays rejected. No host mounts a module UI yet either. The contract
> above is stable and tested; shipping a module UI is not yet possible end to end.
> **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.
### What this does and does not do today
@@ -299,6 +394,10 @@ entrypoint** — a UI artifact that throws on evaluation still discovers success
never evaluated. Hosts perform their own dynamic import and then validate the result with
`parseModuleUiExport`; Core never loads browser code.
Core also never *builds* anything. Building is an authoring and packaging step you run; Core
discovers the compiled artifact you shipped and validates it. Installing a module does not trigger a
build, and neither does starting the API.
### What a module UI will never own
A module UI consumes platform policy and never redefines it.