Reestructurar servidor y configuración

This commit is contained in:
David Arranz 2025-08-12 17:09:04 +02:00
parent 78cd1299bf
commit 58affda516
15 changed files with 661 additions and 228 deletions

View File

@ -1,10 +1,21 @@
NODE_ENV=development
HOST=0.0.0.0
PORT=3002
FRONTEND_URL=http://localhost:5173
DB_DIALECT=mysql
DB_HOST=localhost
DB_PORT=3306
DB_NAME=uecko_erp
DB_USER=rodax
DB_PASSWORD=rodax
DB_NAME=uecko_erp
DB_PORT=3306
PORT=3002
DB_LOGGING=false
DB_SYNC_MODE=alter
APP_TIMEZONE=Europe/Madrid
TRUST_PROXY=0
JWT_SECRET=supersecretkey
JWT_ACCESS_EXPIRATION=1h

25
apps/server/.env.example Normal file
View File

@ -0,0 +1,25 @@
NODE_ENV=development
HOST=0.0.0.0
PORT=3002
FRONTEND_URL=http://localhost:5173
# Base de datos (opción 1: URL)
# DATABASE_URL=postgres://user:pass@localhost:5432/dbname
# Base de datos (opción 2: parámetros sueltos)
DB_DIALECT=mysql
DB_HOST=localhost
DB_PORT=3306
DB_NAME=uecko_erp
DB_USER=rodax
DB_PASSWORD=rodax
DB_LOGGING=false
DB_SYNC_MODE=alter
APP_TIMEZONE=Europe/Madrid
TRUST_PROXY=0
JWT_SECRET=supersecretkey
JWT_ACCESS_EXPIRATION=1h
JWT_REFRESH_EXPIRATION=7d

View File

