Escena Viva ja torna JSON impecable, però ningú no compra entrades llegint JSON. Falta la part que veu l'usuari: un HTML, un full d'estils i una mica de JavaScript de navegador que consumeixi l'API que acabem de construir.

Servir fitxers del disc per HTTP sembla la tasca més simple del mòdul, i és exactament al revés: és on es concentren els problemes interessants. Un d'ells és un forat de seguretat de manual que et buida el projecte sencer amb una sola petició, i que explotarem i tancarem a l'apartat 2. Els altres tenen a veure amb fer-ho bé: enviar amb memòria constant en comptes de carregar el fitxer a la RAM, declarar el tipus correcte, i aconseguir que la segona visita no descarregui res.

Tot això reutilitza el Mòdul 3 sense gairebé afegir conceptes: resoldreDinsDe de la lliçó 03-03, createReadStream i pipeline de 03-04 i 03-05, stat de 03-02 i zlib.createGzip de 03-05. Aquesta vegada, connectat a la xarxa.

Contingut

  1. El mini front-end d'Escena Viva
  2. D'URL a ruta de disc: l'atac i la defensa
  3. El Content-Type per extensió
  4. Enviar el fitxer: createReadStream i pipeline
  5. stat, Content-Length, índex per defecte i 404
  6. Memòria cau al client: Cache-Control, ETag, Last-Modified i el 304
  7. Peticions de rang: Range i 206
  8. Compressió negociada amb Accept-Encoding
  9. En producció això es delega

  1. El mini front-end d'Escena Viva

Crea la carpeta public/ a l'arrel del projecte amb tres fitxers. L'HTML és deliberadament mínim:

<!-- public/index.html -->
<!doctype html>
<html lang="ca">
  <head>
    <meta charset="utf-8" />
    <title>Escena Viva - Cartellera</title>
    <link rel="stylesheet" href="/estils.css" />
  </head>
  <body>
    <h1>Cartellera</h1>
    <ul id="cartellera">Carregant...</ul>
    <script src="/app.js"></script>
  </body>
</html>

I el JavaScript de navegador consumeix l'API de la lliçó anterior:

// public/app.js — s'executa al NAVEGADOR: aqui si que hi ha fetch i document.
fetch('/esdeveniments')
  .then((resposta) => resposta.json())
  .then(({ esdeveniments }) => {
    document.querySelector('#cartellera').innerHTML = esdeveniments
      .map((e) => `<li>${e.titol} - ${e.sala} (${e.ocupacio}% ocupat)</li>`).join('');
  })
  .catch((error) => console.error("No s'ha pogut carregar la cartellera", error));

public/estils.css pot ser qualsevol cosa; el que importa és que existeixi i pesi prou perquè la memòria cau de l'apartat 6 es noti. La separació de carpetes és intencionada: public/ conté el que qualsevol pot llegir; dades/esdeveniments.json i src/, mai.

  1. D'URL a ruta de disc: l'atac i la defensa

La idea és evident: la ruta de la URL s'enganxa al directori públic. I així és com s'escriu l'error:

// PERILL: no executis aixo en res que estigui exposat
const DIRECTORI_PUBLIC = path.join(ARREL, 'public');
const rutaFitxer = path.join(DIRECTORI_PUBLIC, url.pathname);   // <- el forat

Sembla raonable perquè path.join normalitza. El problema és que normalitzar inclou resoldre els .., i qui posa el pathname és el client: curl -s 'http://localhost:3000/../dades/esdeveniments.json'.

Un navegador col·lapsa els .. abans d'enviar la petició, així que l'atac no es fa des de la barra d'adreces: es fa amb curl, amb un script, o codificant la seqüència com %2e%2e%2f perquè ni tan sols sembli el que és. path.join('/…/public', '/../dades/esdeveniments.json') torna /…/dades/esdeveniments.json, i el teu servidor lliura el catàleg sencer amb tota la naturalitat del món. Amb prou ../../, lliura /etc/passwd. Això s'anomena recorregut de directoris (path traversal) i continua sent al capdamunt de les vulnerabilitats web dècades després de descobrir-se.

La defensa ja la vam escriure a la lliçó 03-03: resoldreDinsDe resol la ruta i comprova amb path.relative que el resultat continua dins de la base, llançant RUTA_NO_PERMESA si no.

