feat: build module UI into its declared browser artifact
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:
co-authored by
Claude Opus 5
parent
f9c1108527
commit
1e5866504e
@@ -228,6 +228,14 @@ a way to smuggle anything else in.
|
||||
|
||||
## Consequences
|
||||
|
||||
> **Cross-reference — see [ADR 0023](0023-module-ui-build-pipeline.md).**
|
||||
> The gap this section records — that module UI was not buildable end to end, because no pipeline
|
||||
> turned `.tsx` into the compiled `entrypoints.ui` artifact — is now closed. ADR 0023 selects
|
||||
> esbuild, bundles React rather than externalizing it (preserving this ADR's mount-function ABI),
|
||||
> emits browser ESM at the manifest-declared path, and keeps stylesheet imports unsupported by
|
||||
> failing the build on one. Everything else in this ADR remains accepted and unchanged.
|
||||
|
||||
|
||||
A module author can now write a real screen in React, against a documented four-member host context,
|
||||
and know exactly what the artifact must export. Two hosts can consume that artifact with no shared
|
||||
code beyond one small package, and the test suite proves it by mounting one fixture artifact under
|
||||
|
||||
@@ -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.
|
||||
@@ -31,3 +31,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0020: Module runtime and migration execution hardening](0020-module-runtime-and-migration-execution-hardening.md) — Accepted
|
||||
- [0021: Modules as independently hostable products](0021-modules-as-hostable-products.md) — Accepted
|
||||
- [0022: Module UI authoring model and host contract](0022-module-ui-authoring-and-host-contract.md) — Accepted
|
||||
- [0023: Module UI build pipeline](0023-module-ui-build-pipeline.md) — Accepted
|
||||
|
||||
Reference in New Issue
Block a user