feat: add module runtime lifecycle
This commit is contained in:
@@ -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