El servidor de la lección anterior tiene un defecto que da vergüenza enseñar: responde exactamente lo mismo a GET /eventos, a POST /pedidos y a DELETE /borra-todo, siempre con un 200 OK. No mira el método, no mira la ruta y no mira los parámetros. Es un servidor sordo.

En esta lección le damos oído y voz. Vamos a desmontar los dos objetos que recibe el manejador —req y res—, aprender a extraer bien la información de la petición, y construir el vocabulario con el que Escena Viva contestará durante el resto del curso: los códigos de estado, las cabeceras que importan y un módulo de ayudantes, src/servidor/respuestas.js, con la pieza que más lejos va a llegar: la tabla que traduce los error.codigo de nuestro dominio a estados HTTP. Esa tabla sobrevivirá a la llegada de Express en el Módulo 6.

Contenido

  1. req: anatomía de IncomingMessage
  2. req.url no es una URL: parsearla con new URL
  3. res: anatomía de ServerResponse
  4. ERR_HTTP_HEADERS_SENT: el error más frecuente del módulo
  5. Los códigos de estado que usará el curso
  6. Las cabeceras que importan
  7. src/servidor/respuestas.js
  8. De error.codigo de dominio a estado HTTP
  9. Redirecciones y el método HEAD

  1. req: anatomía de IncomingMessage

El primer argumento del manejador es una instancia de http.IncomingMessage. Y lo primero que hay que saber de ella es que es un stream de lectura: IncomingMessage extiende stream.Readable, con todo lo que eso implica desde la lección 03-04 (eventos data y end, for await...of, contrapresión). Node te entrega el objeto en cuanto ha terminado de leer las cabeceras; el cuerpo puede seguir viajando por la red mientras tu manejador ya se está ejecutando. Por eso el cuerpo hay que leerlo con paciencia, y por eso tiene su propia lección (04-05).

Las propiedades que se usan a diario:

console.error(req.method);                 // 'GET'  (SIEMPRE en mayusculas)
console.error(req.url);                    // '/eventos?sala=Teatro%20Almendra'
console.error(req.httpVersion);            // '1.1'
console.error(req.headers['user-agent']);  // 'curl/8.5.0'
console.error(req.headers.host);           // 'localhost:3000'
console.error(req.socket.remoteAddress);   // '::ffff:127.0.0.1'
Propiedad Tipo Detalle que sorprende
req.method Cadena Siempre en mayúsculas; compárala tal cual, sin toUpperCase()
req.url Cadena Solo ruta y query. Nunca trae el esquema ni el dominio
req.headers Objeto Claves siempre en minúsculas, las mande como las mande el cliente
req.headers['set-cookie'] Array Es la única cabecera que Node entrega siempre como array
req.socket.remoteAddress Cadena La IP del último salto, no necesariamente la del usuario
req.rawHeaders Array Pares planos con las mayúsculas originales del cliente

Dos advertencias que valen dinero. La primera: las claves de req.headers están normalizadas a minúsculas, así que req.headers['Content-Type'] vale undefined y req.headers['content-type'] funciona. El fallo es sutil porque no da error, solo un undefined silencioso. La segunda: req.socket.remoteAddress detrás de un proxy inverso o un balanceador te devolverá la IP del proxy; la del cliente real llega en X-Forwarded-For, una cabecera que solo es fiable si controlas el proxy que la escribe. Volveremos a ello al limitar peticiones por IP en la lección 08-06.

  1. req.url no es una URL: parsearla con new URL

Aquí es donde casi todo el mundo escribe su primer bug. req.url es la URL de petición del protocolo: una ruta con su cadena de consulta, sin origen. Y la tentación es tratarla como texto:

// MAL: no hagas esto
const [ruta, consulta] = req.url.split('?');
const sala = consulta.split('=')[1];

