354 lines
11 KiB
TypeScript
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>;
|
|
}
|