Escena Viva ya devuelve JSON impecable, pero nadie compra entradas leyendo JSON. Falta la parte que ve el usuario: un HTML, una hoja de estilos y un poco de JavaScript de navegador que consuma la API que acabamos de construir.
Servir ficheros del disco por HTTP parece la tarea más simple del módulo, y es exactamente al revés: es donde se concentran los problemas interesantes. Uno de ellos es un agujero de seguridad de manual que te vacía el proyecto entero con una sola petición, y que vamos a explotar y a cerrar en el apartado 2. Los demás tienen que ver con hacerlo bien: enviar con memoria constante en vez de cargar el fichero en RAM, declarar el tipo correcto, y conseguir que la segunda visita no descargue nada.
Todo esto reutiliza el Módulo 3 sin apenas añadir conceptos: resolverDentroDe de la lección 03-03, createReadStream y pipeline de 03-04 y 03-05, stat de 03-02 y zlib.createGzip de 03-05. Esta vez, conectado a la red.
Contenido
- El mini front-end de Escena Viva
- De URL a ruta de disco: el ataque y la defensa
- El
Content-Typepor extensión - Enviar el fichero:
createReadStreamypipeline stat,Content-Length, índice por defecto y404- Caché en el cliente:
Cache-Control,ETag,Last-Modifiedy el304 - Peticiones de rango:
Rangey206 - Compresión negociada con
Accept-Encoding - En producción esto se delega
- El mini front-end de Escena Viva
Crea la carpeta publico/ en la raíz del proyecto con tres ficheros. El HTML es deliberadamente mínimo:
<!-- publico/index.html -->
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Escena Viva - Cartelera</title>
<link rel="stylesheet" href="/estilos.css" />
</head>
<body>
<h1>Cartelera</h1>
<ul id="cartelera">Cargando...</ul>
<script src="/app.js"></script>
</body>
</html>Y el JavaScript de navegador consume la API de la lección anterior:
// publico/app.js — se ejecuta en el NAVEGADOR: aqui si hay fetch y document.
fetch('/eventos')
.then((respuesta) => respuesta.json())
.then(({ eventos }) => {
document.querySelector('#cartelera').innerHTML = eventos
.map((e) => `<li>${e.titulo} - ${e.sala} (${e.ocupacion}% ocupado)</li>`).join('');
})
.catch((error) => console.error('No se pudo cargar la cartelera', error));publico/estilos.css puede ser cualquier cosa; lo que importa es que exista y pese lo suficiente para que la caché del apartado 6 se note. La separación de carpetas es intencionada: publico/ contiene lo que cualquiera puede leer; datos/eventos.json y src/, jamás.
- De URL a ruta de disco: el ataque y la defensa
La idea es evidente: la ruta de la URL se pega al directorio público. Y así es como se escribe el bug:
// PELIGRO: no ejecutes esto en nada que este expuesto
const DIRECTORIO_PUBLICO = path.join(RAIZ, 'publico');
const rutaFichero = path.join(DIRECTORIO_PUBLICO, url.pathname); // <- el agujeroParece razonable porque path.join normaliza. El problema es que normalizar incluye resolver los .., y quien pone el pathname es el cliente: curl -s 'http://localhost:3000/../datos/eventos.json'.
Un navegador colapsa los .. antes de enviar la petición, así que el ataque no se hace desde la barra de direcciones: se hace con curl, con un script, o codificando la secuencia como %2e%2e%2f para que ni siquiera parezca lo que es. path.join('/…/publico', '/../datos/eventos.json') devuelve /…/datos/eventos.json, y tu servidor entrega el catálogo entero con toda la naturalidad del mundo. Con ../../ suficientes, entrega /etc/passwd. Esto se llama recorrido de directorios (path traversal) y sigue estando en el top de vulnerabilidades web décadas después de descubrirse.
La defensa ya la escribimos en la lección 03-03: resolverDentroDe resuelve la ruta y comprueba con path.relative que el resultado sigue dentro de la base, lanzando RUTA_NO_PERMITIDA si no.
// El pathname llega con '/' inicial: quitarlo para que sea relativo a la base.
const relativa = decodeURIComponent(url.pathname).replace(/^\/+/, '');
const rutaFichero = resolverDentroDe(DIRECTORIO_PUBLICO, relativa); // lanza si escapaDos detalles que hacen que la defensa sea completa:
- Se decodifica antes de resolver. Si no,
%2e%2e%2fpasaría el filtro sin ser aún../, y luego el sistema de ficheros lo interpretaría. Decodificar primero y validar después es el orden correcto; al revés es una vulnerabilidad clásica. RUTA_NO_PERMITIDAya está en la tabla de 04-02 y vale403. El manejador de errores central lo traduce solo: el atacante recibe un403 Forbiddenlimpio y enstderrte queda el registro del intento.
Compruébalo:
curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:3000/estilos.css' # 200
curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:3000/../datos/eventos.json' # 403
curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:3000/%2e%2e/datos/eventos.json' # 403Queda un caso que path no cubre, y ya lo avisamos en 03-03: un enlace simbólico dentro de publico/ que apunte fuera produce una ruta que parece legítima. Si tu directorio público admite ficheros subidos por terceros, valida además con fs.realpath antes de servir.
- El
Content-Type por extensión
Content-Type por extensiónEl navegador decide qué hacer con la respuesta por su Content-Type, no por su extensión ni por su contenido. Si sirves estilos.css como text/plain, el navegador no lo aplica; si sirves app.js como text/html, no lo ejecuta; y en ambos casos no hay ningún error visible, solo una página sin estilo que te hace perder media tarde.
// src/servidor/tipos-mime.js
// Tabla extension -> tipo MIME. Corta a proposito: lo que servimos y nada mas.
const path = require('node:path');
const TIPOS_MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.webp': 'image/webp',
'.woff2': 'font/woff2'
};
// Por defecto, "bytes sin interpretar": el navegador lo descarga en vez de
// intentar mostrarlo, que es lo prudente.
const TIPO_POR_DEFECTO = 'application/octet-stream';
function tipoParaFichero(rutaFichero) {
return TIPOS_MIME[path.extname(rutaFichero).toLowerCase()] ?? TIPO_POR_DEFECTO;
}
module.exports = { tipoParaFichero, TIPOS_MIME, TIPO_POR_DEFECTO };Dos reglas que no son opcionales. La primera: charset=utf-8 en todo lo que sea texto. Sin él, "Sala Bóveda" puede acabar como "Sala Bóveda", porque el navegador adivina la codificación y a veces adivina mal.
La segunda es de seguridad. Los navegadores hacen sniffing: si el Content-Type les parece dudoso, husmean los primeros bytes y deciden por su cuenta. Suena útil y es peligroso: un fichero subido por un usuario y servido como text/plain puede ser interpretado como HTML y ejecutar su <script> en tu dominio. La cabecera que lo desactiva es una línea:
Con ella, el navegador obedece tu Content-Type sin discutir. Es una de las cabeceras que helmet pone por ti en la lección 06-05; conviene saber qué hace antes de delegarla.
- Enviar el fichero:
createReadStream y pipeline
createReadStream y pipelineLa versión fácil, res.end(await fs.readFile(rutaFichero)), funciona con estilos.css y tumba el servidor con el cartel de 40 MB. readFile carga el fichero entero en memoria antes de enviar un solo byte: con diez clientes descargando a la vez son 400 MB de RAM, y no hay contrapresión. La forma correcta la conoces desde la lección 03-05:
const { pipeline } = require('node:stream/promises');
const { createReadStream } = require('node:fs');
await pipeline(createReadStream(rutaFichero), res);Tres razones para que sea siempre así:
readFile + end |
createReadStream + pipeline |
|
|---|---|---|
| Memoria | El tamaño del fichero, por cada cliente | Un highWaterMark (64 KB), constante |
| Contrapresión | Ninguna | Automática: el stream pausa si res se satura |
| Primer byte | Tras leer el fichero entero | Casi inmediato |
La contrapresión es el punto que más se subestima. res es un stream de escritura y pipeline respeta su ritmo: si el cliente está en una conexión lenta, la lectura del disco se pausa sola. Y pipeline propaga los errores y destruye ambos extremos, que es justo lo que el pipe a secas no hace: si el cliente cierra la pestaña a mitad de descarga, pipe dejaría el ReadStream abierto consumiendo un descriptor de fichero, y ese es el clásico goteo que acaba en EMFILE: too many open files después de días en marcha.
Un matiz importante: si el cliente aborta, pipeline rechaza con ERR_STREAM_PREMATURE_CLOSE. No es un fallo del servidor y no debe llegar al manejador de errores como un 500:
try {
await pipeline(createReadStream(rutaFichero), res);
} catch (error) {
// El cliente cerro la conexion: no es un error nuestro, solo se anota.
if (error.code !== 'ERR_STREAM_PREMATURE_CLOSE') throw error;
console.error(`[estaticos] descarga abortada por el cliente: ${rutaFichero}`);
}
stat, Content-Length, índice por defecto y 404
stat, Content-Length, índice por defecto y 404Antes de abrir el stream conviene un stat, que nos da tres cosas de una vez: si existe, el tamaño exacto en bytes —que va directo a Content-Length, sin calcular nada, y es mejor que dejar que Node use chunked porque el navegador puede mostrar el progreso real— y la fecha de modificación, que usaremos en el apartado 6.
Además resuelve dos casos: el fichero índice, porque GET / no pide ningún fichero concreto y hay que servir el index.html del directorio; y el fichero inexistente, que se traduce a 404 aplicando la disciplina de 03-01: intentar y capturar, nunca comprobar antes con exists —entre la comprobación y la apertura el fichero puede desaparecer, y además es una llamada al sistema de más en cada petición.
Con todo junto, src/servidor/estaticos.js:
// src/servidor/estaticos.js
// Sirve ficheros de publico/ con ruta blindada, tipo MIME y caché.
const fs = require('node:fs/promises');
const path = require('node:path');
const { createReadStream } = require('node:fs');
const { pipeline } = require('node:stream/promises');
const { RAIZ } = require('../config/rutas.js');
const { resolverDentroDe } = require('../utiles/ruta-segura.js');
const { tipoParaFichero } = require('./tipos-mime.js');
const DIRECTORIO_PUBLICO = path.join(RAIZ, 'publico');
async function servirEstatico(req, res, rutaPedida) {
// 1. Decodificar y blindar: lanza RUTA_NO_PERMITIDA (403) si escapa.
const relativa = decodeURIComponent(rutaPedida).replace(/^\/+/, '');
let rutaFichero = resolverDentroDe(DIRECTORIO_PUBLICO, relativa || 'index.html');
// 2. stat, con indice por defecto para los directorios.
let estado;
try {
estado = await fs.stat(rutaFichero);
if (estado.isDirectory()) {
rutaFichero = path.join(rutaFichero, 'index.html');
estado = await fs.stat(rutaFichero);
}
} catch (error) {
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
const fallo = new Error(`No existe el recurso ${rutaPedida}`);
fallo.codigo = 'RECURSO_NO_ENCONTRADO'; // -> 404 via errores-http.js
throw fallo;
}
// 3. Cabeceras (el apartado 6 anade aqui la cache).
res.statusCode = 200;
res.setHeader('Content-Type', tipoParaFichero(rutaFichero));
res.setHeader('Content-Length', estado.size);
res.setHeader('X-Content-Type-Options', 'nosniff');
if (req.method === 'HEAD') return res.end(); // mismas cabeceras, sin cuerpo
await pipeline(createReadStream(rutaFichero), res);
}
module.exports = { servirEstatico, DIRECTORIO_PUBLICO };Se conecta al enrutador como último recurso: si ninguna ruta de la API casa, se intenta servir un estático antes de dar 404.
- Caché en el cliente:
Cache-Control, ETag, Last-Modified y el 304
Cache-Control, ETag, Last-Modified y el 304Recargar la página vuelve a descargar el CSS y el JS aunque no hayan cambiado ni un byte. HTTP tiene dos mecanismos para evitarlo, y son complementarios.
Cache-Control: max-age=N dice: "durante N segundos, ni me preguntes". El navegador sirve el fichero de su disco sin ninguna petición. Es lo más rápido posible y también lo más peligroso: si publicas una versión nueva, quien tenga la vieja en caché no se enterará hasta que expire.
| Recurso | Valor razonable | Por qué |
|---|---|---|
index.html |
no-cache |
Es la puerta de entrada: debe revalidarse siempre |
estilos.css, app.js |
max-age=3600 |
Cambian poco; una hora es un compromiso prudente |
| Ficheros con hash en el nombre | max-age=31536000, immutable |
Si cambia el contenido cambia el nombre: caducan solos |
| Respuestas de la API | no-store |
Datos vivos: aforo, entradas libres |
Cuidado con la trampa de nombres: no-cache no significa "no caches", sino "cachea pero revalida antes de usar". El que prohíbe guardar es no-store.
La validación condicional es el segundo mecanismo, y es el que produce el 304. Funciona así: el servidor envía una etiqueta con la respuesta; el navegador la guarda y, la próxima vez, la manda de vuelta preguntando "¿sigue siendo válida?". Si lo es, el servidor responde 304 Not Modified sin cuerpo.
Hay dos etiquetas posibles:
Last-Modified, una fecha. El cliente la devuelve enIf-Modified-Since. Su resolución es de un segundo, así que dos cambios en el mismo segundo pasan desapercibidos.ETag, un identificador opaco de la versión. El cliente lo devuelve enIf-None-Match. Es el mecanismo preferente: más preciso y sin ambigüedades de zona horaria.
Un ETag fuerte sería un hash del contenido, pero eso obliga a leer el fichero entero en cada petición, justo lo que queríamos evitar. La solución práctica —la que usan casi todos los servidores— es un ETag débil derivado del stat: tamaño y fecha de modificación.
// ETag debil (prefijo W/): "el mismo tamano y la misma fecha" basta como
// prueba de que el contenido no ha cambiado, y sale gratis con el stat.
function calcularEtag(estado) {
return `W/"${estado.size.toString(16)}-${estado.mtimeMs.toString(16)}"`;
}
function estaEnCacheDelCliente(req, etag, estado) {
const siNoCasa = req.headers['if-none-match'];
if (siNoCasa) return siNoCasa.split(',').some((valor) => valor.trim() === etag);
const siNoModificado = req.headers['if-modified-since'];
// La fecha HTTP tiene resolucion de segundos: hay que truncar la del fichero.
if (siNoModificado) return Math.floor(estado.mtimeMs / 1000) * 1000 <= Date.parse(siNoModificado);
return false;
}Y en servirEstatico, justo antes de escribir el cuerpo:
const etag = calcularEtag(estado);
res.setHeader('ETag', etag);
res.setHeader('Last-Modified', estado.mtime.toUTCString());
res.setHeader('Cache-Control', rutaFichero.endsWith('index.html') ? 'no-cache' : 'max-age=3600');
if (estaEnCacheDelCliente(req, etag, estado)) {
res.removeHeader('Content-Length'); // un 304 va SIN cuerpo y SIN longitud
return responderSinContenido(res, 304);
}La mejora es medible en dos comandos:
curl -s -o /dev/null -w '%{http_code}: %{size_download} bytes\n' http://localhost:3000/estilos.css
# 200: 1842 bytes
ETIQUETA=$(curl -sI http://localhost:3000/estilos.css | awk '/[Ee]Tag/{print $2}' | tr -d '\r')
curl -s -o /dev/null -H "If-None-Match: $ETIQUETA" -w '%{http_code}: %{size_download} bytes\n' http://localhost:3000/estilos.css
# 304: 0 bytesCero bytes de cuerpo. En una página con veinte recursos, la diferencia entre la primera visita y la segunda deja de ser un megabyte para ser un puñado de cabeceras.
- Peticiones de rango:
Range y 206
Range y 206Un cliente puede pedir un trozo de un fichero en vez del entero. Es lo que hace un reproductor de vídeo al saltar al minuto 12, y lo que hace un gestor de descargas al reanudar una descarga cortada. Para el cartel de un evento no es imprescindible, pero conviene saber cómo funciona.
El cliente lo pide con la cabecera Range: bytes=0-1023, y el servidor, si acepta, responde 206 Partial Content con Content-Range y solo esos bytes:
res.setHeader('Accept-Ranges', 'bytes'); // anunciar que sabemos hacerlo
const rango = req.headers.range?.match(/^bytes=(\d*)-(\d*)$/);
if (rango) {
const inicio = rango[1] === '' ? 0 : Number(rango[1]);
const fin = rango[2] === '' ? estado.size - 1 : Math.min(Number(rango[2]), estado.size - 1);
if (inicio > fin) {
res.setHeader('Content-Range', `bytes */${estado.size}`);
return responderSinContenido(res, 416); // Range Not Satisfiable
}
res.statusCode = 206;
res.setHeader('Content-Range', `bytes ${inicio}-${fin}/${estado.size}`);
res.setHeader('Content-Length', fin - inicio + 1);
// start y end de createReadStream son ambos INCLUSIVOS: de ahi el -1 y el +1.
return pipeline(createReadStream(rutaFichero, { start: inicio, end: fin }), res);
}Los dos errores clásicos: olvidar que end de createReadStream es inclusivo (de ahí el -1 y el +1), y devolver 200 en vez de 206, que hace que el cliente crea que le has mandado el fichero entero y lo ensamble mal.
- Compresión negociada con
Accept-Encoding
Accept-EncodingEl CSS y el JS son texto y se comprimen muchísimo: un CSS de 100 KB baja a 15-20 KB con gzip. Pero comprimir solo es válido si el cliente lo entiende, y eso se negocia: el cliente anuncia Accept-Encoding: gzip, deflate, br y el servidor elige y lo declara en Content-Encoding.
const COMPRIMIBLES = new Set(['.html', '.css', '.js', '.json', '.svg', '.txt']);
const aceptaGzip = (req.headers['accept-encoding'] ?? '').includes('gzip');
const merecePena = COMPRIMIBLES.has(path.extname(rutaFichero)) && estado.size > 1024;
if (aceptaGzip && merecePena) {
res.setHeader('Content-Encoding', 'gzip');
res.setHeader('Vary', 'Accept-Encoding');
// El tamano comprimido no se conoce de antemano: fuera Content-Length.
// Node pasa a Transfer-Encoding: chunked automaticamente.
res.removeHeader('Content-Length');
return pipeline(createReadStream(rutaFichero), createGzip(), res);
}Tres puntos donde se equivoca casi todo el mundo:
- Quitar
Content-Length. Si lo dejas, anuncias el tamaño del fichero sin comprimir y el cliente esperará bytes que nunca llegan, quedándose colgado hasta el tiempo límite. - La cabecera
Vary: Accept-Encoding. Sin ella, una caché intermedia puede guardar la versión comprimida y entregársela a un cliente que no acepta gzip, que verá basura binaria. - No comprimir lo que ya está comprimido. Un
.webp, un.pngo un.zipno encogen; solo gastas CPU y, en ficheros pequeños, la respuesta llega a crecer. De ahí el umbral de 1 KB y la listaCOMPRIMIBLES.
Compruébalo: curl -s -o /dev/null -w '%{size_download}\n' http://localhost:3000/estilos.css devuelve 1842 bytes, y con -H 'Accept-Encoding: gzip' baja a 612.
- En producción esto se delega
Has escrito un servidor de estáticos correcto: blindado, con tipos MIME, streams, caché condicional, rangos y compresión. Y en producción, casi con seguridad, no lo usarás. La razón es económica, no técnica: un proxy inverso como Nginx o una CDN sirven ficheros estáticos con código nativo optimizado durante veinte años, sin ocupar tu bucle de eventos, y una CDN además los coloca físicamente cerca del usuario. Tu proceso de Node debería dedicarse a lo que solo él puede hacer.
| Aspecto | Node sirviendo estáticos | Proxy inverso o CDN |
|---|---|---|
| Coste por fichero | Ocupa el bucle de eventos de tu API | Nulo para tu proceso |
| Latencia | La de tu servidor | La del nodo más cercano al usuario |
| TLS, HTTP/2, compresión | A tu cargo | Incluidos |
| Cuándo usarlo | Desarrollo, pruebas, herramientas internas | Producción con tráfico real |
Lo que no ha sido tiempo perdido es entender los mecanismos: cuando configures expires en Nginx o las reglas de caché de una CDN en la lección 11-04, estarás configurando exactamente lo que acabas de implementar a mano.
Errores Comunes y Consejos
- Pegar
url.pathnameal directorio base conpath.join. Es la vulnerabilidad de recorrido de directorios. SiempreresolverDentroDe, y decodificando antes de validar. - Servir con
readFile. Funciona hasta el día del fichero grande o del pico de tráfico.createReadStream+pipeline, sin excepciones. - Usar
pipeen vez depipeline. No propaga errores ni destruye el origen: descriptores de fichero filtrados y, a la larga,EMFILE. - Olvidar
charset=utf-8en los tipos de texto. Acentos rotos que aparecen solo en algunos navegadores. - Dejar
Content-Lengthal comprimir o al responder304. Cliente colgado en el primer caso, respuesta inválida en el segundo. - Comprimir imágenes, o servir el directorio del proyecto entero. Gastas CPU sin ahorrar nada; y
datos/,src/y.envno son públicos. - Consejo:
X-Content-Type-Options: nosniffdesde el primer día: una línea que cierra una familia entera de ataques. Y para depurar la caché,curl -IenseñaETag,Last-ModifiedyCache-Controlsin descargar el cuerpo.
Ejercicios
Ejercicio 1: demostrar el ataque y la defensa
Escribe src/laboratorio/probar-traversal.js que, con el servidor arrancado, pida por fetch estas rutas y muestre una tabla ruta | estado | primeros 40 caracteres: /index.html, /estilos.css, /../datos/eventos.json, /%2e%2e/datos/eventos.json, /../../etc/hosts y /subdir/../estilos.css. Explica por qué la última debe devolver 200.
Ejercicio 2: medir el efecto de la caché
Escribe un script de shell que pida estilos.css tres veces: sin cabeceras condicionales, con el ETag obtenido de la primera respuesta, y con un If-None-Match inventado. Muestra el estado y los bytes descargados de cada una y calcula el ahorro porcentual. Después modifica el fichero (touch publico/estilos.css) y repite: explica qué cambia y por qué basta con tocar la fecha.
Ejercicio 3: Accept-Encoding completo
Amplía la negociación para que soporte también br (Brotli, con zlib.createBrotliCompress), prefiriéndolo sobre gzip cuando el cliente acepte ambos. Comprueba con tres peticiones (gzip, br, sin la cabecera) que el Content-Encoding de la respuesta es el correcto en cada caso, y compara los tres tamaños.
Soluciones
Solución 1. El resultado esperado es 200 para /index.html, /estilos.css y /subdir/../estilos.css, y 403 para las tres restantes.
const rutas = ['/index.html', '/estilos.css', '/../datos/eventos.json',
'/%2e%2e/datos/eventos.json', '/../../etc/hosts', '/subdir/../estilos.css'];
for (const ruta of rutas) {
// No se usa new URL: normalizaria los '..' antes de enviarlos.
const respuesta = await fetch(`http://localhost:3000${ruta}`);
const texto = (await respuesta.text()).slice(0, 40).replace(/\n/g, ' ');
console.log(`${ruta.padEnd(32)} | ${respuesta.status} | ${texto}`);
}/subdir/../estilos.css devuelve 200 y eso es correcto: resolverDentroDe no prohíbe los .., prohíbe salir de la base. Tras resolver, esa ruta apunta a publico/estilos.css, que está dentro. Rechazar toda cadena que contenga .. sería una defensa por lista negra, más frágil y con falsos positivos: la buena es validar el resultado, no la entrada.
Solución 2. El ETag inventado devuelve 200 con el cuerpo completo: no casa con el actual, así que el servidor asume que el cliente tiene una versión vieja.
ETIQUETA=$(curl -sI http://localhost:3000/estilos.css | awk '/[Ee]Tag/{print $2}' | tr -d '\r')
curl -s -o /dev/null -w ' sin cabecera: %{http_code} %{size_download}\n' http://localhost:3000/estilos.css
curl -s -o /dev/null -H "If-None-Match: $ETIQUETA" -w ' etag correcto: %{http_code} %{size_download}\n' http://localhost:3000/estilos.css
curl -s -o /dev/null -H 'If-None-Match: W/"0-0"' -w 'etag inventado: %{http_code} %{size_download}\n' http://localhost:3000/estilos.cssTras touch, el ETag cambia aunque el contenido sea idéntico, porque nuestra etiqueta se calcula con mtimeMs y el tamaño. Ese es el precio del ETag débil: puede invalidar de más (una descarga innecesaria) pero nunca de menos (servir contenido caducado), y ese reparto de riesgos es exactamente el que queremos.
Solución 3. La negociación se resuelve mirando qué acepta el cliente y eligiendo por preferencia nuestra:
const { createGzip, createBrotliCompress } = require('node:zlib');
function elegirCompresor(req) {
const acepta = req.headers['accept-encoding'] ?? '';
if (acepta.includes('br')) return { nombre: 'br', crear: createBrotliCompress };
if (acepta.includes('gzip')) return { nombre: 'gzip', crear: createGzip };
return null;
}Brotli comprime algo mejor que gzip en texto (del orden de un 15-20% menos en un CSS típico) a cambio de más CPU, por eso se prefiere para ficheros estáticos que además se cachean. Sin la cabecera Accept-Encoding, se sirve sin comprimir con su Content-Length original.
Conclusión
Escena Viva ya tiene cara. El mismo servidor que expone la API sirve publico/index.html, publico/estilos.css y publico/app.js, y por el camino has cerrado el agujero más común de todos: mapear la URL a disco con path.join permite que GET /../../datos/eventos.json se lleve tu catálogo, y la defensa es resolverDentroDe de la lección 03-03, decodificando antes de validar, con RUTA_NO_PERMITIDA traduciéndose solo a 403 gracias a la tabla de 04-02. Rechazar la ruta por su resultado y no por contener .. es lo que permite que /subdir/../estilos.css siga funcionando.
Sabes declarar el tipo con una tabla MIME propia —src/servidor/tipos-mime.js, con charset=utf-8 en todo lo textual y application/octet-stream por defecto— y por qué X-Content-Type-Options: nosniff no es opcional. Envías con createReadStream y pipeline, no con readFile: memoria constante, contrapresión automática y destrucción de ambos extremos si el cliente aborta, con ERR_STREAM_PREMATURE_CLOSE tratado como lo que es —una desconexión, no un 500—. Y stat te da de un golpe la existencia, el Content-Length exacto y la fecha para el índice por defecto y el 404.
La caché ya no es un misterio: Cache-Control con sus max-age, no-cache (revalida) y no-store (no guardes), y la validación condicional con ETag débil calculado del tamaño y mtime más Last-Modified, respondiendo 304 Not Modified sin cuerpo cuando llegan If-None-Match o If-Modified-Since —de 1842 bytes a 0—. Has visto los rangos Range/206 con su end inclusivo, y la compresión negociada por Accept-Encoding con zlib.createGzip, Content-Encoding, Vary y la eliminación obligatoria de Content-Length. Todo ello sabiendo que en producción esto se delega a un proxy inverso o una CDN, y que lo aprendido sirve para configurarlos.
Hasta aquí, Escena Viva solo entrega cosas: todo son peticiones GET. Falta lo que convierte una web en una plataforma: recibir. En la próxima lección, Recibiendo Datos: Cuerpos de Petición y JSON, acumularemos los trozos de Buffer que llegan por req —y verás por qué concatenar en una cadena parte los caracteres multibyte—, pondremos un límite de tamaño obligatorio con su 413, parsearemos JSON y formularios, e implementaremos POST /pedidos, con su validación a mano, su llamada al dominio y su respuesta 201 con Location.
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