// El pathname arriba amb '/' inicial: treure'l perque sigui relatiu a la base.
const relativa = decodeURIComponent(url.pathname).replace(/^\/+/, '');
const rutaFitxer = resoldreDinsDe(DIRECTORI_PUBLIC, relativa);   // llanca si escapa

Dos detalls que fan que la defensa sigui completa:

  • Es descodifica abans de resoldre. Si no, %2e%2e%2f passaria el filtre sense ser encara ../, i després el sistema de fitxers ho interpretaria. Descodificar primer i validar després és l'ordre correcte; a l'inrevés és una vulnerabilitat clàssica.
  • RUTA_NO_PERMESA ja és a la taula de 04-02 i val 403. El gestor d'errors central ho tradueix sol: l'atacant rep un 403 Forbidden net i a stderr et queda el registre de l'intent.

Comprova-ho:

curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:3000/estils.css'                     # 200
curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:3000/../dades/esdeveniments.json'     # 403
curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:3000/%2e%2e/dades/esdeveniments.json' # 403

Queda un cas que path no cobreix, i ja el vam advertir a 03-03: un enllaç simbòlic dins de public/ que apunti a fora produeix una ruta que sembla legítima. Si el teu directori públic admet fitxers pujats per tercers, valida a més amb fs.realpath abans de servir.

  1. El Content-Type per extensió

El navegador decideix què fer amb la resposta pel seu Content-Type, no per l'extensió ni pel contingut. Si serveixes estils.css com a text/plain, el navegador no l'aplica; si serveixes app.js com a text/html, no l'executa; i en tots dos casos no hi ha cap error visible, només una pàgina sense estil que et fa perdre mitja tarda.

// src/servidor/tipus-mime.js
// Taula extensio -> tipus MIME. Curta a proposit: el que servim i res mes.

const path = require('node:path');

const TIPUS_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'
};

// Per defecte, "bytes sense interpretar": el navegador els descarrega en comptes
// d'intentar mostrar-los, que es el prudent.
const TIPUS_PER_DEFECTE = 'application/octet-stream';

function tipusPerFitxer(rutaFitxer) {
  return TIPUS_MIME[path.extname(rutaFitxer).toLowerCase()] ?? TIPUS_PER_DEFECTE;
}

module.exports = { tipusPerFitxer, TIPUS_MIME, TIPUS_PER_DEFECTE };

Dues regles que no són opcionals. La primera: charset=utf-8 en tot el que sigui text. Sense ell, "Sala Bóveda" pot acabar com "Sala Bóveda", perquè el navegador endevina la codificació i de vegades l'endevina malament.

La segona és de seguretat. Els navegadors fan sniffing: si el Content-Type els sembla dubtós, ensumen els primers bytes i decideixen pel seu compte. Sona útil i és perillós: un fitxer pujat per un usuari i servit com a text/plain pot ser interpretat com a HTML i executar el seu <script> al teu domini. La capçalera que ho desactiva és una línia:

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

Amb ella, el navegador obeeix el teu Content-Type sense discutir. És una de les capçaleres que helmet posa per tu a la lliçó 06-05; convé saber què fa abans de delegar-la.

  1. Enviar el fitxer: createReadStream i pipeline

La versió fàcil, res.end(await fs.readFile(rutaFitxer)), funciona amb estils.css i tomba el servidor amb el cartell de 40 MB. readFile carrega el fitxer sencer a memòria abans d'enviar un sol byte: amb deu clients descarregant alhora són 400 MB de RAM, i no hi ha contrapressió. La manera correcta la coneixes des de la lliçó 03-05:

const { pipeline } = require('node:stream/promises');
const { createReadStream } = require('node:fs');

await pipeline(createReadStream(rutaFitxer), res);

Tres raons perquè sigui sempre així:

readFile + end createReadStream + pipeline
Memòria La mida del fitxer, per cada client Un highWaterMark (64 KB), constant
Contrapressió Cap Automàtica: el stream fa pausa si res se satura
Primer byte Després de llegir el fitxer sencer Gairebé immediat

