Hasta ahora Escena Viva solo entrega: catálogo, sesiones, HTML, CSS. Todo son GET. Falta lo que convierte una web en una plataforma: recibir. Que alguien pueda comprar una entrada.
Y aquí aparece la asimetría del módulo. Leer req.method, req.url o req.headers es inmediato, porque Node te entrega esos datos ya analizados. El cuerpo, en cambio, no: cuando tu manejador empieza a ejecutarse, el cuerpo puede seguir viajando por la red. req es un stream de lectura, y leerlo es exactamente lo que aprendiste en el Módulo 3, con dos vueltas de tuerca nuevas: los trozos son Buffer y cortarlos mal rompe los caracteres, y quien los envía no está de tu lado, así que hay que ponerles un límite.
Al terminar tendrás src/servidor/cuerpo.js y el primer POST de Escena Viva: POST /pedidos, con validación, llamada al dominio y una respuesta 201 con su cabecera Location.
Contenido
reqes un stream: el cuerpo llega a trozos- Por qué concatenar en una cadena rompe los caracteres
- La forma correcta:
Buffer.concatyfor await...of - El límite de tamaño y el
413 - Tiempos límite y peticiones abortadas
- Parsear según el
Content-Type POST /pedidos: validar y llamar al dominio- Responder
201conLocation, o el error que toque - Idempotencia: por qué un
POSTrepetido duplica pedidos
req es un stream: el cuerpo llega a trozos
req es un stream: el cuerpo llega a trozosNode invoca tu manejador en cuanto ha terminado de leer las cabeceras. El cuerpo llega después, en trozos, a través de los eventos del stream:
function manejarPeticion(req, res) {
const trozos = [];
// trozo es un Buffer. Su tamano lo decide la red, no tu.
req.on('data', (trozo) => trozos.push(trozo));
req.on('end', () => {
const cuerpo = Buffer.concat(trozos).toString('utf8');
console.error(`[cuerpo] ${cuerpo.length} caracteres en ${trozos.length} trozos`);
res.end('recibido\n');
});
// Sin este oyente, una conexion cortada a media transmision tumba el proceso.
req.on('error', (error) => {
console.error('[cuerpo] fallo al leer:', error);
res.statusCode = 400;
res.end();
});
}Tres cosas que hay que interiorizar:
- El tamaño de cada trozo no lo controlas. Depende del MTU de la red, del cliente y de los buffers del sistema. Un cuerpo pequeño puede llegar en un solo
data; uno grande, en cientos. Nunca asumas que un trozo es una unidad con sentido. - Si no lees el cuerpo, se queda ahí. Un manejador que responde sin consumir
reqdeja datos sin leer en el socket, y conkeep-aliveeso puede desincronizar la siguiente petición de esa conexión. Si vas a ignorar el cuerpo, consúmelo (req.resume()) o destruye la petición. reqemiteerror. Una conexión que se corta a media transmisión produce unerroren el stream, y como todoEventEmitter, si no lo escuchas te tumba el proceso.
- Por qué concatenar en una cadena rompe los caracteres
Este es el bug que más veces se ha escrito en la historia de Node:
// MAL. Funciona en tus pruebas y falla en produccion con acentos.
let cuerpo = '';
req.on('data', (trozo) => { cuerpo += trozo; }); // <- trozo.toString() implicito
req.on('end', () => { JSON.parse(cuerpo); });El += convierte cada Buffer a cadena por separado, y ahí está la trampa que estudiamos en la lección 03-06: en UTF-8 un carácter puede ocupar varios bytes. ó son dos bytes (0xC3 0xB3); un emoji, cuatro. Si la frontera entre dos trozos cae en mitad de esa secuencia, cada mitad se decodifica por su cuenta y ninguna de las dos es un carácter válido: el resultado es � (U+FFFD), y esa sustitución es irreversible.
const completo = Buffer.from('{"sala":"Sala Bóveda"}', 'utf8');
// Simulamos que la red parte el buffer justo entre los dos bytes de la 'ó'.
const corte = completo.indexOf(0xc3) + 1;
const trozoA = completo.subarray(0, corte);
const trozoB = completo.subarray(corte);
console.log(trozoA.toString('utf8') + trozoB.toString('utf8'));
// {"sala":"Sala B��veda"} <- dos caracteres de reemplazo
console.log(Buffer.concat([trozoA, trozoB]).toString('utf8'));
// {"sala":"Sala Bóveda"} <- correctoLo perverso es cuándo falla: con cuerpos pequeños casi nunca hay más de un trozo, así que en desarrollo funciona siempre. En producción, con cuerpos mayores y una red real, falla de vez en cuando y solo con acentos, emojis o caracteres CJK. Un JSON.parse que revienta "aleatoriamente" y un usuario llamado "Bóveda" que a veces se guarda mal.
La regla es absoluta: acumula Buffer y decodifica una sola vez, al final. La alternativa, si necesitas ir procesando texto según llega, es el StringDecoder de la lección 03-06, que se guarda los bytes incompletos entre trozo y trozo.
- La forma correcta:
Buffer.concat y for await...of
Buffer.concat y for await...ofCon async/await, req se recorre con for await...of, porque los streams de lectura son iterables asíncronos (lección 03-05). Queda mucho más legible que los eventos sueltos:
async function leerCuerpoCrudo(req) {
const trozos = [];
for await (const trozo of req) {
trozos.push(trozo);
}
return Buffer.concat(trozos); // un unico Buffer con todos los bytes
}Ventajas sobre la versión con on('data'): los errores del stream se convierten en una excepción que captura tu try/catch, el end es simplemente el final del bucle, y el resultado encaja con el resto del código asíncrono. Es la forma que usaremos.
Pero tal cual, esa función es una invitación a que te tumben el servidor. Falta lo del apartado siguiente.
- El límite de tamaño y el
413
413leerCuerpoCrudo acumula todo lo que llegue en memoria. Si un cliente envía un cuerpo de 5 GB, tu proceso intenta guardarlo en RAM y muere por falta de memoria. No hace falta mala fe: basta un bucle mal escrito en un cliente. Y con mala fe, es un ataque de denegación de servicio que cuesta una línea de curl.
Un servidor siempre limita el tamaño del cuerpo, y lo hace dos veces:
- Comprobando
Content-Lengthantes de leer nada. Es barato y rechaza al instante lo obviamente enorme. - Contando los bytes recibidos mientras llegan. Imprescindible, porque
Content-Lengthlo escribe el cliente: puede mentir, o no venir en absoluto si la petición usaTransfer-Encoding: chunked.
// src/servidor/cuerpo.js
// Lectura del cuerpo de una peticion con limite de tamano obligatorio.
const LIMITE_BYTES = 64 * 1024; // 64 KB: de sobra para un pedido en JSON
function errorDominio(codigo, mensaje) {
const error = new Error(mensaje);
error.codigo = codigo;
return error;
}
async function leerCuerpo(req, limiteBytes = LIMITE_BYTES) {
// 1. Filtro barato: lo que el cliente DICE que va a enviar.
const anunciado = Number(req.headers['content-length']);
if (Number.isFinite(anunciado) && anunciado > limiteBytes) {
throw errorDominio('CUERPO_DEMASIADO_GRANDE',
`El cuerpo declara ${anunciado} bytes y el limite es ${limiteBytes}`);
}
// 2. Filtro real: contar lo que llega de verdad.
const trozos = [];
let recibidos = 0;
for await (const trozo of req) {
recibidos += trozo.length;
if (recibidos > limiteBytes) {
// Cortar: si no, el cliente sigue enviando lo que ya hemos descartado.
req.destroy();
throw errorDominio('CUERPO_DEMASIADO_GRANDE', `El cuerpo supera ${limiteBytes} bytes`);
}
trozos.push(trozo);
}
return Buffer.concat(trozos);
}El req.destroy() no es un detalle: sin él, el cliente sigue transmitiendo y tú sigues recibiendo bytes que descartas, gastando ancho de banda y CPU. Destruir la petición cierra el grifo.
CUERPO_DEMASIADO_GRANDE se añade a la tabla de errores-http.js con el estado 413 Payload Too Large:
Sobre el valor del límite: 64 KB es generoso para un pedido en JSON y ridículo para subir una imagen. La respuesta no es "un límite grande por si acaso", sino un límite distinto por ruta: 64 KB en la API, y varios megabytes solo donde de verdad se suben ficheros.
- Tiempos límite y peticiones abortadas
Hay un ataque más sutil que el cuerpo enorme: el cuerpo lentísimo. Un cliente anuncia 60 KB y los envía a razón de un byte por minuto. Nunca supera el límite de tamaño, pero mantiene una conexión y un manejador ocupados durante horas. Con unos cientos de conexiones así, agotas el servidor. Es el ataque conocido como slowloris.
La defensa es un tiempo límite:
// Si la peticion no se completa en 10 s, se corta.
req.setTimeout(10_000, () => {
console.error('[cuerpo] peticion demasiado lenta, se corta');
req.destroy();
});El caso simétrico es la petición abortada: el usuario cierra la pestaña o pierde la cobertura a mitad de envío. Node lo señala en el stream, y conviene detectarlo para no hacer trabajo inútil:
// El evento 'aborted' esta obsoleto desde Node 16: se usa 'close'.
req.on('close', () => {
if (!req.readableEnded) console.error('[cuerpo] el cliente aborto el envio');
});La distinción clave es req.readableEnded: si es true, el cuerpo llegó completo y el close es el normal de después; si es false, la conexión se cortó antes. Cuando eso ocurre, no respondas: no hay nadie escuchando, y cualquier trabajo pesado que fueras a hacer (guardar en disco, llamar a otra API) es tiempo tirado.
- Parsear según el
Content-Type
Content-TypeUn cuerpo son bytes; qué significan lo dice la cabecera Content-Type. Estos son los tres formatos que verás:
Content-Type |
Quién lo envía | Cómo se parsea |
|---|---|---|
application/json |
API, fetch, aplicaciones móviles |
JSON.parse dentro de try/catch |
application/x-www-form-urlencoded |
Un <form> HTML clásico |
new URLSearchParams(texto) |
multipart/form-data |
Un <form> con <input type="file"> |
Con una librería, nunca a mano |
Añadimos a src/servidor/cuerpo.js:
// Devuelve el cuerpo ya interpretado segun su Content-Type.
async function leerCuerpoInterpretado(req, limiteBytes = LIMITE_BYTES) {
const bruto = await leerCuerpo(req, limiteBytes);
if (bruto.length === 0) return {};
// El Content-Type puede traer parametros: 'application/json; charset=utf-8'.
const tipo = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
const texto = bruto.toString('utf8'); // decodificacion UNICA, al final
if (tipo === 'application/json') {
try {
return JSON.parse(texto);
} catch (error) {
// Mensaje util: JSON.parse dice la posicion exacta del fallo.
throw errorDominio('JSON_INVALIDO', `El cuerpo no es JSON valido: ${error.message}`);
}
}
if (tipo === 'application/x-www-form-urlencoded') {
// URLSearchParams decodifica el porcentaje y los '+' por nosotros.
return Object.fromEntries(new URLSearchParams(texto));
}
throw errorDominio('TIPO_NO_ACEPTADO', `Content-Type no soportado: ${tipo || '(ausente)'}`);
}
module.exports = { leerCuerpo, leerCuerpoInterpretado, LIMITE_BYTES };Detalles que importan. El Content-Type se corta por el ;, porque casi siempre viene con parámetros. El JSON.parse va en un try/catch y el mensaje se aprovecha: Unexpected token } in JSON at position 42 le dice al cliente exactamente dónde mirar, y eso vale mucho más que un "petición inválida". Y Object.fromEntries sobre URLSearchParams pierde las claves repetidas (se queda con la última): si tu formulario tiene casillas múltiples, usa getAll explícitamente.
Sobre multipart/form-data: es un formato con separadores generados al azar, partes con sus propias cabeceras, ficheros binarios que no deben pasar por memoria y codificaciones heredadas. Parsearlo a mano es un error de juicio: se resuelve con busboy o multer, que veremos en el Módulo 6. Saber que no hay que hacerlo a mano es parte del oficio.
POST /pedidos: validar y llamar al dominio
POST /pedidos: validar y llamar al dominioCon las piezas listas, el primer POST de Escena Viva. El cuerpo esperado es mínimo: {"sesionId": "ses-001-1", "cantidad": 2, "correo": "[email protected]"}.
La validación se hace a mano y antes de tocar el dominio. Cada comprobación lanza con su error.codigo, y el manejador central de 04-03 los convierte en el estado que toca:
// src/servidor/rutas-pedidos.js
const LIMITE_ENTRADAS_POR_PEDIDO = 6;
function validarPedido(cuerpo) {
const { sesionId, cantidad, correo } = cuerpo;
if (typeof sesionId !== 'string' || sesionId.trim() === '') {
throw errorDominio('PARAMETRO_INVALIDO', 'El campo "sesionId" es obligatorio');
}
if (!Number.isInteger(cantidad) || cantidad < 1) {
throw errorDominio('CANTIDAD_INVALIDA', 'El campo "cantidad" debe ser un entero positivo');
}
if (cantidad > LIMITE_ENTRADAS_POR_PEDIDO) {
// 422: se entiende perfectamente, pero lo prohibe una regla de negocio.
throw errorDominio('LIMITE_POR_PEDIDO',
`Maximo ${LIMITE_ENTRADAS_POR_PEDIDO} entradas por pedido, se han pedido ${cantidad}`);
}
if (typeof correo !== 'string' || !correo.includes('@')) {
throw errorDominio('PARAMETRO_INVALIDO', 'El campo "correo" no es valido');
}
return { sesionId: sesionId.trim(), cantidad, correo: correo.trim().toLowerCase() };
}Cuatro criterios detrás de estas comprobaciones:
Number.isInteger, noparseInt. ConparseInt('2 entradas')obtendrías2y darías por buena una barbaridad. Si el cuerpo es JSON,cantidaddebe llegar como número; una cadena"2"es un cliente mal escrito y merece un400.- Se distingue
400de422.cantidad: 0es un400(el valor no tiene sentido);cantidad: 8es un422(el valor es válido y la regla de negocio lo prohíbe). - Se valida antes de llamar al dominio. Cuanto antes se rechaza lo inválido, menos estado a medias hay que deshacer.
- Se normaliza al final. Recortar espacios y bajar el correo a minúsculas evita que "[email protected]" y "[email protected]" sean dos clientes distintos.
Con los datos ya limpios, el manejador solo orquesta:
enrutador.post('/pedidos', async (req, res) => {
const cuerpo = await leerCuerpoInterpretado(req); // 413, 400 o 415
const { sesionId, cantidad, correo } = validarPedido(cuerpo); // 400 o 422
const eventos = await obtenerCatalogo();
const evento = eventos.find((candidato) => candidato.buscarSesion(sesionId));
if (!evento) {
throw errorDominio('SESION_NO_ENCONTRADA', `No existe la sesion ${sesionId}`); // 404
}
// El dominio decide: lanza AFORO_INSUFICIENTE (409) si no caben.
evento.reservar(sesionId, cantidad);
const gestor = new GestorDeVentas(eventos);
const entradas = gestor.registrarVenta(sesionId, cantidad, `ped-${Date.now()}`);
responderJson(res, 201, { /* ...apartado 8... */ });
});Fíjate en lo que no hay: ni un solo try/catch, ni una sola mención a un número de estado HTTP. El manejador lanza en el vocabulario del dominio y el despachador traduce. Esa es la recompensa de las dos lecciones anteriores.
- Responder
201 con Location, o el error que toque
201 con Location, o el error que toqueUn POST que crea un recurso responde 201 Created con la cabecera Location apuntando a dónde ha quedado:
const pedidoId = `ped-${Date.now()}`;
responderJson(res, 201, {
pedidoId,
sesionId,
cantidad,
correo,
entradas: entradas.map((entrada) => entrada.codigo), // EV-2026-000001, ...
importeCentimos: cantidad * sesion.precioCentimos,
entradasLibres: evento.entradasLibres(sesionId)
}, { Location: `/pedidos/${pedidoId}` });El Location no es decorativo: es lo que permite a un cliente guardar la URL del pedido sin construirla él a base de suposiciones. Y el importe va en céntimos enteros, la convención del proyecto desde el Módulo 1: 2 × 2500 = 5000, nunca 50.00 en coma flotante.
Todos los caminos de error, en una tabla:
| Situación | error.codigo |
Estado |
|---|---|---|
| Cuerpo mayor de 64 KB | CUERPO_DEMASIADO_GRANDE |
413 |
Content-Type no soportado |
TIPO_NO_ACEPTADO |
415 |
| JSON mal formado | JSON_INVALIDO |
400 |
Falta sesionId o el correo no es válido |
PARAMETRO_INVALIDO |
400 |
cantidad no es entero positivo |
CANTIDAD_INVALIDA |
400 |
| Más de 6 entradas | LIMITE_POR_PEDIDO |
422 |
| La sesión no existe | SESION_NO_ENCONTRADA |
404 |
| No quedan entradas suficientes | AFORO_INSUFICIENTE |
409 |
Y la comprobación completa desde la terminal:
CT='Content-Type: application/json'
# 201 Created, con Location y los codigos de entrada
curl -i -X POST http://localhost:3000/pedidos -H "$CT" \
-d '{"sesionId":"ses-001-1","cantidad":2,"correo":"[email protected]"}'
curl -s -X POST http://localhost:3000/pedidos -H "$CT" \
-d '{"sesionId":"ses-001-1","cantidad":8,"correo":"[email protected]"}' # 422
curl -s -X POST http://localhost:3000/pedidos -H "$CT" \
-d '{"sesionId":"ses-999-9","cantidad":1,"correo":"[email protected]"}' # 404
curl -s -X POST http://localhost:3000/pedidos -H "$CT" \
-d '{"sesionId":"ses-002-1","cantidad":5,"correo":"[email protected]"}' # 409: quedan 2
curl -s -X POST http://localhost:3000/pedidos -H "$CT" -d '{no es json}' # 400La sesión ses-002-1 tiene aforo 120 y 118 vendidas: pedir 5 devuelve 409 con el mensaje del dominio, que dice cuántas quedan. Ese detalle —un error que explica el estado real— es lo que convierte una API en algo usable.
- Idempotencia: por qué un
POST repetido duplica pedidos
POST repetido duplica pedidosEjecuta dos veces el curl que crea el pedido de 2 entradas. Obtendrás dos pedidos distintos y cuatro entradas vendidas. Es correcto según la norma: POST no es idempotente por definición, y repetirlo significa crear otro recurso.
| Método | ¿Idempotente? | Repetirlo significa |
|---|---|---|
GET, HEAD |
Sí | Leer otra vez; nada cambia |
PUT, DELETE |
Sí | Dejar el recurso en el mismo estado final |
POST |
No | Crear otro recurso |
El problema es que en la vida real las peticiones se repiten sin querer: el usuario pulsa dos veces "Comprar", la red se corta después de que el servidor procesara el pedido pero antes de que llegara la respuesta, o un cliente móvil reintenta automáticamente. En una plataforma de entradas eso significa cobrar dos veces.
Las tres defensas, en orden de solidez:
- En el cliente: deshabilitar el botón tras el primer clic. Necesario, insuficiente: no protege de un reintento de red.
- Clave de idempotencia: el cliente genera un identificador único por intento y lo envía en una cabecera
Idempotency-Key. El servidor la guarda con la respuesta; si llega otra vez la misma clave, devuelve la respuesta guardada sin volver a ejecutar nada. Es lo que hacen las pasarelas de pago. - Transacciones en la base de datos: reservar aforo y crear el pedido en una operación atómica, con una restricción de unicidad que impida el duplicado.
Nuestro servidor hoy no puede hacer bien ni la 2 ni la 3, y conviene ser honestos sobre por qué: el estado vive en memoria y en un JSON de disco, sin transacciones ni bloqueos. Es más: dos peticiones simultáneas pueden leer el mismo aforo libre y vender las mismas dos butacas, una condición de carrera clásica. Lo resolveremos de verdad en la lección 07-06, con transacciones. Hasta entonces, tenlo presente como lo que es: una limitación conocida, no un descuido.
Errores Comunes y Consejos
- Acumular el cuerpo en una cadena con
+=. Rompe los caracteres multibyte de forma intermitente.Buffer.concaty decodificar al final. - Leer el cuerpo sin límite de tamaño. Un cliente te agota la memoria con una línea de
curl. CompruebaContent-Lengthy cuenta los bytes. - Fiarse solo de
Content-Length. Lo escribe el cliente y conchunkedni siquiera existe. - No destruir la petición al superar el límite. Sigues recibiendo lo que ya has decidido descartar.
JSON.parsesintry/catch. Un cuerpo malformado te da un500en lugar de un400, y una traza en el registro por cada cliente torpe.- Comparar el
Content-Typecon===. Casi siempre trae; charset=utf-8. Córtalo por el;. - Responder
200a una creación, o parsearmultipart/form-dataa mano. UnPOSTque crea devuelve201conLocation; para multipart,busboyomulter. - Consejo: valida antes de tocar el dominio y normaliza al final (recortar, minúsculas). Es más barato rechazar que deshacer.
- Consejo: en los mensajes de error, incluye el dato que falla y el valor esperado.
"Maximo 6 entradas por pedido, se han pedido 8"vale infinitamente más que"Peticion invalida".
Ejercicios
Ejercicio 1: demostrar la rotura multibyte
Escribe src/laboratorio/romper-utf8.js que construya el Buffer de '{"sala":"Sala Bóveda","evento":"Concierto de Otoño"}', lo parta en trozos de 7 bytes, y compare dos reconstrucciones: la de concatenar cadenas y la de Buffer.concat. Muestra ambas, cuenta los caracteres � de cada una e intenta JSON.parse en las dos. Repite con trozos de 5 y de 13 bytes.
Ejercicio 2: límite de tamaño en acción
Genera un fichero de 100 KB (node -e "process.stdout.write('x'.repeat(102400))" > /tmp/grande.txt) y envíalo con curl -X POST --data-binary @/tmp/grande.txt. Comprueba que la respuesta es 413. Después modifica leerCuerpo para que registre en stderr cuántos bytes se llegaron a recibir antes de cortar, y explica por qué ese número casi nunca coincide exactamente con el límite.
Ejercicio 3: formularios y JSON en la misma ruta
Haz que POST /pedidos acepte también application/x-www-form-urlencoded, teniendo en cuenta que en un formulario todos los valores llegan como cadena y cantidad debe convertirse a número antes de validarla. Comprueba con curl -d 'sesionId=ses-003-1&cantidad=3&[email protected]' (sin -H, porque curl ya pone ese Content-Type) y asegúrate de que cantidad=tres sigue devolviendo 400.
Soluciones
Solución 1. Con trozos de 7 bytes el corte cae dentro de la ó de "Bóveda" y de la ñ de "Otoño":
const original = '{"sala":"Sala Bóveda","evento":"Concierto de Otoño"}';
const completo = Buffer.from(original, 'utf8');
for (const tamano of [5, 7, 13]) {
const trozos = [];
for (let i = 0; i < completo.length; i += tamano) trozos.push(completo.subarray(i, i + tamano));
const porCadena = trozos.reduce((texto, trozo) => texto + trozo, '');
const porBuffer = Buffer.concat(trozos).toString('utf8');
const rotos = [...porCadena].filter((caracter) => caracter === '�').length;
console.log(`${tamano} bytes -> ${rotos} caracteres rotos | correcto: ${porBuffer === original}`);
}Buffer.concat devuelve el original en los tres casos; la concatenación de cadenas rompe caracteres siempre que un corte caiga dentro de una secuencia multibyte, y su JSON.parse falla porque � no es un carácter válido dentro de una cadena JSON... salvo que caiga dentro de un valor, en cuyo caso parsea sin error y guarda datos corruptos, que es el escenario peor de todos.
Solución 2. El número casi nunca coincide con el límite porque la comprobación se hace después de recibir un trozo entero: si el límite son 65.536 bytes y el trozo que lo cruza trae 16 KB, te habrás pasado en varios miles. Es el comportamiento correcto —no puedes rechazar medio trozo—, y por eso el límite se elige con margen y no al milímetro.
if (recibidos > limiteBytes) {
console.error(`[cuerpo] cortado tras ${recibidos} bytes (limite ${limiteBytes})`);
req.destroy();
throw errorDominio('CUERPO_DEMASIADO_GRANDE', `El cuerpo supera ${limiteBytes} bytes`);
}Solución 3. La conversión debe ser estricta: Number('tres') es NaN, y Number.isInteger(NaN) es false, así que la validación existente ya lo rechaza sin tocarla.
function normalizarNumeros(cuerpo, esFormulario) {
if (!esFormulario) return cuerpo;
// En un formulario todo llega como cadena: se convierte lo que debe ser numero.
return { ...cuerpo, cantidad: cuerpo.cantidad === undefined ? undefined : Number(cuerpo.cantidad) };
}Usar Number y no parseInt es deliberado: parseInt('3 entradas') devuelve 3 y aceptaría basura, mientras que Number('3 entradas') devuelve NaN y la rechaza. La sesión ses-003-1 tiene aforo suficiente, así que el pedido de 3 entradas devuelve 201.
Conclusión
Escena Viva ya vende entradas. Y el camino ha dejado claro que recibir es bastante más delicado que entregar. req es un stream de lectura cuyos trozos son Buffer de tamaño impredecible, así que la primera regla es acumular Buffer y decodificar una sola vez con Buffer.concat: concatenar en una cadena parte los caracteres multibyte y produce � irrecuperables de forma intermitente, justo el tipo de fallo que no aparece en desarrollo. Con for await...of el código queda además legible y con los errores integrados en try/catch.
La segunda regla es que quien envía el cuerpo no está de tu lado: src/servidor/cuerpo.js comprueba Content-Length como filtro barato y cuenta los bytes recibidos como filtro real, destruye la petición al pasarse y lanza CUERPO_DEMASIADO_GRANDE → 413. A eso se suman req.setTimeout contra el cuerpo lentísimo y la detección de abortos con close más readableEnded, para no trabajar por nadie. El parseo se decide por el Content-Type cortado por el ;: JSON.parse en try/catch con su mensaje aprovechado, URLSearchParams para los formularios, y multipart/form-data delegado a una librería porque hacerlo a mano es un error de juicio.
Y tienes el primer POST real: validación a mano antes de tocar el dominio —Number.isInteger en vez de parseInt, 400 para lo que no tiene sentido y 422 para lo que la regla de negocio prohíbe, con el límite de 6 entradas—, llamada a reservar y a GestorDeVentas, y respuesta 201 con Location e importe en céntimos. Todo sin un solo try/catch en el manejador: lanza en el vocabulario del dominio y el despachador de 04-03 traduce. Sabes también lo que aún no está resuelto: un POST repetido duplica el pedido, y dos simultáneos pueden vender la misma butaca. La solución tiene nombre —claves de idempotencia y transacciones— y fecha: la lección 07-06.
Nos queda darle la vuelta al papel. Hasta ahora Node ha sido siempre el servidor; en la próxima lección, Consumiendo APIs Externas desde Node.js, será el cliente: fetch global y http.request por debajo, el error clásico de que fetch no rechaza ante un 404 ni un 500, tiempos límite con AbortController, reintentos con espera exponencial reutilizando reintentar del Módulo 2, y un servicio real de Escena Viva —src/servicios/cambio-divisas.js— con caché en memoria y degradación elegante cuando el proveedor externo falla.
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