Ese código falla en cuatro escenarios reales:

  1. No decodifica. Con GET /eventos?sala=Sala%20B%C3%B3veda obtienes la cadena literal Sala%20B%C3%B3veda, que no casa con 'Sala Boveda' ni con nada.
  2. Se rompe con más de un parámetro. ?sala=X&orden=fecha deja sala valiendo X&orden=fecha.
  3. Ignora los parámetros repetidos. ?categoria=jazz&categoria=humor es válido en HTTP y significa dos valores.
  4. No contempla el fragmento ni las barras redundantes, ni normaliza nada.

La herramienta correcta es la clase URL, global desde Node 10 y estándar del navegador. Necesita una URL absoluta, así que se le pasa una base:

// La base solo sirve para completar el origen: el que importa es el pathname.
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);

url.pathname;                            // '/eventos'   (ya decodificado en su mayor parte)
url.searchParams.get('sala');            // 'Sala Boveda'  <- decodificado
url.searchParams.get('inexistente');     // null
url.searchParams.getAll('categoria');    // ['jazz', 'humor']
url.searchParams.has('detalle');         // true aunque venga vacio: '?detalle='

searchParams es un URLSearchParams: decodifica el porcentaje y el +, admite claves repetidas con getAll, y es iterable. Comprobación práctica con nuestro catálogo:

curl -s 'http://localhost:3000/eventos?sala=Sala%20B%C3%B3veda&categoria=humor&categoria=jazz'

Un detalle importante sobre pathname: la clase URL no descodifica del todo el camino (por ejemplo %2F sigue siendo %2F, y con razón: una barra codificada no debe convertirse en un separador de segmentos). Si un identificador puede traer caracteres codificados, aplica decodeURIComponent a cada segmento por separado, nunca a la ruta completa. Es una precaución que retomaremos al extraer parámetros de ruta en la próxima lección.

Un patrón útil para leer parámetros numéricos con valor por defecto y validación:

function leerEnteroPositivo(searchParams, nombre, porDefecto) {
  const crudo = searchParams.get(nombre);
  if (crudo === null) return porDefecto;

  const valor = Number(crudo);
  if (!Number.isInteger(valor) || valor < 1) {
    const error = new Error(`El parametro "${nombre}" debe ser un entero positivo`);
    error.codigo = 'CANTIDAD_INVALIDA';   // Vocabulario de dominio: sera un 400
    throw error;
  }
  return valor;
}

  1. res: anatomía de ServerResponse

El segundo argumento es un http.ServerResponse, y su naturaleza es simétrica a la de req: es un stream de escritura, ServerResponse extiende stream.Writable. Eso significa que res.write() devuelve false cuando el buffer se llena, que emite drain, y que puedes conectarle un pipeline —exactamente lo que haremos en la lección 04-04 para servir ficheros.

Una respuesta se construye en tres pasos, y el orden no es negociable:

res.statusCode = 200;                                              // 1. Estado
res.setHeader('Content-Type', 'application/json; charset=utf-8');  //    y cabeceras
res.write('{"eventos":');                                          // 2. Cuerpo,
res.write('3}');                                                   //    en 1 o N escrituras
res.end();                                                         // 3. Fin (OBLIGATORIO)

Los métodos, con su matiz:

Método Qué hace Cuándo usarlo
res.statusCode = 200 Fija el estado Siempre; explícito aunque sea 200
res.setHeader(n, v) Fija una cabecera; se puede sobrescribir y consultar Lo habitual
res.writeHead(200, obj) Estado + cabeceras y las envía ya Atajo, para una respuesta corta
res.write(datos) Escribe un trozo; envía las cabeceras la primera vez Respuestas largas o en streaming
res.end([datos]) Escribe el último trozo y cierra Siempre, en todas las ramas
res.headersSent true si las cabeceras ya salieron Antes de intentar cambiarlas

writeHead y setHeader se pueden combinar —writeHead gana en caso de conflicto—, pero mezclar estilos confunde. En Escena Viva usaremos setHeader para lo acumulativo y writeHead solo cuando la respuesta se cierra de inmediato.

  1. ERR_HTTP_HEADERS_SENT: el error más frecuente del módulo

HTTP envía las cabeceras antes del cuerpo, porque así viaja el mensaje. Y una vez que un byte ha salido por el socket, no hay forma de recuperarlo. Por eso este código explota:

res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end('Todo bien');

res.setHeader('X-Tarde', 'si');
// Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client

En la práctica, el error casi nunca es tan obvio: aparece cuando una función responde y la ejecución continúa hasta otra que responde también.

async function manejar(req, res) {
  if (!req.url.startsWith('/eventos')) {
    responderError(res, 404, 'Ruta no encontrada');
    // FALTA UN return: la ejecucion sigue y abajo se responde otra vez
  }

  const eventos = await obtenerCatalogo();
  responderJson(res, 200, eventos);   // <- revienta si ya se respondio
}

Las tres reglas que lo evitan para siempre:

  • Responder es terminar. Toda llamada a un ayudante de respuesta va precedida de return (return responderError(...)), o dentro de un if/else sin escapatoria.
  • Un manejador responde exactamente una vez. Si necesitas decidir entre varias ramas, calcula primero y responde al final.
  • En los catch, comprueba res.headersSent antes de intentar responder con un 500: si el fallo ocurrió a mitad de un stream, lo único que se puede hacer es res.end() o res.destroy(), porque el cliente ya recibió un 200 que no vas a poder desmentir.

  1. Los códigos de estado que usará el curso

El primer dígito da la familia: 2xx fue bien, 3xx mira en otro sitio, 4xx te has equivocado tú (el cliente), 5xx me he equivocado yo (el servidor). Esa frontera entre 4xx y 5xx es la más importante de todas: un 500 es un aviso para el desarrollador; un 4xx no.

Código Nombre Significado exacto en Escena Viva
200 OK GET /eventos con el catálogo, GET /eventos/evt-001 con el evento
201 Created Un POST /pedidos ha creado el pedido; se acompaña de Location
204 No Content DELETE /pedidos/ped-7 con éxito. Sin cuerpo, y sin Content-Type
304 Not Modified El navegador ya tiene estilos.css en caché y sigue siendo válido
400 Bad Request JSON mal formado o cantidad que no es entero positivo
401 Unauthorized Falta el token o es inválido: no sé quién eres (Módulo 8)
403 Forbidden Sé quién eres, pero org-boveda no puede editar un evento de org-ribera
404 Not Found evt-999 no existe, o la ruta no está registrada
405 Method Not Allowed DELETE /eventos: la ruta existe, el método no. Exige cabecera Allow
409 Conflict Aforo insuficiente: la petición es válida, el estado actual la impide
422 Unprocessable Content Sintaxis correcta y semántica imposible: 8 entradas con un máximo de 6
429 Too Many Requests Demasiadas peticiones desde una IP (limitación de tasa, 08-06)
500 Internal Server Error Una excepción que no supimos clasificar. Es un fallo nuestro
503 Service Unavailable La API de divisas no responde y no podemos degradar (04-06)

Tres distinciones que se preguntan en cualquier entrevista y, más importante, que se equivocan en cualquier API mal hecha:

  • 400 frente a 422. 400 es "no entiendo lo que me mandas" (JSON roto, tipo equivocado). 422 es "te entiendo perfectamente y tu petición no tiene sentido" (pides 8 entradas con un límite de 6). Muchas API usan 400 para ambos; nosotros distinguiremos, porque el mensaje de error resultante es mucho más útil.
  • 401 frente a 403. 401 es "no sé quién eres" y suele acompañarse de WWW-Authenticate; 403 es "sé quién eres y no te dejo". Los nombres oficiales están cambiados respecto a lo que parecen y por eso se confunden.
  • 404 frente a 409. Si la sesión no existe, 404. Si existe pero no quedan entradas, 409: el recurso está ahí, lo que falla es el estado.

  1. Las cabeceras que importan

Content-Type declara qué estás enviando. Para texto, con charset siempre:

res.setHeader('Content-Type', 'application/json; charset=utf-8');

Sin charset=utf-8, un navegador antiguo puede interpretar Bóveda como Bóveda. En JSON el estándar ya obliga a UTF-8, pero declararlo no cuesta nada y en text/html y text/plain es imprescindible.

