🔎 Buscar

📦 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.

Wiki / Apuntes📖 Contenido

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 apt o 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 en devDependencies.

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. Usa npm ci en CI y npm install solo 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ón async/await) sobre la clásica de callbacks. Mismo módulo, otra cara. El prefijo node: 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.log es 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 de unhandledRejection (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 tsc a dist/ y ejecutar el JS resultante con node. 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.PORT siempre es un string (o undefined). 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);
});

⚠️ uncaughtException no 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

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.