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 = new Map(); // Nuevo: control por fases const setupDone = new Set(); // para detección de ciclos const visiting = new Set(); const setupOrder: string[] = []; // Internal store const internalByModule: Map> = new Map(); // Tracking de dependencias realmente usadas por módulo const usedDependenciesByModule: Map> = 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); } 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 (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(moduleName, pkg.dependencies ?? [], serviceName); }; } /** getInternal: por defecto solo permite al propio módulo acceder a su internal */ function makeGetInternal(requesterModule: string) { return (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) { 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(); // ❌ 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; const maybeWarmup = (pkg as any).warmup as undefined | ((p: any) => Promise | 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( moduleName: string, phase: "setup" | "start" | "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; }