282 lines
11 KiB
TypeScript
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 }))),
|
|
});
|
|
}
|