@ -1,69 +1,89 @@
//import { initPackages } from "@/core/package-loader";
import cors from "cors";
import dotenv from "dotenv";
import { logger } from "@/lib/logger";
import cors, { CorsOptions } from "cors";
import express, { Application } from "express";
import helmet from "helmet";
import responseTime from "response-time";
import { ENV } from "./config";
import { logger } from "./lib/logger";
dotenv.config();
// ❗️ No cargamos dotenv aquí. Debe hacerse en el entrypoint o en ./config.
// dotenv.config();
import { ENV } from "./config";
export function createApp(): Application {
const app = express();
app.set("port", process.env.PORT ?? 3002);
// secure apps by setting various HTTP headers
// Oculta la cabecera x-powered-by
app.disable("x-powered-by");
// Middlewares
// Desactiva ETag correctamente a nivel de Express
app.set("etag", false);
// ───────────────────────────────────────────────────────────────────────────
// Parsers
app.use(express.json());
app.use(express.text());
app.use(express.urlencoded({ extended: true }));
app.use(responseTime()); // set up the response-time middleware
// Métrica de tiempo de respuesta
app.use(responseTime());
// enable CORS - Cross Origin Resource Sharing
app.use(
cors({
origin: ENV.NODE_ENV === "development" ? "*" : process.env.FRONTEND_URL,
methods: "GET,POST,PUT,DELETE,OPTIONS",
credentials: true,
// ───────────────────────────────────────────────────────────────────────────
// CORS
const devCors: CorsOptions = {
// En desarrollo reflejamos el Origin entrante (permite credenciales)
origin: true,
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
exposedHeaders: [
"Content-Disposition",
"Content-Type",
"Content-Length",
"X-Total-Count",
"Pagination-Count",
"Pagination-Page",
"Pagination-Limit",
],
};
exposedHeaders: [
"Access-Control-Allow-Headers",
"Access-Control-Allow-Origin",
"Content-Disposition",
"Content-Type",
"Content-Length",
"X-Total-Count",
"Pagination-Count",
"Pagination-Page",
"Pagination-Limit",
],
})
);
const prodCors: CorsOptions = {
origin: ENV.FRONTEND_URL,
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
exposedHeaders: [
"Content-Disposition",
"Content-Type",
"Content-Length",
"X-Total-Count",
"Pagination-Count",
"Pagination-Page",
"Pagination-Limit",
],
};
// secure apps by setting various HTTP headers
app.use(cors(ENV.NODE_ENV === "development" ? devCors : prodCors));
// ───────────────────────────────────────────────────────────────────────────
// Seguridad HTTP
app.use(helmet());
// Middleware global para desactivar la caché en todas las rutas
app.use((req, res, next) => {
// ───────────────────────────────────────────────────────────────────────────
// Política de caché por defecto (no almacenamiento)
app.use((_, res, next) => {
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
res.setHeader("etag", "false");
next(); // Continúa con la siguiente función middleware o la ruta
});
// Inicializar Auth Provider
app.use((req, res, next) => {
//authProvider.initialize();
next();
});
// Inicializar Auth Provider (placeholder)
app.use((_, __, next) => {
// authProvider.initialize();
next();
});
// Logging de cada request
app.use((req, _, next) => {
logger.info(`▶️ Incoming request ${req.method} to ${req.path}`);
logger.info(`▶️ Incoming request ${req.method} to ${req.path}`);
next();
});

View File

@ -0,0 +1,12 @@
export function asNumber(value: string | undefined, fallback: number): number {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
export function asBoolean(value: string | undefined, fallback: boolean): boolean {
if (value === undefined) return fallback;
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
}
export function required(name: string, value: string | undefined): string {
if (!value) throw new Error(`Missing required environment variable: ${name}`);
return value;
}

View File

@ -1,81 +1,84 @@
import { logger } from "@/lib/logger";
import dotenv from "dotenv";
import { Sequelize } from "sequelize";
import { ENV } from "./index";
dotenv.config();
let sequelizeInstance: Sequelize | null = null;
export function getDatabase(): Sequelize {
if (sequelizeInstance) {
return sequelizeInstance;
}
sequelizeInstance = new Sequelize(
process.env.DB_NAME as string,
process.env.DB_USER as string,
process.env.DB_PASSWORD as string,
{
host: process.env.DB_HOST as string,
dialect: "mysql",
port: Number.parseInt(process.env.DB_PORT || "3306", 10),
dialectOptions: {
multipleStatements: true,
dateStrings: true,
typeCast: true,
},
pool: {
max: 10,
min: 0,
acquire: 30000,
idle: 10000,
},
logQueryParameters: true,
logging: process.env.DB_LOGGING === "true" ? logger.debug : false,
define: {
charset: "utf8mb4",
collate: "utf8mb4_unicode_ci",
underscored: true,
timestamps: true,
},
}
);
return sequelizeInstance;
}
export async function tryConnectToDatabase() {
const database = getDatabase();
if (!database) {
const error = new Error("❌ Database not found.");
logger.error(error.message, {
label: "tryConnectToDatabase",
});
throw error;
}
logger.info("🔸 Connecting to database...", {
label: "tryConnectToDatabase",
});
/**
* Crea la instancia de Sequelize según ENV (DATABASE_URL o parámetros sueltos),
* autentica la conexión y devuelve la instancia lista para usar.
*/
export async function tryConnectToDatabase(): Promise<Sequelize> {
const sequelize = buildSequelize();
try {
await database.authenticate();
logger.info(`✔️${" "}Database connection established successfully.`, {
label: "tryConnectToDatabase",
meta: {
host: process.env.DB_HOST,
port: process.env.DB_PORT,
database: process.env.DB_NAME,
user: process.env.DB_USER,
},
await sequelize.authenticate();
logger.info("✔️ Database connection established", {
label: "database",
// Info no sensible:
dialect: sequelize.getDialect(),
host: (sequelize.config.host ?? "url") as string,
database: (sequelize.config.database ?? "url") as string,
});
return database;
} catch (error) {
logger.error(`❌ Unable to connect to the database: ${(error as Error).message}`, {
error,
label: "tryConnectToDatabase",
return sequelize;
} catch (error: any) {
logger.error(`❌ Unable to connect to the database: ${error?.message ?? error}`, {
label: "database",
});
// Cerramos por si quedó algo abierto
try {
await sequelize.close();
} catch {
/* noop */
}
throw error;
}
}
/**
* Cierra la instancia de Sequelize (para integrar con el shutdown).
*/
export async function closeDatabase(sequelize: Sequelize): Promise<void> {
try {
await sequelize.close();
logger.info("Database connection closed", { label: "database" });
} catch (error: any) {
logger.error(`Error while closing database: ${error?.message ?? error}`, {
label: "database",
});
}
}
function buildSequelize(): Sequelize {
const common = {
logging: ENV.DB_LOGGING
? (msg: any) => logger.debug(String(msg), { label: "sequelize" })
: false,
timezone: ENV.APP_TIMEZONE,
pool: {
max: 10,
min: 0,
idle: 10_000,
acquire: 30_000,
},
// dialectOptions: { /* según dialecto (ssl, etc.) */ },
} as const;
if (ENV.DATABASE_URL && ENV.DATABASE_URL.trim() !== "") {
// URL completa (recomendada p.ej. en Postgres/MariaDB)
return new Sequelize(ENV.DATABASE_URL, common);
}
// Parámetros sueltos (asegurar requeridos mínimos)
if (!ENV.DB_DIALECT) {
throw new Error("DB_DIALECT is required when DATABASE_URL is not provided");
}
if (!ENV.DB_NAME || !ENV.DB_USER) {
throw new Error("DB_NAME and DB_USER are required when DATABASE_URL is not provided");
}
return new Sequelize(ENV.DB_NAME, ENV.DB_USER, ENV.DB_PASSWORD, {
host: ENV.DB_HOST,
port: ENV.DB_PORT,
dialect: ENV.DB_DIALECT,
...common,
});
}

View File

@ -1,12 +1,67 @@
import dotenv from "dotenv";
export * from "./database";
import { asBoolean, asNumber, required } from "./config-helpers";
// Carga variables de entorno desde el archivo .env
// Carga de variables de entorno (.env). Si ya están en el entorno, no se sobreescriben.
dotenv.config();
// Exporta una configuración centralizada, aplicando valores por defecto donde sea necesario
type NodeEnv = "development" | "test" | "production";
type DbSyncMode = "none" | "alter" | "force";
type DbDialect = "postgres" | "mysql" | "mariadb" | "mssql" | "sqlite";
const NODE_ENV = (process.env.NODE_ENV as NodeEnv) ?? "development";
const isProd = NODE_ENV === "production";
const isDev = NODE_ENV === "development";
const HOST = process.env.HOST ?? "0.0.0.0";
const PORT = asNumber(process.env.PORT, 3002);
// En producción exigimos FRONTEND_URL definido (según requisitos actuales).
const FRONTEND_URL = isProd
? required("FRONTEND_URL", process.env.FRONTEND_URL)
: (process.env.FRONTEND_URL ?? "http://localhost:5173");
// Base de datos (dos modos: URL o parámetros sueltos)
const DATABASE_URL = process.env.DATABASE_URL; // p.ej. postgres://user:pass@host:5432/dbname
const DB_DIALECT = (process.env.DB_DIALECT as DbDialect | undefined) ?? undefined;
const DB_HOST = process.env.DB_HOST ?? "localhost";
const DB_PORT = asNumber(process.env.DB_PORT, 5432);
const DB_NAME = process.env.DB_NAME ?? "";
const DB_USER = process.env.DB_USER ?? "";
const DB_PASSWORD = process.env.DB_PASSWORD ?? "";
const DB_LOGGING = asBoolean(process.env.DB_LOGGING, false);
// Modo de sincronización usado por model-loader.ts
const DB_SYNC_MODE =
(process.env.DB_SYNC_MODE as DbSyncMode | undefined) ?? (isProd ? "none" : "alter");
// Opcional: timezone para Sequelize (según necesidades)
const APP_TIMEZONE = process.env.APP_TIMEZONE ?? "Europe/Madrid";
// Proxy (no usáis ahora, pero dejamos la variable por si se activa en el futuro)
const TRUST_PROXY = asNumber(process.env.TRUST_PROXY, 0);
export const ENV = {
HOST: process.env.HOST || process.env.HOSTNAME || "localhost",
PORT: process.env.PORT || "18888",
NODE_ENV: process.env.NODE_ENV || "development",
};
NODE_ENV,
HOST,
PORT,
FRONTEND_URL,
DATABASE_URL,
DB_DIALECT,
DB_HOST,
DB_PORT,
DB_NAME,
DB_USER,
DB_PASSWORD,
DB_LOGGING,
DB_SYNC_MODE,
APP_TIMEZONE,
TRUST_PROXY,
} as const;
export const Flags = {
isProd,
isDev,
isTest: NODE_ENV === "test",
} as const;

35
apps/server/src/health.ts Normal file
View File

@ -0,0 +1,35 @@
import { Application, Request, Response } from "express";
import { DateTime } from "luxon";
/**
Registra endpoints de liveness/readiness.
/__health: siempre 200 mientras el proceso esté vivo.
/__ready : 200 si ready=true, 503 en caso contrario.
*/
export function registerHealthRoutes(app: Application, getStatus: () => { ready: boolean }): void {
// Liveness probe: indica que el proceso responde
app.get("/__health", (_req: Request, res: Response) => {
// Información mínima y no sensible
res.status(200).json({
status: "ok",
time: DateTime.now().toISO(),
});
});
// Readiness probe: indica que el servidor está listo para tráfico
app.get("/__ready", (_req: Request, res: Response) => {
const { ready } = getStatus();
if (ready) {
return res.status(200).json({
status: "ready",
time: DateTime.now().toISO(),
});
}
return res.status(503).json({
status: "not_ready",
time: DateTime.now().toISO(),
});
});
}

View File

@ -5,6 +5,7 @@ import os from "node:os";
import { createApp } from "./app";
import { ENV } from "./config";
import { tryConnectToDatabase } from "./config/database";
import { registerHealthRoutes } from "./health";
import { listRoutes } from "./lib";
import { initModules } from "./lib/modules";
import { registerModules } from "./register-modules";
@ -21,48 +22,87 @@ export const currentState = {
connections: {} as Record<string, any>,
};
// Manejo de cierre forzado del servidor (graceful shutdown)
// ─────────────────────────────────────────────────────────────────────────────
// 🔒 Bandera de apagado y promesa para idempotencia
let isShuttingDown = false;
let shutdownPromise: Promise<void> | null = null;
// 🔒 Bandera de readiness (se actualiza durante arranque y apagado)
let isReady = false;
// ─────────────────────────────────────────────────────────────────────────────
// Manejo de cierre forzado del servidor (graceful shutdown + sockets)
const serverStop = (server: http.Server) => {
const forceTimeout = 30000;
return new Promise<void>((resolve, reject) => {
// Devolvemos siempre la misma promesa para evitar dobles cierres
if (shutdownPromise) return shutdownPromise;
shutdownPromise = new Promise<void>((resolve, reject) => {
logger.warn("⚡️ Shutting down server");
setTimeout(() => {
logger.error("Could not close connections in time, forcefully shutting down");
resolve();
}, forceTimeout).unref();
// Tras el timeout, destruimos sockets vivos y resolvemos
const killer = setTimeout(() => {
try {
const sockets = Object.values(currentState.connections) as any[];
let destroyed = 0;
for (const s of sockets) {
try {
// @ts-ignore - algunos runtimes exponen destroy() / end()
if (s.destroy && !s.destroyed) {
s.destroy();
destroyed++;
} else if (s.end) {
s.end();
}
} catch {
/* noop */
}
}
logger.error(`Force timeout reached. Destroyed ${destroyed} remaining connection(s).`);
} finally {
resolve();
}
}, forceTimeout);
// Evita mantener el event-loop vivo por el timeout
killer.unref();
// Intentamos cerrar el servidor de forma limpia
server.close((err) => {
if (err) {
logger.error("Error while closing server", { err, label: "serverStop.close" });
return reject(err);
}
logger.info("Closed out remaining connections.");
logger.info("❎ Bye!");
clearTimeout(killer);
resolve();
});
});
return shutdownPromise;
};
// ─────────────────────────────────────────────────────────────────────────────
// Manejo de errores al iniciar el servidor
const serverError = (error: NodeJS.ErrnoException) => {
logger.error(`⛔️ Server wasn't able to start properly.`, {
logger.error("⛔️ Server wasn't able to start properly.", {
label: "serverError0",
error,
host: ENV.HOST,
port: ENV.PORT,
});
if (error.code === "EADDRINUSE") {
logger.error(error.message, { error, label: "serverError1" });
//logger.error(`The port ${error.port} is already used by another application.`);
logger.error(`Port ${ENV.PORT} already in use`, { error, label: "serverError1" });
} else {
logger.error(error.message, { error, label: "serverError2" });
}
// Dependiendo de la criticidad, podrías forzar el proceso a salir
// Fallo crítico de arranque → salida con código 1
process.exit(1);
};
// Almacena en "connections" cada nueva conexión (descomentar si se quiere seguimiento)
// Almacena en "connections" cada nueva conexión
const serverConnection = (conn: any) => {
const key = `${conn.remoteAddress}:${conn.remotePort}`;
currentState.connections[key] = conn;
@ -72,53 +112,95 @@ const serverConnection = (conn: any) => {
});
};
//const sequelizeConn = createSequelizeAdapter();
//const firebirdConn = createFirebirdAdapter();
// Cargar paquetes de la aplicación: customers, invoices, routes...
registerModules();
const app = createApp();
// Crea el servidor HTTP
const server = http
.createServer(app)
.once("listening", () =>
process.on("SIGINT", async () => {
// Por ejemplo, podrías desconectar la base de datos aquí:
// firebirdConn.disconnect();
// O forzar desconexión en adapters
// sequelizeConn.close();
// Rutas de salud disponibles desde el inicio del proceso
registerHealthRoutes(app, () => ({ ready: isReady }));
await serverStop(server);
})
)
// Crea el servidor HTTP
const server = http.createServer(app);
// ⚙️ Ajustes explícitos de timeouts HTTP (evita cuelgues en keep-alive)
server.keepAliveTimeout = 70000; // el socket puede quedar vivo 70s
server.headersTimeout = 75000; // debe ser > keepAliveTimeout
// ─────────────────────────────────────────────────────────────────────────────
// 🛎️ Señales del SO (registradas antes de listen) + apagado controlado
// En los manejadores de señales/errores, marcar no-ready al iniciar shutdown
const handleSignal = (signal: NodeJS.Signals) => async () => {
if (isShuttingDown) {
logger.warn(`Shutdown already in progress. Ignoring signal ${signal}.`);
return;
}
isShuttingDown = true;
// ⬇️ el servidor deja de estar listo inmediatamente
isReady = false;
logger.warn(`Received ${signal}. Starting graceful shutdown...`);
try {
await serverStop(server);
process.exit(0);
} catch (err) {
logger.error("Error during shutdown, forcing exit(1)", { err, label: "handleSignal" });
process.exit(1);
}
};
process.on("SIGINT", handleSignal("SIGINT"));
process.on("SIGTERM", handleSignal("SIGTERM"));
// ─────────────────────────────────────────────────────────────────────────────
// Eventos del servidor
server
.once("listening", () => {
logger.info(`HTTP server is listening on port ${currentState.port}`);
})
.on("close", () =>
logger.info(`Shutdown at: ${DateTime.now().toLocaleString(DateTime.DATETIME_FULL)}`)
)
.on("connection", serverConnection)
.on("error", serverError);
// Ejemplo de adapters de base de datos (descoméntalos si los necesitas)
// const sequelizeConn = createSequelizeAdapter();
// const firebirdConn = createFirebirdAdapter();
// Manejo de promesas no capturadas
process.on("unhandledRejection", (reason: any, promise: Promise<any>) => {
const error = `❌ Unhandled rejection at:", ${promise}, "reason:", ${reason}`;
logger.error(error);
// Dependiendo de la aplicación, podrías desear una salida total o un cierre controlado
process.exit(1);
// ─────────────────────────────────────────────────────────────────────────────
// Manejo de promesas no capturadas: apagado controlado
// ... unhandledRejection/uncaughtException: también set isReady = false;
process.on("unhandledRejection", async (reason: any, promise: Promise<any>) => {
logger.error("❌ Unhandled rejection", {
label: "unhandledRejection",
reason,
promise: String(promise),
});
if (!isShuttingDown) {
isShuttingDown = true;
isReady = false;
try {
await serverStop(server);
} finally {
process.exit(1);
}
}
});
// Manejo de excepciones no controladas
process.on("uncaughtException", (error: Error) => {
// firebirdConn.disconnect();
logger.error(`❌ Uncaught exception: ${error.message}`);
logger.error(error.stack);
// process.exit(1);
process.on("uncaughtException", async (error: Error) => {
logger.error(`❌ Uncaught exception: ${error.message}`, {
stack: error.stack,
label: "uncaughtException",
});
if (!isShuttingDown) {
isShuttingDown = true;
isReady = false;
try {
await serverStop(server);
} finally {
process.exit(1);
}
}
});
// ─────────────────────────────────────────────────────────────────────────────
// Arranca el servidor si la conexión a la base de datos va bien
(async () => {
try {
@ -129,12 +211,18 @@ process.on("uncaughtException", (error: Error) => {
logger.info(`Process PID: ${process.pid}`);
const database = await tryConnectToDatabase();
// Lógica de inicialización de DB, si procede:
// initStructure(sequelizeConn.connection);
// insertUsers();
await initModules({ app, database, baseRoutePath: API_BASE_PATH, logger });
// ✅ El servidor ya está listo para recibir tráfico
isReady = true;
logger.info("✅ Server is READY (readiness=true)");
logger.info(`startup_duration_ms=${DateTime.now().diff(currentState.launchedAt).toMillis()}`);
server.listen(currentState.port, () => {
server.emit("listening");
@ -153,6 +241,9 @@ process.on("uncaughtException", (error: Error) => {
}
}
// URLs de acceso útiles
logger.info(`⚡️ Server accessible at: http://localhost:${currentState.port}`);
logger.info(`⚡️ Server accessible at: http://${currentState.host}:${currentState.port}`);
for (const address of addresses) {
logger.info(`⚡️ Server accessible at: http://${address}:${currentState.port}`);
}
@ -165,9 +256,13 @@ process.on("uncaughtException", (error: Error) => {
logger.info(`Server path: ${currentState.appPath}`);
logger.info(`Server environment: ${currentState.environment}`);
logger.info("To shut down your server, press <CTRL> + C at any time");
logger.info(JSON.stringify(listRoutes(app._router, API_BASE_PATH), null, 3));
// En entornos de desarrollo puede ser muy verboso
logger.info(JSON.stringify(listRoutes((app as any)._router, API_BASE_PATH), null, 3));
});
} catch (error) {
// Arranque fallido → readiness sigue false
isReady = false;
serverError(error as NodeJS.ErrnoException);
}
})();

View File

@ -1,4 +1,4 @@
import { ILogger } from "@erp/core";
import { ILogger } from "@erp/core/api";
import { ConsoleLogger } from "./console-logger";
// Aquí podrías cambiar por SentryLogger en el futuro

View File

@ -1,55 +1,161 @@
import { ModuleParams } from "@erp/core/api";
import { ENV } from "../../config";
import { logger } from "../logger";
const allModelInitializers: any[] = [];
const registeredModels: { [key: string]: any } = {};
/**
* 🔹 Registra todos los modelos en Sequelize
*/
export const registerModels = (models: any[], params?: ModuleParams) => {
allModelInitializers.push(...models);
Tipos mínimos para evitar acoplarse a una versión concreta de Sequelize
*/
type SequelizeLike = {
sync: (options?: any) => Promise<void>;
models: Record<string, any>;
};
type ModelStatic = {
name: string;
// Algunas implementaciones añaden associate(sequelize) para definir relaciones
associate?: (sequelize: SequelizeLike) => void;
};
export type ModelInitializer = (sequelize: SequelizeLike) => ModelStatic;
/**
Estructuras internas (no exportadas) con trazabilidad de módulo.
*/
type InitializerEntry = { init: ModelInitializer; moduleName: string };
const allModelInitializers: InitializerEntry[] = [];
const registeredModels: Map<string, { model: ModelStatic; moduleName: string }> = new Map();
let modelsInitialized = false;
/**
🔹 Registra inicializadores de modelos de un módulo
models: lista de funciones que definen modelos (sequelize) => ModelStatic
ctx.moduleName: para trazabilidad y detección de colisiones
*/
export const registerModels = (
models: ModelInitializer[],
_params?: ModuleParams,
ctx?: { moduleName: string }
) => {
const moduleName = ctx?.moduleName ?? "unknown";
if (!Array.isArray(models) || models.length === 0) {
logger.warn(`No models provided by module "${moduleName}"`, { label: "registerModels" });
return;
}
for (const init of models) {
// Guardamos con el módulo que los aporta (para logs/errores)
allModelInitializers.push({ init, moduleName });
}
logger.info(`📦 ${models.length} model initializer(s) enqueued from "${moduleName}"`, {
label: "registerModels",
});
};
/**
🔹 Inicializa todos los modelos registrados y configura asociaciones.
Detecta colisiones por nombre de modelo entre módulos.
Controla el modo de sincronización por ENV.
*/
export const initModels = async (params: ModuleParams) => {
if (modelsInitialized) {
logger.warn("Models already initialized. Skipping initModels()", { label: "initModels" });
return;
}
logger.info("Init models...", { label: "initModels" });
const { database } = params;
const { database } = params as { database: SequelizeLike };
if (!database) {
const error = new Error("❌ Database not found.");
logger.error(error.message, {
label: "initModels",
});
logger.error(error.message, { label: "initModels" });
throw error;
}
// Inicializar modelos
for (const initializer of allModelInitializers) {
const model = initializer(database);
registeredModels[model.name] = model;
logger.info(`🔸 Model "${model.name}" registered (sequelize)`, { label: "registerModel" });
}
// 1) Definir modelos (y detectar colisiones)
for (const { init, moduleName } of allModelInitializers) {
try {
const model = init(database);
if (!model || typeof model.name !== "string" || !model.name) {
throw new Error(`Invalid model initializer: missing or empty "name"`);
}
// Configurar asociaciones
for (const model of Object.values(registeredModels)) {
if (typeof model.associate === "function") {
model.associate(database);
if (registeredModels.has(model.name)) {
const existing = registeredModels.get(model.name)!;
throw new Error(
`Model name collision: "${model.name}" from module "${moduleName}" conflicts with existing model from "${existing.moduleName}"`
);
}
registeredModels.set(model.name, { model, moduleName });
logger.info(`🔸 Model "${model.name}" registered (sequelize)`, {
label: "registerModel",
module: moduleName,
});
} catch (err: any) {
// Agregamos contexto del módulo que aportó el initializer
logger.error(`⛔️ Failed to define model (module="${moduleName}") : ${err?.message ?? err}`, {
label: "initModels",
stack: err?.stack,
});
throw err;
}
}
// 2) Configurar asociaciones
for (const { model, moduleName } of registeredModels.values()) {
if (typeof model.associate === "function") {
try {
model.associate(database);
} catch (err: any) {
logger.error(
`⛔️ Failed to associate model "${model.name}" (module="${moduleName}") : ${err?.message ?? err}`,
{ label: "initModels", stack: err?.stack }
);
throw err;
}
}
}
// 3) Sincronizar base de datos (según modo)
const nodeEnv = ENV.NODE_ENV ?? process.env.NODE_ENV ?? "development";
const syncModeEnv = (ENV as any).DB_SYNC_MODE ?? process.env.DB_SYNC_MODE; // "none" | "alter" | "force"
const defaultMode = nodeEnv === "production" ? "none" : "alter";
const mode = (syncModeEnv ?? defaultMode) as "none" | "alter" | "force";
try {
// Sincronizamos DB en modo desarrollo
if (process.env.NODE_ENV !== "production") {
if (mode === "none") {
logger.info("✔️ Database sync skipped (mode=none)", { label: "initModels" });
} else if (mode === "alter") {
await database.sync({ force: false, alter: true });
logger.info(`✔️${" "}Database synchronized successfully.`, { label: "initModels" });
logger.info("✔️ Database synchronized successfully (mode=alter).", { label: "initModels" });
} else if (mode === "force") {
await database.sync({ force: true, alter: false });
logger.warn("⚠️ Database synchronized with FORCE (mode=force).", { label: "initModels" });
} else {
logger.warn("⚠️ Running in production mode - Skipping database sync.", {
label: "initModels",
});
logger.warn(`⚠️ Unknown DB sync mode "${String(mode)}" → skipping`, { label: "initModels" });
}
} catch (err) {
const error = err as Error;
logger.error("❌ Error synchronizing database:", { error, label: "initModels" });
throw error;
} finally {
modelsInitialized = true;
}
};
/**
🔹 Utilidades opcionales (DX/tests)
*/
export const getRegisteredModels = () =>
Array.from(registeredModels.entries()).map(([name, { moduleName }]) => ({
name,
moduleName,
}));
export const resetModelsRegistry = () => {
allModelInitializers.length = 0;
registeredModels.clear();
modelsInitialized = false;
logger.info("Model registry has been reset.", { label: "initModels" });
};

View File

@ -5,50 +5,121 @@ 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
/**
Registra un módulo del servidor en el registry.
Lanza error si el nombre ya existe.
*/
export function registerModule(pkg: IModuleServer) {
console.log(pkg.name);
if (!pkg?.name) {
throw new Error('❌ Invalid module: missing "name" property');
}
if (registeredModules.has(pkg.name)) {
throw new Error(`❌ Paquete "${pkg.name}" ya registrado.`);
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),
y al final inicializa los modelos (Sequelize) en bloque.
*/
export async function initModules(params: ModuleParams) {
registeredModules.forEach((_, name) => {
loadModule(name, params);
for (const name of registeredModules.keys()) {
await loadModule(name, params, []); // secuencial para logs deterministas
}
await withPhase("global", "initModels", async () => {
await initModels(params);
});
await initModels(params);
}
const loadModule = (name: string, params: ModuleParams) => {
/**
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;
const pkg = registeredModules.get(name);
if (!pkg) throw new Error(`❌ Paquete "${name}" no encontrado.`);
// Resolver dependencias primero
const deps = pkg.dependencies || [];
deps.forEach((dep) => loadModule(dep, params));
// Inicializar el module
pkg.init(params);
const pkgApi = pkg.registerDependencies?.(params);
// Registrar modelos de Sequelize, si los expone
if (pkgApi?.models) {
registerModels(pkgApi.models, params);
if (visiting.has(name)) {
// Ciclo detectado: construir traza legible
const cyclePath = [...stack, name].join(" -> ");
throw new Error(`❌ Cyclic dependency detected: ${cyclePath}`);
}
// Registrar sus servicios, si los expone
if (pkgApi?.services) {
const services = pkgApi.services;
if (services && typeof services === "object") {
registerService(pkg.name, services);
}
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));
});
// 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);
logger.info(`✅ Module "${name}" registered`, { label: "loadModule" });
};
visiting.delete(name);
stack.pop();
logger.info(`✅ Module "${name}" registered`, { label: "moduleRegistry" });
}
/**
Helper para anotar fase y módulo en errores y logs.
*/
async function withPhase<T>(
moduleName: string,
phase: "init" | "registerDependencies" | "registerModels" | "registerServices" | "initModels",
fn: () => Promise<T> | T
): Promise<T> {
try {
return await fn();
} catch (error: any) {
// Log enriquecido con contexto
logger.error(
`⛔️ Module "${moduleName}" failed at phase="${phase}": ${error?.message ?? error}`,
{
label: "moduleRegistry",
module: moduleName,
phase,
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;
}
}

View File

@ -4,13 +4,13 @@ export const authAPIModule: IModuleServer = {
name: "auth",
version: "1.0.0",
dependencies: [],
init(params: ModuleParams) {
async init(params: ModuleParams) {
// const contacts = getService<ContactsService>("contacts");
const { logger } = params;
//invoicesRouter(params);
logger.info("🚀 Auth module initialized", { label: "auth" });
},
registerDependencies(params: ModuleParams) {
async registerDependencies(params: ModuleParams) {
const { database, logger } = params;
logger.info("🚀 Auth module dependencies registered", { label: "auth" });
return {

View File

@ -4,6 +4,6 @@ import { ModuleMetadata } from "../../common";
import { ModuleDependencies, ModuleParams } from "./types";
export interface IModuleServer extends ModuleMetadata {
init(params: ModuleParams): void;
registerDependencies?(params: ModuleParams): ModuleDependencies;
init(params: ModuleParams): Promise<void>;
registerDependencies?(params: ModuleParams): Promise<ModuleDependencies>;
}

View File

@ -6,13 +6,13 @@ export const customerInvoicesAPIModule: IModuleServer = {
version: "1.0.0",
dependencies: [],
init(params: ModuleParams) {
async init(params: ModuleParams) {
// const contacts = getService<ContactsService>("contacts");
const { logger } = params;
customerInvoicesRouter(params);
logger.info("🚀 CustomerInvoices module initialized", { label: "customer-invoices" });
},
registerDependencies(params) {
async registerDependencies(params) {
const { database, logger } = params;
logger.info("🚀 CustomerInvoices module dependencies registered", {
label: "customer-invoices",

View File

@ -6,13 +6,13 @@ export const customersAPIModule: IModuleServer = {
version: "1.0.0",
dependencies: [],
init(params: ModuleParams) {
async init(params: ModuleParams) {
// const contacts = getService<ContactsService>("contacts");
const { logger } = params;
customersRouter(params);
logger.info("🚀 Customers module initialized", { label: "customers" });
},
registerDependencies(params) {
async registerDependencies(params) {
const { database, logger } = params;
logger.info("🚀 Customers module dependencies registered", {
label: "customers",