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
req: anatomía deIncomingMessagereq.urlno es una URL: parsearla connew URLres: anatomía deServerResponseERR_HTTP_HEADERS_SENT: el error más frecuente del módulo- Los códigos de estado que usará el curso
- Las cabeceras que importan
src/servidor/respuestas.js- De
error.codigode dominio a estado HTTP - Redirecciones y el método
HEAD
req: anatomía de IncomingMessage
req: anatomía de IncomingMessageEl 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.
req.url no es una URL: parsearla con new URL
req.url no es una URL: parsearla con new URLAquí 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:
- No decodifica. Con
GET /eventos?sala=Sala%20B%C3%B3vedaobtienes la cadena literalSala%20B%C3%B3veda, que no casa con'Sala Boveda'ni con nada. - Se rompe con más de un parámetro.
?sala=X&orden=fechadejasalavaliendoX&orden=fecha. - Ignora los parámetros repetidos.
?categoria=jazz&categoria=humores válido en HTTP y significa dos valores. - 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:
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;
}
res: anatomía de ServerResponse
res: anatomía de ServerResponseEl 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.
ERR_HTTP_HEADERS_SENT: el error más frecuente del módulo
ERR_HTTP_HEADERS_SENT: el error más frecuente del móduloHTTP 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 clientEn 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 unif/elsesin escapatoria. - Un manejador responde exactamente una vez. Si necesitas decidir entre varias ramas, calcula primero y responde al final.
- En los
catch, compruebares.headersSentantes de intentar responder con un 500: si el fallo ocurrió a mitad de un stream, lo único que se puede hacer esres.end()ores.destroy(), porque el cliente ya recibió un200que no vas a poder desmentir.
- 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.
400es "no entiendo lo que me mandas" (JSON roto, tipo equivocado).422es "te entiendo perfectamente y tu petición no tiene sentido" (pides 8 entradas con un límite de 6). Muchas API usan400para ambos; nosotros distinguiremos, porque el mensaje de error resultante es mucho más útil. - 401 frente a 403.
401es "no sé quién eres" y suele acompañarse deWWW-Authenticate;403es "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.
- Las cabeceras que importan
Content-Type declara qué estás enviando. Para texto, con charset siempre:
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).
src/servidor/respuestas.js
src/servidor/respuestas.jsEscribir 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.
- De
error.codigo de dominio a estado HTTP
error.codigo de dominio a estado HTTPLlegamos 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.reservarsigue lanzandoAFORO_INSUFICIENTEsin saber que existe el número 409, así que se puede seguir usando desdesrc/catalogo.jsen la terminal. - El fallo por defecto es
500y es ruidoso. Uncodigodesconocido significa que alguien inventó un error nuevo y olvidó registrarlo aquí: queremos enterarnos, no que se convierta en un400silencioso. - Los
5xxno filtran nada. El mensaje interno va astderrpara 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.
- Redirecciones y el método
HEAD
HEADUna 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. Devuelveundefinedsin error. Las claves están siempre en minúsculas. - Partir
req.urlconsplit('?')osplit('/'). Rompe con parámetros codificados, repetidos y con más de uno. Usanew URL(req.url, base). - Calcular
Content-Lengthconcuerpo.length. Cuenta caracteres, no bytes; con una tilde o una ñ, la respuesta llega cortada. UsaBuffer.byteLength. - Responder sin
return. Es la causa número uno deERR_HTTP_HEADERS_SENT. - Enviar cuerpo en un
204o un304. Está prohibido por la norma y algunos proxis se atragantan. - Devolver
200con{ "error": ... }dentro, o la traza de pila en un500. 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
catchque envuelven streams, compruebares.headersSent; si ya salieron, lo único honesto esres.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
- ¿Qué es Node.js?
- Instalación y Configuración del Entorno
- Tu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Moderno para Node.js
- El Proyecto del Curso: la Plataforma Escena Viva
Módulo 2: Conceptos Básicos
- Arquitectura de Node.js
- El Bucle de Eventos (Event Loop)
- Callbacks y Programación Asíncrona
- Promesas y async/await
- Eventos y EventEmitter
- Módulos CommonJS y require()
- Módulos ES e Interoperabilidad
Módulo 3: Sistema de Archivos y E/S
- Lectura y Escritura de Archivos
- El Módulo fs a Fondo
- Rutas Multiplataforma con el Módulo path
- Trabajando con Streams
- Streams de Transformación y pipeline
- Buffers y Datos Binarios
Módulo 4: HTTP y Servidores Web
- Creando un Servidor HTTP Simple
- Manejo de Solicitudes y Respuestas
- Enrutamiento Manual
- Sirviendo Archivos Estáticos
- Recibiendo Datos: Cuerpos de Petición y JSON
- Consumiendo APIs Externas desde Node.js
Módulo 5: NPM y Gestión de Paquetes
- Introducción a NPM y package.json
- Instalación y Uso de Paquetes
- Versionado Semántico y package-lock
- Scripts de npm y Automatización del Proyecto
- Creación y Publicación de Paquetes
- Seguridad y Mantenimiento de Dependencias
Módulo 6: Framework Express.js
- Introducción a Express.js
- Configuración de una Aplicación Express
- Enrutamiento en Express
- Middleware
- Middleware de Terceros Esenciales
- Validación de Datos de Entrada
- Manejo de Errores
Módulo 7: Bases de Datos y ORMs
- Introducción a las Bases de Datos
- Usando MongoDB con Mongoose
- Operaciones CRUD
- Relaciones, Poblado y Consultas Avanzadas
- Usando Bases de Datos SQL con Sequelize
- Migraciones, Transacciones y Datos de Prueba
Módulo 8: Autenticación y Autorización
- Introducción a la Autenticación
- Registro de Usuarios y Hash de Contraseñas
- Sesiones y Cookies con Passport.js
- Autenticación con JWT
- Control de Acceso Basado en Roles
- Buenas Prácticas de Seguridad en APIs
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con Mocha y Chai
- Dobles de Prueba con Sinon
- Pruebas de Integración
- Cobertura y Automatización de las Pruebas
- Depuración de Aplicaciones Node.js
Módulo 10: Temas Avanzados
- El Módulo Cluster
- Hilos de Trabajo (Worker Threads)
- Caché y Colas de Trabajo con Redis
- Optimización del Rendimiento
- Construcción de APIs RESTful
- GraphQL con Node.js
Módulo 11: Despliegue y DevOps
- Configuración y Variables de Entorno
- Registro y Monitorización en Producción
- Usando PM2 para la Gestión de Procesos
- Empaquetado con Docker
- Desplegando en Heroku y Otras PaaS
- Integración y Despliegue Continuos
