feat: build module UI into its declared browser artifact
ADR 0022 defined what a module UI is and selected React and TypeScript as the authoring model, but closed by recording that it was not buildable end to end: `browser.json` preserves JSX, bundler plugins are denied, and nothing turned a module's `.tsx` into the artifact its manifest declares. This closes exactly that gap. esbuild produces the artifact. It was already the right answer before it was chosen: this workspace resolves esbuild 0.28.1 through Vite/Vitest today and `pnpm-workspace.yaml` already carries `allowBuilds: esbuild`, so the pinned dependency adds no second copy and no new trust decision. It is also the smallest tool that covers TSX, ESM, browser platform, bundling, source maps, and a define-time constant with no plugin ecosystem. Vite library mode would drag in `@vitejs/plugin-react`, which the module boundary denies on purpose; `tsc` alone cannot bundle, so its output would carry bare `react/jsx-runtime` imports no browser resolves. React is bundled, not externalized. That follows from ADR 0022 rather than from convenience: `mount()` was made the ABI precisely so React would not be part of the host contract, and externalizing it would reverse that through the build system — every host would have to supply a compatible React, and a standalone host might not use React at all. The cost is real and accepted: duplicate runtimes and a ~570 KB unminified artifact. Sharing one runtime later is a build optimization that cannot change the contract, because the contract is a mount function. Output is browser ESM at `es2022`, unminified so an operator can review what they install, with `process.env.NODE_ENV` substituted — React reads it at module scope and would otherwise touch a Node global that does not exist in a browser. The output path is read from the module's own manifest rather than a build convention, so declared and produced artifacts cannot drift, and it is validated with the same `packageRelativePathSchema` discovery enforces, so a build can never write where discovery would later reject. `@ekklesia/module-ui-build` owns every option; modules own none. It uses the existing `tooling` role that modules could already depend on, so this pass required no boundary relaxation at all. Its `bin` points at a committed launcher rather than compiled output because pnpm links bins during install, before Turborepo builds anything — a bin targeting `dist/cli.js` fails to link on a fresh clone and breaks every module's UI build in CI. A module with no `entrypoints.ui` is not an error; the CLI reports nothing to build and exits zero, so one script serves both kinds of module. Styling is React inline style objects. A stylesheet import fails the build rather than emitting a sidecar `.css` that no manifest declares and no host loads — an undeclared asset would appear to work in tests and silently do nothing in a real host. `templates/module` is the canonical example and now ships optional UI. Its tests build the artifact from source, validate it with `parseModuleUiExport`, mount it into a real DOM, assert the rendered output, and unmount it; the artifact is rebuilt per run so the proof can never pass against stale output, and `dist/` stays gitignored. Because verify runs both `pnpm test` and `pnpm build`, a UI that cannot be produced fails CI through two independent paths. One correction worth stating plainly: the UI TypeScript project declares `"types": []`, and a UI file referencing `process` does fail in isolation, but TypeScript still pulls Node globals in transitively — a file importing `@ekklesia/module-ui-sdk` reaches `@ekklesia/contracts` and through its Zod and Drizzle types `process.env` typechecks again. The docs and ADR describe this as the convention it is rather than the guard it is not. Core is untouched. Building is an authoring step; discovery still only validates the artifact a module shipped and never executes or produces one. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f9c1108527
commit
1e5866504e
@@ -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();
|
||||
Reference in New Issue
Block a user