ADR 0023 refused stylesheet imports for a good reason: esbuild would emit a
`.css` sidecar beside the artifact, nothing would declare it, no host would
fetch it, and it would work in a local test and silently do nothing in a real
host. Inline style objects were the whole story. They cannot express
`:hover`, `:focus-visible`, `:disabled`, `@media`, or `@keyframes` — so an
accessible focus treatment was not expressible, and the shared elements added
in the previous commit are built from real CSS for exactly that reason.
Compile the stylesheet into the artifact instead of beside it. A module
imports plain `.css`; the canonical builder resolves local `@import`s through
a nested bundle and turns each file into its compiled text, which the author
declares as `defineModuleUi({ styles })`. One declared file still contains
everything: nothing extra to publish, nothing to fetch, and no way for CSS to
become an asset nobody serves. The build additionally fails if a CSS output
ever appears beside an artifact, so a future loader change cannot bring the
sidecar back. Two rules are enforced with their own codes: a module stylesheet
may reference `--ekklesia-*` and never declare one, and every `@import` must
resolve locally or the artifact would fetch styles at run time. Plain `.css`
only; other dialects are a toolchain decision needing their own ADR.
Then make the isolation real. The generic loader mounts every browser artifact
— primary, display, and later a contributed renderer — inside an open Shadow
DOM root it creates, installs the artifact's stylesheet there, and hands the
artifact an element inside it. Shadow DOM is the one mechanism a browser
actually enforces; a class prefix or a "scope your selectors" instruction is a
convention one mistake defeats, and the failure mode is a module silently
restyling the Dashboard. It applies unconditionally, including to artifacts
with no stylesheet, because a conditional boundary is one a module can fall
out of by editing a source file. Both wrappers are `display: contents` through
base rules installed before the artifact's own, so layout is unchanged and a
full-viewport display can still override `:host`. Unmount removes the whole
root in a `finally`, so a cleanup that throws cannot leave styles behind, and
remounting cannot accumulate a second copy.
This is a correctness boundary for styles and is not described as a sandbox:
the root is `open` precisely because module code is trusted, same-realm code
that could reach `document` anyway.
An artifact's DOM is no longer a direct child of the host's element, so the
mount ABI is versioned explicitly. `defineModuleUi` stamps
`MODULE_UI_ABI_VERSION` so it can never drift from the SDK the artifact
compiled against, and a version-1 artifact fails with its own code — "rebuild
your module", not a typo hunt. No dual acceptance, matching every manifest
cutover here. Migration is one `pnpm build`.
`pnpm boundaries` allows `.css` only under `src/ui/` and `src/display/`: a
stylesheet anywhere else in a module is the undeclared sidecar again, just
relocated.
Co-Authored-By: Claude Opus 5 <[email protected]>
216 lines
14 KiB
Markdown
216 lines
14 KiB
Markdown
# 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.
|
|
|
|
> **Correction — this rule is superseded.**
|
|
> [ADR 0040](0040-module-stylesheets-and-artifact-isolation.md) supplies the three missing pieces and
|
|
> reverses this one decision: a module may import plain `.css` from a browser-artifact source root,
|
|
> the canonical builder compiles it **into** the declared artifact as text (so no sidecar is ever
|
|
> emitted, which is what this section was protecting), and the host installs it into a Shadow DOM
|
|
> root it creates for every artifact. No manifest field was needed after all. The rest of this ADR —
|
|
> esbuild, bundled React, fixed source paths, no author bundler configuration, unminified output — is
|
|
> unchanged.
|
|
|
|
### 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
|
|
|
|
> **Cross-reference — see [ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md).**
|
|
> 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/contracts` in 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 use `process`. 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.
|
|
|
|
> **Correction — this deferred item is closed.** `entrypoints.jobs` now requires a compiled `.js` or
|
|
> `.mjs` artifact, exactly like `server`, `schema`, and `ui`. Module discovery expresses the rule as
|
|
> "every entrypoint except the directory entrypoint (`migrations`)", so the omission that let `jobs`
|
|
> escape it cannot recur. No jobs runtime was added: the entrypoint is still declared, validated, and
|
|
> never executed. Every other item in this list remains deferred.
|