diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1ff2389..19dab94 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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//...`, which is Ekklesia's private deployment detail and would break standalone hosting. diff --git a/README.md b/README.md index ff5913f..58f4867 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md b/docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md index 942f09b..f0d8566 100644 --- a/docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md +++ b/docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md @@ -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 diff --git a/docs/architecture/decisions/0023-module-ui-build-pipeline.md b/docs/architecture/decisions/0023-module-ui-build-pipeline.md new file mode 100644 index 0000000..90c27f7 --- /dev/null +++ b/docs/architecture/decisions/0023-module-ui-build-pipeline.md @@ -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. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 703548b..89d612e 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -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 diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index 534f493..1d0bb54 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -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. diff --git a/docs/architecture/repository-boundaries.md b/docs/architecture/repository-boundaries.md index c492097..2c77a08 100644 --- a/docs/architecture/repository-boundaries.md +++ b/docs/architecture/repository-boundaries.md @@ -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 diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 0cdbc22..0036462 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -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 +
…
+``` + +**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. diff --git a/packages/module-ui-build/bin/ekklesia-build-module-ui.mjs b/packages/module-ui-build/bin/ekklesia-build-module-ui.mjs new file mode 100755 index 0000000..cdf0894 --- /dev/null +++ b/packages/module-ui-build/bin/ekklesia-build-module-ui.mjs @@ -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; + } +} diff --git a/packages/module-ui-build/package.json b/packages/module-ui-build/package.json new file mode 100644 index 0000000..3d24a39 --- /dev/null +++ b/packages/module-ui-build/package.json @@ -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" + } +} diff --git a/packages/module-ui-build/src/build-module-ui.ts b/packages/module-ui-build/src/build-module-ui.ts new file mode 100644 index 0000000..a8858a0 --- /dev/null +++ b/packages/module-ui-build/src/build-module-ui.ts @@ -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> = 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 { + 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 { + 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 { + 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 }; +} diff --git a/packages/module-ui-build/src/cli.ts b/packages/module-ui-build/src/cli.ts new file mode 100644 index 0000000..306cb28 --- /dev/null +++ b/packages/module-ui-build/src/cli.ts @@ -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 { + 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 { + 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(); diff --git a/packages/module-ui-build/src/index.ts b/packages/module-ui-build/src/index.ts new file mode 100644 index 0000000..4b0d9d8 --- /dev/null +++ b/packages/module-ui-build/src/index.ts @@ -0,0 +1 @@ +export * from "./build-module-ui.js"; diff --git a/packages/module-ui-build/tests/build-module-ui.test.ts b/packages/module-ui-build/tests/build-module-ui.test.ts new file mode 100644 index 0000000..fde3f75 --- /dev/null +++ b/packages/module-ui-build/tests/build-module-ui.test.ts @@ -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; +} + +async function createModule(options: ModuleFixtureOptions = {}): Promise { + 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"); + }); +}); diff --git a/packages/module-ui-build/tsconfig.build.json b/packages/module-ui-build/tsconfig.build.json new file mode 100644 index 0000000..4f5dbdb --- /dev/null +++ b/packages/module-ui-build/tsconfig.build.json @@ -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"] +} diff --git a/packages/module-ui-build/tsconfig.json b/packages/module-ui-build/tsconfig.json new file mode 100644 index 0000000..0d93162 --- /dev/null +++ b/packages/module-ui-build/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../typescript-config/node.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/packages/typescript-config/browser-react.json b/packages/typescript-config/browser-react.json new file mode 100644 index 0000000..5707887 --- /dev/null +++ b/packages/typescript-config/browser-react.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./browser.json", + "compilerOptions": { + "jsx": "react-jsx" + } +} diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index b220925..f4e1db1 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f6dc36..3e2ca1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,7 +29,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) 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(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(pg@8.22.0)(vitest@4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))) + version: 1.6.23(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))) 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/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) core: dependencies: @@ -81,7 +81,7 @@ importers: version: link:../packages/contracts better-auth: specifier: 1.6.23 - version: 1.6.23(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(pg@8.22.0)(vitest@4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))) + version: 1.6.23(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))) drizzle-orm: specifier: 0.45.2 version: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) @@ -100,7 +100,7 @@ importers: version: 8.20.0 auth: specifier: 1.6.23 - version: 1.6.23(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1)(pg@8.22.0)(vitest@4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))) + version: 1.6.23(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1)(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))) 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/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) packages/contracts: dependencies: @@ -137,7 +137,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) packages/module-sdk: dependencies: @@ -159,7 +159,29 @@ importers: version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + + 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/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) packages/module-ui-sdk: dependencies: @@ -175,7 +197,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) 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/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) + react: + specifier: 19.2.8 + version: 19.2.8 + react-dom: + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) 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/react@19.2.18) 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/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) packages: @@ -1044,15 +1087,29 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -1333,6 +1390,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1519,6 +1579,10 @@ packages: resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==} hasBin: true + happy-dom@20.0.11: + resolution: {integrity: sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g==} + engines: {node: '>=20.0.0'} + hono@4.12.31: resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==} engines: {node: '>=16.9.0'} @@ -1761,6 +1825,15 @@ packages: rc9@3.0.1: resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -1783,6 +1856,9 @@ packages: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -1854,6 +1930,9 @@ packages: engines: {node: '>=16.20.0'} hasBin: true + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -1947,6 +2026,10 @@ packages: jsdom: optional: true + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -2634,6 +2717,10 @@ snapshots: '@types/estree@1.0.9': {} + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + '@types/node@24.13.3': dependencies: undici-types: 7.18.2 @@ -2644,8 +2731,18 @@ snapshots: pg-protocol: 1.15.0 pg-types: 2.2.0 + '@types/react-dom@19.2.4(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + '@types/semver@7.7.1': {} + '@types/whatwg-mimetype@3.0.2': {} + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -2749,7 +2846,7 @@ snapshots: assertion-error@2.0.1: {} - auth@1.6.23(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1)(pg@8.22.0)(vitest@4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))): + auth@1.6.23(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1)(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))): dependencies: '@babel/core': 7.29.7 '@babel/preset-react': 7.29.7(@babel/core@7.29.7) @@ -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(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(pg@8.22.0)(vitest@4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))) + better-auth: 1.6.23(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))) c12: 3.3.4 chalk: 5.6.2 commander: 12.1.0 @@ -2803,7 +2900,7 @@ snapshots: baseline-browser-mapping@2.10.44: {} - better-auth@1.6.23(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(pg@8.22.0)(vitest@4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))): + better-auth@1.6.23(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))): dependencies: '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/drizzle-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0)) @@ -2826,7 +2923,9 @@ snapshots: drizzle-kit: 0.31.10 drizzle-orm: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) pg: 8.22.0 - vitest: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + vitest: 4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -2894,6 +2993,8 @@ snapshots: convert-source-map@2.0.0: {} + csstype@3.2.3: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -3015,6 +3116,12 @@ snapshots: giget@3.3.0: {} + happy-dom@20.0.11: + dependencies: + '@types/node': 20.19.43 + '@types/whatwg-mimetype': 3.0.2 + whatwg-mimetype: 3.0.0 + hono@4.12.31: {} is-docker@3.0.0: {} @@ -3198,6 +3305,13 @@ snapshots: defu: 6.1.7 destr: 2.0.5 + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react@19.2.8: {} + readdirp@5.0.0: {} regexp-to-ast@0.5.0: {} @@ -3229,6 +3343,8 @@ snapshots: run-applescript@7.1.0: {} + scheduler@0.27.0: {} + semver@6.3.1: {} semver@7.8.5: {} @@ -3306,6 +3422,8 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 + undici-types@6.21.0: {} + undici-types@7.18.2: {} update-browserslist-db@1.2.3(browserslist@4.28.6): @@ -3329,7 +3447,7 @@ snapshots: tsx: 4.23.1 yaml: 2.9.0 - vitest@4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + vitest@4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) @@ -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 + whatwg-mimetype@3.0.0: {} + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 diff --git a/templates/module/README.md b/templates/module/README.md index 349a800..7b60417 100644 --- a/templates/module/README.md +++ b/templates/module/README.md @@ -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: diff --git a/templates/module/ekklesia.module.json b/templates/module/ekklesia.module.json index 950625f..bf4fdbf 100644 --- a/templates/module/ekklesia.module.json +++ b/templates/module/ekklesia.module.json @@ -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" } } diff --git a/templates/module/package.json b/templates/module/package.json index 36f56d3..19de833 100644 --- a/templates/module/package.json +++ b/templates/module/package.json @@ -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" }, diff --git a/templates/module/src/ui/ExampleApp.tsx b/templates/module/src/ui/ExampleApp.tsx new file mode 100644 index 0000000..040fe74 --- /dev/null +++ b/templates/module/src/ui/ExampleApp.tsx @@ -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(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

You do not have access to example records.

; + } + + return ( +
+

Example module UI

+

+ {recordCount === undefined ? "Loading records…" : `${recordCount} record(s)`} +

+
+ ); +} diff --git a/templates/module/src/ui/index.tsx b/templates/module/src/ui/index.tsx new file mode 100644 index 0000000..42865fa --- /dev/null +++ b/templates/module/src/ui/index.tsx @@ -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(); + // React owns a root here, so the mount must return an unmount that releases it. + return () => root.unmount(); + }, +}); diff --git a/templates/module/tests/module-ui.test.ts b/templates/module/tests/module-ui.test.ts new file mode 100644 index 0000000..653f225 --- /dev/null +++ b/templates/module/tests/module-ui.test.ts @@ -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 { + // 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)(); + 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)(); + }); + + 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)(); + }); +}); diff --git a/templates/module/tsconfig.json b/templates/module/tsconfig.json index 6df60d4..f6f4e31 100644 --- a/templates/module/tsconfig.json +++ b/templates/module/tsconfig.json @@ -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"] } diff --git a/templates/module/tsconfig.ui-test.json b/templates/module/tsconfig.ui-test.json new file mode 100644 index 0000000..485c891 --- /dev/null +++ b/templates/module/tsconfig.ui-test.json @@ -0,0 +1,8 @@ +{ + "extends": "@ekklesia/typescript-config/browser-react.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["tests/module-ui.test.ts"] +} diff --git a/templates/module/tsconfig.ui.json b/templates/module/tsconfig.ui.json new file mode 100644 index 0000000..ffb681f --- /dev/null +++ b/templates/module/tsconfig.ui.json @@ -0,0 +1,8 @@ +{ + "extends": "@ekklesia/typescript-config/browser-react.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "include": ["src/ui/**/*.tsx", "src/ui/**/*.ts"] +} diff --git a/tests/boundaries.test.ts b/tests/boundaries.test.ts index 2950ace..554de9c 100644 --- a/tests/boundaries.test.ts +++ b/tests/boundaries.test.ts @@ -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")); diff --git a/tests/fixtures/boundaries/module-owns-react-ui/modules/announcements/package.json b/tests/fixtures/boundaries/module-owns-react-ui/modules/announcements/package.json index b2f61d4..576e6aa 100644 --- a/tests/fixtures/boundaries/module-owns-react-ui/modules/announcements/package.json +++ b/tests/fixtures/boundaries/module-owns-react-ui/modules/announcements/package.json @@ -12,5 +12,8 @@ }, "ekklesia": { "layer": "module" + }, + "devDependencies": { + "@ekklesia/module-ui-build": "workspace:*" } } diff --git a/tests/fixtures/boundaries/module-owns-react-ui/packages/module-ui-build/package.json b/tests/fixtures/boundaries/module-owns-react-ui/packages/module-ui-build/package.json new file mode 100644 index 0000000..27c5d4f --- /dev/null +++ b/tests/fixtures/boundaries/module-owns-react-ui/packages/module-ui-build/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/module-ui-build", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "tooling" + } +} diff --git a/tests/fixtures/boundaries/module-owns-react-ui/packages/module-ui-build/src/index.ts b/tests/fixtures/boundaries/module-owns-react-ui/packages/module-ui-build/src/index.ts new file mode 100644 index 0000000..ce8a786 --- /dev/null +++ b/tests/fixtures/boundaries/module-owns-react-ui/packages/module-ui-build/src/index.ts @@ -0,0 +1 @@ +export function buildModuleUi(): void {} diff --git a/tests/fixtures/boundaries/module-ui-bundler-plugin/modules/calendar/package.json b/tests/fixtures/boundaries/module-ui-bundler-plugin/modules/calendar/package.json new file mode 100644 index 0000000..42e1b60 --- /dev/null +++ b/tests/fixtures/boundaries/module-ui-bundler-plugin/modules/calendar/package.json @@ -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" + } +} diff --git a/tests/fixtures/boundaries/module-ui-bundler-plugin/modules/calendar/src/index.ts b/tests/fixtures/boundaries/module-ui-bundler-plugin/modules/calendar/src/index.ts new file mode 100644 index 0000000..1f0c5a0 --- /dev/null +++ b/tests/fixtures/boundaries/module-ui-bundler-plugin/modules/calendar/src/index.ts @@ -0,0 +1 @@ +export const moduleId = "calendar";