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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user