Merge pull request 'feat: build module UI into its declared browser artifact' (#17) from phase-6b/module-ui-build into main
CI / verify (push) Successful in 40s
CI / infrastructure-smoke (push) Successful in 22s
CI / migration-smoke (push) Successful in 47s

Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/17
This commit was merged in pull request #17.
This commit is contained in:
The_River
2026-08-22 02:28:59 +00:00
34 changed files with 1388 additions and 45 deletions
+4 -2
View File
@@ -36,8 +36,10 @@ pnpm infrastructure:config
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by
`pnpm boundaries`; module styling and the module UI build pipeline are not designed yet, and no
host mounts a UI entrypoint yet.
`pnpm boundaries`. UI source lives at `src/ui/index.tsx` and `pnpm build` compiles it to the
artifact the manifest declares, via `@ekklesia/module-ui-build` (ADR 0023) — module authors write
no bundler configuration. Styling is React inline style objects only for now, and no host mounts a
UI entrypoint yet.
- Module UI reaches its host only through `@ekklesia/module-ui-sdk`, and asks the host for its own
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
private deployment detail and would break standalone hosting.
+28 -5
View File
@@ -262,11 +262,34 @@ hints are presentation only** — server-side `requirePermission` remains the so
backend trust model rather than pretending to improve on it. See
[ADR 0022](docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md).
**This is a contract, not a running feature.** No Dashboard mounts a module UI, no module ships one,
and the pipeline that compiles a module's `.tsx` into the compiled artifact is not designed —
`.css` and bundler plugins stay rejected for that reason. Core still never loads browser code:
discovery validates the `entrypoints.ui` path and never evaluates it, and hosts do their own dynamic
import.
Core still never loads browser code: discovery validates the `entrypoints.ui` path and never
evaluates it, and hosts do their own dynamic import.
Phase 6B pass 2 makes that contract **buildable end to end**. A module author writes React and
TypeScript at `src/ui/index.tsx` and runs its normal `pnpm build`; **esbuild** compiles it to the
browser ESM artifact the manifest declares. Authors write no bundler configuration — the new
`@ekklesia/module-ui-build` package owns all of it, and because it uses the existing `tooling` role
that modules could already depend on, **this pass required no boundary relaxation at all**. The
output path is read from the manifest rather than a build convention, so the declared and produced
artifacts cannot drift.
**React is bundled into each artifact rather than shared from the host.** That is the decision ADR
0022 implies: `mount()` is the ABI precisely so React is *not* part of the host contract, and
externalizing it would make every host supply a compatible React — defeating standalone hosting. The
accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template);
sharing one runtime later is a build optimization that does not change the contract. Styling is React
inline style objects only: a stylesheet import **fails the build** rather than emitting a sidecar
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
and installing a module or starting the API never triggers one.
`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 — so a UI that cannot be produced fails CI, and no generated file is ever
committed. See [ADR 0023](docs/architecture/decisions/0023-module-ui-build-pipeline.md).
**Still no host.** A module UI can be authored, built, and tested; **nothing serves one to a user
yet.** The Dashboard shell, navigation, a design system, a stylesheet loading contract, and the
standalone host do not exist.
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
@@ -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.
+1
View File
@@ -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
+11 -2
View File
@@ -195,8 +195,17 @@ authority. Module UI is trusted code in the host's browser realm — there is no
backend trust model rather than overstating it.
Core is untouched by this: discovery still validates the `entrypoints.ui` path and never evaluates
it, and hosts perform their own dynamic import. The Dashboard shell, a UI runtime, the module UI
build and styling pipeline, the standalone host, and every feature module remain future work.
it, and hosts perform their own dynamic import.
**Phase 6B pass 2 makes that contract buildable.**
[ADR 0023](decisions/0023-module-ui-build-pipeline.md) selects esbuild, compiles a module's
`src/ui/index.tsx` into the browser ESM artifact its manifest declares, and puts every bundler
option in one `tooling` package so module authors write no build configuration. React is bundled
rather than externalized, keeping `mount()` the ABI and a module artifact hostable by any host;
styling is inline style objects only, and a stylesheet import fails the build rather than emitting an
asset nothing declares. Building remains an authoring step — Core never builds, and discovery still
only validates the artifact a module shipped. The Dashboard shell, a UI runtime, a design system, a
stylesheet loading contract, the standalone host, and every feature module remain future work.
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
are all unchanged.
+3 -1
View File
@@ -23,7 +23,9 @@ These rules map platform ownership to filesystem and package boundaries. The exe
`@ekklesia/module-ui-sdk`. It still cannot contain `.jsx`, stylesheets, single-file components,
Astro/Marko/MDX/HTML views, or SVG presentation assets, and cannot depend on any other UI framework
(including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on shared UI-role
packages. An unsupported framework aliased behind an allowed name is resolved and rejected by its
packages. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package,
a role modules could already depend on — owns the entire module UI build
([ADR 0023](decisions/0023-module-ui-build-pipeline.md)). An unsupported framework aliased behind an allowed name is resolved and rejected by its
real target.
- Module UI source is subject to the identical layer rules as module backend source: it may not
import Core, an app, a surface, a builder, or another module. Core still owns the containing
+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.
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env node
// Committed launcher, deliberately not generated.
//
// pnpm creates `.bin` links during `pnpm install`, which runs before Turborepo builds anything. A
// `bin` pointing straight at `dist/cli.js` therefore fails to link on a fresh clone — the compiled
// file does not exist yet — and every module's UI build would break in CI. This file always exists,
// so the link always succeeds; it defers to the compiled CLI at run time, by which point
// `dependsOn: ["^build"]` has produced it.
import { fileURLToPath } from "node:url";
const compiledCli = new URL("../dist/cli.js", import.meta.url);
try {
await import(compiledCli.href);
} catch (error) {
if (error?.code === "ERR_MODULE_NOT_FOUND") {
process.stderr.write(
`@ekklesia/module-ui-build is not built yet (${fileURLToPath(compiledCli)}).\n` +
"Run `pnpm build` from the repository root first.\n",
);
process.exitCode = 1;
} else {
throw error;
}
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@ekklesia/module-ui-build",
"version": "0.1.0",
"type": "module",
"files": [
"bin",
"dist"
],
"bin": {
"ekklesia-build-module-ui": "./bin/ekklesia-build-module-ui.mjs"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"lint": "biome lint .",
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-ui-build",
"test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json"
},
"dependencies": {
"@ekklesia/contracts": "workspace:*",
"esbuild": "0.28.1"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@types/node": "24.13.3",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
"ekklesia": {
"layer": "tooling"
}
}
@@ -0,0 +1,181 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { packageRelativePathSchema } from "@ekklesia/contracts";
import { build, type Plugin } from "esbuild";
/**
* The one supported way to turn a module's React/TSX UI source into the compiled browser artifact
* its manifest declares as `entrypoints.ui` (ADR 0021, ADR 0022, ADR 0023).
*
* Module authors do not write bundler configuration. They put their UI at
* {@link MODULE_UI_SOURCE_ENTRYPOINT}, declare `entrypoints.ui` in their manifest, and run the
* build; every option below is owned here so one decision applies to every module.
*/
export const MODULE_UI_SOURCE_ENTRYPOINT = "src/ui/index.tsx";
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
/** Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree. */
export const MODULE_UI_BUILD_TARGET = "es2022";
export type ModuleUiBuildErrorCode =
| "MODULE_UI_MANIFEST_UNREADABLE"
| "MODULE_UI_ENTRYPOINT_NOT_DECLARED"
| "MODULE_UI_ENTRYPOINT_UNSAFE"
| "MODULE_UI_SOURCE_MISSING"
| "MODULE_UI_BUILD_FAILED";
const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.freeze({
MODULE_UI_MANIFEST_UNREADABLE: `${MODULE_MANIFEST_FILENAME} could not be read or parsed.`,
MODULE_UI_ENTRYPOINT_NOT_DECLARED: `${MODULE_MANIFEST_FILENAME} declares no entrypoints.ui, so there is no module UI to build.`,
MODULE_UI_ENTRYPOINT_UNSAFE: `entrypoints.ui must be a package-relative path to a compiled .js or .mjs artifact inside the module.`,
MODULE_UI_SOURCE_MISSING: `Module UI source was not found at ${MODULE_UI_SOURCE_ENTRYPOINT}.`,
MODULE_UI_BUILD_FAILED: "The module UI bundle could not be produced.",
});
/** Carries a stable code and a fixed message. esbuild's own diagnostics are printed to stderr by
* the CLI, where a developer wants them; the thrown error itself stays sanitized, matching every
* other Ekklesia error boundary. */
export class ModuleUiBuildError extends Error {
readonly code: ModuleUiBuildErrorCode;
constructor(code: ModuleUiBuildErrorCode) {
super(errorMessages[code]);
this.name = "ModuleUiBuildError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
}
}
/**
* Rejects a stylesheet import instead of quietly emitting one.
*
* esbuild would happily bundle `import "./styles.css"` into a sidecar `index.css` next to the
* artifact. Nothing declares that file, no host loads it, and the manifest has no field for it — so
* it would work in a local test and silently do nothing in a real host. Module styling has no
* public loading contract yet (ADR 0023), so the build fails loudly rather than producing an
* undeclared asset. `scripts/check-boundaries.ts` rejects a `.css` file authored *inside* a module;
* this additionally covers a stylesheet reached through a dependency.
*/
function rejectStylesheetImports(): Plugin {
return {
name: "ekklesia-reject-stylesheet-imports",
setup(pluginBuild) {
// No `u` flag, deliberately: esbuild compiles a plugin filter to a Go RE2 expression, which
// rejects `(?iu)` outright ("filter is not a valid Go regular expression"). This pattern is
// pure ASCII, so the flag would change nothing anyway.
pluginBuild.onResolve({ filter: /\.(css|s[ac]ss|less|styl|pcss|postcss)$/i }, (args) => ({
errors: [
{
text:
"Module UI cannot import a stylesheet yet: there is no declared way for a host to " +
"load one. Use React inline style objects for now (ADR 0023).",
location: { file: args.importer },
},
],
}));
},
};
}
export interface BuildModuleUiOptions {
/** The module package directory containing `ekklesia.module.json`. */
readonly moduleDirectory: string;
}
export interface ModuleUiBuildResult {
/** Absolute path of the artifact actually written, which is exactly what the manifest declares. */
readonly outputFile: string;
/** Absolute path of the source entrypoint that produced it. */
readonly sourceFile: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** The declared artifact path, validated the same way module discovery validates it, so the build
* can never write somewhere discovery would later reject. */
async function resolveDeclaredArtifact(moduleDirectory: string): Promise<string> {
let manifest: unknown;
try {
manifest = JSON.parse(
await readFile(path.join(moduleDirectory, MODULE_MANIFEST_FILENAME), "utf8"),
) as unknown;
} catch {
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE");
}
const entrypoints = isRecord(manifest) ? manifest.entrypoints : undefined;
const declared = isRecord(entrypoints) ? entrypoints.ui : undefined;
if (declared === undefined) {
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_NOT_DECLARED");
}
// The path grammar is Core's, not this tool's: reusing the contracts schema means the build can
// never write to a location module discovery would later reject, even if that grammar changes.
if (
!packageRelativePathSchema.safeParse(declared).success ||
typeof declared !== "string" ||
![".js", ".mjs"].includes(path.extname(declared).toLowerCase())
) {
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE");
}
return path.resolve(moduleDirectory, declared);
}
/**
* Bundles a module's UI into the artifact its manifest declares.
*
* Never runs during discovery, startup, or install: this is an authoring-time and packaging-time
* step only. Core validates the artifact a module ships; it never produces one (ADR 0021).
*/
export async function buildModuleUi(options: BuildModuleUiOptions): Promise<ModuleUiBuildResult> {
const moduleDirectory = path.resolve(options.moduleDirectory);
const outputFile = await resolveDeclaredArtifact(moduleDirectory);
const sourceFile = path.join(moduleDirectory, MODULE_UI_SOURCE_ENTRYPOINT);
try {
await readFile(sourceFile);
} catch {
throw new ModuleUiBuildError("MODULE_UI_SOURCE_MISSING");
}
try {
await build({
absWorkingDir: moduleDirectory,
// Every dependency, React included, is bundled. The artifact must run in any host, including
// one that uses no React at all, so nothing is externalized (ADR 0023).
bundle: true,
define: {
// React reads this at module scope. Without it the bundle would reference `process`, a
// Node global that does not exist in a browser, and crash on load.
"process.env.NODE_ENV": '"production"',
},
entryPoints: [sourceFile],
// ESM only: the artifact is imported by a host with a dynamic `import()`, and the contract
// requires a real default export.
format: "esm",
jsx: "automatic",
// Unminified deliberately: an operator is expected to review the artifact they install
// (see docs/development/security.md), and nothing else in this repository minifies.
minify: false,
outfile: outputFile,
platform: "browser",
plugins: [rejectStylesheetImports()],
sourcemap: true,
target: MODULE_UI_BUILD_TARGET,
// Fail rather than silently shipping a partial bundle.
logLevel: "silent",
});
} catch (error) {
// esbuild's structured diagnostics are surfaced by the CLI; rethrow a sanitized error so a
// programmatic caller never receives raw paths or internals.
if (isRecord(error) && Array.isArray(error.errors)) {
throw Object.assign(new ModuleUiBuildError("MODULE_UI_BUILD_FAILED"), {
diagnostics: error.errors,
});
}
throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED");
}
return { outputFile, sourceFile };
}
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
import path from "node:path";
import process from "node:process";
import { formatMessages } from "esbuild";
import { buildModuleUi, ModuleUiBuildError } from "./build-module-ui.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* `ekklesia-build-module-ui` — the canonical module UI build command, run from a module directory.
*
* A module with no `entrypoints.ui` is not an error: backend-only modules stay first-class and this
* command reports that there was nothing to build and exits successfully, so one `build` script
* works for every module.
*/
async function main(): Promise<number> {
const moduleDirectory = process.argv[2] ?? process.cwd();
try {
const result = await buildModuleUi({ moduleDirectory });
process.stdout.write(`Built module UI: ${path.relative(moduleDirectory, result.outputFile)}\n`);
return 0;
} catch (error) {
if (error instanceof ModuleUiBuildError) {
if (error.code === "MODULE_UI_ENTRYPOINT_NOT_DECLARED") {
process.stdout.write("No entrypoints.ui declared; skipping the module UI build.\n");
return 0;
}
process.stderr.write(`${error.message}\n`);
// esbuild's own diagnostics carry the file, line, and reason a developer needs. They are
// printed here, never folded into the sanitized error message itself.
const diagnostics = isRecord(error) ? error.diagnostics : undefined;
if (Array.isArray(diagnostics) && diagnostics.length > 0) {
const formatted = await formatMessages(diagnostics, { kind: "error", color: true });
process.stderr.write(formatted.join(""));
}
return 1;
}
process.stderr.write("The module UI build failed unexpectedly.\n");
return 1;
}
}
process.exitCode = await main();
+1
View File
@@ -0,0 +1 @@
export * from "./build-module-ui.js";
@@ -0,0 +1,230 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { buildModuleUi, ModuleUiBuildError } from "../src/index.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true })),
);
});
interface ModuleFixtureOptions {
/** Omit to leave `entrypoints.ui` undeclared, as a backend-only module does. */
readonly ui?: string;
readonly manifest?: string;
readonly files?: Record<string, string>;
}
async function createModule(options: ModuleFixtureOptions = {}): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-ui-build-"));
temporaryDirectories.push(directory);
const manifest =
options.manifest ??
JSON.stringify({
manifestVersion: 4,
id: "example",
name: "Example",
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
entrypoints: {
server: "./dist/server/index.js",
...(options.ui === undefined ? {} : { ui: options.ui }),
},
});
await writeFile(path.join(directory, "ekklesia.module.json"), manifest);
for (const [relativePath, contents] of Object.entries(options.files ?? {})) {
const target = path.join(directory, relativePath);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, contents);
}
return directory;
}
/** A UI entrypoint with no React, so the build tool's own tests exercise the pipeline mechanics
* without depending on a UI framework. The real React path is proven end to end by the module
* template's own test suite. */
const plainUiSource = `
const definition = {
id: "example",
version: "0.1.0",
mount(container) {
container.textContent = "example";
return () => {
container.textContent = "";
};
},
};
export default definition;
`;
describe("module UI build", () => {
it("bundles the declared entrypoint into the exact path the manifest names", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: { "src/ui/index.tsx": plainUiSource },
});
const result = await buildModuleUi({ moduleDirectory: directory });
expect(result.outputFile).toBe(path.join(directory, "dist", "ui", "index.js"));
const artifact = await readFile(result.outputFile, "utf8");
expect(artifact).toContain("export {");
expect(artifact).toContain("as default");
});
it("honours an alternative declared artifact path", async () => {
const directory = await createModule({
ui: "./build/browser/ui.mjs",
files: { "src/ui/index.tsx": plainUiSource },
});
const result = await buildModuleUi({ moduleDirectory: directory });
expect(result.outputFile).toBe(path.join(directory, "build", "browser", "ui.mjs"));
});
it("emits a source map alongside the artifact", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: { "src/ui/index.tsx": plainUiSource },
});
const result = await buildModuleUi({ moduleDirectory: directory });
await expect(readFile(`${result.outputFile}.map`, "utf8")).resolves.toContain("sources");
});
it("reports a backend-only module as having no UI to build rather than failing silently", async () => {
const directory = await createModule({ files: { "src/ui/index.tsx": plainUiSource } });
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_ENTRYPOINT_NOT_DECLARED",
});
});
it("fails when the declared UI has no source entrypoint", async () => {
const directory = await createModule({ ui: "./dist/ui/index.js" });
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_SOURCE_MISSING",
});
});
it("fails when the manifest cannot be read", async () => {
const directory = await createModule({ manifest: "{ not json" });
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_MANIFEST_UNREADABLE",
});
});
it.each([
["escapes the package", "../outside/ui.js"],
["is absolute", "/tmp/ui.js"],
["traverses upward", "./dist/../../ui.js"],
["is source TypeScript", "./src/ui/index.tsx"],
["carries a query", "./dist/ui/index.js?raw"],
["uses backslashes", ".\\dist\\ui\\index.js"],
])("refuses to write an artifact whose declared path %s", async (_label, ui) => {
const directory = await createModule({ ui, files: { "src/ui/index.tsx": plainUiSource } });
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_ENTRYPOINT_UNSAFE",
});
});
it("surfaces a build failure with esbuild's diagnostics attached", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: { "src/ui/index.tsx": "export default { id: 'example', " },
});
try {
await buildModuleUi({ moduleDirectory: directory });
expect.unreachable("expected the build to fail");
} catch (error) {
expect(error).toBeInstanceOf(ModuleUiBuildError);
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_BUILD_FAILED");
// The sanitized message carries no path; the diagnostics a developer needs are attached.
expect((error as ModuleUiBuildError).message).not.toContain(directory);
expect((error as { diagnostics?: unknown[] }).diagnostics?.length).toBeGreaterThan(0);
}
});
it("fails on an unresolvable import instead of emitting a partial bundle", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: { "src/ui/index.tsx": 'import "totally-not-installed";\nexport default {};' },
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_BUILD_FAILED",
});
});
it.each([
"styles.css",
"theme.scss",
"layout.less",
])("rejects a %s import rather than emitting an undeclared sidecar asset", async (stylesheet) => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": `import "./${stylesheet}";\nexport default {};`,
[`src/ui/${stylesheet}`]: ".example { color: red; }",
},
});
try {
await buildModuleUi({ moduleDirectory: directory });
expect.unreachable("expected the stylesheet import to be rejected");
} catch (error) {
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_BUILD_FAILED");
const diagnostics = (error as { diagnostics?: { text?: string }[] }).diagnostics ?? [];
expect(diagnostics.some((entry) => entry.text?.includes("cannot import a stylesheet"))).toBe(
true,
);
}
});
it("never leaves a stylesheet sidecar next to the artifact", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": 'import "./styles.css";\nexport default {};',
"src/ui/styles.css": ".example { color: red; }",
},
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toBeInstanceOf(
ModuleUiBuildError,
);
await expect(
readFile(path.join(directory, "dist", "ui", "index.css"), "utf8"),
).rejects.toThrow();
});
it("substitutes NODE_ENV so the artifact never touches a Node global in a browser", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx":
'const mode = process.env.NODE_ENV;\nexport default { id: "example", version: "0.1.0", mount: () => { void mode; } };',
},
});
const result = await buildModuleUi({ moduleDirectory: directory });
const artifact = await readFile(result.outputFile, "utf8");
expect(artifact).toContain('"production"');
expect(artifact).not.toContain("process.env.NODE_ENV");
});
});
@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../typescript-config/node.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./browser.json",
"compilerOptions": {
"jsx": "react-jsx"
}
}
+2
View File
@@ -4,12 +4,14 @@
"private": true,
"files": [
"base.json",
"browser-react.json",
"browser.json",
"node.json",
"react-native.json"
],
"exports": {
"./base.json": "./base.json",
"./browser-react.json": "./browser-react.json",
"./browser.json": "./browser.json",
"./node.json": "./node.json",
"./react-native.json": "./react-native.json"
+136 -15
View File
@@ -29,7 +29,7 @@ importers:
version: 7.0.2
vitest:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
yaml:
specifier: 2.9.0
version: 2.9.0
@@ -63,7 +63,7 @@ importers:
version: 8.20.0
better-auth:
specifier: 1.6.23
version: 1.6.23([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
version: 1.6.23([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
pg:
specifier: 8.22.0
version: 8.22.0
@@ -72,7 +72,7 @@ importers:
version: 7.0.2
vitest:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
core:
dependencies:
@@ -81,7 +81,7 @@ importers:
version: link:../packages/contracts
better-auth:
specifier: 1.6.23
version: 1.6.23([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
version: 1.6.23([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
drizzle-orm:
specifier: 0.45.2
version: 0.45.2(@types/[email protected])([email protected])([email protected])
@@ -100,7 +100,7 @@ importers:
version: 8.20.0
auth:
specifier: 1.6.23
version: 1.6.23(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
version: 1.6.23(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
drizzle-kit:
specifier: 0.31.10
version: 0.31.10
@@ -109,7 +109,7 @@ importers:
version: 7.0.2
vitest:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages/contracts:
dependencies:
@@ -137,7 +137,7 @@ importers:
version: 7.0.2
vitest:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages/module-sdk:
dependencies:
@@ -159,7 +159,29 @@ importers:
version: 7.0.2
vitest:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages/module-ui-build:
dependencies:
'@ekklesia/contracts':
specifier: workspace:*
version: link:../contracts
esbuild:
specifier: 0.28.1
version: 0.28.1
devDependencies:
'@biomejs/biome':
specifier: 2.5.3
version: 2.5.3
'@types/node':
specifier: 24.13.3
version: 24.13.3
typescript:
specifier: 7.0.2
version: 7.0.2
vitest:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages/module-ui-sdk:
dependencies:
@@ -175,7 +197,7 @@ importers:
version: 7.0.2
vitest:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages/typescript-config: {}
@@ -184,28 +206,49 @@ importers:
'@ekklesia/module-sdk':
specifier: workspace:*
version: link:../../packages/module-sdk
'@ekklesia/module-ui-sdk':
specifier: workspace:*
version: link:../../packages/module-ui-sdk
drizzle-orm:
specifier: 0.45.2
version: 0.45.2(@types/[email protected])([email protected])([email protected])
react:
specifier: 19.2.8
version: 19.2.8
react-dom:
specifier: 19.2.8
version: 19.2.8([email protected])
devDependencies:
'@biomejs/biome':
specifier: 2.5.3
version: 2.5.3
'@ekklesia/module-ui-build':
specifier: workspace:*
version: link:../../packages/module-ui-build
'@ekklesia/typescript-config':
specifier: workspace:*
version: link:../../packages/typescript-config
'@types/node':
specifier: 24.13.3
version: 24.13.3
'@types/react':
specifier: 19.2.18
version: 19.2.18
'@types/react-dom':
specifier: 19.2.4
version: 19.2.4(@types/[email protected])
drizzle-kit:
specifier: 0.31.10
version: 0.31.10
happy-dom:
specifier: 20.0.11
version: 20.0.11
typescript:
specifier: 7.0.2
version: 7.0.2
vitest:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages:
@@ -1044,15 +1087,29 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/[email protected]':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
'@types/[email protected]':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
'@types/[email protected]':
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
'@types/[email protected]':
resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
peerDependencies:
'@types/react': ^19.2.0
'@types/[email protected]':
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
'@types/[email protected]':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
'@types/[email protected]':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
'@typescript/[email protected]':
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
engines: {node: '>=16.20.0'}
@@ -1333,6 +1390,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
[email protected]:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
[email protected]:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -1519,6 +1579,10 @@ packages:
resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g==}
engines: {node: '>=20.0.0'}
[email protected]:
resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==}
engines: {node: '>=16.9.0'}
@@ -1761,6 +1825,15 @@ packages:
[email protected]:
resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==}
[email protected]:
resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
peerDependencies:
react: ^19.2.8
[email protected]:
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
@@ -1783,6 +1856,9 @@ packages:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
[email protected]:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -1854,6 +1930,9 @@ packages:
engines: {node: '>=16.20.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
[email protected]:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
@@ -1947,6 +2026,10 @@ packages:
jsdom:
optional: true
[email protected]:
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
@@ -2634,6 +2717,10 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
undici-types: 6.21.0
'@types/[email protected]':
dependencies:
undici-types: 7.18.2
@@ -2644,8 +2731,18 @@ snapshots:
pg-protocol: 1.15.0
pg-types: 2.2.0
'@types/[email protected](@types/[email protected])':
dependencies:
'@types/react': 19.2.18
'@types/[email protected]':
dependencies:
csstype: 3.2.3
'@types/[email protected]': {}
'@types/[email protected]': {}
'@typescript/[email protected]':
optional: true
@@ -2749,7 +2846,7 @@ snapshots:
[email protected]: {}
[email protected](@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))):
[email protected](@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))):
dependencies:
'@babel/core': 7.29.7
'@babel/preset-react': 7.29.7(@babel/[email protected])
@@ -2759,7 +2856,7 @@ snapshots:
'@better-auth/utils': 0.4.2
'@clack/prompts': 0.11.0
'@mrleebo/prisma-ast': 0.13.1
better-auth: 1.6.23([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
better-auth: 1.6.23([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
c12: 3.3.4
chalk: 5.6.2
commander: 12.1.0
@@ -2803,7 +2900,7 @@ snapshots:
[email protected]: {}
[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))):
[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))):
dependencies:
'@better-auth/core': 1.6.23(@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
'@better-auth/drizzle-adapter': 1.6.23(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])([email protected](@types/[email protected])([email protected])([email protected]))
@@ -2826,7 +2923,9 @@ snapshots:
drizzle-kit: 0.31.10
drizzle-orm: 0.45.2(@types/[email protected])([email protected])([email protected])
pg: 8.22.0
vitest: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
react: 19.2.8
react-dom: 19.2.8([email protected])
vitest: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
transitivePeerDependencies:
- '@cloudflare/workers-types'
- '@opentelemetry/api'
@@ -2894,6 +2993,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
ms: 2.1.3
@@ -3015,6 +3116,12 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
'@types/node': 20.19.43
'@types/whatwg-mimetype': 3.0.2
whatwg-mimetype: 3.0.0
[email protected]: {}
[email protected]: {}
@@ -3198,6 +3305,13 @@ snapshots:
defu: 6.1.7
destr: 2.0.5
[email protected]([email protected]):
dependencies:
react: 19.2.8
scheduler: 0.27.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -3229,6 +3343,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -3306,6 +3422,8 @@ snapshots:
'@typescript/typescript-win32-arm64': 7.0.2
'@typescript/typescript-win32-x64': 7.0.2
[email protected]: {}
[email protected]: {}
[email protected]([email protected]):
@@ -3329,7 +3447,7 @@ snapshots:
tsx: 4.23.1
yaml: 2.9.0
[email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])):
[email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])):
dependencies:
'@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
@@ -3353,9 +3471,12 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.13.3
happy-dom: 20.0.11
transitivePeerDependencies:
- msw
[email protected]: {}
[email protected]:
dependencies:
siginfo: 2.0.0
+12 -6
View File
@@ -8,12 +8,18 @@ route (`GET /records`, declared in `register`) that reads that table through
module-constructed Drizzle instance or a raw `pg` client. Migration execution and storage
provisioning are intentionally not included.
This template ships backend behavior only, which is a choice rather than a constraint: as of
[ADR 0021](../../docs/architecture/decisions/0021-modules-as-hostable-products.md) a module may also
declare an optional compiled `entrypoints.ui` artifact. The template does not, because no host loads
a UI entrypoint yet — see
[Declaring a UI entrypoint](../../docs/module-development/README.md#declaring-a-ui-entrypoint) for
what is and is not supported today.
It also ships an **optional** React UI (`src/ui/`), compiled to the `entrypoints.ui` artifact its
manifest declares by `pnpm build`
([ADR 0023](../../docs/architecture/decisions/0023-module-ui-build-pipeline.md)). UI is a choice, not
a requirement: to keep a module backend-only, delete `src/ui/`, `tsconfig.ui.json`,
`tsconfig.ui-test.json`, `tests/module-ui.test.ts`, the `ui` entrypoint from the manifest, and the
React and module-UI dependencies. Everything else keeps working, and `ekklesia-build-module-ui`
simply reports that there is nothing to build.
No host mounts a module UI yet — the Dashboard shell does not exist. `tests/module-ui.test.ts` builds
the artifact from source, validates it with `parseModuleUiExport`, mounts it into a real DOM, and
unmounts it, which is what proves the pipeline today. See
[Declaring a UI entrypoint](../../docs/module-development/README.md#declaring-a-ui-entrypoint).
To create a module inside this repository:
+3 -2
View File
@@ -2,7 +2,7 @@
"manifestVersion": 4,
"id": "example",
"name": "Example Module",
"description": "A minimal Ekklesia module template with backend behavior only.",
"description": "A minimal Ekklesia module template with backend behavior and optional UI.",
"version": "0.1.0",
"compatibility": {
"platform": "^0.1.0",
@@ -32,6 +32,7 @@
"entrypoints": {
"server": "./dist/server/index.js",
"schema": "./dist/schema/index.js",
"migrations": "./drizzle"
"migrations": "./drizzle",
"ui": "./dist/ui/index.js"
}
}
+10 -3
View File
@@ -19,22 +19,29 @@
}
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui",
"lint": "biome lint .",
"migration:generate": "drizzle-kit generate --config=drizzle.config.ts",
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-template",
"test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json"
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json && tsc --noEmit --project tsconfig.ui-test.json"
},
"dependencies": {
"@ekklesia/module-sdk": "workspace:*",
"drizzle-orm": "0.45.2"
"@ekklesia/module-ui-sdk": "workspace:*",
"drizzle-orm": "0.45.2",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@ekklesia/module-ui-build": "workspace:*",
"@ekklesia/typescript-config": "workspace:*",
"@types/node": "24.13.3",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"drizzle-kit": "0.31.10",
"happy-dom": "20.0.11",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
+53
View File
@@ -0,0 +1,53 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { type ReactElement, useEffect, useState } from "react";
/**
* The template's example screen. It is deliberately tiny, and deliberately host-neutral: it reads
* everything it needs from the host context and hardcodes no URL, no origin, and no global.
*/
export function ExampleApp({ context }: { readonly context: ModuleUiHostContext }): ReactElement {
const [recordCount, setRecordCount] = useState<number | undefined>(undefined);
// Presentation-only gating. The server decides what actually happens; this only decides what the
// viewer is offered. See docs/development/security.md.
const canRead = context.permissions.has("example.records.read");
useEffect(() => {
if (!canRead) {
return;
}
let cancelled = false;
// Relative to this module's own API root. Never `/api/v1/modules/example/records`: that prefix
// is Ekklesia's private deployment detail, and building it here would make this artifact
// unhostable by a standalone host (ADR 0022).
void context.api
.request("records")
.then((response) => (response.ok ? response.json() : []))
.then((records: unknown) => {
if (!cancelled) {
setRecordCount(Array.isArray(records) ? records.length : 0);
}
})
.catch(() => {
if (!cancelled) {
setRecordCount(0);
}
});
return () => {
cancelled = true;
};
}, [canRead, context.api]);
if (!canRead) {
return <p style={{ margin: 0 }}>You do not have access to example records.</p>;
}
return (
<section style={{ display: "grid", gap: "0.5rem" }}>
<h1 style={{ margin: 0 }}>Example module UI</h1>
<p style={{ margin: 0 }} data-testid="record-count">
{recordCount === undefined ? "Loading records…" : `${recordCount} record(s)`}
</p>
</section>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
import { createRoot } from "react-dom/client";
import { ExampleApp } from "./ExampleApp.js";
/**
* The module's compiled UI entrypoint. `pnpm build` bundles this file, and everything it imports,
* into the single browser artifact this module's manifest declares as `entrypoints.ui`.
*
* `id` and `version` must match the manifest: a host verifies both before mounting, so a stale
* artifact is caught rather than rendered.
*/
export default defineModuleUi({
id: "example",
version: "0.1.0",
mount(container, context) {
const root = createRoot(container);
root.render(<ExampleApp context={context} />);
// React owns a root here, so the mount must return an unmount that releases it.
return () => root.unmount();
},
});
+185
View File
@@ -0,0 +1,185 @@
// @vitest-environment happy-dom
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { buildModuleUi } from "@ekklesia/module-ui-build";
import { parseModuleManifest } from "@ekklesia/module-sdk";
import {
type ModuleUiApiClient,
type ModuleUiHostContext,
parseModuleUiExport,
} from "@ekklesia/module-ui-sdk";
import { beforeAll, describe, expect, it } from "vitest";
/**
* The end-to-end proof for the module UI build pipeline (ADR 0023):
*
* React/TSX source -> `pnpm build` -> compiled artifact at the manifest's `entrypoints.ui`
* -> `parseModuleUiExport` -> `mount(container, context)` -> `unmount()`
*
* The artifact is rebuilt from source here rather than read from a checked-in file, so the test can
* never pass against stale output. `dist/` is gitignored; nothing generated is committed.
*/
// `happy-dom` replaces the global `URL` with the DOM implementation, which `node:fs` rejects, so
// this test works in plain filesystem paths and converts to a file URL only for dynamic import.
const moduleDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
let artifactHref: string;
let artifactPath: string;
let manifestUi: string;
let manifestIdentity: { id: string; version: string };
/** Records what the host was asked for, so the test can prove the UI never builds a URL itself. */
function recordingHost(options: {
readonly canRead: boolean;
readonly records: readonly unknown[];
readonly requested: string[];
readonly resolve: (path: string) => void;
}): ModuleUiHostContext {
const api: ModuleUiApiClient = {
async request(path) {
options.requested.push(path);
options.resolve(path);
return new Response(JSON.stringify(options.records), {
headers: { "content-type": "application/json" },
status: 200,
});
},
};
return {
moduleId: "example",
api,
principal: { kind: "anonymous" },
permissions: { has: () => options.canRead },
};
}
async function flush(): Promise<void> {
// Let React commit and the awaited fetch microtasks settle.
for (let index = 0; index < 5; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
describe("module UI build pipeline", () => {
beforeAll(async () => {
const manifest = parseModuleManifest(
JSON.parse(
await readFile(path.join(moduleDirectory, "ekklesia.module.json"), "utf8"),
) as unknown,
);
if (!manifest.entrypoints.ui) {
throw new Error("The template manifest must declare entrypoints.ui for this test.");
}
manifestUi = manifest.entrypoints.ui;
manifestIdentity = { id: manifest.id, version: manifest.version };
const result = await buildModuleUi({ moduleDirectory });
artifactPath = result.outputFile;
artifactHref = pathToFileURL(result.outputFile).href;
}, 60_000);
it("writes the artifact exactly where the manifest declares it", () => {
expect(manifestUi).toBe("./dist/ui/index.js");
expect(artifactPath.endsWith(path.join("dist", "ui", "index.js"))).toBe(true);
});
it("produces a browser-safe ESM artifact that needs no host-provided React", async () => {
const artifact = await readFile(artifactPath, "utf8");
// React is bundled, so the artifact resolves nothing at run time (ADR 0023). A bare top-level
// import would mean the host had to supply that module.
expect(artifact).not.toMatch(/^import\s.*from\s*["'][^.]/mu);
expect(artifact).toContain("export {");
// No Ekklesia deployment detail may be baked into a portable artifact.
expect(artifact).not.toContain("/api/v1/modules");
// `process.env` must be substituted at build time; a browser has no `process`.
expect(artifact).not.toContain("process.env.NODE_ENV");
});
it("satisfies parseModuleUiExport and matches the manifest identity", async () => {
const namespace = (await import(artifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, manifestIdentity);
expect(definition.id).toBe("example");
expect(definition.version).toBe("0.1.0");
expect(typeof definition.mount).toBe("function");
});
it("mounts real React into a container, then unmounts and cleans up", async () => {
const namespace = (await import(artifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, manifestIdentity);
const container = document.createElement("div");
document.body.append(container);
const requested: string[] = [];
const unmount = await definition.mount(
container,
recordingHost({
canRead: true,
records: [
{ id: "a", value: "one" },
{ id: "b", value: "two" },
],
requested,
resolve: () => undefined,
}),
);
await flush();
expect(container.textContent).toContain("Example module UI");
expect(container.querySelector("[data-testid='record-count']")?.textContent).toBe(
"2 record(s)",
);
expect(typeof unmount).toBe("function");
await (unmount as () => Promise<void>)();
expect(container.textContent).toBe("");
container.remove();
});
it("asks its host for a relative path, never Ekklesia's module API prefix", async () => {
const namespace = (await import(artifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, manifestIdentity);
const container = document.createElement("div");
const requested: string[] = [];
const resolvedUrls: string[] = [];
const unmount = await definition.mount(
container,
recordingHost({
canRead: true,
records: [],
requested,
// A standalone host resolving the same relative path to a completely different URL.
resolve: (path) => {
resolvedUrls.push(`https://example.test/api/${path}`);
},
}),
);
await flush();
expect(requested).toEqual(["records"]);
expect(resolvedUrls).toEqual(["https://example.test/api/records"]);
await (unmount as () => Promise<void>)();
});
it("renders a denied state from the host's presentation-only permission hint", async () => {
const namespace = (await import(artifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, manifestIdentity);
const container = document.createElement("div");
const requested: string[] = [];
const unmount = await definition.mount(
container,
recordingHost({ canRead: false, records: [], requested, resolve: () => undefined }),
);
await flush();
expect(container.textContent).toContain("do not have access");
// A hidden control is a usability choice, not enforcement: the module simply did not ask.
// Server-side authorization is what actually protects the route.
expect(requested).toEqual([]);
await (unmount as () => Promise<void>)();
});
});
+2 -1
View File
@@ -4,5 +4,6 @@
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["drizzle.config.ts", "src/**/*.ts", "tests/**/*.ts"]
"include": ["drizzle.config.ts", "src/**/*.ts", "tests/**/*.ts"],
"exclude": ["tests/module-ui.test.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@ekklesia/typescript-config/browser-react.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["tests/module-ui.test.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@ekklesia/typescript-config/browser-react.json",
"compilerOptions": {
"noEmit": true,
"types": []
},
"include": ["src/ui/**/*.tsx", "src/ui/**/*.ts"]
}
+14 -1
View File
@@ -49,6 +49,7 @@ describe("repository dependency boundaries", () => {
["module-ui-framework", "modules-do-not-own-ui"],
["module-ui-aliased-framework", "modules-do-not-own-ui"],
["module-ui-imports-core", "modules-use-public-platform-contracts"],
["module-ui-bundler-plugin", "modules-do-not-own-ui"],
["module-imports-ui-package", "modules-use-public-platform-contracts"],
["cross-package-relative", "workspace-imports-use-public-exports"],
["cycle", "no-circular-source-dependencies"],
@@ -111,12 +112,24 @@ describe("repository dependency boundaries", () => {
]);
});
it("accepts a module that owns React UI through the public module UI SDK", async () => {
it("accepts the canonical module UI dependency set: React, the UI SDK, and the build tool", async () => {
// Mirrors what `templates/module` actually declares (ADR 0023). The build tool is a `tooling`
// shared package, a role modules were already permitted to depend on, so the module UI build
// pipeline required no boundary relaxation at all.
const result = await checkBoundaries(path.join(fixtures, "module-owns-react-ui"));
expect(result.violations).toEqual([]);
});
it("still rejects a module reaching for a bundler plugin ecosystem", async () => {
// Module authors do not configure a bundler; `@ekklesia/module-ui-build` owns that entirely.
const result = await checkBoundaries(path.join(fixtures, "module-ui-bundler-plugin"));
expect(result.violations.map((violation) => violation.message)).toEqual([
"Module package depends on UI framework @vitejs/plugin-react.",
]);
});
it("rejects an unsupported framework aliased behind the supported one", async () => {
const result = await checkBoundaries(path.join(fixtures, "module-ui-aliased-framework"));
@@ -12,5 +12,8 @@
},
"ekklesia": {
"layer": "module"
},
"devDependencies": {
"@ekklesia/module-ui-build": "workspace:*"
}
}
@@ -0,0 +1,11 @@
{
"name": "@ekklesia/module-ui-build",
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "tooling"
}
}
@@ -0,0 +1 @@
export function buildModuleUi(): void {}
@@ -0,0 +1,14 @@
{
"name": "@ekklesia/module-calendar",
"version": "0.0.0",
"type": "module",
"devDependencies": {
"@vitejs/plugin-react": "5.1.1"
},
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "module"
}
}
@@ -0,0 +1 @@
export const moduleId = "calendar";