Files
Ekklesia/packages/module-ui-build/src/build-module-ui.ts
T
ekklesia 2e0fe059c3
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 59s
feat: add display kiosk host
2026-08-23 20:50:56 -05:00

282 lines
11 KiB
TypeScript

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<Record<ModuleUiBuildErrorCode, string>> = 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<string, unknown> {
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<readonly DeclaredModuleUiArtifact[]> {
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<ModuleUiBuildResult> {
const moduleDirectory = path.resolve(options.moduleDirectory);
const artifacts = await resolveDeclaredArtifacts(moduleDirectory);
const pendingWrites: Array<readonly { path: string; contents: Uint8Array }[]> = [];
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 }))),
});
}