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,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 };
|
||||
}
|
||||
Reference in New Issue
Block a user