import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { moduleDisplayEntrypointSchema, packageRelativePathSchema } from "@ekklesia/contracts"; import { build, type Plugin } from "esbuild"; /** * The one supported way to turn a module's React/TSX UI sources into the compiled browser artifacts * its manifest declares as `entrypoints.ui` and `entrypoints.display`. * * Module authors do not write bundler configuration. They put their UI at * the fixed role-specific source locations below, declare either or both artifacts, and run one * 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_DISPLAY_SOURCE_ENTRYPOINT = "src/display/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_UNSAFE" | "MODULE_UI_ENTRYPOINT_COLLISION" | "MODULE_UI_SOURCE_MISSING" | "MODULE_UI_BUILD_FAILED"; export type ModuleUiArtifactKind = "ui" | "display"; const errorMessages: Readonly> = Object.freeze({ MODULE_UI_MANIFEST_UNREADABLE: `${MODULE_MANIFEST_FILENAME} could not be read or parsed.`, MODULE_UI_ENTRYPOINT_UNSAFE: "A module UI entrypoint must be a package-relative path to a compiled .js or .mjs artifact inside the module.", MODULE_UI_ENTRYPOINT_COLLISION: "A display UI entrypoint must not overwrite another declared artifact.", MODULE_UI_SOURCE_MISSING: "A declared module UI source entrypoint was not found.", MODULE_UI_BUILD_FAILED: "A 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; /** Safe role vocabulary that lets a developer identify which declared artifact failed without * exposing a filesystem path or raw manifest value. */ readonly kind?: ModuleUiArtifactKind; constructor(code: ModuleUiBuildErrorCode, kind?: ModuleUiArtifactKind) { super(errorMessages[code]); this.name = "ModuleUiBuildError"; this.code = code; if (kind !== undefined) { this.kind = kind; } 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 { /** Built artifacts in stable role order: primary UI first, then display. Empty for backend-only * modules, which makes the canonical command usable unchanged by every module package. */ readonly artifacts: readonly ModuleUiBuildArtifactResult[]; } export interface ModuleUiBuildArtifactResult { readonly kind: ModuleUiArtifactKind; /** Absolute path of the artifact actually written, exactly as the manifest declares. */ readonly outputFile: string; /** Absolute path of the fixed source entrypoint that produced it. */ readonly sourceFile: string; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } type DeclaredModuleUiArtifact = ModuleUiBuildArtifactResult; /** Reads the inert manifest and returns every declared UI artifact in deterministic role order. */ async function resolveDeclaredArtifacts( 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; if (!isRecord(entrypoints)) { throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE"); } const display = entrypoints.display; const declarations: readonly { readonly declared: unknown; readonly kind: ModuleUiArtifactKind; readonly present: boolean; readonly source: string; }[] = [ { declared: entrypoints.ui, kind: "ui", present: entrypoints.ui !== undefined, source: MODULE_UI_SOURCE_ENTRYPOINT, }, { declared: isRecord(display) ? display.artifact : display, kind: "display", present: display !== undefined, source: MODULE_DISPLAY_SOURCE_ENTRYPOINT, }, ]; const artifacts: DeclaredModuleUiArtifact[] = []; for (const declaration of declarations) { if (!declaration.present) { continue; } const declared = declaration.declared; // The path grammar is Core's, not this tool's: reusing the contracts schema means the build can // never write somewhere discovery would later reject, even if that grammar changes. if ( typeof declared !== "string" || !packageRelativePathSchema.safeParse(declared).success || ![".js", ".mjs"].includes(path.extname(declared).toLowerCase()) ) { throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE", declaration.kind); } artifacts.push({ kind: declaration.kind, outputFile: path.resolve(moduleDirectory, declared), sourceFile: path.join(moduleDirectory, declaration.source), }); } if (display !== undefined && !moduleDisplayEntrypointSchema.safeParse(display).success) { throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "display"); } const displayArtifact = artifacts.find((artifact) => artifact.kind === "display"); if (displayArtifact !== undefined) { for (const entrypointName of ["server", "schema", "migrations", "jobs", "ui"] as const) { const configured = entrypoints[entrypointName]; if ( typeof configured === "string" && path.resolve(moduleDirectory, configured) === displayArtifact.outputFile ) { throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_COLLISION", "display"); } } } // Complete preflight before esbuild starts: a missing second source must not leave the first role's // output looking like a successful package build. for (const artifact of artifacts) { try { await readFile(artifact.sourceFile); } catch { throw new ModuleUiBuildError("MODULE_UI_SOURCE_MISSING", artifact.kind); } } return artifacts; } /** * 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 artifacts = await resolveDeclaredArtifacts(moduleDirectory); const pendingWrites: Array = []; for (const artifact of artifacts) { try { const result = 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: [artifact.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: artifact.outputFile, platform: "browser", plugins: [rejectStylesheetImports()], sourcemap: true, target: MODULE_UI_BUILD_TARGET, // Fail rather than silently shipping a partial bundle. logLevel: "silent", // Compile every role successfully before writing any role. This makes a two-artifact build // atomic with respect to parse/resolve/bundle failures rather than leaving a stale partial set. write: false, }); pendingWrites.push( result.outputFiles.map((outputFile) => ({ contents: outputFile.contents, path: outputFile.path, })), ); } 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", artifact.kind), { diagnostics: error.errors, }); } throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifact.kind); } } for (const [index, outputs] of pendingWrites.entries()) { try { for (const output of outputs) { await mkdir(path.dirname(output.path), { recursive: true }); await writeFile(output.path, output.contents); } } catch { throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifacts[index]?.kind); } } return Object.freeze({ artifacts: Object.freeze(artifacts.map((artifact) => Object.freeze({ ...artifact }))), }); }