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 }; }