import { IModuleServer, ModuleParams } from "@erp/core/api"; import { logger } from "../logger"; import { initModels, registerModels } from "./model-loader"; import { getService, listServices, registerService } from "./service-registry"; const registeredModules: Map = new Map(); const initializedModules = new Set(); const visiting = new Set(); // 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"; /** 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) { for (const name of registeredModules.keys()) { await loadModule(name, params, []); // secuencial para logs deterministas } await withPhase("global", "initModels", async () => { await initModels(params); }); await warmupModules(params); } /** Carga recursivamente un módulo y sus dependencias con detección de ciclos. */ async function loadModule(name: string, params: ModuleParams, stack: string[]) { 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}`); } 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) Resolver dependencias primero (en orden) const deps = pkg.dependencies || []; for (const dep of deps) { await loadModule(dep, params, stack.slice()); } // 2) Inicializar el módulo (permite async) await withPhase(name, "init", async () => { await Promise.resolve(pkg.init(params)); }); // 3) Registrar dependencias que expone (permite async) const pkgApi = await withPhase(name, "registerDependencies", async () => { return await Promise.resolve( pkg.registerDependencies?.({ ...params, listServices, getService, }) ); }); // 4) Registrar modelos de Sequelize, si existen if (pkgApi?.models) { await withPhase(name, "registerModels", async () => { await Promise.resolve(registerModels(pkgApi.models, params, { moduleName: pkg.name })); }); } // 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)); }); } 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 }) .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( moduleName: string, phase: | "init" | "registerDependencies" | "registerModels" | "registerServices" | "initModels" | "warmup", fn: () => Promise | T ): Promise { 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(p: Promise, ms: number, label: string): Promise { let timer: NodeJS.Timeout | null = null; const timeout = new Promise((_, 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; }