Content-Length frente a Transfer-Encoding: chunked. Si conoces el tamaño exacto en bytes, decláralo: el cliente puede mostrar una barra de progreso y reutilizar mejor la conexión. Si no lo conoces —porque estás generando la respuesta sobre la marcha o enviando un stream—, Node usa chunked automáticamente, troceando el cuerpo con marcas de longitud.

const cuerpo = JSON.stringify(datos, null, 2);

// CORRECTO: Buffer.byteLength cuenta BYTES, no caracteres (leccion 03-06).
res.setHeader('Content-Length', Buffer.byteLength(cuerpo, 'utf8'));

Usar cuerpo.length aquí es un error clásico y muy dañino: 'Bóveda' tiene 6 caracteres y 7 bytes en UTF-8. Anunciar un byte de menos hace que el cliente corte la respuesta y falle el JSON.parse, con un mensaje incomprensible.

Cache-Control dice cuánto tiempo puede guardarse la respuesta. En una API de datos vivos, no-store; en un fichero estático, segundos o años (04-04). Location indica dónde está el recurso: obligatoria en un 201 (dónde quedó lo creado) y en un 3xx (adónde ir).

  1. src/servidor/respuestas.js

Escribir statusCode, setHeader y end en cada rama es repetitivo y, sobre todo, es donde se cuelan las incoherencias: una ruta que olvida el charset, otra que devuelve el error como texto plano. Centralicémoslo.

// src/servidor/respuestas.js
// Ayudantes para construir respuestas HTTP coherentes en todo el servidor.

const JSON_UTF8 = 'application/json; charset=utf-8';
const TEXTO_UTF8 = 'text/plain; charset=utf-8';

// Escribe un cuerpo ya serializado con su Content-Type y su longitud exacta.
function responderCuerpo(res, estado, tipo, cuerpo, cabeceras = {}) {
  if (res.headersSent) {
    console.error('[respuestas] se intento responder dos veces; se ignora');
    return;
  }

  res.statusCode = estado;
  res.setHeader('Content-Type', tipo);
  res.setHeader('Content-Length', Buffer.byteLength(cuerpo, 'utf8'));
  for (const [nombre, valor] of Object.entries(cabeceras)) res.setHeader(nombre, valor);

  res.end(cuerpo);
}

function responderJson(res, estado, datos, cabeceras = {}) {
  responderCuerpo(res, estado, JSON_UTF8, JSON.stringify(datos, null, 2), cabeceras);
}

function responderTexto(res, estado, texto, cabeceras = {}) {
  responderCuerpo(res, estado, TEXTO_UTF8, texto, cabeceras);
}

// 204 y 304 NO llevan cuerpo: enviarlo viola la norma y confunde a los proxis.
function responderSinContenido(res, estado = 204, cabeceras = {}) {
  if (res.headersSent) return;
  res.statusCode = estado;
  for (const [nombre, valor] of Object.entries(cabeceras)) res.setHeader(nombre, valor);
  res.end();
}

// Formato de error unico para toda la API. Que el cliente pueda programar
// contra "codigo" es mas util que leerle el "mensaje" a un humano.
function responderError(res, estado, mensaje, codigo = 'ERROR', extra = {}) {
  responderJson(res, estado, { error: { codigo, mensaje, estado, ...extra } });
}

function responderRedireccion(res, estado, destino) {
  responderSinContenido(res, estado, { Location: destino });
}

module.exports = { responderJson, responderTexto, responderError, responderSinContenido, responderRedireccion };

Tres decisiones deliberadas. Todos los errores tienen la misma forma ({ error: { codigo, mensaje, estado } }): un cliente puede programar contra codigo, que es estable, en vez de contra mensaje, que cambiará. Content-Length se calcula siempre con Buffer.byteLength, nunca con length. Y headersSent se comprueba en un único sitio, de modo que una doble respuesta ensucia el registro pero no tumba el proceso.

  1. De error.codigo de dominio a estado HTTP