La contrapressió és el punt que més se subestima. res és un stream d'escriptura i pipeline respecta el seu ritme: si el client és en una connexió lenta, la lectura del disc es pausa sola. I pipeline propaga els errors i destrueix tots dos extrems, que és justament el que el pipe a seques no fa: si el client tanca la pestanya a mitja descàrrega, pipe deixaria el ReadStream obert consumint un descriptor de fitxer, i aquest és el degoteig clàssic que acaba en EMFILE: too many open files després de dies en marxa.

Un matís important: si el client avorta, pipeline rebutja amb ERR_STREAM_PREMATURE_CLOSE. No és una fallada del servidor i no ha d'arribar al gestor d'errors com un 500:

try {
  await pipeline(createReadStream(rutaFitxer), res);
} catch (error) {
  // El client ha tancat la connexio: no es un error nostre, nomes s'anota.
  if (error.code !== 'ERR_STREAM_PREMATURE_CLOSE') throw error;
  console.error(`[estatics] descarrega avortada pel client: ${rutaFitxer}`);
}

  1. stat, Content-Length, índex per defecte i 404

Abans d'obrir el stream convé un stat, que ens dóna tres coses alhora: si existeix, la mida exacta en bytes —que va directa a Content-Length, sense calcular res, i és millor que deixar que Node faci servir chunked perquè el navegador pot mostrar el progrés real— i la data de modificació, que farem servir a l'apartat 6.

A més resol dos casos: el fitxer índex, perquè GET / no demana cap fitxer concret i cal servir l'index.html del directori; i el fitxer inexistent, que es tradueix a 404 aplicant la disciplina de 03-01: intentar i capturar, mai comprovar abans amb exists —entre la comprovació i l'obertura el fitxer pot desaparèixer, i a més és una crida al sistema de més a cada petició.

Amb tot junt, src/servidor/estatics.js:

// src/servidor/estatics.js
// Serveix fitxers de public/ amb ruta blindada, tipus MIME i memoria cau.

const fs = require('node:fs/promises');
const path = require('node:path');
const { createReadStream } = require('node:fs');
const { pipeline } = require('node:stream/promises');
const { ARREL } = require('../config/rutes.js');
const { resoldreDinsDe } = require('../utils/ruta-segura.js');
const { tipusPerFitxer } = require('./tipus-mime.js');

const DIRECTORI_PUBLIC = path.join(ARREL, 'public');

async function servirEstatic(req, res, rutaDemanada) {
  // 1. Descodificar i blindar: llanca RUTA_NO_PERMESA (403) si escapa.
  const relativa = decodeURIComponent(rutaDemanada).replace(/^\/+/, '');
  let rutaFitxer = resoldreDinsDe(DIRECTORI_PUBLIC, relativa || 'index.html');

  // 2. stat, amb index per defecte per als directoris.
  let estat;
  try {
    estat = await fs.stat(rutaFitxer);
    if (estat.isDirectory()) {
      rutaFitxer = path.join(rutaFitxer, 'index.html');
      estat = await fs.stat(rutaFitxer);
    }
  } catch (error) {
    if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
    const fallada = new Error(`No existeix el recurs ${rutaDemanada}`);
    fallada.codi = 'RECURS_NO_TROBAT';   // -> 404 via errors-http.js
    throw fallada;
  }

  // 3. Capcaleres (l'apartat 6 hi afegeix la memoria cau).
  res.statusCode = 200;
  res.setHeader('Content-Type', tipusPerFitxer(rutaFitxer));
  res.setHeader('Content-Length', estat.size);
  res.setHeader('X-Content-Type-Options', 'nosniff');

  if (req.method === 'HEAD') return res.end();   // mateixes capcaleres, sense cos

  await pipeline(createReadStream(rutaFitxer), res);
}

module.exports = { servirEstatic, DIRECTORI_PUBLIC };

Es connecta a l'enrutador com a últim recurs: si cap ruta de l'API no casa, s'intenta servir un estàtic abans de donar 404.

  1. Memòria cau al client: Cache-Control, ETag, Last-Modified i el 304

Recarregar la pàgina torna a descarregar el CSS i el JS encara que no hagin canviat ni un byte. HTTP té dos mecanismes per evitar-ho, i són complementaris.

