feat: add module runtime lifecycle
This commit is contained in:
@@ -3,6 +3,7 @@ import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
isVersionCompatible,
|
||||
type ModuleCapabilityDeclaration,
|
||||
type ModuleManifest,
|
||||
safeParseModuleManifest,
|
||||
semanticVersionSchema,
|
||||
@@ -12,6 +13,7 @@ export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
|
||||
|
||||
export type ModuleDiscoveryIssueCode =
|
||||
| "DISCOVERY_CONFIGURATION_INVALID"
|
||||
| "MODULE_CAPABILITY_INCOMPATIBLE"
|
||||
| "MODULE_CAPABILITY_MISSING"
|
||||
| "MODULE_DEPENDENCY_CYCLE"
|
||||
| "MODULE_DEPENDENCY_INCOMPATIBLE"
|
||||
@@ -83,6 +85,11 @@ interface ModuleCandidate {
|
||||
packageName: string;
|
||||
}
|
||||
|
||||
interface CapabilityProvider {
|
||||
candidate: ModuleCandidate;
|
||||
declaration: ModuleCapabilityDeclaration;
|
||||
}
|
||||
|
||||
export class ModuleDiscoveryError extends Error {
|
||||
readonly issues: readonly ModuleDiscoveryIssue[];
|
||||
|
||||
@@ -90,6 +97,7 @@ export class ModuleDiscoveryError extends Error {
|
||||
const sortedIssues = [...issues].sort(compareIssues);
|
||||
super(`Module discovery failed with ${sortedIssues.length} issue(s).`);
|
||||
this.name = "ModuleDiscoveryError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
this.issues = freezeRecursively(sortedIssues);
|
||||
}
|
||||
}
|
||||
@@ -229,6 +237,19 @@ async function resolveEntrypoints(
|
||||
if (relativeEntrypoint === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
entrypointName === "server" &&
|
||||
![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase())
|
||||
) {
|
||||
issues.push({
|
||||
code: "MODULE_ENTRYPOINT_INVALID",
|
||||
message: "server must reference compiled ESM JavaScript with a .js or .mjs extension.",
|
||||
moduleDirectory: directoryName,
|
||||
moduleId: manifest.id,
|
||||
path: "entrypoints.server",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const configuredPath = path.resolve(moduleDirectory, relativeEntrypoint);
|
||||
if (!pathIsInside(moduleDirectory, configuredPath)) {
|
||||
issues.push({
|
||||
@@ -590,7 +611,7 @@ export async function discoverModules(
|
||||
.map((candidate) => [candidate.manifest.id, candidate] as const),
|
||||
);
|
||||
|
||||
const capabilityProviders = new Map<string, ModuleCandidate>();
|
||||
const capabilityProviders = new Map<string, CapabilityProvider>();
|
||||
for (const candidate of modulesById.values()) {
|
||||
if (!isVersionCompatible(options.platformVersion, candidate.manifest.compatibility.platform)) {
|
||||
issues.push({
|
||||
@@ -613,8 +634,8 @@ export async function discoverModules(
|
||||
});
|
||||
}
|
||||
|
||||
for (const capability of candidate.manifest.capabilities) {
|
||||
capabilityProviders.set(capability, candidate);
|
||||
for (const declaration of candidate.manifest.capabilities) {
|
||||
capabilityProviders.set(declaration.id, { candidate, declaration });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,9 +678,9 @@ export async function discoverModules(
|
||||
}
|
||||
|
||||
for (const dependencyKind of ["required", "optional"] as const) {
|
||||
for (const [index, capability] of candidate.manifest.dependencies.capabilities[
|
||||
dependencyKind
|
||||
].entries()) {
|
||||
for (const [capability, versionRange] of Object.entries(
|
||||
candidate.manifest.dependencies.capabilities[dependencyKind],
|
||||
)) {
|
||||
const provider = capabilityProviders.get(capability);
|
||||
if (!provider) {
|
||||
if (dependencyKind === "required") {
|
||||
@@ -668,12 +689,21 @@ export async function discoverModules(
|
||||
message: `Required capability ${capability} is not installed.`,
|
||||
moduleDirectory: candidate.directoryName,
|
||||
moduleId: candidate.manifest.id,
|
||||
path: `dependencies.capabilities.${dependencyKind}.${index}`,
|
||||
path: `dependencies.capabilities.${dependencyKind}.${capability}`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
addGraphEdge(provider.manifest.id, candidate.manifest.id, dependents, indegrees);
|
||||
if (!isVersionCompatible(provider.declaration.version, versionRange)) {
|
||||
issues.push({
|
||||
code: "MODULE_CAPABILITY_INCOMPATIBLE",
|
||||
message: `Capability ${capability} ${provider.declaration.version} does not satisfy ${versionRange}.`,
|
||||
moduleDirectory: candidate.directoryName,
|
||||
moduleId: candidate.manifest.id,
|
||||
path: `dependencies.capabilities.${dependencyKind}.${capability}`,
|
||||
});
|
||||
}
|
||||
addGraphEdge(provider.candidate.manifest.id, candidate.manifest.id, dependents, indegrees);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./discovery.js";
|
||||
export * from "./runtime.js";
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
type CapabilityId,
|
||||
type CapabilityRegistrar,
|
||||
type CapabilityResolver,
|
||||
capabilityIdSchema,
|
||||
type ModuleId,
|
||||
type ModuleRegistrationContext,
|
||||
type ModuleRuntimeContext,
|
||||
type ModuleRuntimeDefinition,
|
||||
parseModuleRuntimeDefinition,
|
||||
type RegisteredCapability,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
type ModuleDiscoveryOptions,
|
||||
} from "./discovery.js";
|
||||
|
||||
export type ModuleRuntimePhase = "load" | "register" | "rollback" | "start" | "stop";
|
||||
|
||||
export type ModuleRuntimeIssueCode =
|
||||
| "MODULE_CAPABILITY_ACCESS_UNDECLARED"
|
||||
| "MODULE_CAPABILITY_DUPLICATE"
|
||||
| "MODULE_CAPABILITY_ID_INVALID"
|
||||
| "MODULE_CAPABILITY_UNDECLARED"
|
||||
| "MODULE_CAPABILITY_UNREGISTERED"
|
||||
| "MODULE_REGISTER_FAILED"
|
||||
| "MODULE_ROLLBACK_FAILED"
|
||||
| "MODULE_RUNTIME_EXPORT_INVALID"
|
||||
| "MODULE_RUNTIME_ID_MISMATCH"
|
||||
| "MODULE_RUNTIME_IMPORT_FAILED"
|
||||
| "MODULE_RUNTIME_VERSION_MISMATCH"
|
||||
| "MODULE_START_FAILED"
|
||||
| "MODULE_STOP_FAILED";
|
||||
|
||||
export interface ModuleRuntimeIssue {
|
||||
readonly code: ModuleRuntimeIssueCode;
|
||||
readonly message: string;
|
||||
readonly moduleId: ModuleId;
|
||||
readonly phase: ModuleRuntimePhase;
|
||||
}
|
||||
|
||||
export interface CapabilityRegistry extends CapabilityResolver {
|
||||
list(): readonly RegisteredCapability[];
|
||||
}
|
||||
|
||||
export interface RunningModule {
|
||||
readonly id: ModuleId;
|
||||
readonly version: string;
|
||||
}
|
||||
|
||||
export interface StartedModuleRuntime {
|
||||
readonly capabilities: CapabilityRegistry;
|
||||
readonly modules: readonly RunningModule[];
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartModuleRuntimeOptions extends ModuleDiscoveryOptions {}
|
||||
|
||||
interface LoadedModule {
|
||||
readonly definition: ModuleRuntimeDefinition;
|
||||
readonly discovered: DiscoveredModule;
|
||||
}
|
||||
|
||||
interface RegistrationStage {
|
||||
readonly context: ModuleRegistrationContext;
|
||||
commit(): readonly ModuleRuntimeIssue[];
|
||||
}
|
||||
|
||||
class ModuleRuntimeSignal extends Error {
|
||||
readonly issue: ModuleRuntimeIssue;
|
||||
|
||||
constructor(issue: ModuleRuntimeIssue) {
|
||||
super(issue.message);
|
||||
this.name = "ModuleRuntimeSignal";
|
||||
this.issue = issue;
|
||||
}
|
||||
}
|
||||
|
||||
export class ModuleRuntimeError extends Error {
|
||||
readonly issues: readonly ModuleRuntimeIssue[];
|
||||
|
||||
constructor(issues: readonly ModuleRuntimeIssue[]) {
|
||||
super(`Module runtime failed with ${issues.length} issue(s).`);
|
||||
this.name = "ModuleRuntimeError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
this.issues = Object.freeze(issues.map((issue) => Object.freeze({ ...issue })));
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeIssue(
|
||||
code: ModuleRuntimeIssueCode,
|
||||
phase: ModuleRuntimePhase,
|
||||
moduleId: ModuleId,
|
||||
message: string,
|
||||
): ModuleRuntimeIssue {
|
||||
return { code, message, moduleId, phase };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function defaultExport(moduleNamespace: unknown): unknown {
|
||||
return isRecord(moduleNamespace) ? moduleNamespace.default : undefined;
|
||||
}
|
||||
|
||||
class StagedCapabilityRegistry {
|
||||
readonly #registered = new Map<CapabilityId, RegisteredCapability>();
|
||||
|
||||
createRegistrationStage(module: DiscoveredModule): RegistrationStage {
|
||||
const local = new Map<CapabilityId, RegisteredCapability>();
|
||||
const declarations = new Map(
|
||||
module.manifest.capabilities.map((declaration) => [declaration.id, declaration] as const),
|
||||
);
|
||||
const resolve = (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
||||
this.#assertAccess(module, capabilityId, "register");
|
||||
return local.get(capabilityId) ?? this.#registered.get(capabilityId);
|
||||
};
|
||||
const capabilities: CapabilityRegistrar = Object.freeze({
|
||||
provide: (capabilityId: CapabilityId, implementation: unknown): void => {
|
||||
this.#assertValidCapabilityId(module, capabilityId, "register");
|
||||
const declaration = declarations.get(capabilityId);
|
||||
if (!declaration) {
|
||||
throw new ModuleRuntimeSignal(
|
||||
runtimeIssue(
|
||||
"MODULE_CAPABILITY_UNDECLARED",
|
||||
"register",
|
||||
module.manifest.id,
|
||||
`Module ${module.manifest.id} attempted to provide undeclared capability ${capabilityId}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (local.has(capabilityId) || this.#registered.has(capabilityId)) {
|
||||
throw new ModuleRuntimeSignal(
|
||||
runtimeIssue(
|
||||
"MODULE_CAPABILITY_DUPLICATE",
|
||||
"register",
|
||||
module.manifest.id,
|
||||
`Capability ${capabilityId} was provided more than once.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
local.set(
|
||||
capabilityId,
|
||||
Object.freeze({
|
||||
id: capabilityId,
|
||||
implementation,
|
||||
moduleId: module.manifest.id,
|
||||
version: declaration.version,
|
||||
}),
|
||||
);
|
||||
},
|
||||
resolve,
|
||||
});
|
||||
const context: ModuleRegistrationContext = Object.freeze({
|
||||
capabilities,
|
||||
moduleId: module.manifest.id,
|
||||
});
|
||||
|
||||
return {
|
||||
context,
|
||||
commit: (): readonly ModuleRuntimeIssue[] => {
|
||||
const missing = [...declarations.keys()]
|
||||
.filter((capabilityId) => !local.has(capabilityId))
|
||||
.sort();
|
||||
if (missing.length > 0) {
|
||||
return missing.map((capabilityId) =>
|
||||
runtimeIssue(
|
||||
"MODULE_CAPABILITY_UNREGISTERED",
|
||||
"register",
|
||||
module.manifest.id,
|
||||
`Module ${module.manifest.id} did not provide declared capability ${capabilityId}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const [capabilityId, capability] of local) {
|
||||
this.#registered.set(capabilityId, capability);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
createRuntimeContext(
|
||||
module: DiscoveredModule,
|
||||
phase: "rollback" | "start" | "stop",
|
||||
): ModuleRuntimeContext {
|
||||
const capabilities: CapabilityResolver = Object.freeze({
|
||||
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
||||
this.#assertAccess(module, capabilityId, phase);
|
||||
return this.#registered.get(capabilityId);
|
||||
},
|
||||
});
|
||||
return Object.freeze({ capabilities, moduleId: module.manifest.id });
|
||||
}
|
||||
|
||||
publish(): CapabilityRegistry {
|
||||
const capabilities = Object.freeze(
|
||||
[...this.#registered.values()].sort((left, right) =>
|
||||
left.id < right.id ? -1 : left.id > right.id ? 1 : 0,
|
||||
),
|
||||
);
|
||||
const byId = new Map(capabilities.map((capability) => [capability.id, capability] as const));
|
||||
return Object.freeze({
|
||||
list: (): readonly RegisteredCapability[] => capabilities,
|
||||
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined =>
|
||||
byId.get(capabilityId),
|
||||
});
|
||||
}
|
||||
|
||||
#assertAccess(
|
||||
module: DiscoveredModule,
|
||||
capabilityId: CapabilityId,
|
||||
phase: "register" | "rollback" | "start" | "stop",
|
||||
): void {
|
||||
this.#assertValidCapabilityId(module, capabilityId, phase);
|
||||
const ownsCapability = module.manifest.capabilities.some(
|
||||
(declaration) => declaration.id === capabilityId,
|
||||
);
|
||||
const declaresDependency =
|
||||
Object.hasOwn(module.manifest.dependencies.capabilities.required, capabilityId) ||
|
||||
Object.hasOwn(module.manifest.dependencies.capabilities.optional, capabilityId);
|
||||
if (!ownsCapability && !declaresDependency) {
|
||||
throw new ModuleRuntimeSignal(
|
||||
runtimeIssue(
|
||||
"MODULE_CAPABILITY_ACCESS_UNDECLARED",
|
||||
phase,
|
||||
module.manifest.id,
|
||||
`Module ${module.manifest.id} attempted to resolve undeclared capability ${capabilityId}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#assertValidCapabilityId(
|
||||
module: DiscoveredModule,
|
||||
capabilityId: unknown,
|
||||
phase: "register" | "rollback" | "start" | "stop",
|
||||
): asserts capabilityId is CapabilityId {
|
||||
if (!capabilityIdSchema.safeParse(capabilityId).success) {
|
||||
throw new ModuleRuntimeSignal(
|
||||
runtimeIssue(
|
||||
"MODULE_CAPABILITY_ID_INVALID",
|
||||
phase,
|
||||
module.manifest.id,
|
||||
`Module ${module.manifest.id} used an invalid capability identifier.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRuntimeDefinitions(
|
||||
discoveredModules: readonly DiscoveredModule[],
|
||||
): Promise<readonly LoadedModule[]> {
|
||||
const issues: ModuleRuntimeIssue[] = [];
|
||||
const loaded: LoadedModule[] = [];
|
||||
|
||||
for (const discovered of discoveredModules) {
|
||||
let moduleNamespace: unknown;
|
||||
try {
|
||||
moduleNamespace = (await import(
|
||||
pathToFileURL(discovered.entrypoints.server).href
|
||||
)) as unknown;
|
||||
} catch {
|
||||
issues.push(
|
||||
runtimeIssue(
|
||||
"MODULE_RUNTIME_IMPORT_FAILED",
|
||||
"load",
|
||||
discovered.manifest.id,
|
||||
`Module ${discovered.manifest.id} server entrypoint could not be imported.`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let definition: ModuleRuntimeDefinition;
|
||||
try {
|
||||
definition = parseModuleRuntimeDefinition(defaultExport(moduleNamespace));
|
||||
} catch {
|
||||
issues.push(
|
||||
runtimeIssue(
|
||||
"MODULE_RUNTIME_EXPORT_INVALID",
|
||||
"load",
|
||||
discovered.manifest.id,
|
||||
`Module ${discovered.manifest.id} server entrypoint has an invalid default export.`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let matchesManifest = true;
|
||||
if (definition.id !== discovered.manifest.id) {
|
||||
matchesManifest = false;
|
||||
issues.push(
|
||||
runtimeIssue(
|
||||
"MODULE_RUNTIME_ID_MISMATCH",
|
||||
"load",
|
||||
discovered.manifest.id,
|
||||
`Module runtime ID ${definition.id} does not match manifest ID ${discovered.manifest.id}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (definition.version !== discovered.manifest.version) {
|
||||
matchesManifest = false;
|
||||
issues.push(
|
||||
runtimeIssue(
|
||||
"MODULE_RUNTIME_VERSION_MISMATCH",
|
||||
"load",
|
||||
discovered.manifest.id,
|
||||
`Module runtime version ${definition.version} does not match manifest version ${discovered.manifest.version}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (matchesManifest) {
|
||||
loaded.push({ definition, discovered });
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
throw new ModuleRuntimeError(issues);
|
||||
}
|
||||
return Object.freeze(loaded);
|
||||
}
|
||||
|
||||
async function registerModules(
|
||||
modules: readonly LoadedModule[],
|
||||
registry: StagedCapabilityRegistry,
|
||||
): Promise<void> {
|
||||
for (const module of modules) {
|
||||
const stage = registry.createRegistrationStage(module.discovered);
|
||||
try {
|
||||
await module.definition.register?.(stage.context);
|
||||
} catch (error) {
|
||||
if (error instanceof ModuleRuntimeSignal) {
|
||||
throw new ModuleRuntimeError([error.issue]);
|
||||
}
|
||||
throw new ModuleRuntimeError([
|
||||
runtimeIssue(
|
||||
"MODULE_REGISTER_FAILED",
|
||||
"register",
|
||||
module.discovered.manifest.id,
|
||||
`Module ${module.discovered.manifest.id} registration failed.`,
|
||||
),
|
||||
]);
|
||||
}
|
||||
const issues = stage.commit();
|
||||
if (issues.length > 0) {
|
||||
throw new ModuleRuntimeError(issues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stopModules(
|
||||
modules: readonly LoadedModule[],
|
||||
registry: StagedCapabilityRegistry,
|
||||
phase: "rollback" | "stop",
|
||||
): Promise<readonly ModuleRuntimeIssue[]> {
|
||||
const issues: ModuleRuntimeIssue[] = [];
|
||||
for (const module of [...modules].reverse()) {
|
||||
try {
|
||||
await module.definition.stop?.(registry.createRuntimeContext(module.discovered, phase));
|
||||
} catch (error) {
|
||||
if (error instanceof ModuleRuntimeSignal) {
|
||||
issues.push(error.issue);
|
||||
} else {
|
||||
issues.push(
|
||||
runtimeIssue(
|
||||
phase === "rollback" ? "MODULE_ROLLBACK_FAILED" : "MODULE_STOP_FAILED",
|
||||
phase,
|
||||
module.discovered.manifest.id,
|
||||
`Module ${module.discovered.manifest.id} ${phase} failed.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
async function startModules(
|
||||
modules: readonly LoadedModule[],
|
||||
registry: StagedCapabilityRegistry,
|
||||
): Promise<void> {
|
||||
const started: LoadedModule[] = [];
|
||||
for (const module of modules) {
|
||||
try {
|
||||
await module.definition.start?.(registry.createRuntimeContext(module.discovered, "start"));
|
||||
started.push(module);
|
||||
} catch (error) {
|
||||
const startIssue =
|
||||
error instanceof ModuleRuntimeSignal
|
||||
? error.issue
|
||||
: runtimeIssue(
|
||||
"MODULE_START_FAILED",
|
||||
"start",
|
||||
module.discovered.manifest.id,
|
||||
`Module ${module.discovered.manifest.id} startup failed.`,
|
||||
);
|
||||
const rollbackIssues = await stopModules(started, registry, "rollback");
|
||||
throw new ModuleRuntimeError([startIssue, ...rollbackIssues]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function startModuleRuntime(
|
||||
options: StartModuleRuntimeOptions,
|
||||
): Promise<StartedModuleRuntime> {
|
||||
const discovered = await discoverModules(options);
|
||||
const modules = await loadRuntimeDefinitions(discovered);
|
||||
const stagedRegistry = new StagedCapabilityRegistry();
|
||||
await registerModules(modules, stagedRegistry);
|
||||
await startModules(modules, stagedRegistry);
|
||||
|
||||
const capabilities = stagedRegistry.publish();
|
||||
const runningModules = Object.freeze(
|
||||
modules.map((module) =>
|
||||
Object.freeze({
|
||||
id: module.discovered.manifest.id,
|
||||
version: module.discovered.manifest.version,
|
||||
}),
|
||||
),
|
||||
);
|
||||
let stopPromise: Promise<void> | undefined;
|
||||
|
||||
return Object.freeze({
|
||||
capabilities,
|
||||
modules: runningModules,
|
||||
stop: (): Promise<void> => {
|
||||
stopPromise ??= (async () => {
|
||||
const issues = await stopModules(modules, stagedRegistry, "stop");
|
||||
if (issues.length > 0) {
|
||||
throw new ModuleRuntimeError(issues);
|
||||
}
|
||||
})();
|
||||
return stopPromise;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
discoverModules,
|
||||
@@ -32,7 +31,7 @@ async function createModulesDirectory(): Promise<string> {
|
||||
|
||||
function manifestFor(id: string): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 1,
|
||||
manifestVersion: 2,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -40,10 +39,10 @@ function manifestFor(id: string): Record<string, unknown> {
|
||||
platform: "^0.1.0",
|
||||
moduleSdk: "^0.1.0",
|
||||
},
|
||||
capabilities: [`${id}.records`],
|
||||
capabilities: [{ id: `${id}.records`, version: "1.0.0" }],
|
||||
permissions: [`${id}.records.read`],
|
||||
entrypoints: {
|
||||
server: "./src/server/index.ts",
|
||||
server: "./dist/server/index.js",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -63,13 +62,13 @@ async function createModule(
|
||||
ekklesia: { layer: "module" },
|
||||
};
|
||||
|
||||
await mkdir(path.join(moduleDirectory, "src/server"), { recursive: true });
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(path.join(moduleDirectory, "package.json"), JSON.stringify(packageJson)),
|
||||
writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "src/server/index.ts"),
|
||||
options.serverSource ?? `export const moduleId = ${JSON.stringify(id)};`,
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
options.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
|
||||
),
|
||||
]);
|
||||
}
|
||||
@@ -125,13 +124,18 @@ describe("module discovery", () => {
|
||||
...manifestFor("calendar"),
|
||||
dependencies: {
|
||||
modules: { required: { people: "^0.1.0" } },
|
||||
capabilities: { required: ["people.records"] },
|
||||
capabilities: { required: { "people.records": "^2.0.0" } },
|
||||
},
|
||||
storage: { buckets: [{ id: "assets" }, { id: "exports" }] },
|
||||
},
|
||||
serverSource: 'throw new Error("discovery must never execute server code");',
|
||||
});
|
||||
await createModule(modulesDirectory, "people");
|
||||
await createModule(modulesDirectory, "people", {
|
||||
manifest: {
|
||||
...manifestFor("people"),
|
||||
capabilities: [{ id: "people.records", version: "2.1.0" }],
|
||||
},
|
||||
});
|
||||
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
@@ -149,19 +153,6 @@ describe("module discovery", () => {
|
||||
expect(() => mutableBuckets.push({ id: "tampered" })).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("accepts the repository's cloneable module template", async () => {
|
||||
const testDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const templatesDirectory = path.resolve(testDirectory, "../../templates");
|
||||
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: templatesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
expect(modules.map((module) => module.manifest.id)).toEqual(["example"]);
|
||||
});
|
||||
|
||||
it("aggregates malformed packages and manifests without partial discovery", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await mkdir(path.join(modulesDirectory, "missing"));
|
||||
@@ -223,7 +214,7 @@ describe("module discovery", () => {
|
||||
manifest: {
|
||||
...manifestFor("one"),
|
||||
id: "shared",
|
||||
capabilities: ["shared.records"],
|
||||
capabilities: [{ id: "shared.records", version: "1.0.0" }],
|
||||
permissions: ["shared.records.read"],
|
||||
},
|
||||
packageJson: {
|
||||
@@ -237,7 +228,7 @@ describe("module discovery", () => {
|
||||
manifest: {
|
||||
...manifestFor("two"),
|
||||
id: "shared",
|
||||
capabilities: ["shared.records"],
|
||||
capabilities: [{ id: "shared.records", version: "1.0.0" }],
|
||||
permissions: ["shared.records.read"],
|
||||
},
|
||||
packageJson: {
|
||||
@@ -312,18 +303,67 @@ describe("module discovery", () => {
|
||||
expect(issueCodes(error)).toContain("MODULE_DEPENDENCY_INCOMPATIBLE");
|
||||
});
|
||||
|
||||
it("rejects missing required capabilities", async () => {
|
||||
it("allows absent optional capabilities but rejects missing required capabilities", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "optional-consumer", {
|
||||
manifest: {
|
||||
...manifestFor("optional-consumer"),
|
||||
dependencies: { capabilities: { optional: { "people.records": "^1.0.0" } } },
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
}),
|
||||
).resolves.toHaveLength(1);
|
||||
|
||||
await createModule(modulesDirectory, "consumer", {
|
||||
manifest: {
|
||||
...manifestFor("consumer"),
|
||||
dependencies: { capabilities: { required: ["people.records"] } },
|
||||
dependencies: { capabilities: { required: { "people.records": "^1.0.0" } } },
|
||||
},
|
||||
});
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
expect(issueCodes(error)).toContain("MODULE_CAPABILITY_MISSING");
|
||||
});
|
||||
|
||||
it("rejects incompatible installed required and optional capabilities", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "people");
|
||||
await createModule(modulesDirectory, "required-consumer", {
|
||||
manifest: {
|
||||
...manifestFor("required-consumer"),
|
||||
dependencies: { capabilities: { required: { "people.records": "^2.0.0" } } },
|
||||
},
|
||||
});
|
||||
await createModule(modulesDirectory, "optional-consumer", {
|
||||
manifest: {
|
||||
...manifestFor("optional-consumer"),
|
||||
dependencies: { capabilities: { optional: { "people.records": "^3.0.0" } } },
|
||||
},
|
||||
});
|
||||
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
const incompatibilities = error.issues.filter(
|
||||
(issue) => issue.code === "MODULE_CAPABILITY_INCOMPATIBLE",
|
||||
);
|
||||
expect(incompatibilities).toHaveLength(2);
|
||||
expect(incompatibilities).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
message: "Capability people.records 1.0.0 does not satisfy ^2.0.0.",
|
||||
path: "dependencies.capabilities.required.people.records",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
message: "Capability people.records 1.0.0 does not satisfy ^3.0.0.",
|
||||
path: "dependencies.capabilities.optional.people.records",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects dependency cycles", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "one", {
|
||||
@@ -357,7 +397,7 @@ describe("module discovery", () => {
|
||||
await createModule(modulesDirectory, "missing-server", {
|
||||
manifest: {
|
||||
...manifestFor("missing-server"),
|
||||
entrypoints: { server: "./src/server/missing.ts" },
|
||||
entrypoints: { server: "./dist/server/missing.js" },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -368,14 +408,33 @@ describe("module discovery", () => {
|
||||
expect(issueCodes(error)).toContain("DISCOVERY_CONFIGURATION_INVALID");
|
||||
});
|
||||
|
||||
it("rejects source TypeScript server entrypoints", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "source-server", {
|
||||
manifest: {
|
||||
...manifestFor("source-server"),
|
||||
entrypoints: { server: "./src/server/index.ts" },
|
||||
},
|
||||
});
|
||||
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
expect(error.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "MODULE_ENTRYPOINT_INVALID",
|
||||
message: "server must reference compiled ESM JavaScript with a .js or .mjs extension.",
|
||||
path: "entrypoints.server",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires executable entrypoints to be files and migrations to be a directory", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "wrong-entrypoint-kind", {
|
||||
manifest: {
|
||||
...manifestFor("wrong-entrypoint-kind"),
|
||||
entrypoints: {
|
||||
server: "./src/server",
|
||||
migrations: "./src/server/index.ts",
|
||||
server: "./dist/server",
|
||||
migrations: "./dist/server/index.js",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
ModuleDiscoveryError,
|
||||
ModuleRuntimeError,
|
||||
type ModuleRuntimeIssueCode,
|
||||
startModuleRuntime,
|
||||
} from "../src/modules/index.js";
|
||||
|
||||
interface RuntimeModuleFixture {
|
||||
readonly directory?: string;
|
||||
readonly manifest?: Record<string, unknown>;
|
||||
readonly packageJson?: Record<string, unknown>;
|
||||
readonly serverSource: string;
|
||||
}
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
const eventSymbols: symbol[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
const globalValues = globalThis as unknown as Record<PropertyKey, unknown>;
|
||||
for (const symbol of eventSymbols.splice(0)) {
|
||||
delete globalValues[symbol];
|
||||
}
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function createModulesDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-runtime-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
function manifestFor(
|
||||
id: string,
|
||||
options: {
|
||||
capabilities?: Array<{ id: string; version: string }>;
|
||||
dependencies?: Record<string, unknown>;
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 2,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
compatibility: {
|
||||
platform: "^0.1.0",
|
||||
moduleSdk: "^0.1.0",
|
||||
},
|
||||
capabilities: options.capabilities ?? [],
|
||||
permissions: [],
|
||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createRuntimeModule(
|
||||
modulesDirectory: string,
|
||||
id: string,
|
||||
fixture: RuntimeModuleFixture,
|
||||
): Promise<void> {
|
||||
const directoryName = fixture.directory ?? id;
|
||||
const moduleDirectory = path.join(modulesDirectory, directoryName);
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
JSON.stringify(
|
||||
fixture.packageJson ?? {
|
||||
name: `@test/module-${id}`,
|
||||
version: "0.1.0",
|
||||
type: "module",
|
||||
ekklesia: { layer: "module" },
|
||||
},
|
||||
),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify(fixture.manifest ?? manifestFor(id)),
|
||||
),
|
||||
writeFile(path.join(moduleDirectory, "dist/server/index.js"), fixture.serverSource),
|
||||
]);
|
||||
}
|
||||
|
||||
function createEventRecorder(): { events: string[]; expression: string } {
|
||||
const key = `ekklesia.runtime.test.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
|
||||
const symbol = Symbol.for(key);
|
||||
const events: string[] = [];
|
||||
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = events;
|
||||
eventSymbols.push(symbol);
|
||||
return {
|
||||
events,
|
||||
expression: `globalThis[Symbol.for(${JSON.stringify(key)})]`,
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeOptions(modulesDirectory: string) {
|
||||
return {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeIssueCodes(error: ModuleRuntimeError): ModuleRuntimeIssueCode[] {
|
||||
return error.issues.map((issue) => issue.code);
|
||||
}
|
||||
|
||||
async function captureRuntimeError(modulesDirectory: string): Promise<ModuleRuntimeError> {
|
||||
try {
|
||||
await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||
} catch (error) {
|
||||
if (error instanceof ModuleRuntimeError) {
|
||||
return error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error("Expected module runtime startup to fail.");
|
||||
}
|
||||
|
||||
describe("module runtime", () => {
|
||||
it("discovers, loads, registers, starts, and stops dependency-first modules end to end", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createRuntimeModule(modulesDirectory, "people", {
|
||||
manifest: manifestFor("people", {
|
||||
capabilities: [{ id: "people.records", version: "2.1.0" }],
|
||||
}),
|
||||
serverSource: `
|
||||
export default {
|
||||
id: "people",
|
||||
version: "0.1.0",
|
||||
register({ capabilities }) {
|
||||
${recorder.expression}.push("people:register");
|
||||
capabilities.provide("people.records", { kind: "people-records" });
|
||||
},
|
||||
start() { ${recorder.expression}.push("people:start"); },
|
||||
stop() { ${recorder.expression}.push("people:stop"); }
|
||||
};
|
||||
`,
|
||||
});
|
||||
await createRuntimeModule(modulesDirectory, "calendar", {
|
||||
manifest: manifestFor("calendar", {
|
||||
capabilities: [{ id: "calendar.events", version: "1.0.0" }],
|
||||
dependencies: {
|
||||
capabilities: { required: { "people.records": "^2.0.0" } },
|
||||
},
|
||||
}),
|
||||
serverSource: `
|
||||
export default {
|
||||
id: "calendar",
|
||||
version: "0.1.0",
|
||||
register({ capabilities }) {
|
||||
${recorder.expression}.push("calendar:register");
|
||||
const people = capabilities.resolve("people.records");
|
||||
if (!people || people.version !== "2.1.0") throw new Error("people unavailable");
|
||||
capabilities.provide("calendar.events", { kind: "calendar-events" });
|
||||
},
|
||||
start({ capabilities }) {
|
||||
if (!capabilities.resolve("people.records")) throw new Error("people unavailable");
|
||||
${recorder.expression}.push("calendar:start");
|
||||
},
|
||||
stop() { ${recorder.expression}.push("calendar:stop"); }
|
||||
};
|
||||
`,
|
||||
});
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||
|
||||
expect(recorder.events).toEqual([
|
||||
"people:register",
|
||||
"calendar:register",
|
||||
"people:start",
|
||||
"calendar:start",
|
||||
]);
|
||||
expect(runtime.modules.map((module) => module.id)).toEqual(["people", "calendar"]);
|
||||
expect(runtime.capabilities.resolve("people.records")).toMatchObject({
|
||||
moduleId: "people",
|
||||
version: "2.1.0",
|
||||
});
|
||||
expect(runtime.capabilities.list().map((capability) => capability.id)).toEqual([
|
||||
"calendar.events",
|
||||
"people.records",
|
||||
]);
|
||||
|
||||
await Promise.all([runtime.stop(), runtime.stop()]);
|
||||
expect(recorder.events.slice(-2)).toEqual(["calendar:stop", "people:stop"]);
|
||||
});
|
||||
|
||||
it("completes discovery before importing any server entrypoint", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createRuntimeModule(modulesDirectory, "valid", {
|
||||
serverSource: `${recorder.expression}.push("valid:import"); export default { id: "valid", version: "0.1.0" };`,
|
||||
});
|
||||
await createRuntimeModule(modulesDirectory, "invalid", {
|
||||
manifest: { ...manifestFor("invalid"), manifestVersion: 1 },
|
||||
serverSource: `${recorder.expression}.push("invalid:import"); export default { id: "invalid", version: "0.1.0" };`,
|
||||
});
|
||||
|
||||
await expect(startModuleRuntime(runtimeOptions(modulesDirectory))).rejects.toBeInstanceOf(
|
||||
ModuleDiscoveryError,
|
||||
);
|
||||
expect(recorder.events).toEqual([]);
|
||||
});
|
||||
|
||||
it("validates every imported definition before running registration hooks", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createRuntimeModule(modulesDirectory, "alpha", {
|
||||
serverSource: `export default {
|
||||
id: "alpha", version: "0.1.0",
|
||||
register() { ${recorder.expression}.push("alpha:register"); }
|
||||
};`,
|
||||
});
|
||||
await createRuntimeModule(modulesDirectory, "beta", {
|
||||
serverSource: `export default { id: "wrong-id", version: "0.1.0" };`,
|
||||
});
|
||||
await createRuntimeModule(modulesDirectory, "gamma", {
|
||||
serverSource: `export default { id: "gamma", version: "9.0.0" };`,
|
||||
});
|
||||
|
||||
const error = await captureRuntimeError(modulesDirectory);
|
||||
|
||||
expect(runtimeIssueCodes(error)).toEqual([
|
||||
"MODULE_RUNTIME_ID_MISMATCH",
|
||||
"MODULE_RUNTIME_VERSION_MISMATCH",
|
||||
]);
|
||||
expect(recorder.events).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects incomplete and undeclared capability registration before startup", async () => {
|
||||
const missingDirectory = await createModulesDirectory();
|
||||
await createRuntimeModule(missingDirectory, "missing", {
|
||||
manifest: manifestFor("missing", {
|
||||
capabilities: [{ id: "missing.records", version: "1.0.0" }],
|
||||
}),
|
||||
serverSource: `export default { id: "missing", version: "0.1.0", register() {} };`,
|
||||
});
|
||||
let error = await captureRuntimeError(missingDirectory);
|
||||
expect(runtimeIssueCodes(error)).toEqual(["MODULE_CAPABILITY_UNREGISTERED"]);
|
||||
|
||||
const undeclaredDirectory = await createModulesDirectory();
|
||||
await createRuntimeModule(undeclaredDirectory, "undeclared", {
|
||||
serverSource: `export default {
|
||||
id: "undeclared", version: "0.1.0",
|
||||
register({ capabilities }) { capabilities.provide("undeclared.extra", {}); }
|
||||
};`,
|
||||
});
|
||||
error = await captureRuntimeError(undeclaredDirectory);
|
||||
expect(runtimeIssueCodes(error)).toEqual(["MODULE_CAPABILITY_UNDECLARED"]);
|
||||
});
|
||||
|
||||
it("restricts module capability lookup to declared ownership and dependencies", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createRuntimeModule(modulesDirectory, "snooper", {
|
||||
serverSource: `export default {
|
||||
id: "snooper", version: "0.1.0",
|
||||
register({ capabilities }) { capabilities.resolve("people.records"); }
|
||||
};`,
|
||||
});
|
||||
|
||||
const error = await captureRuntimeError(modulesDirectory);
|
||||
expect(runtimeIssueCodes(error)).toEqual(["MODULE_CAPABILITY_ACCESS_UNDECLARED"]);
|
||||
});
|
||||
|
||||
it("rejects malformed runtime capability IDs without echoing their contents", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const unsafeCapabilityId = "secret-token at /machine/private/path";
|
||||
await createRuntimeModule(modulesDirectory, "unsafe", {
|
||||
serverSource: `export default {
|
||||
id: "unsafe", version: "0.1.0",
|
||||
register({ capabilities }) {
|
||||
capabilities.resolve(${JSON.stringify(unsafeCapabilityId)});
|
||||
}
|
||||
};`,
|
||||
});
|
||||
|
||||
const error = await captureRuntimeError(modulesDirectory);
|
||||
expect(runtimeIssueCodes(error)).toEqual(["MODULE_CAPABILITY_ID_INVALID"]);
|
||||
expect(`${error.message}\n${error.stack}\n${JSON.stringify(error.issues)}`).not.toContain(
|
||||
unsafeCapabilityId,
|
||||
);
|
||||
expect(error.stack).not.toContain(modulesDirectory);
|
||||
});
|
||||
|
||||
it("rolls back started modules in reverse order and aggregates cleanup failures", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
for (const [id, dependency, stopFails] of [
|
||||
["one", undefined, false],
|
||||
["two", "one", true],
|
||||
["three", "two", false],
|
||||
] as const) {
|
||||
await createRuntimeModule(modulesDirectory, id, {
|
||||
manifest: manifestFor(id, {
|
||||
...(dependency
|
||||
? { dependencies: { modules: { required: { [dependency]: "^0.1.0" } } } }
|
||||
: {}),
|
||||
}),
|
||||
serverSource: `export default {
|
||||
id: ${JSON.stringify(id)}, version: "0.1.0",
|
||||
start() {
|
||||
${recorder.expression}.push(${JSON.stringify(`${id}:start`)});
|
||||
${id === "three" ? 'throw new Error("secret-token at /private/path");' : ""}
|
||||
},
|
||||
stop() {
|
||||
${recorder.expression}.push(${JSON.stringify(`${id}:stop`)});
|
||||
${stopFails ? 'throw new Error("rollback secret");' : ""}
|
||||
}
|
||||
};`,
|
||||
});
|
||||
}
|
||||
|
||||
const error = await captureRuntimeError(modulesDirectory);
|
||||
|
||||
expect(runtimeIssueCodes(error)).toEqual(["MODULE_START_FAILED", "MODULE_ROLLBACK_FAILED"]);
|
||||
expect(recorder.events).toEqual([
|
||||
"one:start",
|
||||
"two:start",
|
||||
"three:start",
|
||||
"two:stop",
|
||||
"one:stop",
|
||||
]);
|
||||
expect(JSON.stringify(error.issues)).not.toContain("secret");
|
||||
expect(JSON.stringify(error.issues)).not.toContain("/private/path");
|
||||
});
|
||||
|
||||
it("continues reverse shutdown after a module stop failure", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createRuntimeModule(modulesDirectory, "provider", {
|
||||
serverSource: `export default {
|
||||
id: "provider", version: "0.1.0",
|
||||
stop() { ${recorder.expression}.push("provider:stop"); }
|
||||
};`,
|
||||
});
|
||||
await createRuntimeModule(modulesDirectory, "consumer", {
|
||||
manifest: manifestFor("consumer", {
|
||||
dependencies: { modules: { required: { provider: "^0.1.0" } } },
|
||||
}),
|
||||
serverSource: `export default {
|
||||
id: "consumer", version: "0.1.0",
|
||||
stop() { ${recorder.expression}.push("consumer:stop"); throw new Error("hidden"); }
|
||||
};`,
|
||||
});
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||
|
||||
await expect(runtime.stop()).rejects.toMatchObject({
|
||||
issues: [expect.objectContaining({ code: "MODULE_STOP_FAILED", moduleId: "consumer" })],
|
||||
});
|
||||
expect(recorder.events).toEqual(["consumer:stop", "provider:stop"]);
|
||||
});
|
||||
|
||||
it("reports import failures without leaking exception text or machine paths", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createRuntimeModule(modulesDirectory, "broken", {
|
||||
serverSource: `throw new Error("database-password at /machine/private/path");`,
|
||||
});
|
||||
|
||||
const error = await captureRuntimeError(modulesDirectory);
|
||||
expect(runtimeIssueCodes(error)).toEqual(["MODULE_RUNTIME_IMPORT_FAILED"]);
|
||||
expect(JSON.stringify(error.issues)).not.toContain("database-password");
|
||||
expect(JSON.stringify(error.issues)).not.toContain(modulesDirectory);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user