Llegamos a la pieza clave del módulo. Desde el Módulo 2, nuestro dominio lanza errores con un codigo propio: SESION_NO_ENCONTRADA, AFORO_INSUFICIENTE, CANTIDAD_INVALIDA. Ese vocabulario es de Escena Viva y no sabe nada de HTTP: es exactamente lo que queremos, porque GestorDeVentas debe poder usarse desde una CLI, desde una tarea programada o desde una API.

El puente entre los dos mundos es una tabla, y vive en la capa HTTP, no en el dominio:

// src/servidor/errores-http.js
// Traduce el vocabulario de errores del DOMINIO al de HTTP.
// El dominio no conoce HTTP; esta tabla es la unica que conoce a ambos.

const ESTADO_POR_CODIGO = {
  // 400: peticion mal formada o mal tipada
  CANTIDAD_INVALIDA: 400, PARAMETRO_INVALIDO: 400, JSON_INVALIDO: 400,
  // 403: prohibido por politica
  RUTA_NO_PERMITIDA: 403,
  // 404: el recurso no existe
  EVENTO_NO_ENCONTRADO: 404, SESION_NO_ENCONTRADA: 404,
  PEDIDO_NO_ENCONTRADO: 404, RECURSO_NO_ENCONTRADO: 404,
  // 409: peticion valida que choca con el estado actual
  AFORO_INSUFICIENTE: 409, ESTADO_INVALIDO: 409, PEDIDO_YA_PAGADO: 409,
  // 422: se entiende, pero lo prohiben las reglas de negocio
  LIMITE_POR_PEDIDO: 422,
  // 503: dependemos de algo externo que ahora mismo no esta
  SERVICIO_EXTERNO_CAIDO: 503
};

// Sin traduccion conocida -> 500: es un fallo NUESTRO y hay que verlo.
function estadoParaError(error) {
  return ESTADO_POR_CODIGO[error?.codigo] ?? 500;
}

// Un 5xx nunca revela detalles internos al cliente, pero SI se registra.
function cuerpoParaError(error) {
  const estado = estadoParaError(error);
  if (estado >= 500) {
    console.error('[error] fallo no clasificado:', error);
    return { estado, codigo: 'ERROR_INTERNO', mensaje: 'Error interno del servidor' };
  }
  return { estado, codigo: error.codigo, mensaje: error.message };
}

module.exports = { estadoParaError, cuerpoParaError, ESTADO_POR_CODIGO };

Por qué esto es lo correcto y no un lujo de arquitecto:

  • El dominio no se contamina. Evento.reservar sigue lanzando AFORO_INSUFICIENTE sin saber que existe el número 409, así que se puede seguir usando desde src/catalogo.js en la terminal.
  • El fallo por defecto es 500 y es ruidoso. Un codigo desconocido significa que alguien inventó un error nuevo y olvidó registrarlo aquí: queremos enterarnos, no que se convierta en un 400 silencioso.
  • Los 5xx no filtran nada. El mensaje interno va a stderr para nosotros; al cliente le llega un texto genérico. Una traza de pila en la respuesta es un regalo para quien busca tu versión de Node y tus rutas de disco.
  • La tabla es una sola. Cuando lleguemos a Express en la lección 06-07, el manejador de errores cambiará de forma, pero seguirá consultando este mismo fichero.

Usarlo es una línea:

try {
  responderJson(res, 200, await buscarEvento(id));   // lanza EVENTO_NO_ENCONTRADO
} catch (error) {
  const { estado, codigo, mensaje } = cuerpoParaError(error);
  responderError(res, estado, mensaje, codigo);
}

En la próxima lección ese try/catch dejará de repetirse en cada ruta: subirá una vez al despachador del enrutador.

  1. Redirecciones y el método HEAD

Una redirección es un 3xx con la cabecera Location. Las cuatro que importan:

Código Nombre Método al reintentar Cuándo
301 Moved Permanently Puede cambiar a GET La URL cambió para siempre; el navegador lo cachea
302 Found Puede cambiar a GET Traslado temporal
307 Temporary Redirect Se conserva Temporal preservando un POST
308 Permanent Redirect Se conserva Permanente preservando un POST
// /evento/evt-001 quedo obsoleto: la ruta buena es /eventos/evt-001
responderRedireccion(res, 301, `/eventos/${id}`);