Cache-Control: max-age=N diu: "durant N segons, ni m'ho preguntis". El navegador serveix el fitxer del seu disc sense cap petició. És el més ràpid possible i també el més perillós: si publiques una versió nova, qui tingui la vella a la memòria cau no se n'assabentarà fins que caduqui.

Recurs Valor raonable Per què
index.html no-cache És la porta d'entrada: s'ha de revalidar sempre
estils.css, app.js max-age=3600 Canvien poc; una hora és un compromís prudent
Fitxers amb hash al nom max-age=31536000, immutable Si canvia el contingut canvia el nom: caduquen sols
Respostes de l'API no-store Dades vives: aforament, entrades lliures

Compte amb el parany dels noms: no-cache no vol dir "no guardis a la memòria cau", sinó "guarda-ho però revalida abans de fer-ho servir". El que prohibeix desar és no-store.

La validació condicional és el segon mecanisme, i és el que produeix el 304. Funciona així: el servidor envia una etiqueta amb la resposta; el navegador la desa i, el cop següent, la torna a enviar preguntant "encara és vàlida?". Si ho és, el servidor respon 304 Not Modified sense cos.

Hi ha dues etiquetes possibles:

  • Last-Modified, una data. El client la torna a If-Modified-Since. La seva resolució és d'un segon, així que dos canvis dins del mateix segon passen desapercebuts.
  • ETag, un identificador opac de la versió. El client el torna a If-None-Match. És el mecanisme preferent: més precís i sense ambigüitats de fus horari.

Un ETag fort seria un hash del contingut, però això obliga a llegir el fitxer sencer a cada petició, justament el que volíem evitar. La solució pràctica —la que fan servir gairebé tots els servidors— és un ETag feble derivat del stat: mida i data de modificació.

// ETag feble (prefix W/): "la mateixa mida i la mateixa data" ja serveix com a
// prova que el contingut no ha canviat, i surt gratis amb el stat.
function calcularEtag(estat) {
  return `W/"${estat.size.toString(16)}-${estat.mtimeMs.toString(16)}"`;
}

function estaEnCacheDelClient(req, etag, estat) {
  const siNoCasa = req.headers['if-none-match'];
  if (siNoCasa) return siNoCasa.split(',').some((valor) => valor.trim() === etag);

  const siNoModificat = req.headers['if-modified-since'];
  // La data HTTP te resolucio de segons: cal truncar la del fitxer.
  if (siNoModificat) return Math.floor(estat.mtimeMs / 1000) * 1000 <= Date.parse(siNoModificat);
  return false;
}

I a servirEstatic, just abans d'escriure el cos:

const etag = calcularEtag(estat);
res.setHeader('ETag', etag);
res.setHeader('Last-Modified', estat.mtime.toUTCString());
res.setHeader('Cache-Control', rutaFitxer.endsWith('index.html') ? 'no-cache' : 'max-age=3600');

if (estaEnCacheDelClient(req, etag, estat)) {
  res.removeHeader('Content-Length');   // un 304 va SENSE cos i SENSE longitud
  return respondreSenseContingut(res, 304);
}

La millora és mesurable en dues ordres:

