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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user