feat: add display kiosk host
This commit is contained in:
@@ -1,15 +1,16 @@
|
||||
import { z } from "zod";
|
||||
import { type PermissionDeclaration, permissionDeclarationSchema } from "./authorization.js";
|
||||
import {
|
||||
type ModuleConfigurationDeclaration,
|
||||
moduleConfigurationDeclarationSchema,
|
||||
} from "./module-configuration.js";
|
||||
import {
|
||||
capabilityIdSchema,
|
||||
logicalBucketIdSchema,
|
||||
moduleIdSchema,
|
||||
packageRelativePathSchema,
|
||||
permissionIdSchema,
|
||||
} from "./identifiers.js";
|
||||
import {
|
||||
type ModuleConfigurationDeclaration,
|
||||
moduleConfigurationDeclarationSchema,
|
||||
} from "./module-configuration.js";
|
||||
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||
|
||||
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
|
||||
@@ -64,17 +65,27 @@ const moduleStorageSchema = z
|
||||
.default(() => ({ buckets: [] }));
|
||||
|
||||
/**
|
||||
* The compiled artifacts a module publishes for the platform to resolve. Every entry is a
|
||||
* package-relative path to a build output, never source: Core resolves and validates these
|
||||
* statically during discovery, and the manifest stays inert JSON (ADR 0006, ADR 0021).
|
||||
* A module's optional full-viewport display UI. The artifact is kept beside its access policy so a
|
||||
* display can never be declared without saying which one of the module's own permissions protects
|
||||
* it. Core validates the reference against `permissions` below without executing module code.
|
||||
*/
|
||||
export const moduleDisplayEntrypointSchema = z
|
||||
.object({
|
||||
artifact: packageRelativePathSchema,
|
||||
permission: permissionIdSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* The compiled artifacts a module publishes for the platform to resolve. File entries are
|
||||
* package-relative paths to build output, never source: Core resolves and validates them statically
|
||||
* during discovery, and the manifest stays inert JSON (ADR 0006, ADR 0021).
|
||||
*
|
||||
* `ui` is a module's optional, host-neutral presentation entrypoint (ADR 0021). It is declared and
|
||||
* statically validated here, exactly like `jobs` already is, but **no runtime consumer exists yet**
|
||||
* — nothing in this repository imports, evaluates, mounts, or renders it. Its name is deliberately
|
||||
* not `dashboard`: a module's UI must be hostable by Ekklesia's own application shell *and* by a
|
||||
* future standalone host, so the contract cannot be named after one of them. It is deliberately not
|
||||
* `surface` either — `surfaces/*` is a separate ownership layer (ADR 0001), and reusing that word
|
||||
* for a module-owned artifact would conflate two different things.
|
||||
* statically validated here and mounted by a compatible host. Its name is deliberately not
|
||||
* `dashboard`: a module's primary UI must be hostable by Ekklesia's application shell *and* by a
|
||||
* future standalone host. `display` is separate because full-viewport output and primary management
|
||||
* UI are distinct product experiences; both still use the same browser mount ABI.
|
||||
*/
|
||||
const entrypointsSchema = z
|
||||
.object({
|
||||
@@ -83,6 +94,7 @@ const entrypointsSchema = z
|
||||
migrations: packageRelativePathSchema.optional(),
|
||||
jobs: packageRelativePathSchema.optional(),
|
||||
ui: packageRelativePathSchema.optional(),
|
||||
display: moduleDisplayEntrypointSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -106,10 +118,9 @@ function reportDuplicates(
|
||||
}
|
||||
|
||||
/**
|
||||
* The one manifest shape Core accepts. Adding a top-level `configuration` array is an incompatible
|
||||
* shape change — version 4's manifest object is `.strict()` and rejects `configuration` outright —
|
||||
* so the canonical manifest moves to `manifestVersion: 5` rather than reinterpreting version 4
|
||||
* (ADR 0029).
|
||||
* The one manifest shape Core accepts. Version 5's strict `entrypoints` object rejects `display`, so
|
||||
* the static display declaration is an incompatible shape change and the canonical manifest moves
|
||||
* to `manifestVersion: 6` rather than silently reinterpreting version 5.
|
||||
*
|
||||
* Configuration is declared **statically, in this inert document**, rather than registered by
|
||||
* executable module code, for one decisive reason: the platform validates a module's configuration
|
||||
@@ -118,13 +129,13 @@ function reportDuplicates(
|
||||
* inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without
|
||||
* importing a single line of module code, exactly as they always have.
|
||||
*
|
||||
* Version 4, and every other version, fails validation: there is no compatibility adapter, no
|
||||
* Version 5, and every other version, fails validation: there is no compatibility adapter, no
|
||||
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
|
||||
* (ADR 0006), version 2 by version 3 (ADR 0014), and version 3 by version 4 (ADR 0021). One
|
||||
* manifest document can therefore never be read as two different versions. Upgrading a version 4
|
||||
* manifest is a one-character edit: no field added by version 5 is required.
|
||||
* manifest document can therefore never be read as two different versions. Upgrading a version 5
|
||||
* manifest is a one-character edit for a module that declares no display: the new field is optional.
|
||||
*/
|
||||
export const MODULE_MANIFEST_VERSION = 5;
|
||||
export const MODULE_MANIFEST_VERSION = 6;
|
||||
|
||||
export const moduleManifestSchema = z
|
||||
.object({
|
||||
@@ -199,6 +210,39 @@ export const moduleManifestSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
const display = manifest.entrypoints.display;
|
||||
if (display !== undefined) {
|
||||
const displayPermission = manifest.permissions.find(
|
||||
(permission) => permission.id === display.permission,
|
||||
);
|
||||
if (displayPermission === undefined) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A display entrypoint must reference a permission declared by this module.",
|
||||
path: ["entrypoints", "display", "permission"],
|
||||
});
|
||||
} else if (displayPermission.baseline === "public") {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A display entrypoint permission must require an authenticated principal.",
|
||||
path: ["entrypoints", "display", "permission"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
Object.entries(manifest.entrypoints).some(
|
||||
([entrypointName, configured]) =>
|
||||
entrypointName !== "display" && configured === display.artifact,
|
||||
)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"The display UI must use a different path from every other configured entrypoint.",
|
||||
path: ["entrypoints", "display", "artifact"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const dependencyKind of ["required", "optional"] as const) {
|
||||
if (Object.hasOwn(manifest.dependencies.modules[dependencyKind], manifest.id)) {
|
||||
context.addIssue({
|
||||
@@ -253,6 +297,7 @@ export function safeParseModuleManifest(input: unknown) {
|
||||
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
||||
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
||||
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
||||
export type ModuleDisplayEntrypoint = z.output<typeof moduleDisplayEntrypointSchema>;
|
||||
/** A module manifest's `configuration` array entry — the same `ModuleConfigurationDeclaration` shape
|
||||
* Core's configuration resolver consumes (`packages/contracts/src/module-configuration.ts`), aliased
|
||||
* here for discoverability alongside the manifest's other per-field declaration types. */
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isVersionCompatible,
|
||||
isModuleConfigurationBlockedReason,
|
||||
isVersionCompatible,
|
||||
MODULE_BLOCKED_REASONS,
|
||||
MODULE_CONFIGURATION_BLOCKED_REASONS,
|
||||
MODULE_DEPENDENCY_KINDS,
|
||||
MODULE_STORAGE_BLOCKED_REASON,
|
||||
MODULE_MANIFEST_VERSION,
|
||||
MODULE_RUNTIME_STATES,
|
||||
MODULE_STORAGE_BLOCKED_REASON,
|
||||
packageRelativePathSchema,
|
||||
parseModuleManifest,
|
||||
parsePermissionId,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
|
||||
function validManifest(): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 6,
|
||||
id: "calendar",
|
||||
name: "Calendar",
|
||||
version: "0.1.0",
|
||||
@@ -226,9 +226,15 @@ describe("module manifest contract", () => {
|
||||
expect(safeParseModuleManifest(version4Manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a version 5 manifest rather than silently interpreting it as version 6", () => {
|
||||
const version5Manifest = { ...validManifest(), manifestVersion: 5 };
|
||||
|
||||
expect(safeParseModuleManifest(version5Manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
|
||||
expect(MODULE_MANIFEST_VERSION).toBe(5);
|
||||
for (const manifestVersion of [1, 2, 3, 4, 6, "5", 5.5, null]) {
|
||||
expect(MODULE_MANIFEST_VERSION).toBe(6);
|
||||
for (const manifestVersion of [1, 2, 3, 4, 5, 7, "6", 6.5, null]) {
|
||||
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
|
||||
}
|
||||
expect(
|
||||
@@ -391,6 +397,7 @@ describe("module manifest contract", () => {
|
||||
const manifest = parseModuleManifest(validManifest());
|
||||
|
||||
expect(manifest.entrypoints.ui).toBeUndefined();
|
||||
expect(manifest.entrypoints.display).toBeUndefined();
|
||||
expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
|
||||
});
|
||||
|
||||
@@ -405,6 +412,153 @@ describe("module manifest contract", () => {
|
||||
expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
|
||||
});
|
||||
|
||||
it("accepts an independently declared display UI protected by a module-owned permission", () => {
|
||||
const manifest = parseModuleManifest({
|
||||
...validManifest(),
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
display: {
|
||||
artifact: "./dist/display/index.js",
|
||||
permission: "calendar.events.read",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(manifest.entrypoints.ui).toBeUndefined();
|
||||
expect(manifest.entrypoints.display).toEqual({
|
||||
artifact: "./dist/display/index.js",
|
||||
permission: "calendar.events.read",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a display permission that this module did not declare", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
...validManifest(),
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
display: {
|
||||
artifact: "./dist/display/index.js",
|
||||
permission: "calendar.display.view",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
|
||||
"entrypoints.display.permission",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a display permission whose baseline would admit an anonymous principal", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
...validManifest(),
|
||||
permissions: [
|
||||
{
|
||||
id: "calendar.display.view",
|
||||
baseline: "public",
|
||||
description: "View the calendar display.",
|
||||
},
|
||||
],
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
display: {
|
||||
artifact: "./dist/display/index.js",
|
||||
permission: "calendar.display.view",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
|
||||
"entrypoints.display.permission",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
for (const entrypointName of ["server", "schema", "migrations", "jobs", "ui"] as const) {
|
||||
it(`rejects a display artifact that is also configured as the ${entrypointName} entrypoint`, () => {
|
||||
const sharedPath = "./dist/shared.js";
|
||||
const result = safeParseModuleManifest({
|
||||
...validManifest(),
|
||||
entrypoints: {
|
||||
server: entrypointName === "server" ? sharedPath : "./dist/server/index.js",
|
||||
...(entrypointName === "server" ? {} : { [entrypointName]: sharedPath }),
|
||||
display: {
|
||||
artifact: sharedPath,
|
||||
permission: "calendar.events.read",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
|
||||
"entrypoints.display.artifact",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("rejects primary and display UI declarations that name the same artifact", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
...validManifest(),
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
ui: "./dist/ui/index.js",
|
||||
display: {
|
||||
artifact: "./dist/ui/index.js",
|
||||
permission: "calendar.events.read",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
|
||||
"entrypoints.display.artifact",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"../outside/display.js",
|
||||
"/absolute/display.js",
|
||||
"./dist/../../display.js",
|
||||
"file:///dist/display.js",
|
||||
".\\dist\\display.js",
|
||||
"./dist/display.js?raw",
|
||||
"",
|
||||
])("rejects the unsafe display artifact path %s", (artifact) => {
|
||||
expect(
|
||||
safeParseModuleManifest({
|
||||
...validManifest(),
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
display: { artifact, permission: "calendar.events.read" },
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown properties in the static display declaration", () => {
|
||||
expect(
|
||||
safeParseModuleManifest({
|
||||
...validManifest(),
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
display: {
|
||||
artifact: "./dist/display/index.js",
|
||||
permission: "calendar.events.read",
|
||||
public: true,
|
||||
},
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["../outside/ui.js", "escapes the package boundary"],
|
||||
["/absolute/ui.js", "is an absolute path"],
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { packageRelativePathSchema } from "@ekklesia/contracts";
|
||||
import { moduleDisplayEntrypointSchema, packageRelativePathSchema } from "@ekklesia/contracts";
|
||||
import { build, type Plugin } from "esbuild";
|
||||
|
||||
/**
|
||||
* The one supported way to turn a module's React/TSX UI source into the compiled browser artifact
|
||||
* its manifest declares as `entrypoints.ui` (ADR 0021, ADR 0022, ADR 0023).
|
||||
* 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
|
||||
* {@link MODULE_UI_SOURCE_ENTRYPOINT}, declare `entrypoints.ui` in their manifest, and run the
|
||||
* 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. */
|
||||
@@ -19,17 +20,21 @@ export const MODULE_UI_BUILD_TARGET = "es2022";
|
||||
|
||||
export type ModuleUiBuildErrorCode =
|
||||
| "MODULE_UI_MANIFEST_UNREADABLE"
|
||||
| "MODULE_UI_ENTRYPOINT_NOT_DECLARED"
|
||||
| "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_NOT_DECLARED: `${MODULE_MANIFEST_FILENAME} declares no entrypoints.ui, so there is no module UI to build.`,
|
||||
MODULE_UI_ENTRYPOINT_UNSAFE: `entrypoints.ui must be a package-relative path to a compiled .js or .mjs artifact inside the module.`,
|
||||
MODULE_UI_SOURCE_MISSING: `Module UI source was not found at ${MODULE_UI_SOURCE_ENTRYPOINT}.`,
|
||||
MODULE_UI_BUILD_FAILED: "The module UI bundle could not be produced.",
|
||||
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
|
||||
@@ -37,11 +42,17 @@ const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.f
|
||||
* 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) {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
@@ -83,9 +94,16 @@ export interface BuildModuleUiOptions {
|
||||
}
|
||||
|
||||
export interface ModuleUiBuildResult {
|
||||
/** Absolute path of the artifact actually written, which is exactly what the manifest declares. */
|
||||
/** 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 source entrypoint that produced it. */
|
||||
/** Absolute path of the fixed source entrypoint that produced it. */
|
||||
readonly sourceFile: string;
|
||||
}
|
||||
|
||||
@@ -93,9 +111,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** The declared artifact path, validated the same way module discovery validates it, so the build
|
||||
* can never write somewhere discovery would later reject. */
|
||||
async function resolveDeclaredArtifact(moduleDirectory: string): Promise<string> {
|
||||
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(
|
||||
@@ -106,20 +127,81 @@ async function resolveDeclaredArtifact(moduleDirectory: string): Promise<string>
|
||||
}
|
||||
|
||||
const entrypoints = isRecord(manifest) ? manifest.entrypoints : undefined;
|
||||
const declared = isRecord(entrypoints) ? entrypoints.ui : undefined;
|
||||
if (declared === undefined) {
|
||||
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_NOT_DECLARED");
|
||||
if (!isRecord(entrypoints)) {
|
||||
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE");
|
||||
}
|
||||
// The path grammar is Core's, not this tool's: reusing the contracts schema means the build can
|
||||
// never write to a location module discovery would later reject, even if that grammar changes.
|
||||
if (
|
||||
!packageRelativePathSchema.safeParse(declared).success ||
|
||||
typeof declared !== "string" ||
|
||||
![".js", ".mjs"].includes(path.extname(declared).toLowerCase())
|
||||
) {
|
||||
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE");
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
return path.resolve(moduleDirectory, declared);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,52 +212,70 @@ async function resolveDeclaredArtifact(moduleDirectory: string): Promise<string>
|
||||
*/
|
||||
export async function buildModuleUi(options: BuildModuleUiOptions): Promise<ModuleUiBuildResult> {
|
||||
const moduleDirectory = path.resolve(options.moduleDirectory);
|
||||
const outputFile = await resolveDeclaredArtifact(moduleDirectory);
|
||||
const sourceFile = path.join(moduleDirectory, MODULE_UI_SOURCE_ENTRYPOINT);
|
||||
const artifacts = await resolveDeclaredArtifacts(moduleDirectory);
|
||||
const pendingWrites: Array<readonly { path: string; contents: Uint8Array }[]> = [];
|
||||
|
||||
try {
|
||||
await readFile(sourceFile);
|
||||
} catch {
|
||||
throw new ModuleUiBuildError("MODULE_UI_SOURCE_MISSING");
|
||||
}
|
||||
|
||||
try {
|
||||
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: [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: outputFile,
|
||||
platform: "browser",
|
||||
plugins: [rejectStylesheetImports()],
|
||||
sourcemap: true,
|
||||
target: MODULE_UI_BUILD_TARGET,
|
||||
// Fail rather than silently shipping a partial bundle.
|
||||
logLevel: "silent",
|
||||
});
|
||||
} 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"), {
|
||||
diagnostics: error.errors,
|
||||
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);
|
||||
}
|
||||
throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED");
|
||||
}
|
||||
|
||||
return { outputFile, sourceFile };
|
||||
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 }))),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,24 +11,28 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
/**
|
||||
* `ekklesia-build-module-ui` — the canonical module UI build command, run from a module directory.
|
||||
*
|
||||
* A module with no `entrypoints.ui` is not an error: backend-only modules stay first-class and this
|
||||
* command reports that there was nothing to build and exits successfully, so one `build` script
|
||||
* works for every module.
|
||||
* A module with neither UI role is not an error: backend-only modules stay first-class and this
|
||||
* command reports that there was nothing to build and exits successfully. When one or both roles
|
||||
* are declared, this one command builds exactly that set.
|
||||
*/
|
||||
async function main(): Promise<number> {
|
||||
const moduleDirectory = process.argv[2] ?? process.cwd();
|
||||
|
||||
try {
|
||||
const result = await buildModuleUi({ moduleDirectory });
|
||||
process.stdout.write(`Built module UI: ${path.relative(moduleDirectory, result.outputFile)}\n`);
|
||||
if (result.artifacts.length === 0) {
|
||||
process.stdout.write("No module UI entrypoints declared; skipping the module UI build.\n");
|
||||
return 0;
|
||||
}
|
||||
for (const artifact of result.artifacts) {
|
||||
process.stdout.write(
|
||||
`Built module UI (${artifact.kind}): ${path.relative(moduleDirectory, artifact.outputFile)}\n`,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
} catch (error) {
|
||||
if (error instanceof ModuleUiBuildError) {
|
||||
if (error.code === "MODULE_UI_ENTRYPOINT_NOT_DECLARED") {
|
||||
process.stdout.write("No entrypoints.ui declared; skipping the module UI build.\n");
|
||||
return 0;
|
||||
}
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.stderr.write(`${error.kind ? `${error.kind}: ` : ""}${error.message}\n`);
|
||||
// esbuild's own diagnostics carry the file, line, and reason a developer needs. They are
|
||||
// printed here, never folded into the sanitized error message itself.
|
||||
const diagnostics = isRecord(error) ? error.diagnostics : undefined;
|
||||
|
||||
@@ -17,6 +17,12 @@ afterEach(async () => {
|
||||
interface ModuleFixtureOptions {
|
||||
/** Omit to leave `entrypoints.ui` undeclared, as a backend-only module does. */
|
||||
readonly ui?: string;
|
||||
readonly schema?: string;
|
||||
readonly migrations?: string;
|
||||
readonly jobs?: string;
|
||||
readonly server?: string;
|
||||
/** The complete inert `entrypoints.display` value, including its permission policy. */
|
||||
readonly display?: unknown;
|
||||
readonly manifest?: string;
|
||||
readonly files?: Record<string, string>;
|
||||
}
|
||||
@@ -28,14 +34,18 @@ async function createModule(options: ModuleFixtureOptions = {}): Promise<string>
|
||||
const manifest =
|
||||
options.manifest ??
|
||||
JSON.stringify({
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 6,
|
||||
id: "example",
|
||||
name: "Example",
|
||||
version: "0.1.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
server: options.server ?? "./dist/server/index.js",
|
||||
...(options.schema === undefined ? {} : { schema: options.schema }),
|
||||
...(options.migrations === undefined ? {} : { migrations: options.migrations }),
|
||||
...(options.jobs === undefined ? {} : { jobs: options.jobs }),
|
||||
...(options.ui === undefined ? {} : { ui: options.ui }),
|
||||
...(options.display === undefined ? {} : { display: options.display }),
|
||||
},
|
||||
});
|
||||
await writeFile(path.join(directory, "ekklesia.module.json"), manifest);
|
||||
@@ -73,9 +83,14 @@ describe("module UI build", () => {
|
||||
});
|
||||
|
||||
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||
const artifactResult = result.artifacts[0];
|
||||
|
||||
expect(result.outputFile).toBe(path.join(directory, "dist", "ui", "index.js"));
|
||||
const artifact = await readFile(result.outputFile, "utf8");
|
||||
expect(result.artifacts).toHaveLength(1);
|
||||
expect(artifactResult).toMatchObject({
|
||||
kind: "ui",
|
||||
outputFile: path.join(directory, "dist", "ui", "index.js"),
|
||||
});
|
||||
const artifact = await readFile(artifactResult?.outputFile ?? "", "utf8");
|
||||
expect(artifact).toContain("export {");
|
||||
expect(artifact).toContain("as default");
|
||||
});
|
||||
@@ -88,7 +103,9 @@ describe("module UI build", () => {
|
||||
|
||||
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||
|
||||
expect(result.outputFile).toBe(path.join(directory, "build", "browser", "ui.mjs"));
|
||||
expect(result.artifacts[0]?.outputFile).toBe(
|
||||
path.join(directory, "build", "browser", "ui.mjs"),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits a source map alongside the artifact", async () => {
|
||||
@@ -99,15 +116,15 @@ describe("module UI build", () => {
|
||||
|
||||
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||
|
||||
await expect(readFile(`${result.outputFile}.map`, "utf8")).resolves.toContain("sources");
|
||||
await expect(readFile(`${result.artifacts[0]?.outputFile}.map`, "utf8")).resolves.toContain(
|
||||
"sources",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a backend-only module as having no UI to build rather than failing silently", async () => {
|
||||
it("returns an explicit empty result for a backend-only module", async () => {
|
||||
const directory = await createModule({ files: { "src/ui/index.tsx": plainUiSource } });
|
||||
|
||||
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||
code: "MODULE_UI_ENTRYPOINT_NOT_DECLARED",
|
||||
});
|
||||
await expect(buildModuleUi({ moduleDirectory: directory })).resolves.toEqual({ artifacts: [] });
|
||||
});
|
||||
|
||||
it("fails when the declared UI has no source entrypoint", async () => {
|
||||
@@ -115,9 +132,134 @@ describe("module UI build", () => {
|
||||
|
||||
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||
code: "MODULE_UI_SOURCE_MISSING",
|
||||
kind: "ui",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a display-only module from the fixed display source", async () => {
|
||||
const directory = await createModule({
|
||||
display: {
|
||||
artifact: "./dist/display/index.js",
|
||||
permission: "example.display.view",
|
||||
},
|
||||
files: { "src/display/index.tsx": plainUiSource },
|
||||
});
|
||||
|
||||
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||
|
||||
expect(result.artifacts).toEqual([
|
||||
{
|
||||
kind: "display",
|
||||
outputFile: path.join(directory, "dist/display/index.js"),
|
||||
sourceFile: path.join(directory, "src/display/index.tsx"),
|
||||
},
|
||||
]);
|
||||
await expect(readFile(result.artifacts[0]?.outputFile ?? "", "utf8")).resolves.toContain(
|
||||
"as default",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds primary and display artifacts in deterministic role order", async () => {
|
||||
const directory = await createModule({
|
||||
ui: "./dist/ui/index.js",
|
||||
display: {
|
||||
artifact: "./dist/display/index.mjs",
|
||||
permission: "example.display.view",
|
||||
},
|
||||
files: {
|
||||
"src/ui/index.tsx": plainUiSource.replaceAll("example", "primary"),
|
||||
"src/display/index.tsx": plainUiSource.replaceAll("example", "display"),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||
|
||||
expect(result.artifacts.map((artifact) => artifact.kind)).toEqual(["ui", "display"]);
|
||||
await expect(readFile(path.join(directory, "dist/ui/index.js"), "utf8")).resolves.toContain(
|
||||
'"primary"',
|
||||
);
|
||||
await expect(
|
||||
readFile(path.join(directory, "dist/display/index.mjs"), "utf8"),
|
||||
).resolves.toContain('"display"');
|
||||
});
|
||||
|
||||
it("preflights every declared source before writing either artifact", async () => {
|
||||
const directory = await createModule({
|
||||
ui: "./dist/ui/index.js",
|
||||
display: {
|
||||
artifact: "./dist/display/index.js",
|
||||
permission: "example.display.view",
|
||||
},
|
||||
files: { "src/ui/index.tsx": plainUiSource },
|
||||
});
|
||||
|
||||
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||
code: "MODULE_UI_SOURCE_MISSING",
|
||||
kind: "display",
|
||||
});
|
||||
await expect(readFile(path.join(directory, "dist/ui/index.js"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("writes neither role when the second declared bundle fails to compile", async () => {
|
||||
const directory = await createModule({
|
||||
ui: "./dist/ui/index.js",
|
||||
display: {
|
||||
artifact: "./dist/display/index.js",
|
||||
permission: "example.display.view",
|
||||
},
|
||||
files: {
|
||||
"src/ui/index.tsx": plainUiSource,
|
||||
"src/display/index.tsx": "export default { broken:",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||
code: "MODULE_UI_BUILD_FAILED",
|
||||
kind: "display",
|
||||
});
|
||||
await expect(readFile(path.join(directory, "dist/ui/index.js"))).rejects.toThrow();
|
||||
await expect(readFile(path.join(directory, "dist/display/index.js"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects colliding primary and display output paths before building", async () => {
|
||||
const directory = await createModule({
|
||||
ui: "./dist/ui/index.js",
|
||||
display: {
|
||||
artifact: "./dist/ui/index.js",
|
||||
permission: "example.display.view",
|
||||
},
|
||||
files: {
|
||||
"src/ui/index.tsx": plainUiSource,
|
||||
"src/display/index.tsx": plainUiSource,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||
code: "MODULE_UI_ENTRYPOINT_COLLISION",
|
||||
kind: "display",
|
||||
});
|
||||
});
|
||||
|
||||
for (const entrypointName of ["server", "schema", "migrations", "jobs"] as const) {
|
||||
it(`rejects a display output that would overwrite the declared ${entrypointName} artifact before building`, async () => {
|
||||
const sharedPath = "./dist/display/index.js";
|
||||
const directory = await createModule({
|
||||
[entrypointName]: sharedPath,
|
||||
display: {
|
||||
artifact: sharedPath,
|
||||
permission: "example.display.view",
|
||||
},
|
||||
files: { "src/display/index.tsx": plainUiSource },
|
||||
});
|
||||
|
||||
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||
code: "MODULE_UI_ENTRYPOINT_COLLISION",
|
||||
kind: "display",
|
||||
});
|
||||
await expect(readFile(path.join(directory, "dist/display/index.js"))).rejects.toThrow();
|
||||
});
|
||||
}
|
||||
|
||||
it("fails when the manifest cannot be read", async () => {
|
||||
const directory = await createModule({ manifest: "{ not json" });
|
||||
|
||||
@@ -138,9 +280,38 @@ describe("module UI build", () => {
|
||||
|
||||
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||
code: "MODULE_UI_ENTRYPOINT_UNSAFE",
|
||||
kind: "ui",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies the same safe compiled-output rules to display artifacts", async () => {
|
||||
const directory = await createModule({
|
||||
display: {
|
||||
artifact: "./src/display/index.tsx",
|
||||
permission: "example.display.view",
|
||||
},
|
||||
files: { "src/display/index.tsx": plainUiSource },
|
||||
});
|
||||
|
||||
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||
code: "MODULE_UI_ENTRYPOINT_UNSAFE",
|
||||
kind: "display",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reinterpret a bare display path as the version 6 display declaration", async () => {
|
||||
const directory = await createModule({
|
||||
display: "./dist/display/index.js",
|
||||
files: { "src/display/index.tsx": plainUiSource },
|
||||
});
|
||||
|
||||
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||
code: "MODULE_UI_MANIFEST_UNREADABLE",
|
||||
kind: "display",
|
||||
});
|
||||
await expect(readFile(path.join(directory, "dist/display/index.js"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("surfaces a build failure with esbuild's diagnostics attached", async () => {
|
||||
const directory = await createModule({
|
||||
ui: "./dist/ui/index.js",
|
||||
@@ -212,6 +383,25 @@ describe("module UI build", () => {
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("enforces the stylesheet boundary for display UI too", async () => {
|
||||
const directory = await createModule({
|
||||
display: {
|
||||
artifact: "./dist/display/index.js",
|
||||
permission: "example.display.view",
|
||||
},
|
||||
files: {
|
||||
"src/display/index.tsx": 'import "./styles.css";\nexport default {};',
|
||||
"src/display/styles.css": ".display { position: fixed; }",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||
code: "MODULE_UI_BUILD_FAILED",
|
||||
kind: "display",
|
||||
});
|
||||
await expect(readFile(path.join(directory, "dist/display/index.css"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("substitutes NODE_ENV so the artifact never touches a Node global in a browser", async () => {
|
||||
const directory = await createModule({
|
||||
ui: "./dist/ui/index.js",
|
||||
@@ -222,7 +412,7 @@ describe("module UI build", () => {
|
||||
});
|
||||
|
||||
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||
const artifact = await readFile(result.outputFile, "utf8");
|
||||
const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
|
||||
|
||||
expect(artifact).toContain('"production"');
|
||||
expect(artifact).not.toContain("process.env.NODE_ENV");
|
||||
|
||||
@@ -9,11 +9,12 @@ import {
|
||||
* How a host obtains a module's UI artifact.
|
||||
*
|
||||
* `url` is a **browser-loadable module URL**, not a filesystem path. That distinction is the whole
|
||||
* reason this type exists. Core's module discovery resolves `entrypoints.ui` to an absolute path on
|
||||
* the server's disk (`ResolvedModuleEntrypoints.ui`); a browser cannot import that, and a host that
|
||||
* passed one here would fail at run time in a way no test on a server would catch. Turning an
|
||||
* installed artifact into a URL a browser may fetch is **asset publication**, a separate concern
|
||||
* this package deliberately does not own and Ekklesia does not implement yet (ADR 0024).
|
||||
* reason this type exists. Core's module discovery resolves each primary or display entrypoint to an
|
||||
* absolute path on the server's disk; a browser cannot import that, and a host that passed one here
|
||||
* would fail at run time in a way no test on a server would catch. Turning an installed artifact
|
||||
* into a URL a browser may fetch is **asset publication**, a separate concern this generic package
|
||||
* deliberately does not own. Ekklesia implements that concern for both roles at its API edge (ADR
|
||||
* 0025, ADR 0033).
|
||||
*
|
||||
* `moduleId` and `version` are the identity the host already read from the module's validated
|
||||
* manifest. They are checked against the artifact before anything is mounted.
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifestVersion": 5,
|
||||
"manifestVersion": 6,
|
||||
"id": "example",
|
||||
"name": "Example",
|
||||
"version": "0.1.0",
|
||||
|
||||
@@ -56,7 +56,11 @@ function standaloneContext(requested: string[]): ModuleUiHostContext {
|
||||
describe("loading a real compiled artifact", () => {
|
||||
beforeAll(async () => {
|
||||
const result = await buildModuleUi({ moduleDirectory: fixtureDirectory });
|
||||
artifactSource = await readFile(result.outputFile, "utf8");
|
||||
const primaryArtifact = result.artifacts.find((artifact) => artifact.kind === "ui");
|
||||
if (!primaryArtifact) {
|
||||
throw new Error("The real-artifact fixture must declare a primary UI artifact.");
|
||||
}
|
||||
artifactSource = await readFile(primaryArtifact.outputFile, "utf8");
|
||||
artifactUrl = `data:text/javascript;base64,${Buffer.from(artifactSource, "utf8").toString("base64")}`;
|
||||
}, 60_000);
|
||||
|
||||
|
||||
@@ -41,7 +41,9 @@ export type ModuleUiPrincipal = ModuleUiAnonymousPrincipal | ModuleUiAuthenticat
|
||||
* than to add. See ADR 0022.
|
||||
*
|
||||
* Nothing in this object is an Ekklesia implementation. Every member is satisfiable by a standalone
|
||||
* host that never runs Core, which is what makes one module UI hostable by both. This package has
|
||||
* host that never runs Core, which is what makes one module UI hostable by both. The same context is
|
||||
* supplied to primary and display artifacts: there is deliberately no role or presentation-mode
|
||||
* flag, because the separately declared artifact already selects the experience. This package has
|
||||
* no dependencies at all, so importing it cannot pull a backend type graph into a browser program.
|
||||
*/
|
||||
export interface ModuleUiHostContext {
|
||||
|
||||
@@ -11,7 +11,8 @@ export type Awaitable<Value> = Value | PromiseLike<Value>;
|
||||
export type ModuleUiUnmount = () => Awaitable<void>;
|
||||
|
||||
/**
|
||||
* What a module's compiled `entrypoints.ui` artifact default-exports (ADR 0021, ADR 0022).
|
||||
* What either compiled browser artifact (`entrypoints.ui` or `entrypoints.display.artifact`)
|
||||
* default-exports (ADR 0021, ADR 0022, ADR 0033).
|
||||
*
|
||||
* `id` and `version` are build-artifact identity markers, mirroring `ModuleRuntimeDefinition`
|
||||
* exactly (ADR 0006): they let a host prove the UI bundle it imported was built from the module it
|
||||
@@ -115,8 +116,9 @@ export interface ModuleUiIdentity {
|
||||
* Deliberately does **not** import anything itself. Each host performs its own dynamic import — a
|
||||
* browser host from a served URL, a test host from a file URL — and passes the resulting module
|
||||
* namespace here. That keeps this package free of any loader, bundler, or URL assumption, and keeps
|
||||
* Core out of the business of evaluating browser code entirely: module discovery still only
|
||||
* resolves and validates the `entrypoints.ui` path and never executes it (ADR 0021).
|
||||
* Core out of the business of evaluating browser code entirely: module discovery only resolves
|
||||
* and validates each declared browser artifact path and never executes either role (ADR 0021,
|
||||
* ADR 0033).
|
||||
*/
|
||||
export function parseModuleUiExport(
|
||||
namespace: unknown,
|
||||
|
||||
@@ -14,8 +14,8 @@ async function sourceFiles(): Promise<string[]> {
|
||||
* This package is bundled into a browser. `@ekklesia/module-sdk` re-exports all of
|
||||
* `@ekklesia/contracts`, which pulls Zod, Drizzle, and semver behind one barrel export, so a single
|
||||
* value import from a backend package here would drag that whole graph into every module UI bundle
|
||||
* (ADR 0022). The dependency on `@ekklesia/contracts` is deliberately type-only and must stay that
|
||||
* way; `import type` is erased at build time, a plain `import` is not.
|
||||
* (ADR 0022). ADR 0024 removed even the former type-only contracts dependency because its type graph
|
||||
* leaked Node globals into browser programs; this package must remain completely dependency-free.
|
||||
*/
|
||||
describe("module UI SDK stays browser-safe", () => {
|
||||
it("imports nothing from another package at all, at type level or runtime", async () => {
|
||||
|
||||
Reference in New Issue
Block a user