feat: build module UI into its declared browser artifact
CI / verify (pull_request) Successful in 32s
CI / infrastructure-smoke (pull_request) Successful in 26s
CI / migration-smoke (pull_request) Successful in 50s

ADR 0022 defined what a module UI is and selected React and TypeScript
as the authoring model, but closed by recording that it was not buildable
end to end: `browser.json` preserves JSX, bundler plugins are denied, and
nothing turned a module's `.tsx` into the artifact its manifest declares.
This closes exactly that gap.

esbuild produces the artifact. It was already the right answer before it
was chosen: this workspace resolves esbuild 0.28.1 through Vite/Vitest
today and `pnpm-workspace.yaml` already carries `allowBuilds: esbuild`,
so the pinned dependency adds no second copy and no new trust decision.
It is also the smallest tool that covers TSX, ESM, browser platform,
bundling, source maps, and a define-time constant with no plugin
ecosystem. Vite library mode would drag in `@vitejs/plugin-react`, which
the module boundary denies on purpose; `tsc` alone cannot bundle, so its
output would carry bare `react/jsx-runtime` imports no browser resolves.

React is bundled, not externalized. That follows from ADR 0022 rather
than from convenience: `mount()` was made the ABI precisely so React
would not be part of the host contract, and externalizing it would
reverse that through the build system — every host would have to supply
a compatible React, and a standalone host might not use React at all.
The cost is real and accepted: duplicate runtimes and a ~570 KB
unminified artifact. Sharing one runtime later is a build optimization
that cannot change the contract, because the contract is a mount
function.

Output is browser ESM at `es2022`, unminified so an operator can review
what they install, with `process.env.NODE_ENV` substituted — React reads
it at module scope and would otherwise touch a Node global that does not
exist in a browser. The output path is read from the module's own
manifest rather than a build convention, so declared and produced
artifacts cannot drift, and it is validated with the same
`packageRelativePathSchema` discovery enforces, so a build can never
write where discovery would later reject.

`@ekklesia/module-ui-build` owns every option; modules own none. It uses
the existing `tooling` role that modules could already depend on, so this
pass required no boundary relaxation at all. Its `bin` points at a
committed launcher rather than compiled output because pnpm links bins
during install, before Turborepo builds anything — a bin targeting
`dist/cli.js` fails to link on a fresh clone and breaks every module's UI
build in CI. A module with no `entrypoints.ui` is not an error; the CLI
reports nothing to build and exits zero, so one script serves both kinds
of module.

Styling is React inline style objects. A stylesheet import fails the
build rather than emitting a sidecar `.css` that no manifest declares and
no host loads — an undeclared asset would appear to work in tests and
silently do nothing in a real host.

`templates/module` is the canonical example and now ships optional UI.
Its tests build the artifact from source, validate it with
`parseModuleUiExport`, mount it into a real DOM, assert the rendered
output, and unmount it; the artifact is rebuilt per run so the proof can
never pass against stale output, and `dist/` stays gitignored. Because
verify runs both `pnpm test` and `pnpm build`, a UI that cannot be
produced fails CI through two independent paths.

One correction worth stating plainly: the UI TypeScript project declares
`"types": []`, and a UI file referencing `process` does fail in
isolation, but TypeScript still pulls Node globals in transitively — a
file importing `@ekklesia/module-ui-sdk` reaches `@ekklesia/contracts`
and through its Zod and Drizzle types `process.env` typechecks again.
The docs and ADR describe this as the convention it is rather than the
guard it is not.

Core is untouched. Building is an authoring step; discovery still only
validates the artifact a module shipped and never executes or produces
one.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-21 21:24:18 -05:00
co-authored by Claude Opus 5
parent f9c1108527
commit 1e5866504e
34 changed files with 1388 additions and 45 deletions
@@ -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";