The platform has had a complete story for one module using another's
*backend* since ADR 0006 — capabilities, versioned ranges, provider
registration. It has had none at all for one module using another's *browser*
code, and every workaround is wrong in a way that matters: a consumer cannot
import a peer module, it cannot re-implement a renderer for a domain it does
not own, and a second `entrypoints` role would put the platform in charge of
deciding when a slot inside someone else's screen should be filled.
Add a generic browser contribution. `manifestVersion: 7` declares one:
namespaced id, its own version, a target capability, a compatible range, and
one artifact. That is the complete list, and the omissions are the decision —
no payload schema, slot, ordering, or rendering hint exists anywhere, so Core
cannot learn what a slide is because the declaration cannot express it.
Discovery resolves a contribution artifact through the *identical* loop every
other compiled entrypoint uses, inheriting the path, containment, symlink,
extension, and file-kind checks rather than getting a weaker copy of them. The
registry publishes one only when another eligible module provides the target
capability within the declared range; otherwise it is absent rather than
mounted and broken. `apps/api` serves it as a sibling of the artifact and
display routes, with the same policy: three lookup keys, one exact match, one
already-validated path, and one indistinguishable 404 for unknown, undeclared,
stale, and incompatible alike.
A consumer discovers through the host, never through a URL. Its descriptor
carries identity, version, and capability; the URL stays server-side and is
resolved exactly as `api.request("records")` is. A module can only ever see
contributions aimed at a capability it itself provides, and cannot name its
way to anything else — a mount for a withheld id fails without fetching.
The consumer owns the domain contract entirely. Input is opaque JSON, checked
structurally so a Date, a function, a cycle, or `undefined` fails at the
boundary rather than vanishing somewhere far from the mistake, and checked for
nothing else. A contributed renderer receives its own id and that input: no
API client, realtime, principal, configuration, database, storage, or
credential. Loading one grants its module nothing.
The contribution ABI is versioned separately from the module UI ABI, because
the two have different shapes and lifecycles and coupling them would force
every module to rebuild whenever either moved. It supports asynchronous
readiness, update-without-remount (a screen that remounted per tick would
restart animations and reload media), deterministic cleanup in a `finally`,
and sanitized failure that never carries the consumer's domain data. Each
renderer mounts in its own isolated root with its own stylesheet.
The template now declares a contribution, so `pnpm build` exercises the whole
authoring path, and the shell suite drives the full relationship — one module
discovering, mounting, updating, and unmounting another's React renderer with
neither importing the other.
Co-Authored-By: Claude Opus 5 <[email protected]>
417 lines
18 KiB
TypeScript
417 lines
18 KiB
TypeScript
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import {
|
|
contributionLocalName,
|
|
moduleContributionDeclarationSchema,
|
|
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";
|
|
/** Where a declared browser contribution's source lives: `src/contributions/<local-name>/index.tsx`,
|
|
* where the local name is the part of the contribution id after the module's own id. Derived rather
|
|
* than configured, exactly like the two role entrypoints, so an author points the build nowhere. */
|
|
export const MODULE_CONTRIBUTION_SOURCE_DIRECTORY = "src/contributions";
|
|
|
|
export function moduleContributionSourceEntrypoint(contributionId: string): string {
|
|
return `${MODULE_CONTRIBUTION_SOURCE_DIRECTORY}/${contributionLocalName(contributionId)}/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_STYLESHEET_INVALID"
|
|
| "MODULE_UI_STYLESHEET_UNDECLARED"
|
|
| "MODULE_UI_BUILD_FAILED";
|
|
|
|
export type ModuleUiArtifactKind = "ui" | "display" | "contribution";
|
|
|
|
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_STYLESHEET_INVALID:
|
|
"A module stylesheet may not define a global Ekklesia theme variable or keep an unresolved @import.",
|
|
MODULE_UI_STYLESHEET_UNDECLARED:
|
|
"A module UI build produced a stylesheet beside its artifact. Every style must be compiled into the declared artifact.",
|
|
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}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A CSS custom property in Ekklesia's own global theme namespace, being **declared** rather than
|
|
* referenced. `var(--ekklesia-x)` and `var(--ekklesia-x, fallback)` are reads and are fine; only a
|
|
* `--ekklesia-x:` declaration matches, because that is what would invent a global theme variable
|
|
* outside the registry a future theme editor enumerates (ADR 0039).
|
|
*/
|
|
const GLOBAL_THEME_DECLARATION = /--ekklesia-[a-z0-9-]*\s*:/iu;
|
|
|
|
/** An `@import` that survived compilation — a remote or otherwise unresolvable one. A local import
|
|
* is inlined by the nested bundle below and leaves nothing behind, so anything still here would make
|
|
* the artifact fetch a stylesheet at run time, which is exactly what "self-contained" excludes. */
|
|
const SURVIVING_IMPORT = /@import\s/iu;
|
|
|
|
/**
|
|
* Compiles each imported stylesheet into the artifact instead of emitting a sidecar.
|
|
*
|
|
* esbuild's default for `import "./styles.css"` from JavaScript is to collect every such stylesheet
|
|
* into an `index.css` output beside the artifact. Nothing declares that file, no host fetches it,
|
|
* and the manifest has no field for it — it would work in a local test and silently do nothing in a
|
|
* real host. ADR 0023 therefore rejected the import outright; ADR 0040 compiles it instead.
|
|
*
|
|
* Each `.css` import becomes a JavaScript module whose default export is that stylesheet's compiled
|
|
* text, produced by a nested esbuild run so local `@import`s are resolved and the result is one
|
|
* self-contained string. The author passes that string to `defineModuleUi({ styles })` and the host
|
|
* installs it into the artifact's isolated root. The declared `.js` artifact therefore remains the
|
|
* only file the platform publishes, exactly as before stylesheets existed.
|
|
*
|
|
* Other stylesheet dialects stay rejected. Adding one is a language and toolchain decision, not a
|
|
* detail of this pass, and would need its own ADR.
|
|
*/
|
|
const STYLESHEET_PLUGIN_NAME = "ekklesia-compile-stylesheet-imports";
|
|
|
|
function compileStylesheetImports(): Plugin {
|
|
const decoder = new TextDecoder();
|
|
return {
|
|
name: STYLESHEET_PLUGIN_NAME,
|
|
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"). These patterns are
|
|
// pure ASCII, so the flag would change nothing anyway.
|
|
pluginBuild.onResolve({ filter: /\.(s[ac]ss|less|styl|pcss|postcss)$/i }, (args) => ({
|
|
errors: [
|
|
{
|
|
text:
|
|
"Module UI supports plain .css only. Other stylesheet dialects have no compilation " +
|
|
"contract in this platform (ADR 0040).",
|
|
location: { file: args.importer },
|
|
},
|
|
],
|
|
}));
|
|
|
|
pluginBuild.onLoad({ filter: /\.css$/i }, async (args) => {
|
|
const compiled = await build({
|
|
bundle: true,
|
|
entryPoints: [args.path],
|
|
logLevel: "silent",
|
|
minify: false,
|
|
target: MODULE_UI_BUILD_TARGET,
|
|
write: false,
|
|
});
|
|
const stylesheet = compiled.outputFiles
|
|
.map((outputFile) => decoder.decode(outputFile.contents))
|
|
.join("\n");
|
|
|
|
if (GLOBAL_THEME_DECLARATION.test(stylesheet)) {
|
|
return {
|
|
errors: [
|
|
{
|
|
text:
|
|
"A module stylesheet may reference Ekklesia's global theme variables but must " +
|
|
"never declare one. Derive a private alias under your own prefix instead " +
|
|
"(ADR 0039).",
|
|
location: { file: args.path },
|
|
},
|
|
],
|
|
};
|
|
}
|
|
if (SURVIVING_IMPORT.test(stylesheet)) {
|
|
return {
|
|
errors: [
|
|
{
|
|
text:
|
|
"A module stylesheet left an unresolved @import. A declared artifact must be " +
|
|
"self-contained, so every import has to resolve to a local file (ADR 0040).",
|
|
location: { file: args.path },
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
return { contents: `export default ${JSON.stringify(stylesheet)};`, loader: "js" };
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
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, then display, then each declared contribution
|
|
* in manifest order. 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;
|
|
/** For a contribution, the declared contribution id — so a multi-contribution build reports which
|
|
* artifact it produced. Absent for the two role entrypoints. */
|
|
readonly contributionId?: string;
|
|
/** 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 manifestContributions = isRecord(manifest) ? manifest.contributions : undefined;
|
|
const declaredContributions = Array.isArray(manifestContributions) ? manifestContributions : [];
|
|
for (const contribution of declaredContributions) {
|
|
if (!moduleContributionDeclarationSchema.safeParse(contribution).success) {
|
|
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "contribution");
|
|
}
|
|
}
|
|
const declarations: readonly {
|
|
readonly contributionId?: string;
|
|
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,
|
|
},
|
|
...(declaredContributions as { artifact: string; id: string }[]).map((contribution) => ({
|
|
contributionId: contribution.id,
|
|
declared: contribution.artifact,
|
|
kind: "contribution" as const,
|
|
present: true,
|
|
source: moduleContributionSourceEntrypoint(contribution.id),
|
|
})),
|
|
];
|
|
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({
|
|
...(declaration.contributionId === undefined
|
|
? {}
|
|
: { contributionId: declaration.contributionId }),
|
|
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");
|
|
}
|
|
|
|
// No declared artifact may overwrite another declared file — an entrypoint this build does not
|
|
// produce (`server`, `schema`, ...), or another artifact it does. Checked before esbuild runs, so
|
|
// a collision can never destroy output that was already correct.
|
|
const backendEntrypointPaths = new Set(
|
|
(["server", "schema", "migrations", "jobs"] as const)
|
|
.map((entrypointName) => entrypoints[entrypointName])
|
|
.filter((configured): configured is string => typeof configured === "string")
|
|
.map((configured) => path.resolve(moduleDirectory, configured)),
|
|
);
|
|
const claimedOutputs = new Set<string>();
|
|
for (const artifact of artifacts) {
|
|
if (
|
|
backendEntrypointPaths.has(artifact.outputFile) ||
|
|
claimedOutputs.has(artifact.outputFile)
|
|
) {
|
|
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_COLLISION", artifact.kind);
|
|
}
|
|
claimedOutputs.add(artifact.outputFile);
|
|
}
|
|
|
|
// 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: [compileStylesheetImports()],
|
|
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,
|
|
});
|
|
// Belt and braces on the stylesheet contract. The plugin above turns every `.css` import into
|
|
// JavaScript, so esbuild has nothing left to emit a stylesheet from — but a future option or
|
|
// loader change could reintroduce a sidecar, and a sidecar is precisely the failure this
|
|
// design exists to make impossible: a file nothing declares, no host fetches, and every local
|
|
// test silently passes without. Producing one fails the build rather than shipping it.
|
|
const sidecar = result.outputFiles.find(
|
|
(outputFile) => path.extname(outputFile.path).toLowerCase() === ".css",
|
|
);
|
|
if (sidecar) {
|
|
throw new ModuleUiBuildError("MODULE_UI_STYLESHEET_UNDECLARED", artifact.kind);
|
|
}
|
|
pendingWrites.push(
|
|
result.outputFiles.map((outputFile) => ({
|
|
contents: outputFile.contents,
|
|
path: outputFile.path,
|
|
})),
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof ModuleUiBuildError) {
|
|
throw 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)) {
|
|
// A stylesheet the module authored broke one of this platform's own rules, rather than
|
|
// failing to compile. Reported with its own code so a caller can tell "your CSS is not
|
|
// allowed to do that" from "your TSX does not compile".
|
|
const code = error.errors.some(
|
|
(diagnostic) => isRecord(diagnostic) && diagnostic.pluginName === STYLESHEET_PLUGIN_NAME,
|
|
)
|
|
? "MODULE_UI_STYLESHEET_INVALID"
|
|
: "MODULE_UI_BUILD_FAILED";
|
|
throw Object.assign(new ModuleUiBuildError(code, 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 }))),
|
|
});
|
|
}
|