FactuGES_ERP/apps/server/src/lib/modules/module-loader.ts

217 lines
6.5 KiB
TypeScript
Raw Normal View History

2025-05-28 14:21:09 +00:00
import { IModuleServer, ModuleParams } from "@erp/core/api";
2025-05-09 10:45:32 +00:00
import { logger } from "../logger";
2025-05-04 20:06:57 +00:00
import { initModels, registerModels } from "./model-loader";
import { registerService } from "./service-registry";
const registeredModules: Map<string, IModuleServer> = new Map();
const initializedModules = new Set<string>();
const visiting = new Set<string>(); // para detección de ciclos
const initializationOrder: string[] = []; // orden de init → para warmup
// Config opcional para warmup (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";
2025-05-04 20:06:57 +00:00
/**
Registra un módulo del servidor en el registry.
Lanza error si el nombre ya existe.
*/
2025-05-04 20:06:57 +00:00
export function registerModule(pkg: IModuleServer) {
if (!pkg?.name) {
throw new Error('❌ Invalid module: missing "name" property');
}
2025-06-11 13:13:21 +00:00
if (registeredModules.has(pkg.name)) {
throw new Error(`❌ Module "${pkg.name}" already registered`);
2025-05-04 20:06:57 +00:00
}
2025-06-11 13:13:21 +00:00
registeredModules.set(pkg.name, pkg);
logger.info(`📦 Module enqueued: "${pkg.name}"`, { label: "moduleRegistry" });
2025-05-04 20:06:57 +00:00
}
/**
Inicializa todos los módulos registrados (resolviendo dependencias),
luego inicializa los modelos (Sequelize) en bloque y, por último, ejecuta warmups opcionales.
*/
2025-05-04 20:06:57 +00:00
export async function initModules(params: ModuleParams) {
for (const name of registeredModules.keys()) {
await loadModule(name, params, []); // secuencial para logs deterministas
}
await withPhase("global", "initModels", async () => {
await initModels(params);
2025-05-04 20:06:57 +00:00
});
await warmupModules(params);
2025-05-04 20:06:57 +00:00
}
/**
Carga recursivamente un módulo y sus dependencias con detección de ciclos.
*/
async function loadModule(name: string, params: ModuleParams, stack: string[]) {
2025-05-04 20:06:57 +00:00
if (initializedModules.has(name)) return;
if (visiting.has(name)) {
// Ciclo detectado: construir traza legible
const cyclePath = [...stack, name].join(" -> ");
throw new Error(`❌ Cyclic dependency detected: ${cyclePath}`);
}
2025-05-04 20:06:57 +00:00
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);
2025-05-04 20:06:57 +00:00
// 1) Resolver dependencias primero (en orden)
2025-06-11 13:13:21 +00:00
const deps = pkg.dependencies || [];
for (const dep of deps) {
await loadModule(dep, params, stack.slice());
}
2025-05-04 20:06:57 +00:00
// 2) Inicializar el módulo (permite async)
await withPhase(name, "init", async () => {
await Promise.resolve(pkg.init(params));
});
2025-05-04 20:06:57 +00:00
// 3) Registrar dependencias que expone (permite async)
const pkgApi = await withPhase(name, "registerDependencies", async () => {
return await Promise.resolve(pkg.registerDependencies?.(params));
});
2025-05-04 20:06:57 +00:00
// 4) Registrar modelos de Sequelize, si existen
2025-05-04 20:06:57 +00:00
if (pkgApi?.models) {
await withPhase(name, "registerModels", async () => {
await Promise.resolve(registerModels(pkgApi.models, params, { moduleName: pkg.name }));
});
2025-05-04 20:06:57 +00:00
}
// 5) Registrar servicios, si existen
if (pkgApi?.services && typeof pkgApi.services === "object") {
await withPhase(name, "registerServices", async () => {
await Promise.resolve(registerService(pkg.name, pkgApi.services));
});
2025-05-04 20:06:57 +00:00
}
initializedModules.add(name);
initializationOrder.push(name); // recordamos el orden para warmup
visiting.delete(name);
stack.pop();
logger.info(`✅ Module "${name}" registered`, { label: "moduleRegistry" });
}
/**
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 initializationOrder) {
const pkg = registeredModules.get(name);
if (!pkg) continue;
const maybeWarmup = (pkg as unknown as { warmup?: (p: ModuleParams) => Promise<void> | void })
.warmup;
if (typeof maybeWarmup === "function") {
try {
await withPhase(name, "warmup", () =>
withTimeout(Promise.resolve(maybeWarmup(params)), 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:
| "init"
| "registerDependencies"
| "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 (error: any) {
const duration = Date.now() - startedAt;
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 err = new Error(
`[module=${moduleName}] phase=${phase} failed: ${error?.message ?? "Unknown error"}`
);
(err as any).cause = error;
throw err;
}
}
/**
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>;
}