feat: add display kiosk host
This commit is contained in:
+24
-13
@@ -1,6 +1,6 @@
|
||||
# Ekklesia module template
|
||||
|
||||
This is the smallest supported backend-only Ekklesia module. It proves the static manifest,
|
||||
This is the smallest supported Ekklesia module product. It proves the static manifest,
|
||||
compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, a
|
||||
generated module-owned migration, logical storage-bucket declarations, and one permissioned HTTP
|
||||
route (`GET /records`, declared in `register`) that reads that table through
|
||||
@@ -8,18 +8,25 @@ 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.
|
||||
|
||||
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.
|
||||
It also ships two independently optional React roles: a primary/management UI at `src/ui/index.tsx`
|
||||
and a full-viewport display at `src/display/index.tsx`. One invocation of `pnpm build` compiles each
|
||||
declared role to the exact artifact in `entrypoints.ui` and `entrypoints.display.artifact`
|
||||
([ADR 0023](../../docs/architecture/decisions/0023-module-ui-build-pipeline.md)). Both default-export
|
||||
the same role-neutral `defineModuleUi({ id, version, mount })` ABI and receive the same
|
||||
`ModuleUiHostContext`; there is deliberately no mode flag. The manifest entrypoint and the host route
|
||||
choose the role.
|
||||
|
||||
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).
|
||||
Either role can be omitted independently. To remove only the display example, delete `src/display/`
|
||||
and `entrypoints.display` (and remove `example.display.view` if nothing else uses it). To keep the
|
||||
module backend-only, also delete `src/ui/`, `tsconfig.ui.json`, `tsconfig.ui-test.json`,
|
||||
`tests/module-ui.test.ts`, the `ui` entrypoint, and the React/module-UI dependencies. The builder
|
||||
succeeds with an empty artifact list when neither role is declared.
|
||||
|
||||
`tests/module-ui.test.ts` builds both artifacts from source, validates them with
|
||||
`parseModuleUiExport`, mounts each into a real DOM, and unmounts it. Ekklesia mounts the primary UI
|
||||
inside its Dashboard shell and serves the display without Dashboard chrome at the stable
|
||||
`/display/example` page. See
|
||||
[Declaring UI entrypoints](../../docs/module-development/README.md#declaring-ui-entrypoints).
|
||||
|
||||
To create a module inside this repository:
|
||||
|
||||
@@ -46,7 +53,11 @@ To create a module inside this repository:
|
||||
root. The declared production entrypoint is the compiled `dist/server/index.js`, not source
|
||||
TypeScript; the schema entrypoint is likewise compiled to `dist/schema/index.js`.
|
||||
|
||||
Do not add UI components, styles, renderers, navigation, or layouts. Those belong to surfaces.
|
||||
The module owns its feature screens and display renderer. Do not add platform navigation, the
|
||||
containing shell, global theme, authentication screens, or top-level routing; those belong to
|
||||
surfaces and Core. A module UI asks its host for relative API paths and realtime state, and never
|
||||
hardcodes Ekklesia routes. The display permission is enforced by the host before it serves the page
|
||||
or descriptor; `context.permissions` remains a presentation-only hint.
|
||||
Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`,
|
||||
and release them in `stop`. Never construct a Drizzle instance, read `DATABASE_URL`, or reach for a
|
||||
raw `pg` client — `context.services.database` is the only supported way to query this module's own
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"manifestVersion": 5,
|
||||
"manifestVersion": 6,
|
||||
"id": "example",
|
||||
"name": "Example Module",
|
||||
"description": "A minimal Ekklesia module template with backend behavior and optional UI.",
|
||||
"description": "A minimal Ekklesia module template with backend behavior and optional primary and display UI.",
|
||||
"version": "0.1.0",
|
||||
"compatibility": {
|
||||
"platform": "^0.1.0",
|
||||
@@ -19,6 +19,11 @@
|
||||
"id": "example.records.read",
|
||||
"baseline": "restricted",
|
||||
"description": "Read example records."
|
||||
},
|
||||
{
|
||||
"id": "example.display.view",
|
||||
"baseline": "restricted",
|
||||
"description": "View the example full-screen display."
|
||||
}
|
||||
],
|
||||
"configuration": [
|
||||
@@ -34,6 +39,10 @@
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js",
|
||||
"migrations": "./drizzle",
|
||||
"ui": "./dist/ui/index.js"
|
||||
"ui": "./dist/ui/index.js",
|
||||
"display": {
|
||||
"artifact": "./dist/display/index.js",
|
||||
"permission": "example.display.view"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { defineModuleUi, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
function ExampleDisplay(props: { readonly context: ModuleUiHostContext }) {
|
||||
const mayView = props.context.permissions.has("example.display.view");
|
||||
|
||||
return (
|
||||
<main
|
||||
data-testid="example-display"
|
||||
style={{
|
||||
alignItems: "center",
|
||||
background: "#111827",
|
||||
color: "#f9fafb",
|
||||
display: "flex",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
height: "100%",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
padding: "2rem",
|
||||
textAlign: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<h1 style={{ margin: 0 }}>
|
||||
{mayView
|
||||
? `Example full-screen display (${props.context.moduleId})`
|
||||
: "Display unavailable"}
|
||||
</h1>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A display uses the same role-neutral ABI as the primary UI. Its separate manifest entrypoint —
|
||||
* not a mode flag in `ModuleUiHostContext` — tells a host which artifact it is mounting.
|
||||
*/
|
||||
export default defineModuleUi({
|
||||
id: "example",
|
||||
version: "0.1.0",
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
root.render(<ExampleDisplay context={context} />);
|
||||
return () => root.unmount();
|
||||
},
|
||||
});
|
||||
@@ -59,7 +59,7 @@ async function registerAndCaptureRoutes(): Promise<{
|
||||
}
|
||||
|
||||
describe("module template", () => {
|
||||
it("ships a valid backend-only manifest", async () => {
|
||||
it("ships a valid product manifest with independently declared primary and display UI", async () => {
|
||||
const manifestUrl = new URL("../ekklesia.module.json", import.meta.url);
|
||||
const manifestInput = JSON.parse(await readFile(manifestUrl, "utf8")) as unknown;
|
||||
const manifest = parseModuleManifest(manifestInput);
|
||||
@@ -72,6 +72,11 @@ describe("module template", () => {
|
||||
baseline: "restricted",
|
||||
description: "Read example records.",
|
||||
},
|
||||
{
|
||||
id: "example.display.view",
|
||||
baseline: "restricted",
|
||||
description: "View the example full-screen display.",
|
||||
},
|
||||
]);
|
||||
// The template declares **no** logical storage bucket, deliberately. Since ADR 0030 a declared
|
||||
// bucket is a real requirement: a module that declares one is blocked on an installation with no
|
||||
@@ -80,6 +85,11 @@ describe("module template", () => {
|
||||
expect(manifest.storage.buckets).toEqual([]);
|
||||
expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js");
|
||||
expect(manifest.entrypoints.migrations).toBe("./drizzle");
|
||||
expect(manifest.entrypoints.ui).toBe("./dist/ui/index.js");
|
||||
expect(manifest.entrypoints.display).toEqual({
|
||||
artifact: "./dist/display/index.js",
|
||||
permission: "example.display.view",
|
||||
});
|
||||
});
|
||||
|
||||
it("packages a generated stable Drizzle migration and matching policy", async () => {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
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 { buildModuleUi } from "@ekklesia/module-ui-build";
|
||||
import {
|
||||
type ModuleUiApiClient,
|
||||
type ModuleUiHostContext,
|
||||
@@ -14,7 +14,7 @@ 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`
|
||||
* React/TSX source -> `pnpm build` -> compiled artifacts at the manifest's UI entrypoints
|
||||
* -> `parseModuleUiExport` -> `mount(container, context)` -> `unmount()`
|
||||
*
|
||||
* The artifact is rebuilt from source here rather than read from a checked-in file, so the test can
|
||||
@@ -26,7 +26,10 @@ const moduleDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)
|
||||
|
||||
let artifactHref: string;
|
||||
let artifactPath: string;
|
||||
let displayArtifactHref: string;
|
||||
let displayArtifactPath: string;
|
||||
let manifestUi: string;
|
||||
let manifestDisplay: 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. */
|
||||
@@ -72,30 +75,49 @@ describe("module UI build pipeline", () => {
|
||||
if (!manifest.entrypoints.ui) {
|
||||
throw new Error("The template manifest must declare entrypoints.ui for this test.");
|
||||
}
|
||||
if (!manifest.entrypoints.display) {
|
||||
throw new Error("The template manifest must declare entrypoints.display for this test.");
|
||||
}
|
||||
manifestUi = manifest.entrypoints.ui;
|
||||
manifestDisplay = manifest.entrypoints.display.artifact;
|
||||
manifestIdentity = { id: manifest.id, version: manifest.version };
|
||||
|
||||
const result = await buildModuleUi({ moduleDirectory });
|
||||
artifactPath = result.outputFile;
|
||||
artifactHref = pathToFileURL(result.outputFile).href;
|
||||
const uiArtifact = result.artifacts.find((artifact) => artifact.kind === "ui");
|
||||
const displayArtifact = result.artifacts.find((artifact) => artifact.kind === "display");
|
||||
if (!uiArtifact || !displayArtifact) {
|
||||
throw new Error("The template build must produce both declared UI roles.");
|
||||
}
|
||||
expect(result.artifacts.map((artifact) => artifact.kind)).toEqual(["ui", "display"]);
|
||||
artifactPath = uiArtifact.outputFile;
|
||||
artifactHref = pathToFileURL(uiArtifact.outputFile).href;
|
||||
displayArtifactPath = displayArtifact.outputFile;
|
||||
displayArtifactHref = pathToFileURL(displayArtifact.outputFile).href;
|
||||
}, 60_000);
|
||||
|
||||
it("writes the artifact exactly where the manifest declares it", () => {
|
||||
it("writes both artifacts exactly where the manifest declares them", () => {
|
||||
expect(manifestUi).toBe("./dist/ui/index.js");
|
||||
expect(artifactPath.endsWith(path.join("dist", "ui", "index.js"))).toBe(true);
|
||||
expect(manifestDisplay).toBe("./dist/display/index.js");
|
||||
expect(displayArtifactPath.endsWith(path.join("dist", "display", "index.js"))).toBe(true);
|
||||
});
|
||||
|
||||
it("produces a browser-safe ESM artifact that needs no host-provided React", async () => {
|
||||
const artifact = await readFile(artifactPath, "utf8");
|
||||
it("produces browser-safe ESM artifacts that need no host-provided React", async () => {
|
||||
const artifacts = await Promise.all([
|
||||
readFile(artifactPath, "utf8"),
|
||||
readFile(displayArtifactPath, "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");
|
||||
for (const artifact of artifacts) {
|
||||
// React is bundled, so each 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 () => {
|
||||
@@ -139,6 +161,26 @@ describe("module UI build pipeline", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("mounts the display artifact through the same ABI, then unmounts it", async () => {
|
||||
const namespace = (await import(displayArtifactHref)) as unknown;
|
||||
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
||||
const container = document.createElement("div");
|
||||
const requested: string[] = [];
|
||||
|
||||
const unmount = await definition.mount(
|
||||
container,
|
||||
recordingHost({ canRead: true, records: [], requested, resolve: () => undefined }),
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(container.querySelector("[data-testid='example-display']")?.textContent).toContain(
|
||||
"Example full-screen display (example)",
|
||||
);
|
||||
expect(requested).toEqual([]);
|
||||
await (unmount as () => Promise<void>)();
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/ui/**/*.tsx", "src/ui/**/*.ts"]
|
||||
"include": ["src/ui/**/*.tsx", "src/ui/**/*.ts", "src/display/**/*.tsx", "src/display/**/*.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user