📦 Node.js para principiantes
Qué es Node y su modelo asíncrono, instalación y versiones LTS, módulos ESM y CommonJS, package.json, npm, módulos core, eventos, debugging y variables de entorno.
Node.js para principiantes
Node.js es un runtime de JavaScript fuera del navegador, creado por Ryan Dahl en 2009 sobre el motor V8 de Chrome. Su propuesta radical fue llevar el modelo de eventos de JavaScript al servidor: en vez de un hilo por conexión (como Apache), un solo proceso puede atender miles de conexiones a la vez porque no espera bloqueado a ninguna. Este artículo cubre lo esencial: instalar, configurar un proyecto, los módulos del sistema y cómo ejecutar TypeScript en Node.
Qué es Node y el modelo asíncrono
Node ejecuta JavaScript con un bucle de eventos (event loop) de un solo hilo. La idea central: cuando una operación tarda (lectura de disco, red, base de datos), no bloquea al proceso: se delega al sistema y se ejecuta un callback cuando el resultado llega. El hilo, entretanto, sigue atendiendo otras peticiones.
💡 Node es ideal para I/O intensivo (APIs, proxys, chat, streaming). Es inadecuado para CPU intensivo (procesamiento de imágenes, cálculo pesado): ahí el hilo único se satura. Para eso existen
worker_threads(ver el artículo de asincronía).
Instalar y elegir versión
Node publica dos tipos de versiones:
| Tipo | Ejemplo | Para quién |
|---|---|---|
| LTS (Long Term Support) | 20.x, 22.x | Producción y casi todos los proyectos |
| Current | 23.x | Quienes quieren lo más nuevo ya |
Regla: siempre LTS en producción. Se actualiza una versión LTS cada par de años (pares: 20, 22, 24…).
# Verificar instalación
node --version
npm --version
# Descargar del sitio oficial (nodejs.org) o con nvm en Linux/macOS
# Windows: instala el .msi oficial, o usa fnm para gestionar versiones
fnm install --lts
fnm use --lts
⚠️ No instales Node desde
apto un instalador genérico: obtienes versiones viejas. Usa el instalador oficial de nodejs.org o un gestor de versiones (nvm,fnm,volta) para poder cambiar de versión cuando un proyecto lo exija.
El modelo de módulos: ESM vs CommonJS
Todo archivo de Node es un módulo. Hay dos sistemas:
| CommonJS (CJS) | ES Modules (ESM) | |
|---|---|---|
| Cargar | require("./app.js") |
import app from "./app.js" |
| Exportar | module.exports / exports.x |
export default / export const x |
| Activación | por defecto (sin "type" en package.json) |
"type": "module" o extensión .mjs |
__dirname |
✅ disponible | ❌ usar import.meta.dirname |
ESM es el estándar moderno (el mismo que el navegador). Empieza proyectos nuevos en ESM:
// app.js
import saludo, { formatear } from "./helper.js";
console.log(saludo("Ana"), formatear(19.9));
// app-cjs.cjs (mismo proyecto, forzado a CommonJS por extensión)
const { formatear } = require("./helper.js");
console.log(formatear(42));
💡 Nota ESM: los imports de archivos locales requieren la extensión:
import x from "./helper.js", no"./helper". Es obligatorio, no un error tipográfico.
package.json a fondo
package.json es el manifiesto del proyecto. Node y npm lo leen para saber qué instalar y cómo ejecutar:
{
"name": "mi-api",
"version": "1.0.0",
"type": "module",
"engines": { "node": ">=20.0.0" },
"scripts": {
"dev": "node --watch src/index.js",
"build": "tsc",
"start": "node dist/index.js",
"test": "vitest run"
},
"dependencies": { "express": "^4.19.0" },
"devDependencies": { "typescript": "^5.5.0" }
}
| Campo | Para qué |
|---|---|
type |
"module" activa ESM en los .js del paquete |
main |
Punto de entrada que usará quien importe tu paquete |
scripts |
Comandos ejecutables con npm run <nombre> |
engines |
Rango de versiones de Node que tu proyecto necesita |
dependencies |
Dependencias de producción |
devDependencies |
Solo para desarrollo: compiladores, linters, test runners |
⚠️ Regla mental: si la app en producción necesita el paquete para correr, va en
dependencies. Si solo lo usa el desarrollador (TypeScript, Prettier, Vitest), va endevDependencies.
npm en la práctica
npm (Node Package Manager) instala paquetes del registro público npmjs.com:
npm install express # dependencia de producción
npm install -D typescript # dependencia de desarrollo
npm install -g pnpm # global (CLIs de sistema)
npx tsc --init # ejecutar una CLI sin instalarla
npm run dev # ejecutar un script del package.json
npm outdated # ver qué dependencias están viejas
npm audit # ver vulnerabilidades conocidas
npm publish # publicar tu paquete (¡solo cuando toca!)
| Comando | Efecto |
|---|---|
npm install <paquete> |
Instala y añade a dependencies |
npm install -D <paquete> |
Instala y añade a devDependencies |
npm install -g <paquete> |
Instala global para la CLI del sistema |
npx <paquete> |
Ejecuta sin instalación permanente |
El lockfile
package-lock.json congela la versión exacta de cada dependencia y sus transitivas. Cualquier package.json dice "^4.19.0" (permite 4.x); el lockfile dice exactamente 4.19.3 con todo su árbol.
💡 El lockfile se commitea al repositorio y no se edita a mano. Cuando alguien clona y hace
npm ci, instala EXACTAMENTE lo que tú probaste. Usanpm cien CI ynpm installsolo cuando quieras actualizar dependencias.
Módulos core: el corazón del sistema
Node incluye módulos sin instalar nada. Los imprescindibles:
path y fs
import path from "node:path";
import { readFile, writeFile, mkdir } from "node:fs/promises";
const dir = path.join("datos", "usuarios");
await mkdir(dir, { recursive: true });
const ruta = path.join(dir, "usuario-1.json");
await writeFile(ruta, JSON.stringify({ id: 1, nombre: "Ana" }));
const contenido = await readFile(ruta, "utf8");
console.log(JSON.parse(contenido)); // { id: 1, nombre: "Ana" }
💡 Prefiero
node:fs/promises(la versiónasync/await) sobre la clásica de callbacks. Mismo módulo, otra cara. El prefijonode:deja claro que es un módulo del sistema, no un paquete de npm.
Función de fs/promises |
Qué hace |
|---|---|
readFile(ruta, "utf8") |
Lee un archivo completo |
writeFile(ruta, datos) |
Escribe (crea si no existe) |
mkdir(path, { recursive }) |
Crea directorios |
readdir(ruta) |
Lista el contenido de un directorio |
stat(ruta) |
Metadatos del archivo (tamaño, fechas) |
unlink(ruta) |
Borra un archivo |
os, url y process
import os from "node:os";
import { fileURLToPath } from "node:url";
import path from "node:path";
console.log(os.platform(), os.cpus().length, os.totalmem() / 1e9, "GB");
// win32 8 16 GB
// ESM no tiene __dirname: se deriva del URL del módulo
const __dirname = path.dirname(fileURLToPath(import.meta.url));
process es el objeto global del proceso actual: argumentos, variables de entorno, salida:
const [,, ...args] = process.argv;
console.log("Argumentos:", args, "| PID:", process.pid, "| Node:", process.version);
process.exitCode = 1; // salir indicando error (mejor que process.exit())
child_process
Para ejecutar programas del sistema operativo desde Node:
import { exec, spawn } from "node:child_process";
// exec: captura la salida completa de una vez
exec("git status --short", (error, stdout, stderr) => {
if (error) return console.error("Fallo:", error.message);
console.log(stdout);
});
// spawn: ideal para comandos largos, transmite la salida por eventos
const ls = spawn("ls", ["-la"], { cwd: process.cwd() });
ls.stdout.on("data", (chunk) => process.stdout.write(chunk));
ls.on("close", (code) => console.log("Salió con código", code));
events
Un EventEmitter permite que un objeto emita eventos y otros se suscriban:
import { EventEmitter } from "node:events";
const gestor = new EventEmitter();
gestor.on("pedido", (id, total) => {
console.log(`Pedido ${id} por $${total} recibido`);
});
gestor.once("arranque", () => console.log("Primer arranque"));
gestor.emit("pedido", 101, 25.5); // ✅ imprime
gestor.emit("arranque"); // ✅ imprime (once = una sola vez)
gestor.emit("arranque"); // ❌ silencio
| Método | Qué hace |
|---|---|
emitter.on(evento, cb) |
Suscribirse |
emitter.once(evento, cb) |
Suscribirse a una sola emisión |
emitter.emit(evento, ...args) |
Disparar el evento |
emitter.off(evento, cb) |
Cancelar suscripción |
emitter.removeAllListeners() |
Limpiar todas |
util
Utilidades menores: util.promisify convierte funciones de callback a Promise, y util.format es un printf para consolas:
import { promisify } from "node:util";
import { exec } from "node:child_process";
const execAsync = promisify(exec);
const { stdout } = await execAsync("node --version");
console.log(stdout.trim());
Debugging con el inspector
Node integra el protocolo de depuración de Chrome:
node --inspect src/index.js # abre inspector
node --inspect-brk src/index.js # pausa en la primera línea
Con --inspect abre en Chrome chrome://inspect o el DevTools de VS Code, y puedes poner breakpoints, ver la pila y las variables en vivo. Para algo rápido sin DevTools:
node --watch src/index.js # reinicia al cambiar el archivo (Node 18+)
node --trace-warnings # muestra avisos con su origen
💡
console.loges tu primer debugger, pero para errores asíncronos no basta: usa--trace-warnings, el inspector, y sobre todo haz que los errores sean visibles con un manejador global deunhandledRejection(ver abajo).
Ejecutar TypeScript en Node
Node no entiende tipos, pero desde la versión 22+ puede eliminarlos al vuelo (type stripping) sin compilar:
# Opción A: Node nativo con type stripping (22.6+)
node --experimental-strip-types src/index.ts # 22.x
node --experimental-transform-types src/index.ts # 23.x
# Opción B: tsx, la solución estable y con tipo completo
npm install -D tsx
npx tsx src/index.ts
// package.json: script de desarrollo con tsx
{
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
⚠️ Para producción el estándar sigue siendo compilar con
tscadist/y ejecutar el JS resultante connode. El type stripping es para desarrollo y para prototipos, no para reemplazar el compilador (que además valida los tipos).
Variables de entorno
Node expone las variables del sistema en process.env:
// .env — nunca se commitea
PORT=3000
DB_URL=postgres://usuario:pass@localhost:5432/miapp
API_KEY=secreto-supersecreto
import "dotenv/config"; // npm install dotenv
const puerto = process.env.PORT ?? 3000;
const dbUrl = process.env.DB_URL;
const apiKey = process.env.API_KEY;
if (!dbUrl || !apiKey) {
console.error("Faltan variables de entorno obligatorias");
process.exit(1);
}
console.log(`Arrancando en el puerto ${puerto}`);
| Práctica | Por qué |
|---|---|
Nunca comitear .env |
Guarda secretos: añádelo a .gitignore |
Proporcionar .env.example |
Documenta qué variables se necesitan |
Valores por defecto con ?? |
El servicio arranca aunque falte algo |
| Fallar temprano si falta una obligatoria | Un crash claro vale más que un bug raro |
💡 No confundas tipos:
process.env.PORTsiempre es un string (oundefined). Si necesitas un número, conviértelo:Number(process.env.PORT ?? 3000).
Error handling y unhandledRejection
La regla de oro: cada operación asíncrona debe tener un camino de error. Con async/await, eso es un try/catch:
import { readFile } from "node:fs/promises";
async function leerConfig() {
try {
const raw = await readFile("./config.json", "utf8");
return JSON.parse(raw);
} catch (error) {
if (error.code === "ENOENT") {
throw new Error("config.json no existe: cópialo desde config.example.json");
}
throw error;
}
}
Si una Promise se rechaza y nadie la maneja, Node lanza unhandledRejection. En Node 15+ eso mata el proceso por defecto. Puedes decidir tu política:
process.on("unhandledRejection", (razon) => {
console.error("Promise rechazada sin manejar:", razon);
process.exit(1); // falla rápido: el estado es inconsistente
});
process.on("uncaughtException", (err) => {
console.error("Excepción no capturada:", err);
process.exit(1);
});
⚠️
uncaughtExceptionno debería servir para “seguir corriendo” tras un error grave: el proceso puede quedar con recursos a medias. La práctica recomendada es registrar el error, liberar recursos y salir con código distinto de 0.
Ejemplo real completo
// src/servidor.js — un mini servicio con eventos, fs y env
import { EventEmitter } from "node:events";
import { appendFile, mkdir } from "node:fs/promises";
import path from "node:path";
import "dotenv/config";
const logDir = process.env.LOG_DIR ?? "./logs";
await mkdir(logDir, { recursive: true });
const bus = new EventEmitter();
bus.on("evento", async ({ tipo, datos }) => {
const linea = `${new Date().toISOString()} ${tipo} ${JSON.stringify(datos)}\n`;
await appendFile(path.join(logDir, "eventos.log"), linea);
console.log(`[evento] ${tipo}`);
});
bus.on("error", (err) => {
console.error("Error en el bus:", err.message);
process.exit(1);
});
async function registrar(tipo, datos) {
try {
bus.emit("evento", { tipo, datos });
} catch (err) {
bus.emit("error", err);
}
}
await registrar("usuario.creado", { id: 1, nombre: "Ana" });
await registrar("usuario.creado", { id: 2, nombre: "Luis" });
console.log("Listo. PID:", process.pid);
Cheatsheet
| Quieres… | Usas… |
|---|---|
| “Crear un proyecto moderno” | npm init -y + "type": "module" |
| “Lectura de archivos limpia” | import { readFile } from "node:fs/promises" |
| “Script de arranque” | "dev": "node --watch src/index.js" |
| “Dependencia solo de dev” | npm install -D paquete |
| “Ejecutar un paquete puntual” | npx paquete |
| “Reproducir instalaciones exactas” | npm ci (con el lockfile commiteado) |
| “Eventos desacoplados” | EventEmitter con on/emit |
| “Ver errores asíncronos” | manejador de unhandledRejection |
| “Secretos fuera del código” | process.env + dotenv + .gitignore |
Para profundizar
- Node.js — Getting Started Guide: introducción oficial, desde instalación hasta el primer servidor.
- Node.js — ES Modules: diferencias ESM vs CommonJS y cómo migrar.
- Node.js — fs documentation: referencia completa del módulo de sistema de archivos.
- Node.js API Reference: todos los módulos core documentados.
- package.json — npm docs: cada campo del manifiesto, explicado por npm.
- Ruta completa: Backend con Node.