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
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env node
// Committed launcher, deliberately not generated.
//
// pnpm creates `.bin` links during `pnpm install`, which runs before Turborepo builds anything. A
// `bin` pointing straight at `dist/cli.js` therefore fails to link on a fresh clone — the compiled
// file does not exist yet — and every module's UI build would break in CI. This file always exists,
// so the link always succeeds; it defers to the compiled CLI at run time, by which point
// `dependsOn: ["^build"]` has produced it.
import { fileURLToPath } from "node:url";
const compiledCli = new URL("../dist/cli.js", import.meta.url);
try {
await import(compiledCli.href);
} catch (error) {
if (error?.code === "ERR_MODULE_NOT_FOUND") {
process.stderr.write(
`@ekklesia/module-ui-build is not built yet (${fileURLToPath(compiledCli)}).\n` +
"Run `pnpm build` from the repository root first.\n",
);
process.exitCode = 1;
} else {
throw error;
}
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@ekklesia/module-ui-build",
"version": "0.1.0",
"type": "module",
"files": [
"bin",
"dist"
],
"bin": {
"ekklesia-build-module-ui": "./bin/ekklesia-build-module-ui.mjs"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"lint": "biome lint .",
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-ui-build",
"test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json"
},
"dependencies": {
"@ekklesia/contracts": "workspace:*",
"esbuild": "0.28.1"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@types/node": "24.13.3",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
"ekklesia": {
"layer": "tooling"
}
}
@@ -0,0 +1,181 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { packageRelativePathSchema } from "@ekklesia/contracts";
import { build, type Plugin } from "esbuild";
/**
* The one supported way to turn a module's React/TSX UI source into the compiled browser artifact
* its manifest declares as `entrypoints.ui` (ADR 0021, ADR 0022, ADR 0023).
*
* Module authors do not write bundler configuration. They put their UI at
* {@link MODULE_UI_SOURCE_ENTRYPOINT}, declare `entrypoints.ui` in their manifest, and run the
* build; every option below is owned here so one decision applies to every module.
*/
export const MODULE_UI_SOURCE_ENTRYPOINT = "src/ui/index.tsx";
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
/** Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree. */
export const MODULE_UI_BUILD_TARGET = "es2022";
export type ModuleUiBuildErrorCode =
| "MODULE_UI_MANIFEST_UNREADABLE"
| "MODULE_UI_ENTRYPOINT_NOT_DECLARED"
| "MODULE_UI_ENTRYPOINT_UNSAFE"
| "MODULE_UI_SOURCE_MISSING"
| "MODULE_UI_BUILD_FAILED";
const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.freeze({
MODULE_UI_MANIFEST_UNREADABLE: `${MODULE_MANIFEST_FILENAME} could not be read or parsed.`,
MODULE_UI_ENTRYPOINT_NOT_DECLARED: `${MODULE_MANIFEST_FILENAME} declares no entrypoints.ui, so there is no module UI to build.`,
MODULE_UI_ENTRYPOINT_UNSAFE: `entrypoints.ui must be a package-relative path to a compiled .js or .mjs artifact inside the module.`,
MODULE_UI_SOURCE_MISSING: `Module UI source was not found at ${MODULE_UI_SOURCE_ENTRYPOINT}.`,
MODULE_UI_BUILD_FAILED: "The module UI bundle could not be produced.",
});
/** Carries a stable code and a fixed message. esbuild's own diagnostics are printed to stderr by
* the CLI, where a developer wants them; the thrown error itself stays sanitized, matching every
* other Ekklesia error boundary. */
export class ModuleUiBuildError extends Error {
readonly code: ModuleUiBuildErrorCode;
constructor(code: ModuleUiBuildErrorCode) {
super(errorMessages[code]);
this.name = "ModuleUiBuildError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
}
}
/**
* Rejects a stylesheet import instead of quietly emitting one.
*
* esbuild would happily bundle `import "./styles.css"` into a sidecar `index.css` next to the
* artifact. Nothing declares that file, no host loads it, and the manifest has no field for it — so
* it would work in a local test and silently do nothing in a real host. Module styling has no
* public loading contract yet (ADR 0023), so the build fails loudly rather than producing an
* undeclared asset. `scripts/check-boundaries.ts` rejects a `.css` file authored *inside* a module;
* this additionally covers a stylesheet reached through a dependency.
*/
function rejectStylesheetImports(): Plugin {
return {
name: "ekklesia-reject-stylesheet-imports",
setup(pluginBuild) {
// No `u` flag, deliberately: esbuild compiles a plugin filter to a Go RE2 expression, which
// rejects `(?iu)` outright ("filter is not a valid Go regular expression"). This pattern is
// pure ASCII, so the flag would change nothing anyway.
pluginBuild.onResolve({ filter: /\.(css|s[ac]ss|less|styl|pcss|postcss)$/i }, (args) => ({
errors: [
{
text:
"Module UI cannot import a stylesheet yet: there is no declared way for a host to " +
"load one. Use React inline style objects for now (ADR 0023).",
location: { file: args.importer },
},
],
}));
},
};
}
export interface BuildModuleUiOptions {
/** The module package directory containing `ekklesia.module.json`. */
readonly moduleDirectory: string;
}
export interface ModuleUiBuildResult {
/** Absolute path of the artifact actually written, which is exactly what the manifest declares. */
readonly outputFile: string;
/** Absolute path of the source entrypoint that produced it. */
readonly sourceFile: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** The declared artifact path, validated the same way module discovery validates it, so the build
* can never write somewhere discovery would later reject. */
async function resolveDeclaredArtifact(moduleDirectory: string): Promise<string> {
let manifest: unknown;
try {
manifest = JSON.parse(
await readFile(path.join(moduleDirectory, MODULE_MANIFEST_FILENAME), "utf8"),
) as unknown;
} catch {
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE");
}
const entrypoints = isRecord(manifest) ? manifest.entrypoints : undefined;
const declared = isRecord(entrypoints) ? entrypoints.ui : undefined;
if (declared === undefined) {
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_NOT_DECLARED");
}
// The path grammar is Core's, not this tool's: reusing the contracts schema means the build can
// never write to a location module discovery would later reject, even if that grammar changes.
if (
!packageRelativePathSchema.safeParse(declared).success ||
typeof declared !== "string" ||
![".js", ".mjs"].includes(path.extname(declared).toLowerCase())
) {
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE");
}
return path.resolve(moduleDirectory, declared);
}
/**
* Bundles a module's UI into the artifact its manifest declares.
*
* Never runs during discovery, startup, or install: this is an authoring-time and packaging-time
* step only. Core validates the artifact a module ships; it never produces one (ADR 0021).
*/
export async function buildModuleUi(options: BuildModuleUiOptions): Promise<ModuleUiBuildResult> {
const moduleDirectory = path.resolve(options.moduleDirectory);
const outputFile = await resolveDeclaredArtifact(moduleDirectory);
const sourceFile = path.join(moduleDirectory, MODULE_UI_SOURCE_ENTRYPOINT);
try {
await readFile(sourceFile);
} catch {
throw new ModuleUiBuildError("MODULE_UI_SOURCE_MISSING");
}
try {
await build({
absWorkingDir: moduleDirectory,
// Every dependency, React included, is bundled. The artifact must run in any host, including
// one that uses no React at all, so nothing is externalized (ADR 0023).
bundle: true,
define: {
// React reads this at module scope. Without it the bundle would reference `process`, a
// Node global that does not exist in a browser, and crash on load.
"process.env.NODE_ENV": '"production"',
},
entryPoints: [sourceFile],
// ESM only: the artifact is imported by a host with a dynamic `import()`, and the contract
// requires a real default export.
format: "esm",
jsx: "automatic",
// Unminified deliberately: an operator is expected to review the artifact they install
// (see docs/development/security.md), and nothing else in this repository minifies.
minify: false,
outfile: outputFile,
platform: "browser",
plugins: [rejectStylesheetImports()],
sourcemap: true,
target: MODULE_UI_BUILD_TARGET,
// Fail rather than silently shipping a partial bundle.
logLevel: "silent",
});
} catch (error) {
// esbuild's structured diagnostics are surfaced by the CLI; rethrow a sanitized error so a
// programmatic caller never receives raw paths or internals.
if (isRecord(error) && Array.isArray(error.errors)) {
throw Object.assign(new ModuleUiBuildError("MODULE_UI_BUILD_FAILED"), {
diagnostics: error.errors,
});
}
throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED");
}
return { outputFile, sourceFile };
}
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
import path from "node:path";
import process from "node:process";
import { formatMessages } from "esbuild";
import { buildModuleUi, ModuleUiBuildError } from "./build-module-ui.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* `ekklesia-build-module-ui` — the canonical module UI build command, run from a module directory.
*
* A module with no `entrypoints.ui` is not an error: backend-only modules stay first-class and this
* command reports that there was nothing to build and exits successfully, so one `build` script
* works for every module.
*/
async function main(): Promise<number> {
const moduleDirectory = process.argv[2] ?? process.cwd();
try {
const result = await buildModuleUi({ moduleDirectory });
process.stdout.write(`Built module UI: ${path.relative(moduleDirectory, result.outputFile)}\n`);
return 0;
} catch (error) {
if (error instanceof ModuleUiBuildError) {
if (error.code === "MODULE_UI_ENTRYPOINT_NOT_DECLARED") {
process.stdout.write("No entrypoints.ui declared; skipping the module UI build.\n");
return 0;
}
process.stderr.write(`${error.message}\n`);
// esbuild's own diagnostics carry the file, line, and reason a developer needs. They are
// printed here, never folded into the sanitized error message itself.
const diagnostics = isRecord(error) ? error.diagnostics : undefined;
if (Array.isArray(diagnostics) && diagnostics.length > 0) {
const formatted = await formatMessages(diagnostics, { kind: "error", color: true });
process.stderr.write(formatted.join(""));
}
return 1;
}
process.stderr.write("The module UI build failed unexpectedly.\n");
return 1;
}
}
process.exitCode = await main();
+1
View File
@@ -0,0 +1 @@
export * from "./build-module-ui.js";
@@ -0,0 +1,230 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { buildModuleUi, ModuleUiBuildError } from "../src/index.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true })),
);
});
interface ModuleFixtureOptions {
/** Omit to leave `entrypoints.ui` undeclared, as a backend-only module does. */
readonly ui?: string;
readonly manifest?: string;
readonly files?: Record<string, string>;
}
async function createModule(options: ModuleFixtureOptions = {}): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-ui-build-"));
temporaryDirectories.push(directory);
const manifest =
options.manifest ??
JSON.stringify({
manifestVersion: 4,
id: "example",
name: "Example",
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
entrypoints: {
server: "./dist/server/index.js",
...(options.ui === undefined ? {} : { ui: options.ui }),
},
});
await writeFile(path.join(directory, "ekklesia.module.json"), manifest);
for (const [relativePath, contents] of Object.entries(options.files ?? {})) {
const target = path.join(directory, relativePath);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, contents);
}
return directory;
}
/** A UI entrypoint with no React, so the build tool's own tests exercise the pipeline mechanics
* without depending on a UI framework. The real React path is proven end to end by the module
* template's own test suite. */
const plainUiSource = `
const definition = {
id: "example",
version: "0.1.0",
mount(container) {
container.textContent = "example";
return () => {
container.textContent = "";
};
},
};
export default definition;
`;
describe("module UI build", () => {
it("bundles the declared entrypoint into the exact path the manifest names", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: { "src/ui/index.tsx": plainUiSource },
});
const result = await buildModuleUi({ moduleDirectory: directory });
expect(result.outputFile).toBe(path.join(directory, "dist", "ui", "index.js"));
const artifact = await readFile(result.outputFile, "utf8");
expect(artifact).toContain("export {");
expect(artifact).toContain("as default");
});
it("honours an alternative declared artifact path", async () => {
const directory = await createModule({
ui: "./build/browser/ui.mjs",
files: { "src/ui/index.tsx": plainUiSource },
});
const result = await buildModuleUi({ moduleDirectory: directory });
expect(result.outputFile).toBe(path.join(directory, "build", "browser", "ui.mjs"));
});
it("emits a source map alongside the artifact", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: { "src/ui/index.tsx": plainUiSource },
});
const result = await buildModuleUi({ moduleDirectory: directory });
await expect(readFile(`${result.outputFile}.map`, "utf8")).resolves.toContain("sources");
});
it("reports a backend-only module as having no UI to build rather than failing silently", async () => {
const directory = await createModule({ files: { "src/ui/index.tsx": plainUiSource } });
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_ENTRYPOINT_NOT_DECLARED",
});
});
it("fails when the declared UI has no source entrypoint", async () => {
const directory = await createModule({ ui: "./dist/ui/index.js" });
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_SOURCE_MISSING",
});
});
it("fails when the manifest cannot be read", async () => {
const directory = await createModule({ manifest: "{ not json" });
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_MANIFEST_UNREADABLE",
});
});
it.each([
["escapes the package", "../outside/ui.js"],
["is absolute", "/tmp/ui.js"],
["traverses upward", "./dist/../../ui.js"],
["is source TypeScript", "./src/ui/index.tsx"],
["carries a query", "./dist/ui/index.js?raw"],
["uses backslashes", ".\\dist\\ui\\index.js"],
])("refuses to write an artifact whose declared path %s", async (_label, ui) => {
const directory = await createModule({ ui, files: { "src/ui/index.tsx": plainUiSource } });
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_ENTRYPOINT_UNSAFE",
});
});
it("surfaces a build failure with esbuild's diagnostics attached", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: { "src/ui/index.tsx": "export default { id: 'example', " },
});
try {
await buildModuleUi({ moduleDirectory: directory });
expect.unreachable("expected the build to fail");
} catch (error) {
expect(error).toBeInstanceOf(ModuleUiBuildError);
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_BUILD_FAILED");
// The sanitized message carries no path; the diagnostics a developer needs are attached.
expect((error as ModuleUiBuildError).message).not.toContain(directory);
expect((error as { diagnostics?: unknown[] }).diagnostics?.length).toBeGreaterThan(0);
}
});
it("fails on an unresolvable import instead of emitting a partial bundle", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: { "src/ui/index.tsx": 'import "totally-not-installed";\nexport default {};' },
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_BUILD_FAILED",
});
});
it.each([
"styles.css",
"theme.scss",
"layout.less",
])("rejects a %s import rather than emitting an undeclared sidecar asset", async (stylesheet) => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": `import "./${stylesheet}";\nexport default {};`,
[`src/ui/${stylesheet}`]: ".example { color: red; }",
},
});
try {
await buildModuleUi({ moduleDirectory: directory });
expect.unreachable("expected the stylesheet import to be rejected");
} catch (error) {
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_BUILD_FAILED");
const diagnostics = (error as { diagnostics?: { text?: string }[] }).diagnostics ?? [];
expect(diagnostics.some((entry) => entry.text?.includes("cannot import a stylesheet"))).toBe(
true,
);
}
});
it("never leaves a stylesheet sidecar next to the artifact", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": 'import "./styles.css";\nexport default {};',
"src/ui/styles.css": ".example { color: red; }",
},
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toBeInstanceOf(
ModuleUiBuildError,
);
await expect(
readFile(path.join(directory, "dist", "ui", "index.css"), "utf8"),
).rejects.toThrow();
});
it("substitutes NODE_ENV so the artifact never touches a Node global in a browser", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx":
'const mode = process.env.NODE_ENV;\nexport default { id: "example", version: "0.1.0", mount: () => { void mode; } };',
},
});
const result = await buildModuleUi({ moduleDirectory: directory });
const artifact = await readFile(result.outputFile, "utf8");
expect(artifact).toContain('"production"');
expect(artifact).not.toContain("process.env.NODE_ENV");
});
});
@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../typescript-config/node.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}