Cuidado con el 301: los navegadores lo guardan de forma agresiva y, si te equivocas de destino, los usuarios seguirán yendo al sitio malo aunque arregles el servidor, porque ni siquiera te lo van a preguntar. En la duda, 302.

El método HEAD pide una respuesta idéntica a la de GET pero sin cuerpo: sirve para consultar el tamaño o la fecha de un recurso antes de descargarlo. La buena noticia es que Node lo maneja casi solo: si el método es HEAD, descarta el cuerpo que escribas y envía solo las cabeceras. Aun así, conviene ser explícito para no generar trabajo inútil:

// Registrar HEAD junto a GET: mismas cabeceras, cuerpo solo si es GET.
const cuerpo = JSON.stringify(datos, null, 2);

res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Length', Buffer.byteLength(cuerpo, 'utf8'));
res.end(req.method === 'HEAD' ? undefined : cuerpo);

Nota que el Content-Length se sigue enviando aunque no haya cuerpo: es justo el dato que el cliente venía a buscar.

Errores Comunes y Consejos

  • Leer req.headers['Content-Type'] con mayúsculas. Devuelve undefined sin error. Las claves están siempre en minúsculas.
  • Partir req.url con split('?') o split('/'). Rompe con parámetros codificados, repetidos y con más de uno. Usa new URL(req.url, base).
  • Calcular Content-Length con cuerpo.length. Cuenta caracteres, no bytes; con una tilde o una ñ, la respuesta llega cortada. Usa Buffer.byteLength.
  • Responder sin return. Es la causa número uno de ERR_HTTP_HEADERS_SENT.
  • Enviar cuerpo en un 204 o un 304. Está prohibido por la norma y algunos proxis se atragantan.
  • Devolver 200 con { "error": ... } dentro, o la traza de pila en un 500. El estado es parte de la respuesta, y la traza va al registro, nunca al cliente.
  • Consejo: decide desde el principio un único formato de error para toda la API y no lo cambies. Tus clientes lo agradecerán más que cualquier funcionalidad.
  • Consejo: en los catch que envuelven streams, comprueba res.headersSent; si ya salieron, lo único honesto es res.destroy().

Ejercicios

Ejercicio 1: filtro por sala y categoría

Amplía el manejador para que GET /eventos acepte ?sala= y ?categoria= (esta última repetible) y devuelva el catálogo filtrado, con la forma { total, filtros, eventos }. Valida que sala no esté vacía y responde 400 con codigo: 'PARAMETRO_INVALIDO' si lo está. Compruébalo con curl -s 'http://localhost:3000/eventos?sala=Sala%20B%C3%B3veda'.

Ejercicio 2: ampliar la tabla de errores

Añade a src/servidor/errores-http.js los códigos DATOS_CORRUPTOS (el JSON del catálogo está mal: es fallo nuestro) y FORMATO_NO_ACEPTADO (el cliente pide un formato que no servimos). Elige el estado de cada uno y justifícalo. Después escribe src/servidor/probar-errores.js, un script que recorra ESTADO_POR_CODIGO e imprima una tabla codigo | estado | familia, más el recuento por familia.

Ejercicio 3: HEAD y Content-Length correctos

Haz que el manejador responda a HEAD /eventos con las mismas cabeceras que GET /eventos pero sin cuerpo, y comprueba con curl -I que Content-Length coincide exactamente con el número de bytes que devuelve curl -s ... | wc -c. Añade una sala con tilde al catálogo y verifica que sigue cuadrando.

Soluciones

Solución 1. Toda la información sale de searchParams, y la validación lanza con error.codigo para que la traduzca la tabla:

const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
const sala = url.searchParams.get('sala');
const categorias = url.searchParams.getAll('categoria');

if (sala !== null && sala.trim() === '') {
  const error = new Error('El parametro "sala" no puede estar vacio');
  error.codigo = 'PARAMETRO_INVALIDO';
  throw error;
}

const eventos = (await obtenerCatalogo())
  .filter((evento) => sala === null || evento.sala === sala)
  .filter((evento) => categorias.length === 0 || categorias.includes(evento.categoria));

