FactuGES_ERP/apps/server/src/lib/modules/module-loader.ts
2026-05-05 13:34:36 +02:00

354 lines
11 KiB
TypeScript

import type { IModuleServer, ModuleParams } from "@erp/core/api";
import { logger } from "../logger";
import { initModels, registerModels } from "./model-loader";
import { getServiceScoped, listServices, registerService } from "./service-registry";
const registeredModules: Map<string, IModuleServer> = new Map();
// Nuevo: control por fases
const setupDone = new Set<string>(); // para detección de ciclos
const visiting = new Set<string>();
const setupOrder: string[] = [];
// Internal store
const internalByModule: Map<string, Record<string, unknown>> = new Map();
// Tracking de dependencias realmente usadas por módulo
const usedDependenciesByModule: Map<string, Set<string>> = new Map();
// Warmup config (valores por defecto seguros)
const WARMUP_TIMEOUT_MS = Number(process.env.WARMUP_TIMEOUT_MS) || 10_000;
const WARMUP_STRICT =
String(process.env.WARMUP_STRICT ?? "false")
.toLowerCase()
.trim() === "true";
/**
- Registra un módulo del servidor en el registry.
- Lanza error si el nombre ya existe.
*/
export function registerModule(pkg: IModuleServer) {
if (!pkg?.name) throw new Error('❌ Invalid module: missing "name" property');
if (registeredModules.has(pkg.name))
throw new Error(`❌ Module "${pkg.name}" already registered`);
registeredModules.set(pkg.name, pkg);
logger.info(`📦 Module enqueued: "${pkg.name}"`, { label: "moduleRegistry" });
}
/**
- Inicializa todos los módulos registrados (resolviendo dependencias),
- luego inicializa los modelos (Sequelize) en bloque y, por último, ejecuta warmups opcionales.
*/
export async function initModules(params: ModuleParams) {
// 1) SETUP
for (const name of registeredModules.keys()) {
await setupModule(name, params, []); // secuencial para logs deterministas
}
// 2) MODELS global
await withPhase("global", "initModels", async () => await initModels(params));
// 3) START por orden de setup
for (const name of setupOrder) {
const pkg = registeredModules.get(name);
if (!pkg?.start) continue;
// Si aún hay rastros del anterior init/registerDependencies en algún módulo:
// - start() ≈ init() en el contrato nuevo
// - setup() ≈ registerDependencies() en el contrato nuevo
await withPhase(name, "start", async () => {
// Aquí YA se permite getService/getInternal
await Promise.resolve(
pkg.start?.({
...params,
getService: makeGetService(name, pkg),
getInternal: makeGetInternal(name),
listServices, // opcional: útil en debugging
} as any)
);
});
}
// 4) WARMUP opcional
await warmupModules(params);
// 5) Validación dependencias usadas vs declaradas
validateModuleDependencies();
}
/**
* SETUP recursivo: resuelve deps, ejecuta setup del módulo (antiguo "registerDependencies"),
* registra modelos/servicios/internal, y guarda orden.
*/
async function setupModule(name: string, params: ModuleParams, stack: string[]) {
if (setupDone.has(name)) return;
if (visiting.has(name)) {
throw new Error(`❌ Cyclic dependency detected: ${[...stack, name].join(" -> ")}`);
}
const pkg = registeredModules.get(name);
if (!pkg)
throw new Error(
`❌ Module "${name}" not found (required by: ${stack[stack.length - 1] ?? "root"})`
);
visiting.add(name);
stack.push(name);
// 1) deps first
for (const dep of pkg.dependencies ?? []) {
await setupModule(dep, params, stack.slice());
}
// 2) setup phase (contrato nuevo) = registerDependencies (en tu código actual)
const pkgApi = await withPhase(name, "setup", async () => {
return await Promise.resolve(
pkg.setup?.({
...params,
// En setup se permite getService para wiring.
getService: makeGetService(name, pkg),
listServices,
} as any)
);
});
// 3) internal store
if (pkgApi?.internal) {
internalByModule.set(name, pkgApi.internal as Record<string, unknown>);
} else {
internalByModule.set(name, {});
}
// 4) models
if (pkgApi?.models) {
await withPhase(name, "registerModels", async () => {
await Promise.resolve(registerModels(pkgApi.models!, params, { moduleName: name }));
});
}
// 5) services (namespaced)
if (pkgApi?.services) {
await withPhase(name, "registerServices", async () => {
validateModuleServices(name, pkgApi.services!);
for (const [serviceKey, serviceApi] of Object.entries(pkgApi.services!)) {
const fullName = buildServiceName(name, serviceKey);
registerService(fullName, serviceApi);
}
});
}
setupDone.add(name);
setupOrder.push(name);
visiting.delete(name);
stack.pop();
logger.info(`✅ Module "${name}" setup done`, { label: "moduleRegistry" });
}
/** getService instrumentado + scoped */
function makeGetService(moduleName: string, pkg: IModuleServer) {
return <T>(serviceName: string): T => {
const [serviceModule] = serviceName.split(":");
// No registrar dependencias para modulos
// que usan sus propios servicios.
if (serviceModule !== "self") {
trackDependencyUse(moduleName, serviceModule);
}
// IMPORTANTE: devolver el valor
return getServiceScoped<T>(moduleName, pkg.dependencies ?? [], serviceName);
};
}
/** getInternal: por defecto solo permite al propio módulo acceder a su internal */
function makeGetInternal(requesterModule: string) {
return <T>(moduleName: string, key?: string): T => {
if (moduleName !== requesterModule) {
throw new Error(
`Module "${requesterModule}" attempted to access internal of "${moduleName}". ` +
"Internal is private to the owning module."
);
}
const internal = internalByModule.get(moduleName) ?? {};
if (!key) return internal as unknown as T;
if (!(key in internal)) {
throw new Error(`Internal key "${key}" not found in module "${moduleName}".`);
}
return internal[key] as T;
};
}
function trackDependencyUse(requester: string, dep: string) {
let set = usedDependenciesByModule.get(requester);
if (!set) {
set = new Set();
usedDependenciesByModule.set(requester, set);
}
set.add(dep);
}
function buildServiceName(moduleName: string, serviceKey: string): string {
return `${moduleName}:${serviceKey}`;
}
function validateModuleServices(moduleName: string, services: Record<string, unknown>) {
for (const [serviceKey, serviceApi] of Object.entries(services)) {
if (!serviceKey || typeof serviceKey !== "string") {
throw new Error(`Invalid service key from module "${moduleName}"`);
}
const fullName = `${moduleName}:${serviceKey}`;
if (serviceApi === undefined) {
throw new Error(`Service "${fullName}" is undefined`);
}
}
}
function validateModuleDependencies() {
for (const [moduleName, pkg] of registeredModules.entries()) {
const declared = new Set(pkg.dependencies ?? []);
const used = usedDependenciesByModule.get(moduleName) ?? new Set<string>();
// ❌ usadas pero no declaradas
const undeclaredUsed = [...used].filter((d) => !declared.has(d));
if (undeclaredUsed.length > 0) {
throw new Error(
`❌ Module "${moduleName}" used undeclared dependencies: ${undeclaredUsed.join(", ")}`
);
}
// ⚠️ declaradas pero no usadas (opcional)
const unusedDeclared = [...declared].filter((d) => !used.has(d));
if (unusedDeclared.length > 0) {
logger.warn(
`⚠️ Module "${moduleName}" declared unused dependencies: ${unusedDeclared.join(", ")}`,
{ label: "moduleRegistry", module: moduleName }
);
}
}
}
/**
- Ejecuta warmup() opcional de cada módulo en orden de inicialización.
- Si WARMUP_STRICT=true, un fallo aborta el arranque; si no, se avisa y continúa.
*/
async function warmupModules(params: ModuleParams) {
logger.info("🌡️ Warmup: starting...", { label: "moduleRegistry" });
for (const name of setupOrder) {
const pkg = registeredModules.get(name);
if (!pkg?.warmup) continue;
// biome-ignore lint/suspicious/noExplicitAny: <Para que funione>
const maybeWarmup = (pkg as any).warmup as undefined | ((p: any) => Promise<void> | void);
if (typeof maybeWarmup !== "function") continue;
try {
await withPhase(name, "warmup", () =>
withTimeout(
Promise.resolve(
maybeWarmup({
...params,
getService: makeGetService(name, pkg),
getInternal: makeGetInternal(name),
listServices,
})
),
WARMUP_TIMEOUT_MS,
`${name}.warmup`
)
);
} catch (error) {
if (WARMUP_STRICT) {
logger.error("⛔️ Warmup failed (strict=true). Aborting.", {
label: "moduleRegistry",
module: name,
error,
});
throw error;
}
logger.warn("⚠️ Warmup failed but continuing (strict=false)", {
label: "moduleRegistry",
module: name,
error,
});
}
}
logger.info("🌡️ Warmup: done.", { label: "moduleRegistry" });
}
/**
Helper para anotar fase y módulo en logs (inicio/fin/duración) y errores.
*/
async function withPhase<T>(
moduleName: string,
phase: "setup" | "start" | "registerModels" | "registerServices" | "initModels" | "warmup",
fn: () => Promise<T> | T
): Promise<T> {
const startedAt = Date.now();
logger.info(`▶️ phase=start module=${moduleName} ${phase}`, {
label: "moduleRegistry",
module: moduleName,
phase,
});
try {
const out = await fn();
const duration = Date.now() - startedAt;
logger.info(`✅ phase=done module=${moduleName} ${phase} durationMs=${duration}`, {
label: "moduleRegistry",
module: moduleName,
phase,
durationMs: duration,
});
return out;
} catch (err: unknown) {
const duration = Date.now() - startedAt;
const error = err as Error;
logger.error(
`⛔️ Module "${moduleName}" failed at phase="${phase}" after ${duration}ms: ${error?.message ?? error}`,
{
label: "moduleRegistry",
module: moduleName,
phase,
durationMs: duration,
stack: error?.stack,
}
);
// Re-lanzamos con contexto preservado
const newError = new Error(
`[module=${moduleName}] phase=${phase} failed: ${error?.message ?? "Unknown error"}`
);
newError.cause = error;
throw newError;
}
}
/**
Promesa con timeout; rechaza si excede el tiempo dado.
*/
function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
let timer: NodeJS.Timeout | null = null;
const timeout = new Promise<T>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`Timeout after ${ms}ms (${label})`));
}, ms).unref();
});
return Promise.race([p, timeout]).finally(() => {
if (timer) clearTimeout(timer);
}) as Promise<T>;
}