feat: build module UI into its declared browser artifact
ADR 0022 defined what a module UI is and selected React and TypeScript as the authoring model, but closed by recording that it was not buildable end to end: `browser.json` preserves JSX, bundler plugins are denied, and nothing turned a module's `.tsx` into the artifact its manifest declares. This closes exactly that gap. esbuild produces the artifact. It was already the right answer before it was chosen: this workspace resolves esbuild 0.28.1 through Vite/Vitest today and `pnpm-workspace.yaml` already carries `allowBuilds: esbuild`, so the pinned dependency adds no second copy and no new trust decision. It is also the smallest tool that covers TSX, ESM, browser platform, bundling, source maps, and a define-time constant with no plugin ecosystem. Vite library mode would drag in `@vitejs/plugin-react`, which the module boundary denies on purpose; `tsc` alone cannot bundle, so its output would carry bare `react/jsx-runtime` imports no browser resolves. React is bundled, not externalized. That follows from ADR 0022 rather than from convenience: `mount()` was made the ABI precisely so React would not be part of the host contract, and externalizing it would reverse that through the build system — every host would have to supply a compatible React, and a standalone host might not use React at all. The cost is real and accepted: duplicate runtimes and a ~570 KB unminified artifact. Sharing one runtime later is a build optimization that cannot change the contract, because the contract is a mount function. Output is browser ESM at `es2022`, unminified so an operator can review what they install, with `process.env.NODE_ENV` substituted — React reads it at module scope and would otherwise touch a Node global that does not exist in a browser. The output path is read from the module's own manifest rather than a build convention, so declared and produced artifacts cannot drift, and it is validated with the same `packageRelativePathSchema` discovery enforces, so a build can never write where discovery would later reject. `@ekklesia/module-ui-build` owns every option; modules own none. It uses the existing `tooling` role that modules could already depend on, so this pass required no boundary relaxation at all. Its `bin` points at a committed launcher rather than compiled output because pnpm links bins during install, before Turborepo builds anything — a bin targeting `dist/cli.js` fails to link on a fresh clone and breaks every module's UI build in CI. A module with no `entrypoints.ui` is not an error; the CLI reports nothing to build and exits zero, so one script serves both kinds of module. Styling is React inline style objects. A stylesheet import fails the build rather than emitting a sidecar `.css` that no manifest declares and no host loads — an undeclared asset would appear to work in tests and silently do nothing in a real host. `templates/module` is the canonical example and now ships optional UI. Its tests build the artifact from source, validate it with `parseModuleUiExport`, mount it into a real DOM, assert the rendered output, and unmount it; the artifact is rebuilt per run so the proof can never pass against stale output, and `dist/` stays gitignored. Because verify runs both `pnpm test` and `pnpm build`, a UI that cannot be produced fails CI through two independent paths. One correction worth stating plainly: the UI TypeScript project declares `"types": []`, and a UI file referencing `process` does fail in isolation, but TypeScript still pulls Node globals in transitively — a file importing `@ekklesia/module-ui-sdk` reaches `@ekklesia/contracts` and through its Zod and Drizzle types `process.env` typechecks again. The docs and ADR describe this as the convention it is rather than the guard it is not. Core is untouched. Building is an authoring step; discovery still only validates the artifact a module shipped and never executes or produces one. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f9c1108527
commit
1e5866504e
@@ -8,12 +8,18 @@ route (`GET /records`, declared in `register`) that reads that table through
|
||||
module-constructed Drizzle instance or a raw `pg` client. Migration execution and storage
|
||||
provisioning are intentionally not included.
|
||||
|
||||
This template ships backend behavior only, which is a choice rather than a constraint: as of
|
||||
[ADR 0021](../../docs/architecture/decisions/0021-modules-as-hostable-products.md) a module may also
|
||||
declare an optional compiled `entrypoints.ui` artifact. The template does not, because no host loads
|
||||
a UI entrypoint yet — see
|
||||
[Declaring a UI entrypoint](../../docs/module-development/README.md#declaring-a-ui-entrypoint) for
|
||||
what is and is not supported today.
|
||||
It also ships an **optional** React UI (`src/ui/`), compiled to the `entrypoints.ui` artifact its
|
||||
manifest declares by `pnpm build`
|
||||
([ADR 0023](../../docs/architecture/decisions/0023-module-ui-build-pipeline.md)). UI is a choice, not
|
||||
a requirement: to keep a module backend-only, delete `src/ui/`, `tsconfig.ui.json`,
|
||||
`tsconfig.ui-test.json`, `tests/module-ui.test.ts`, the `ui` entrypoint from the manifest, and the
|
||||
React and module-UI dependencies. Everything else keeps working, and `ekklesia-build-module-ui`
|
||||
simply reports that there is nothing to build.
|
||||
|
||||
No host mounts a module UI yet — the Dashboard shell does not exist. `tests/module-ui.test.ts` builds
|
||||
the artifact from source, validates it with `parseModuleUiExport`, mounts it into a real DOM, and
|
||||
unmounts it, which is what proves the pipeline today. See
|
||||
[Declaring a UI entrypoint](../../docs/module-development/README.md#declaring-a-ui-entrypoint).
|
||||
|
||||
To create a module inside this repository:
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifestVersion": 4,
|
||||
"id": "example",
|
||||
"name": "Example Module",
|
||||
"description": "A minimal Ekklesia module template with backend behavior only.",
|
||||
"description": "A minimal Ekklesia module template with backend behavior and optional UI.",
|
||||
"version": "0.1.0",
|
||||
"compatibility": {
|
||||
"platform": "^0.1.0",
|
||||
@@ -32,6 +32,7 @@
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js",
|
||||
"migrations": "./drizzle"
|
||||
"migrations": "./drizzle",
|
||||
"ui": "./dist/ui/index.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,22 +19,29 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui",
|
||||
"lint": "biome lint .",
|
||||
"migration:generate": "drizzle-kit generate --config=drizzle.config.ts",
|
||||
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-template",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json && tsc --noEmit --project tsconfig.ui-test.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ekklesia/module-sdk": "workspace:*",
|
||||
"drizzle-orm": "0.45.2"
|
||||
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@ekklesia/module-ui-build": "workspace:*",
|
||||
"@ekklesia/typescript-config": "workspace:*",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"happy-dom": "20.0.11",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* The template's example screen. It is deliberately tiny, and deliberately host-neutral: it reads
|
||||
* everything it needs from the host context and hardcodes no URL, no origin, and no global.
|
||||
*/
|
||||
export function ExampleApp({ context }: { readonly context: ModuleUiHostContext }): ReactElement {
|
||||
const [recordCount, setRecordCount] = useState<number | undefined>(undefined);
|
||||
|
||||
// Presentation-only gating. The server decides what actually happens; this only decides what the
|
||||
// viewer is offered. See docs/development/security.md.
|
||||
const canRead = context.permissions.has("example.records.read");
|
||||
|
||||
useEffect(() => {
|
||||
if (!canRead) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
// Relative to this module's own API root. Never `/api/v1/modules/example/records`: that prefix
|
||||
// is Ekklesia's private deployment detail, and building it here would make this artifact
|
||||
// unhostable by a standalone host (ADR 0022).
|
||||
void context.api
|
||||
.request("records")
|
||||
.then((response) => (response.ok ? response.json() : []))
|
||||
.then((records: unknown) => {
|
||||
if (!cancelled) {
|
||||
setRecordCount(Array.isArray(records) ? records.length : 0);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setRecordCount(0);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canRead, context.api]);
|
||||
|
||||
if (!canRead) {
|
||||
return <p style={{ margin: 0 }}>You do not have access to example records.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ display: "grid", gap: "0.5rem" }}>
|
||||
<h1 style={{ margin: 0 }}>Example module UI</h1>
|
||||
<p style={{ margin: 0 }} data-testid="record-count">
|
||||
{recordCount === undefined ? "Loading records…" : `${recordCount} record(s)`}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ExampleApp } from "./ExampleApp.js";
|
||||
|
||||
/**
|
||||
* The module's compiled UI entrypoint. `pnpm build` bundles this file, and everything it imports,
|
||||
* into the single browser artifact this module's manifest declares as `entrypoints.ui`.
|
||||
*
|
||||
* `id` and `version` must match the manifest: a host verifies both before mounting, so a stale
|
||||
* artifact is caught rather than rendered.
|
||||
*/
|
||||
export default defineModuleUi({
|
||||
id: "example",
|
||||
version: "0.1.0",
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
root.render(<ExampleApp context={context} />);
|
||||
// React owns a root here, so the mount must return an unmount that releases it.
|
||||
return () => root.unmount();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { buildModuleUi } from "@ekklesia/module-ui-build";
|
||||
import { parseModuleManifest } from "@ekklesia/module-sdk";
|
||||
import {
|
||||
type ModuleUiApiClient,
|
||||
type ModuleUiHostContext,
|
||||
parseModuleUiExport,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* The end-to-end proof for the module UI build pipeline (ADR 0023):
|
||||
*
|
||||
* React/TSX source -> `pnpm build` -> compiled artifact at the manifest's `entrypoints.ui`
|
||||
* -> `parseModuleUiExport` -> `mount(container, context)` -> `unmount()`
|
||||
*
|
||||
* The artifact is rebuilt from source here rather than read from a checked-in file, so the test can
|
||||
* never pass against stale output. `dist/` is gitignored; nothing generated is committed.
|
||||
*/
|
||||
// `happy-dom` replaces the global `URL` with the DOM implementation, which `node:fs` rejects, so
|
||||
// this test works in plain filesystem paths and converts to a file URL only for dynamic import.
|
||||
const moduleDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
let artifactHref: string;
|
||||
let artifactPath: string;
|
||||
let manifestUi: string;
|
||||
let manifestIdentity: { id: string; version: string };
|
||||
|
||||
/** Records what the host was asked for, so the test can prove the UI never builds a URL itself. */
|
||||
function recordingHost(options: {
|
||||
readonly canRead: boolean;
|
||||
readonly records: readonly unknown[];
|
||||
readonly requested: string[];
|
||||
readonly resolve: (path: string) => void;
|
||||
}): ModuleUiHostContext {
|
||||
const api: ModuleUiApiClient = {
|
||||
async request(path) {
|
||||
options.requested.push(path);
|
||||
options.resolve(path);
|
||||
return new Response(JSON.stringify(options.records), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
};
|
||||
return {
|
||||
moduleId: "example",
|
||||
api,
|
||||
principal: { kind: "anonymous" },
|
||||
permissions: { has: () => options.canRead },
|
||||
};
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
// Let React commit and the awaited fetch microtasks settle.
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
describe("module UI build pipeline", () => {
|
||||
beforeAll(async () => {
|
||||
const manifest = parseModuleManifest(
|
||||
JSON.parse(
|
||||
await readFile(path.join(moduleDirectory, "ekklesia.module.json"), "utf8"),
|
||||
) as unknown,
|
||||
);
|
||||
if (!manifest.entrypoints.ui) {
|
||||
throw new Error("The template manifest must declare entrypoints.ui for this test.");
|
||||
}
|
||||
manifestUi = manifest.entrypoints.ui;
|
||||
manifestIdentity = { id: manifest.id, version: manifest.version };
|
||||
|
||||
const result = await buildModuleUi({ moduleDirectory });
|
||||
artifactPath = result.outputFile;
|
||||
artifactHref = pathToFileURL(result.outputFile).href;
|
||||
}, 60_000);
|
||||
|
||||
it("writes the artifact exactly where the manifest declares it", () => {
|
||||
expect(manifestUi).toBe("./dist/ui/index.js");
|
||||
expect(artifactPath.endsWith(path.join("dist", "ui", "index.js"))).toBe(true);
|
||||
});
|
||||
|
||||
it("produces a browser-safe ESM artifact that needs no host-provided React", async () => {
|
||||
const artifact = await readFile(artifactPath, "utf8");
|
||||
|
||||
// React is bundled, so the artifact resolves nothing at run time (ADR 0023). A bare top-level
|
||||
// import would mean the host had to supply that module.
|
||||
expect(artifact).not.toMatch(/^import\s.*from\s*["'][^.]/mu);
|
||||
expect(artifact).toContain("export {");
|
||||
// No Ekklesia deployment detail may be baked into a portable artifact.
|
||||
expect(artifact).not.toContain("/api/v1/modules");
|
||||
// `process.env` must be substituted at build time; a browser has no `process`.
|
||||
expect(artifact).not.toContain("process.env.NODE_ENV");
|
||||
});
|
||||
|
||||
it("satisfies parseModuleUiExport and matches the manifest identity", async () => {
|
||||
const namespace = (await import(artifactHref)) as unknown;
|
||||
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
||||
|
||||
expect(definition.id).toBe("example");
|
||||
expect(definition.version).toBe("0.1.0");
|
||||
expect(typeof definition.mount).toBe("function");
|
||||
});
|
||||
|
||||
it("mounts real React into a container, then unmounts and cleans up", async () => {
|
||||
const namespace = (await import(artifactHref)) as unknown;
|
||||
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const requested: string[] = [];
|
||||
|
||||
const unmount = await definition.mount(
|
||||
container,
|
||||
recordingHost({
|
||||
canRead: true,
|
||||
records: [
|
||||
{ id: "a", value: "one" },
|
||||
{ id: "b", value: "two" },
|
||||
],
|
||||
requested,
|
||||
resolve: () => undefined,
|
||||
}),
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("Example module UI");
|
||||
expect(container.querySelector("[data-testid='record-count']")?.textContent).toBe(
|
||||
"2 record(s)",
|
||||
);
|
||||
|
||||
expect(typeof unmount).toBe("function");
|
||||
await (unmount as () => Promise<void>)();
|
||||
expect(container.textContent).toBe("");
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("asks its host for a relative path, never Ekklesia's module API prefix", async () => {
|
||||
const namespace = (await import(artifactHref)) as unknown;
|
||||
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
||||
const container = document.createElement("div");
|
||||
const requested: string[] = [];
|
||||
const resolvedUrls: string[] = [];
|
||||
|
||||
const unmount = await definition.mount(
|
||||
container,
|
||||
recordingHost({
|
||||
canRead: true,
|
||||
records: [],
|
||||
requested,
|
||||
// A standalone host resolving the same relative path to a completely different URL.
|
||||
resolve: (path) => {
|
||||
resolvedUrls.push(`https://example.test/api/${path}`);
|
||||
},
|
||||
}),
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(requested).toEqual(["records"]);
|
||||
expect(resolvedUrls).toEqual(["https://example.test/api/records"]);
|
||||
await (unmount as () => Promise<void>)();
|
||||
});
|
||||
|
||||
it("renders a denied state from the host's presentation-only permission hint", async () => {
|
||||
const namespace = (await import(artifactHref)) as unknown;
|
||||
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
||||
const container = document.createElement("div");
|
||||
const requested: string[] = [];
|
||||
|
||||
const unmount = await definition.mount(
|
||||
container,
|
||||
recordingHost({ canRead: false, records: [], requested, resolve: () => undefined }),
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("do not have access");
|
||||
// A hidden control is a usability choice, not enforcement: the module simply did not ask.
|
||||
// Server-side authorization is what actually protects the route.
|
||||
expect(requested).toEqual([]);
|
||||
await (unmount as () => Promise<void>)();
|
||||
});
|
||||
});
|
||||
@@ -4,5 +4,6 @@
|
||||
"noEmit": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["drizzle.config.ts", "src/**/*.ts", "tests/**/*.ts"]
|
||||
"include": ["drizzle.config.ts", "src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["tests/module-ui.test.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@ekklesia/typescript-config/browser-react.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["tests/module-ui.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@ekklesia/typescript-config/browser-react.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/ui/**/*.tsx", "src/ui/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user