Reestructurar servidor y configuración
This commit is contained in:
parent
78cd1299bf
commit
58affda516
@ -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
25
apps/server/.env.example
Normal 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
|
||||
@ -1,38 +1,40 @@
|
||||
//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",
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// CORS
|
||||
const devCors: CorsOptions = {
|
||||
// En desarrollo reflejamos el Origin entrante (permite credenciales)
|
||||
origin: true,
|
||||
credentials: true,
|
||||
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
exposedHeaders: [
|
||||
"Access-Control-Allow-Headers",
|
||||
"Access-Control-Allow-Origin",
|
||||
"Content-Disposition",
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
@ -41,27 +43,45 @@ export function createApp(): Application {
|
||||
"Pagination-Page",
|
||||
"Pagination-Limit",
|
||||
],
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// secure apps by setting various HTTP headers
|
||||
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",
|
||||
],
|
||||
};
|
||||
|
||||
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}`);
|
||||
next();
|
||||
|
||||
12
apps/server/src/config/config-helpers.ts
Normal file
12
apps/server/src/config/config-helpers.ts
Normal 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;
|
||||
}
|
||||
@ -1,81 +1,84 @@
|
||||
import { logger } from "@/lib/logger";
|
||||
import dotenv from "dotenv";
|
||||
import { Sequelize } from "sequelize";
|
||||
import { ENV } from "./index";
|
||||
|
||||
dotenv.config();
|
||||
/**
|
||||
* 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();
|
||||
|
||||
let sequelizeInstance: Sequelize | null = null;
|
||||
|
||||
export function getDatabase(): Sequelize {
|
||||
if (sequelizeInstance) {
|
||||
return sequelizeInstance;
|
||||
try {
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
/**
|
||||
* 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,
|
||||
acquire: 30000,
|
||||
idle: 10000,
|
||||
idle: 10_000,
|
||||
acquire: 30_000,
|
||||
},
|
||||
logQueryParameters: true,
|
||||
logging: process.env.DB_LOGGING === "true" ? logger.debug : false,
|
||||
define: {
|
||||
charset: "utf8mb4",
|
||||
collate: "utf8mb4_unicode_ci",
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
// dialectOptions: { /* según dialecto (ssl, etc.) */ },
|
||||
} as const;
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
return database;
|
||||
} catch (error) {
|
||||
logger.error(`❌ Unable to connect to the database: ${(error as Error).message}`, {
|
||||
error,
|
||||
label: "tryConnectToDatabase",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
35
apps/server/src/health.ts
Normal 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(),
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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");
|
||||
// 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).unref();
|
||||
}
|
||||
}, 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 }));
|
||||
|
||||
// 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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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);
|
||||
}
|
||||
})();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 (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" });
|
||||
};
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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(`❌ Paquete "${name}" no encontrado.`);
|
||||
if (!pkg) {
|
||||
throw new Error(
|
||||
`❌ Module "${name}" not found (required by: ${stack[stack.length - 1] ?? "root"})`
|
||||
);
|
||||
}
|
||||
|
||||
// Resolver dependencias primero
|
||||
visiting.add(name);
|
||||
stack.push(name);
|
||||
|
||||
// 1) Resolver dependencias primero (en orden)
|
||||
const deps = pkg.dependencies || [];
|
||||
deps.forEach((dep) => loadModule(dep, params));
|
||||
for (const dep of deps) {
|
||||
await loadModule(dep, params, stack.slice());
|
||||
}
|
||||
|
||||
// Inicializar el module
|
||||
pkg.init(params);
|
||||
// 2) Inicializar el módulo (permite async)
|
||||
await withPhase(name, "init", async () => {
|
||||
await Promise.resolve(pkg.init(params));
|
||||
});
|
||||
|
||||
const pkgApi = pkg.registerDependencies?.(params);
|
||||
// 3) Registrar dependencias que expone (permite async)
|
||||
const pkgApi = await withPhase(name, "registerDependencies", async () => {
|
||||
return await Promise.resolve(pkg.registerDependencies?.(params));
|
||||
});
|
||||
|
||||
// Registrar modelos de Sequelize, si los expone
|
||||
// 4) Registrar modelos de Sequelize, si existen
|
||||
if (pkgApi?.models) {
|
||||
registerModels(pkgApi.models, params);
|
||||
await withPhase(name, "registerModels", async () => {
|
||||
await Promise.resolve(registerModels(pkgApi.models, params, { moduleName: pkg.name }));
|
||||
});
|
||||
}
|
||||
|
||||
// Registrar sus servicios, si los expone
|
||||
if (pkgApi?.services) {
|
||||
const services = pkgApi.services;
|
||||
if (services && typeof services === "object") {
|
||||
registerService(pkg.name, services);
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user