curl -s -o /dev/null -w '%{http_code}: %{size_download} bytes\n' http://localhost:3000/estils.css
# 200: 1842 bytes
ETIQUETA=$(curl -sI http://localhost:3000/estils.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/estils.css
# 304: 0 bytes

Zero bytes de cos. En una pàgina amb vint recursos, la diferència entre la primera visita i la segona deixa de ser un megabyte per ser un grapat de capçaleres.

  1. Peticions de rang: Range i 206

Un client pot demanar un tros d'un fitxer en comptes del sencer. És el que fa un reproductor de vídeo en saltar al minut 12, i el que fa un gestor de descàrregues en reprendre una descàrrega tallada. Per al cartell d'un esdeveniment no és imprescindible, però convé saber com funciona.

El client ho demana amb la capçalera Range: bytes=0-1023, i el servidor, si accepta, respon 206 Partial Content amb Content-Range i només aquests bytes:

res.setHeader('Accept-Ranges', 'bytes');   // anunciar que ho sabem fer
const rang = req.headers.range?.match(/^bytes=(\d*)-(\d*)$/);

if (rang) {
  const inici = rang[1] === '' ? 0 : Number(rang[1]);
  const fi = rang[2] === '' ? estat.size - 1 : Math.min(Number(rang[2]), estat.size - 1);

  if (inici > fi) {
    res.setHeader('Content-Range', `bytes */${estat.size}`);
    return respondreSenseContingut(res, 416);   // Range Not Satisfiable
  }

  res.statusCode = 206;
  res.setHeader('Content-Range', `bytes ${inici}-${fi}/${estat.size}`);
  res.setHeader('Content-Length', fi - inici + 1);
  // start i end de createReadStream son tots dos INCLUSIUS: d'aqui el -1 i el +1.
  return pipeline(createReadStream(rutaFitxer, { start: inici, end: fi }), res);
}

Els dos errors clàssics: oblidar que end de createReadStream és inclusiu (d'aquí el -1 i el +1), i tornar 200 en comptes de 206, cosa que fa que el client es pensi que li has enviat el fitxer sencer i l'acobli malament.

  1. Compressió negociada amb Accept-Encoding

El CSS i el JS són text i es comprimeixen moltíssim: un CSS de 100 KB baixa a 15-20 KB amb gzip. Però comprimir només és vàlid si el client ho entén, i això es negocia: el client anuncia Accept-Encoding: gzip, deflate, br i el servidor tria i ho declara a Content-Encoding.

const COMPRIMIBLES = new Set(['.html', '.css', '.js', '.json', '.svg', '.txt']);
const acceptaGzip = (req.headers['accept-encoding'] ?? '').includes('gzip');
const valLaPena = COMPRIMIBLES.has(path.extname(rutaFitxer)) && estat.size > 1024;

if (acceptaGzip && valLaPena) {
  res.setHeader('Content-Encoding', 'gzip');
  res.setHeader('Vary', 'Accept-Encoding');

  // La mida comprimida no es coneix per endavant: fora Content-Length.
  // Node passa a Transfer-Encoding: chunked automaticament.
  res.removeHeader('Content-Length');

  return pipeline(createReadStream(rutaFitxer), createGzip(), res);
}

Tres punts on s'equivoca gairebé tothom:

  • Treure Content-Length. Si el deixes, anuncies la mida del fitxer sense comprimir i el client esperarà bytes que no arriben mai, quedant-se penjat fins al temps límit.
  • La capçalera Vary: Accept-Encoding. Sense ella, una memòria cau intermèdia pot desar la versió comprimida i lliurar-la a un client que no accepta gzip, que veurà brossa binària.
  • No comprimir el que ja està comprimit. Un .webp, un .png o un .zip no s'encongeixen; només gastes CPU i, en fitxers petits, la resposta arriba a créixer. D'aquí el llindar d'1 KB i la llista COMPRIMIBLES.

Comprova-ho: curl -s -o /dev/null -w '%{size_download}\n' http://localhost:3000/estils.css torna 1842 bytes, i amb -H 'Accept-Encoding: gzip' baixa a 612.

  1. En producció això es delega

Has escrit un servidor d'estàtics correcte: blindat, amb tipus MIME, streams, memòria cau condicional, rangs i compressió. I en producció, gairebé amb seguretat, no el faràs servir. La raó és econòmica, no tècnica: un proxy invers com Nginx o una CDN serveixen fitxers estàtics amb codi natiu optimitzat durant vint anys, sense ocupar el teu bucle d'esdeveniments, i una CDN a més els col·loca físicament a prop de l'usuari. El teu procés de Node s'hauria de dedicar al que només ell pot fer.

Aspecte Node servint estàtics Proxy invers o CDN
Cost per fitxer Ocupa el bucle d'esdeveniments de la teva API Nul per al teu procés
Latència La del teu servidor La del node més proper a l'usuari
TLS, HTTP/2, compressió Al teu càrrec Inclosos
Quan fer-ho servir Desenvolupament, proves, eines internes Producció amb trànsit real

El que no ha estat temps perdut és entendre els mecanismes: quan configuris expires a Nginx o les regles de memòria cau d'una CDN a la lliçó 11-04, estaràs configurant exactament el que acabes d'implementar a mà.

Errors Comuns i Consells

  • Enganxar url.pathname al directori base amb path.join. És la vulnerabilitat de recorregut de directoris. Sempre resoldreDinsDe, i descodificant abans de validar.
  • Servir amb readFile. Funciona fins al dia del fitxer gran o del pic de trànsit. createReadStream + pipeline, sense excepcions.
  • Fer servir pipe en comptes de pipeline. No propaga errors ni destrueix l'origen: descriptors de fitxer filtrats i, a la llarga, EMFILE.
  • Oblidar charset=utf-8 als tipus de text. Accents trencats que apareixen només en alguns navegadors.
  • Deixar Content-Length en comprimir o en respondre 304. Client penjat en el primer cas, resposta invàlida en el segon.
  • Comprimir imatges, o servir el directori sencer del projecte. Gastes CPU sense estalviar res; i dades/, src/ i .env no són públics.
  • Consell: X-Content-Type-Options: nosniff des del primer dia: una línia que tanca una família sencera d'atacs. I per depurar la memòria cau, curl -I ensenya ETag, Last-Modified i Cache-Control sense descarregar el cos.

Exercicis

Exercici 1: demostrar l'atac i la defensa

Escriu src/laboratori/provar-traversal.js que, amb el servidor arrencat, demani per fetch aquestes rutes i mostri una taula ruta | estat | primers 40 caràcters: /index.html, /estils.css, /../dades/esdeveniments.json, /%2e%2e/dades/esdeveniments.json, /../../etc/hosts i /subdir/../estils.css. Explica per què l'última ha de tornar 200.

Exercici 2: mesurar l'efecte de la memòria cau

Escriu un script de shell que demani estils.css tres vegades: sense capçaleres condicionals, amb l'ETag obtingut de la primera resposta, i amb un If-None-Match inventat. Mostra l'estat i els bytes descarregats de cadascuna i calcula l'estalvi percentual. Després modifica el fitxer (touch public/estils.css) i repeteix-ho: explica què canvia i per què n'hi ha prou de tocar la data.

Exercici 3: Accept-Encoding complet

Amplia la negociació perquè admeti també br (Brotli, amb zlib.createBrotliCompress), preferint-lo per sobre de gzip quan el client accepti tots dos. Comprova amb tres peticions (gzip, br, sense la capçalera) que el Content-Encoding de la resposta és el correcte en cada cas, i compara les tres mides.

Solucions

Solució 1. El resultat esperat és 200 per a /index.html, /estils.css i /subdir/../estils.css, i 403 per a les tres restants.

const rutes = ['/index.html', '/estils.css', '/../dades/esdeveniments.json',
  '/%2e%2e/dades/esdeveniments.json', '/../../etc/hosts', '/subdir/../estils.css'];

for (const ruta of rutes) {
  // No es fa servir new URL: normalitzaria els '..' abans d'enviar-los.
  const resposta = await fetch(`http://localhost:3000${ruta}`);
  const text = (await resposta.text()).slice(0, 40).replace(/\n/g, ' ');
  console.log(`${ruta.padEnd(34)} | ${resposta.status} | ${text}`);
}

/subdir/../estils.css torna 200 i això és correcte: resoldreDinsDe no prohibeix els .., prohibeix sortir de la base. Un cop resolta, aquesta ruta apunta a public/estils.css, que és a dins. Rebutjar tota cadena que contingui .. seria una defensa per llista negra, més fràgil i amb falsos positius: la bona és validar el resultat, no l'entrada.

Solució 2. L'ETag inventat torna 200 amb el cos complet: no casa amb l'actual, així que el servidor assumeix que el client té una versió vella.

ETIQUETA=$(curl -sI http://localhost:3000/estils.css | awk '/[Ee]Tag/{print $2}' | tr -d '\r')
curl -s -o /dev/null -w ' sense capcalera: %{http_code} %{size_download}\n' http://localhost:3000/estils.css
curl -s -o /dev/null -H "If-None-Match: $ETIQUETA" -w '  etag correcte: %{http_code} %{size_download}\n' http://localhost:3000/estils.css
curl -s -o /dev/null -H 'If-None-Match: W/"0-0"' -w '  etag inventat: %{http_code} %{size_download}\n' http://localhost:3000/estils.css

Després del touch, l'ETag canvia encara que el contingut sigui idèntic, perquè la nostra etiqueta es calcula amb mtimeMs i la mida. Aquest és el preu de l'ETag feble: pot invalidar de més (una descàrrega innecessària) però mai de menys (servir contingut caducat), i aquest repartiment de riscos és exactament el que volem.

Solució 3. La negociació es resol mirant què accepta el client i triant per preferència nostra:

const { createGzip, createBrotliCompress } = require('node:zlib');

function triarCompressor(req) {
  const accepta = req.headers['accept-encoding'] ?? '';
  if (accepta.includes('br')) return { nom: 'br', crear: createBrotliCompress };
  if (accepta.includes('gzip')) return { nom: 'gzip', crear: createGzip };
  return null;
}

Brotli comprimeix una mica millor que gzip en text (de l'ordre d'un 15-20% menys en un CSS típic) a canvi de més CPU, per això es prefereix per a fitxers estàtics que a més es guarden a la memòria cau. Sense la capçalera Accept-Encoding, se serveix sense comprimir amb el seu Content-Length original.

Conclusió

Escena Viva ja té cara. El mateix servidor que exposa l'API serveix public/index.html, public/estils.css i public/app.js, i pel camí has tancat el forat més comú de tots: mapejar la URL a disc amb path.join permet que GET /../../dades/esdeveniments.json s'emporti el teu catàleg, i la defensa és resoldreDinsDe de la lliçó 03-03, descodificant abans de validar, amb RUTA_NO_PERMESA traduint-se sola a 403 gràcies a la taula de 04-02. Rebutjar la ruta pel seu resultat i no per contenir .. és el que permet que /subdir/../estils.css continuï funcionant.

Saps declarar el tipus amb una taula MIME pròpia —src/servidor/tipus-mime.js, amb charset=utf-8 en tot el que és textual i application/octet-stream per defecte— i per què X-Content-Type-Options: nosniff no és opcional. Envies amb createReadStream i pipeline, no amb readFile: memòria constant, contrapressió automàtica i destrucció de tots dos extrems si el client avorta, amb ERR_STREAM_PREMATURE_CLOSE tractat com el que és —una desconnexió, no un 500—. I stat et dóna d'un sol cop l'existència, el Content-Length exacte i la data per a l'índex per defecte i el 404.

La memòria cau ja no és cap misteri: Cache-Control amb els seus max-age, no-cache (revalida) i no-store (no ho desis), i la validació condicional amb ETag feble calculat de la mida i el mtime més Last-Modified, responent 304 Not Modified sense cos quan arriben If-None-Match o If-Modified-Since —de 1842 bytes a 0—. Has vist els rangs Range/206 amb el seu end inclusiu, i la compressió negociada per Accept-Encoding amb zlib.createGzip, Content-Encoding, Vary i l'eliminació obligatòria de Content-Length. Tot això sabent que en producció això es delega a un proxy invers o una CDN, i que el que has après serveix per configurar-los.

Fins aquí, Escena Viva només lliura coses: tot són peticions GET. Falta el que converteix un web en una plataforma: rebre. A la lliçó següent, Rebent Dades: Cossos de Petició i JSON, acumularem els trossos de Buffer que arriben per req —i veuràs per què concatenar en una cadena parteix els caràcters multibyte—, posarem un límit de mida obligatori amb el seu 413, analitzarem JSON i formularis, i implementarem POST /comandes, amb la seva validació a mà, la seva crida al domini i la seva resposta 201 amb Location.

Curs de Node.js: De Principiant a Avançat

Mòdul 1: Introducció a Node.js

Mòdul 2: Conceptes Bàsics

Mòdul 3: Sistema de Fitxers i E/S

Mòdul 4: HTTP i Servidors Web

Mòdul 5: NPM i Gestió de Paquets

Mòdul 6: Framework Express.js

Mòdul 7: Bases de Dades i ORMs

Mòdul 8: Autenticació i Autorització

Mòdul 9: Proves i Depuració

Mòdul 10: Temes Avançats

Mòdul 11: Desplegament i DevOps

Mòdul 12: Projectes del Món Real

© Copyright 2026. Tots els drets reservats