¿Qué es Node.js?
$ node --version → v20.x.x
Runtime de JavaScript fuera del navegador. Construido sobre V8 (motor de Chrome) + libuv para I/O asíncrono no bloqueante.
Node.js no es un lenguaje ni un framework — es un entorno de ejecución que permite correr JavaScript en el servidor, en la línea de comandos y en servicios como los del ERP. La clave es su modelo de I/O no bloqueante: mientras espera que la base de datos responda, atiende otras peticiones. Un solo proceso Node puede manejar miles de conexiones simultáneas.
v20.11.0
$ node -e "console.log('Hola desde Node!')"
Hola desde Node!
$ node # REPL interactivo
> process.version
'v20.11.0'
> process.platform
'linux'
El Event Loop
Mecanismo que permite a Node ser asíncrono con un solo hilo. Procesa callbacks en fases ordenadas: timers → I/O → poll → check → close.
Cuando Node encuentra operaciones asíncronas (leer archivo, consultar BD, esperar timer), las delega al sistema operativo y continúa ejecutando código. Cuando la operación termina, el callback queda en cola para la fase correcta del Event Loop.
console.log('1 — síncrono'); setTimeout(() => console.log('4 — timer (macrotask)'), 0); Promise.resolve().then(() => console.log('3 — microtask (Promise.then)')); queueMicrotask(() => console.log('3b — microtask (queueMicrotask)')); setImmediate(() => console.log('5 — setImmediate (check phase)')); console.log('2 — síncrono, fin de script'); // Orden: 1 → 2 → 3 → 3b → 4 → 5 // Regla: Síncrono → Microtasks → Timers → setImmediate
Módulos ESM y NPM
Node soporta CommonJS (require) y ES Modules (import/export). En proyectos nuevos: siempre ESM — es el estándar, soporta top-level await y tiene mejor tree-shaking.
{
"name": "erp-backend",
"version": "1.0.0",
"type": "module", ← habilita ESM en todos los .js
"engines": { "node": ">=20.0.0" },
"scripts": {
"start": "node src/broker.js",
"dev": "node --watch src/broker.js", ← hot-reload nativo Node 18+
"test": "vitest"
},
"dependencies": {
"moleculer": "^0.14.0",
"bullmq": "^5.0.0",
"ioredis": "^5.0.0",
"pg": "^8.0.0",
"dotenv": "^16.0.0"
}
}
// ── ESM (moderno — usar siempre) ────────────────────── import { createServer } from 'node:http'; // builtin con prefijo node: import { readFile } from 'node:fs/promises'; import express from 'express'; // paquete npm import { config } from './config.js'; // local — extensión .js obligatoria export const VERSION = '2.0.0'; export default function iniciar() { /* ... */ } // Top-level await — solo en ESM const datos = await readFile('config.json', 'utf8'); // ── CommonJS (legacy — evitar en proyectos nuevos) ──── // const http = require('http'); ← CJS // const config = require('./config'); ← sin extensión // module.exports = { VERSION, iniciar }; ← CJS export // ── Módulos builtin más usados ──────────────────────── import path from 'node:path'; // join, resolve, dirname, extname import os from 'node:os'; // cpus, totalmem, homedir import crypto from 'node:crypto'; // randomUUID, createHash, pbkdf2 import { EventEmitter } from 'node:events'; import { Worker } from 'node:worker_threads';
$ npm install -D vitest # solo en desarrollo
$ npm ci # install limpio desde package-lock.json (CI/CD)
$ npm run dev # ejecutar script "dev"
$ npm outdated # ver paquetes desactualizados
$ npm audit # revisar vulnerabilidades
Sistema de Archivos
Node expone el FS del OS a través de node:fs. Usa siempre la API de promesas (fs/promises) — más limpia y compatible con async/await.
import { readFile, writeFile, readdir, mkdir, stat } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; // En ESM no hay __dirname — se construye así: const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // ── Leer archivo ────────────────────────────────────── const raw = await readFile(path.join(__dirname, '../data/productos.json'), 'utf8'); const productos = JSON.parse(raw); // ── Escribir archivo ────────────────────────────────── await writeFile( path.join(__dirname, '../logs/sync.json'), JSON.stringify({ ts: Date.now(), status: 'ok' }, null, 2) ); // ── Listar directorio y cargar servicios dinámicamente async function cargarServicios(dir) { const files = await readdir(dir); return Promise.all( files .filter(f => f.endsWith('.service.js')) .map(f => import(path.join(dir, f))) // import dinámico ); } // ── Crear directorio recursivo ──────────────────────── await mkdir(path.join(__dirname, '../logs/2024/01'), { recursive: true }); // ── Metadata: tamaño y fecha de modificación ───────── const info = await stat('./package.json'); console.log(info.size, info.mtime);
Servidor HTTP Nativo
Node incluye node:http para crear servidores sin dependencias. Entenderlo es la base para cualquier framework web.
import { createServer } from 'node:http'; import crypto from 'node:crypto'; const server = createServer(async (req, res) => { const { method, url } = req; const json = (data, status = 200) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data)); }; const leerBody = () => new Promise(resolve => { let body = ''; req.on('data', chunk => body += chunk); req.on('end', () => resolve(JSON.parse(body || '{}'))); }); if (method === 'GET' && url === '/health') return json({ status: 'ok', uptime: process.uptime() }); if (method === 'POST' && url === '/ventas') { const body = await leerBody(); return json({ id: crypto.randomUUID(), ...body }, 201); } json({ error: 'Not Found' }, 404); }); server.listen(4000, () => console.log('🟢 http://localhost:4000'));
Esperando peticiones…
Express.js — Framework HTTP
El framework HTTP más popular de Node.js. Añade routing, middleware y helpers sobre el HTTP nativo. Minimalista, sin opinión, extensible.
import express from 'express'; import { productosRouter } from './routes/productos.js'; import { authMiddleware, logger, errorHandler } from './middleware/index.js'; const app = express(); // Middleware global app.use(express.json()); app.use(logger); app.use(authMiddleware); // Rutas app.get('/health', (req, res) => res.json({ status: 'ok', uptime: process.uptime() }) ); app.use('/api/v1/productos', productosRouter); // Error handler (siempre al final, 4 params) app.use(errorHandler); app.listen(process.env.PORT ?? 3000); // ── routes/productos.js ─────────────────────────────── export const productosRouter = express.Router(); productosRouter .get('/', listarProductos) .get('/:id', obtenerProducto) .post('/', crearProducto) .put('/:id', actualizarProducto) .delete('/:id', eliminarProducto); async function listarProductos(req, res, next) { try { const { pagina = 1, limite = 20, busqueda } = req.query; const datos = await productosService.listar({ pagina, limite, busqueda }); res.json({ data: datos.rows, total: datos.count, pagina: +pagina }); } catch (err) { next(err); // pasa al error handler global } } async function obtenerProducto(req, res, next) { try { const producto = await productosService.porId(req.params.id); if (!producto) return res.status(404).json({ error: 'No encontrado' }); res.json(producto); } catch (err) { next(err); } }
Middleware — Pipeline
Un middleware es una función (req, res, next). Express procesa cada petición pasándola por una cadena de middlewares en orden. Cada uno puede modificar req/res o llamar next().
// ── Auth middleware — verifica JWT ──────────────────── export function authMiddleware(req, res, next) { const token = req.headers['authorization']?.split(' ')[1]; const publicas = ['/health', '/api/v1/auth/login']; if (publicas.includes(req.path)) return next(); if (!token) return res.status(401).json({ error: 'Token requerido' }); try { req.usuario = verificarJWT(token); next(); } catch { res.status(401).json({ error: 'Token inválido' }); } } // ── Logger — mide tiempo de respuesta ──────────────── export function logger(req, res, next) { const t0 = Date.now(); res.on('finish', () => console.log(`${req.method} ${req.path} ${res.statusCode} — ${Date.now()-t0}ms`) ); next(); } // ── Rate limit — max peticiones por IP ─────────────── export function rateLimit(max = 100, windowMs = 60_000) { const contadores = new Map(); return (req, res, next) => { const ip = req.ip; const ahora = Date.now(); const datos = contadores.get(ip) ?? { count: 0, desde: ahora }; if (ahora - datos.desde > windowMs) { datos.count = 0; datos.desde = ahora; } datos.count++; contadores.set(ip, datos); if (datos.count > max) return res.status(429).json({ error: 'Too Many Requests' }); next(); }; } // ── Error handler global — 4 parámetros (req, res, err, next) export function errorHandler(err, req, res, next) { console.error(err); res.status(err.status ?? 500).json({ error: err.message }); }
Streams — Datos en Flujo
Los Streams procesan datos en chunks en lugar de cargar todo en memoria. Esenciales para archivos grandes, importaciones CSV y exportaciones de reportes.
import { createReadStream } from 'node:fs'; import { Transform, pipeline } from 'node:stream'; import { promisify } from 'node:util'; import { createInterface } from 'node:readline'; const pipelineAsync = promisify(pipeline); // ── Leer CSV línea por línea (memoria constante) ────── async function procesarCSV(ruta) { const rl = createInterface({ input: createReadStream(ruta) }); let headers = [], lineaNum = 0, errores = []; for await (const linea of rl) { if (lineaNum === 0) { headers = linea.split(','); lineaNum++; continue; } const fila = Object.fromEntries(headers.map((h,i) => [h, linea.split(',')[i]])); try { await insertarProducto(fila); } catch(e) { errores.push({ linea: lineaNum, error: e.message }); } lineaNum++; } return { procesadas: lineaNum - 1, errores }; } // ── Exportar CSV al cliente HTTP con Transform stream async function exportarVentas(res) { res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Disposition', 'attachment; filename="ventas.csv"'); const transform = new Transform({ objectMode: true, transform(venta, enc, cb) { cb(null, `${venta.id},${venta.fecha},${venta.total}\n`); }, }); await pipelineAsync( streamVentasDB(), // Readable de BD transform, // objeto → línea CSV res // Writable — escribe al cliente HTTP ); }
Worker Threads
Para tareas CPU-intensivas que bloquearían el Event Loop: encriptación pesada, parsing de XML/CSV grande, cálculos de nómina complejos.
// ── workers/nomina.worker.js ────────────────────────── import { parentPort, workerData } from 'node:worker_threads'; const { empleados, periodo } = workerData; // Este código corre en hilo separado — no bloquea el Event Loop const calculos = empleados.map(emp => ({ id: emp.id, salarioBruto: emp.diasTrabajados * emp.salarioDiario, imss: emp.salarioBruto * 0.0192, isr: calcularISR(emp.salarioBruto), neto: emp.salarioBruto - emp.imss - emp.isr, })); parentPort.postMessage({ calculos, periodo }); // ── servicios/nomina.service.js — uso del worker ───── import { Worker } from 'node:worker_threads'; function calcularNominaEnWorker(empleados, periodo) { return new Promise((resolve, reject) => { const worker = new Worker( './workers/nomina.worker.js', { workerData: { empleados, periodo } } ); worker.on('message', resolve); worker.on('error', reject); worker.on('exit', code => { if (code !== 0) reject(new Error(`Worker terminó con código ${code}`)); }); }); } // El hilo principal sigue atendiendo peticiones HTTP // mientras el worker calcula la nómina en paralelo const resultado = await calcularNominaEnWorker(empleados, '2024-01');
Variables de Entorno
Credenciales y configuración sensible nunca van en el código. Se inyectan como variables de entorno en producción y se leen desde .env en desarrollo.
// ── .env (nunca commitear — en .gitignore) ──────────── // NODE_ENV=development // PORT=4000 // DB_HOST=localhost // DB_PASSWORD=supersecreta123 // REDIS_URL=redis://localhost:6379 // JWT_SECRET=clave-muy-larga-y-aleatoria-minimo-32-chars // ── config/index.js — valida al arranque ────────────── import 'dotenv/config'; // carga .env en process.env function requerida(nombre) { const val = process.env[nombre]; if (!val) throw new Error(`Variable requerida: ${nombre}`); return val; } export const config = { env: process.env.NODE_ENV ?? 'development', port: Number(process.env.PORT ?? 4000), isdev: process.env.NODE_ENV !== 'production', db: { host: requerida('DB_HOST'), port: Number(process.env.DB_PORT ?? 5432), database: requerida('DB_NAME'), user: requerida('DB_USER'), password: requerida('DB_PASSWORD'), ssl: process.env.NODE_ENV === 'production', }, redis: { url: requerida('REDIS_URL'), password: process.env.REDIS_PASSWORD, }, jwt: { secret: requerida('JWT_SECRET'), expires: process.env.JWT_EXPIRES ?? '15m', }, }; // Si falta alguna variable requerida, el proceso lanza // un error al arrancar — falla rápido, nunca en runtime
Manejo de Errores
Un error sin manejar puede tumbar el proceso completo. Estrategia: capturar a nivel de función, errores tipados con contexto, handlers globales como red de seguridad.
// ── Errores personalizados con status y código ──────── export class AppError extends Error { constructor(message, status = 500, code = 'INTERNAL_ERROR') { super(message); this.name = this.constructor.name; this.status = status; this.code = code; Error.captureStackTrace(this, this.constructor); } } export class NotFoundError extends AppError { constructor(m) { super(m, 404, 'NOT_FOUND'); } } export class ValidationError extends AppError { constructor(m) { super(m, 422, 'VALIDATION'); } } export class UnauthorizedError extends AppError { constructor(m) { super(m, 401, 'UNAUTHORIZED'); } } // ── Handlers globales — red de seguridad ────────────── process.on('uncaughtException', (err) => { console.error('💥 uncaughtException:', err); process.exit(1); // estado del proceso es incierto, salir limpio }); process.on('unhandledRejection', (reason) => { console.error('💥 unhandledRejection:', reason); // Node 20 ya lo convierte en uncaughtException automáticamente }); // ── Graceful shutdown — cerrar conexiones limpiamente ─ async function shutdown(signal) { console.log(`🛑 ${signal} — cerrando limpiamente…`); await broker.stop(); // Moleculer await db.end(); // PostgreSQL pool await redis.quit(); // Redis process.exit(0); } process.on('SIGTERM', () => shutdown('SIGTERM')); // Docker / Kubernetes process.on('SIGINT', () => shutdown('SIGINT')); // Ctrl+C
Performance y Diagnóstico
Medir antes de optimizar. Node expone perf_hooks para instrumentar código, y process.memoryUsage() / cpuUsage() para métricas del proceso.
import { performance } from 'node:perf_hooks'; import cluster from 'node:cluster'; import os from 'node:os'; // ── Medir tiempo de operación ───────────────────────── async function medir(nombre, fn) { const t0 = performance.now(); const r = await fn(); console.log(`[PERF] ${nombre}: ${(performance.now()-t0).toFixed(2)}ms`); return r; } // ── Métricas del proceso ────────────────────────────── export function metricas() { const m = process.memoryUsage(); return { heapUsed: Math.round(m.heapUsed / 1024 / 1024) + ' MB', heapTotal: Math.round(m.heapTotal / 1024 / 1024) + ' MB', rss: Math.round(m.rss / 1024 / 1024) + ' MB', uptime: Math.round(process.uptime()) + 's', }; } // ── Cluster — usar todos los CPUs disponibles ───────── if (cluster.isPrimary) { const numCPUs = os.cpus().length; console.log(`Iniciando ${numCPUs} workers`); for (let i = 0; i < numCPUs; i++) cluster.fork(); cluster.on('exit', (worker) => { console.log(`Worker ${worker.process.pid} caído — reiniciando`); cluster.fork(); // auto-restart }); } else { await iniciarApp(); // cada worker corre la app normalmente }
| Técnica | Cuándo usar | Herramienta |
|---|---|---|
| Cluster | HTTP API multi-core en producción | node:cluster |
| Worker Threads | CPU-bound: nómina, encriptación, parsing | node:worker_threads |
| Streams | Archivos grandes, export CSV/XLSX | node:stream |
| Caching | Respuestas repetidas de BD o APIs | Redis + ioredis |
| Connection Pool | Reutilizar conexiones a PostgreSQL | pg.Pool(max:20) |
| Profiling | Encontrar cuellos de botella reales | --inspect + Chrome DevTools |
Node.js en el ERP Carnicerías
Node es el runtime de todo el backend: Moleculer corre sobre Node, los Workers de BullMQ corren sobre Node, el sync de sucursales usa Node. Este es el punto de entrada real del sistema.
import 'dotenv/config'; import { ServiceBroker } from 'moleculer'; import { readdir } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { config } from './config/index.js'; import { iniciarWorkers } from './workers/index.js'; import { metricas } from './utils/metrics.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function main() { console.log(`🥩 Iniciando ERP — nodo: ${config.nodeId}`); // 1. Crear broker Moleculer con Redis como transporter const broker = new ServiceBroker({ nodeID: config.nodeId, transporter: config.redis.url, cacher: 'redis', }); // 2. Cargar servicios automáticamente desde el directorio const servicesDir = path.join(__dirname, 'services'); const files = await readdir(servicesDir); for (const file of files.filter(f => f.endsWith('.service.js'))) { const { default: schema } = await import(path.join(servicesDir, file)); broker.createService(schema); console.log(` ✓ Servicio: ${file}`); } // 3. Arrancar broker (Service Discovery via Redis) await broker.start(); // 4. Iniciar BullMQ workers (CFDI, nómina, reportes, sync) await iniciarWorkers(broker); // 5. Reporte de métricas cada 5 minutos setInterval(() => console.log('[STATUS]', metricas()), 5 * 60_000); console.log('🟢 Sistema listo — todos los servicios activos'); } // Graceful shutdown al recibir señales del SO/Docker/K8s const shutdown = async (sig) => { console.log(`🛑 ${sig} — apagando broker y workers…`); await broker.stop(); process.exit(0); }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); main().catch(err => { console.error(err); process.exit(1); });