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

  1. El mini front-end de Escena Viva
  2. De URL a ruta de disco: el ataque y la defensa
  3. El Content-Type por extensión
  4. Enviar el fichero: createReadStream y pipeline
  5. stat, Content-Length, índice por defecto y 404
  6. Caché en el cliente: Cache-Control, ETag, Last-Modified y el 304
  7. Peticiones de rango: Range y 206
  8. Compresión negociada con Accept-Encoding
  9. En producción esto se delega

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

  1. 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 agujero

Parece 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 escapa

Dos detalles que hacen que la defensa sea completa:

  • Se decodifica antes de resolver. Si no, %2e%2e%2f pasarí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_PERMITIDA ya está en la tabla de 04-02 y vale 403. El manejador de errores central lo traduce solo: el atacante recibe un 403 Forbidden limpio y en stderr te 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'  # 403

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

  1. El Content-Type por extensión

El 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:

res.setHeader('X-Content-Type-Options', 'nosniff');

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.

  1. Enviar el fichero: createReadStream y pipeline

La 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}`);
}

  1. stat, Content-Length, índice por defecto y 404

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

  1. Caché en el cliente: Cache-Control, ETag, Last-Modified y el 304

Recargar 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 en If-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 en If-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 bytes

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

  1. Peticiones de rango: Range y 206

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

  1. Compresión negociada con Accept-Encoding

El 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 .png o un .zip no encogen; solo gastas CPU y, en ficheros pequeños, la respuesta llega a crecer. De ahí el umbral de 1 KB y la lista COMPRIMIBLES.

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.

  1. 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.pathname al directorio base con path.join. Es la vulnerabilidad de recorrido de directorios. Siempre resolverDentroDe, 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 pipe en vez de pipeline. No propaga errores ni destruye el origen: descriptores de fichero filtrados y, a la larga, EMFILE.
  • Olvidar charset=utf-8 en los tipos de texto. Acentos rotos que aparecen solo en algunos navegadores.
  • Dejar Content-Length al comprimir o al responder 304. 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 .env no son públicos.
  • Consejo: X-Content-Type-Options: nosniff desde el primer día: una línea que cierra una familia entera de ataques. Y para depurar la caché, curl -I enseña ETag, Last-Modified y Cache-Control sin 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.css

Tras 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

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