responderJson(res, 200, { total: eventos.length, filtros: { sala, categorias }, eventos });

Con ?sala=Sala%20B%C3%B3veda devuelve 1 evento (evt-002, Noche de Monologos). Si hubieras usado split('='), la comparación sería contra 'Sala%20B%C3%B3veda' y el resultado, cero eventos: un filtro que "no encuentra nada" y parece un problema de datos.

Solución 2. DATOS_CORRUPTOS es un 500: el fichero del catálogo es responsabilidad nuestra y el cliente no puede hacer nada al respecto, así que además queremos que quede registrado. FORMATO_NO_ACEPTADO es un 406 Not Acceptable, el estado específico para "no puedo producir ninguno de los formatos que aceptas"; si prefieres no ampliar el vocabulario del curso, 400 es defendible, pero 406 es más preciso. El script se apoya en que la familia es el primer dígito:

const { ESTADO_POR_CODIGO } = require('./errores-http.js');

const conteo = {};
for (const [codigo, estado] of Object.entries(ESTADO_POR_CODIGO)) {
  const familia = `${Math.floor(estado / 100)}xx`;
  conteo[familia] = (conteo[familia] ?? 0) + 1;
  console.log(`${codigo.padEnd(24)} | ${estado} | ${familia}`);
}
console.log(JSON.stringify(conteo, null, 2));

Solución 3. La clave es calcular el cuerpo igual en ambos métodos y decidir solo al final si se envía:

const cuerpo = JSON.stringify(datos, null, 2);
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Length', Buffer.byteLength(cuerpo, 'utf8'));
res.end(req.method === 'HEAD' ? undefined : cuerpo);

curl -I (que envía HEAD) y curl -s ... | wc -c deben dar el mismo número. Al añadir una sala con tilde, Buffer.byteLength sube más que el número de caracteres —cada carácter acentuado ocupa dos bytes en UTF-8—, y ahí se ve por qué cuerpo.length habría mentido.

Conclusión

Ya sabes escuchar y contestar con propiedad. req es un IncomingMessage, que además de method, headers —siempre en minúsculas—, httpVersion y socket.remoteAddress, es un stream de lectura cuyo cuerpo todavía puede estar viajando. Su url no es una URL completa, sino ruta más consulta, y la única forma sensata de interpretarla es new URL(req.url, base) con searchParams: decodifica el porcentaje, admite claves repetidas con getAll y no se rompe con ?sala=Sala%20B%C3%B3veda.

res es un ServerResponse y un stream de escritura, con un orden inviolable: estado y cabeceras primero, cuerpo después, end() siempre. De ahí sale el error más frecuente del módulo, ERR_HTTP_HEADERS_SENT, que se cura con una regla simple: responder es terminar, y se responde una sola vez. Tienes el catálogo completo de estados que usará el curso, con las fronteras que más se equivocan —400 frente a 422, 401 frente a 403, 404 frente a 409— y las cabeceras esenciales, incluida la disciplina de calcular Content-Length con Buffer.byteLength y no con String.length.

Y Escena Viva se lleva dos módulos que la acompañarán hasta el final: src/servidor/respuestas.js, con responderJson, responderTexto, responderError, responderSinContenido y responderRedireccion, todos con un formato de error único; y src/servidor/errores-http.js, con la tabla que traduce error.codigo a estado HTTP —EVENTO_NO_ENCONTRADO a 404, AFORO_INSUFICIENTE a 409, CANTIDAD_INVALIDA a 400— y un 500 ruidoso por defecto para lo que no sepamos clasificar.

Nos falta lo evidente: decidir qué manejador atiende cada petición. En la próxima lección, Enrutamiento Manual, empezaremos con el if/else ingenuo, veremos exactamente dónde se rompe, y construiremos src/servidor/enrutador.js con una tabla de rutas, patrones tipo /eventos/:id compilados a expresiones regulares, extracción de parámetros, respuestas 405 con cabecera Allow y un try/catch central que usará la tabla que acabas de escribir.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados