Module discovery gated the compiled-ESM rule on an allowlist, `["schema", "server", "ui"]`, so `entrypoints.jobs` accepted `./src/jobs/index.ts`. Every other executable entrypoint required a built `.js` or `.mjs`; `jobs` did not, purely because it was never added to that list. It is the second entrypoint to slip through the same way — `ui` did too, until Phase 6A added it explicitly — which is the argument against the allowlist rather than against `jobs`. The rule is now expressed as "every entrypoint except a directory entrypoint", with `migrations` the only member of that set. A new manifest entrypoint therefore defaults to the stricter validation instead of silently escaping it. The same constant replaces the two existing `=== "migrations"` special cases for the file/directory kind check and its message, so the one thing that distinguishes `migrations` is stated once. The directory message is now templated and produces byte-identical text. Every other entrypoint guarantee is untouched: package-relative paths, no traversal, inside-package before and after symlink resolution, the artifact must exist, and it must be a file. No runtime was added. `entrypoints.jobs` still has no consumer — it is declared, validated, and never executed — exactly as it has been since ADR 0006, and as `ui` was before its host loader existed. The manifest schema is unchanged and this stays within v4: the field, its type, and its optionality are all the same, and only discovery's validation of the declared path tightened. Compatibility impact is nil in first-party code: no module, template, or fixture declares a `jobs` entrypoint at all. A third-party module declaring a source-TypeScript path would now fail discovery, which is the intended correction. Tests cover accepted `.js` and `.mjs`, rejected `.ts`/`.tsx`/`.cjs`, a missing artifact, a directory, a symlink escaping the package, and that discovery still never evaluates the artifact. The three rejection cases were confirmed to fail against the previous implementation. Co-Authored-By: Claude Opus 5 <[email protected]>
207 lines
13 KiB
Markdown
207 lines
13 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.
|
|
|
|
### 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.
|