Tres horas. Media hora para entender qué hace exactamente un servidor web y qué código de estado corresponde a cada situación, y dos horas y media escribiendo uno con el módulo nativo hasta servir tu propia web y tu propia API.

Hoy · Hoja de ruta

  1. 1. Aprende: Cómo se escribe un servidor con el módulo nativo, cómo se lee una petición y se compone una respuesta, qué estado corresponde a cada caso y cómo se sirve un fichero sin abrir un agujero.
  2. 2. Haz: Levanta tu primer servidor, sirve el catálogo en /api/productos y la web de las unidades anteriores en /.
  3. 3. Comprueba: Cada situación devuelve su código, y no se puede pedir un fichero fuera de la carpeta pública.

Antes de empezar · 5 minutos, sin apuntes

  1. Cuando en la UD4 hacías fetch, ¿qué le llegaba al otro lado?
  2. ¿Qué significan 200, 201, 400, 404 y 500?
  3. ¿Qué pasaría si alguien pidiera a tu servidor /../../.env?

Se explica

25 minutos · conceptos y demostración

Hoy pasas al otro lado. Durante dos unidades has sido el cliente; ahora escribes el programa que recibe la petición y decide qué responder, con qué código y con qué cabeceras.

Diez líneas

import http from "node:http";

const servidor = http.createServer((peticion, respuesta) => {
  respuesta.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
  respuesta.end("Hola desde mi servidor");
});

servidor.listen(3000, () => {
  console.log("Escuchando en http://localhost:3000");
});

Eso es un servidor web. Arráncalo, abre el navegador y ahí está: por primera vez, el otro lado es tuyo.

Lo que ocurre en cada visita
  1. El navegador abre una conexión al puerto 3000
  2. Envía la petición: método, ruta, cabeceras y quizá cuerpo
  3. Node ejecuta tu función
  4. Tu función escribe el estado, las cabeceras y el cuerpo
  5. Se cierra la respuesta y el navegador la pinta
peticion.method;      // "GET", "POST"…
peticion.url;         // "/api/productos?categoria=teclados"
peticion.headers;     // un objeto con las cabeceras

const url = new URL(peticion.url, `http://${peticion.headers.host}`);
url.pathname;                       // "/api/productos"
url.searchParams.get("categoria");  // "teclados"

peticion.url trae la ruta y la cadena de consulta juntas y sin analizar. URL las separa, y searchParams da los parámetros ya decodificados: es la misma clase que existe en el navegador.

respuesta.statusCode = 404;
respuesta.setHeader("Content-Type", "application/json; charset=utf-8");
respuesta.end(JSON.stringify({ error: "No encontrado" }));

Toda respuesta se cierra, y solo una vez

Si tu función termina sin llamar a end, el navegador permanece a la espera hasta agotar el tiempo límite. Si se invoca dos veces —típico de un if sin return— Node lanza un error de cabeceras ya enviadas.

Escribe siempre return después de responder. Es la costumbre que te ahorrará las dos caras del mismo problema.

Si al arrancar ves EADDRINUSE, el puerto está ocupado: casi siempre por otro servidor tuyo que se quedó corriendo. El puerto, como toda la configuración, sale del entorno:

const PUERTO = Number(process.env.PUERTO ?? 3000);

Enrutar a mano

const servidor = http.createServer(async (peticion, respuesta) => {
  const url = new URL(peticion.url, `http://${peticion.headers.host}`);
  const ruta = url.pathname;

  // …

if (peticion.method === "GET" && ruta === "/api/productos") {
  return responderJson(respuesta, 200, await listar());
}

if (peticion.method === "GET" && ruta.startsWith("/api/productos/")) {
  const id = Number(ruta.split("/").pop());
  const producto = await obtener(id);
  if (!producto) return responderJson(respuesta, 404, { error: "No encontrado" });
  return responderJson(respuesta, 200, producto);
}

Se ve venir el problema: con quince rutas esto es una escalera de condicionales, y cada ruta con parámetro exige partir el texto a mano. Guárdalo en la memoria para la sesión 5.

El cuerpo, por su parte, no llega como una propiedad legible:

async function leerCuerpo(peticion) {
  const trozos = [];
  for await (const trozo of peticion) trozos.push(trozo);
  const texto = Buffer.concat(trozos).toString("utf8");
  return texto === "" ? null : JSON.parse(texto);
}

El cuerpo llega a trozos

El cuerpo no es una propiedad legible de una sola vez, sino un flujo que llega por fragmentos. Por eso hay que acumular los trozos y solo entonces convertirlos a texto y analizarlos.

Y ese JSON.parse es un dato de fuera: un cuerpo mal formado lanza una excepción que, sin capturar, tumba la petición con un 500 cuando en realidad el fallo es del cliente y merece un 400.

Los códigos que vas a usar

Código Cuándo
200 Todo bien, aquí está
201 Creado; con la cabecera Location
204 Todo bien, no hay nada que devolver
400 La petición está mal formada o los datos no son válidos
401 / 403 No autenticado / autenticado pero sin permiso
404 El recurso no existe
405 El método no está permitido en esta ruta
409 Conflicto: ya existe algo así
500 Se ha roto algo en el servidor

La familia del código dice de quién es el problema

Los 4xx significan «lo has pedido mal»; los 5xx, «se me ha roto a mí». Devolver 200 con un cuerpo que dice «error» rompe esa convención, y cualquier cliente automático —incluido tu propio fetch de la UD4, que mira respuesta.ok— se lo creerá.

Un 500 en tus registros es una tarea pendiente para ti. Un 400 es información para quien llama.

Que todas las respuestas de error tengan la misma forma permite al cliente escribir un solo tratamiento. Es un contrato, y romperlo a mitad de una API es una fuente inagotable de fallos en el cliente.

{ "error": "Producto no encontrado", "detalles": [] }

Servir ficheros del disco

const PUBLICO = path.join(aqui, "..", "publico");

const TIPOS = {
  ".html": "text/html; charset=utf-8",
  ".css": "text/css; charset=utf-8",
  ".js": "text/javascript; charset=utf-8",
  ".json": "application/json; charset=utf-8",
  ".webp": "image/webp",
  ".svg": "image/svg+xml"
};

async function servirEstatico(ruta, respuesta) {
  const relativa = ruta === "/" ? "/index.html" : ruta;
  const destino = path.join(PUBLICO, relativa);

  if (!destino.startsWith(PUBLICO)) {
    return responderJson(respuesta, 403, { error: "Prohibido" });
  }

  try {
    const contenido = await readFile(destino);
    respuesta.writeHead(200, { "Content-Type": TIPOS[path.extname(destino)] ?? "application/octet-stream" });
    respuesta.end(contenido);
  } catch (error) {
    if (error.code === "ENOENT") return responderJson(respuesta, 404, { error: "No encontrado" });
    throw error;
  }
}

Un servidor de ficheros sin comprobar la ruta es un agujero

Si compones la ruta con lo que pide el cliente y no compruebas nada, una petición con tramos .. sale de tu carpeta pública y llega a cualquier fichero que el proceso pueda leer: tu .env, tu código, ficheros del sistema.

Se llama path traversal y es una de las vulnerabilidades más antiguas que existen. La defensa es la de arriba: resolver la ruta completa y comprobar que sigue estando dentro de la carpeta permitida. Después de resolver, nunca antes.

El tipo de contenido tampoco es un detalle. Si sirves un CSS sin su cabecera, el navegador recibe texto y no aplica los estilos, sin decir nada claro. Con un módulo de JavaScript es peor: se niega a ejecutarlo por el tipo incorrecto. Es un fallo que parece de tu código y es de tus cabeceras.

Con una sola línea conviven, por último, la web y la API:

if (ruta.startsWith("/api/")) return atenderApi(peticion, respuesta, url);
return servirEstatico(ruta, respuesta);

Al proceder todo del mismo origen, desaparece el problema de CORS que viste en la UD4.

Se trabaja

150 minutos · tu propio servidor

Al final de la tarde tendrás tu web y tu API servidas por un programa que has escrito entero. Los dos últimos pasos comprueban que responde lo que debe en cada caso, que es lo que separa un servidor de una demostración.

Paso 1 · Tu primer servidor · 30 min

  1. Escribe src/servidor.js que responda texto en cualquier ruta.
  2. Registra por consola método, ruta y momento de cada petición.
  3. Responde de forma distinta a / y a /hola.
  4. Lee un parámetro de la cadena de consulta y devuélvelo.
  5. Comprueba todo con el navegador y con tu cliente HTTP.
  6. Provoca a propósito una respuesta sin cerrar y observa qué hace el navegador. Después provoca una que se cierre dos veces y lee el error.

Antes de continuar: el puerto sale de una variable de entorno con valor por defecto, no de un número escrito en el código.

Paso 2 · La API a mano · 50 min

Es el trabajo central de la sesión.

  1. Implementa GET /api/productos con filtro por categoría en la consulta.
  2. Implementa GET /api/productos/:id con su 404.
  3. Implementa POST /api/productos con validación, 201 y cabecera Location.
  4. Devuelve 400 con la lista de errores cuando la validación falle.
  5. Responde 405 si el método no está soportado en una ruta que sí existe. Fíjate en la diferencia con el 404: la ruta existe, el método no.
  6. Escribe una función responderJson(respuesta, estado, datos) y úsala en todas partes, para que el formato de error sea el mismo siempre.

Paso 3 · Tu web, servida por ti · 40 min

  1. Copia el sitio de la UD4 a publico/.
  2. Sírvelo desde tu servidor, con index.html por defecto.
  3. Resuelve el tipo de contenido por extensión.
  4. Protege contra rutas que salgan de la carpeta pública.
  5. Devuelve 404 con una página propia cuando el fichero no exista.
  6. Comprueba que el fetch de la UD4 funciona ahora contra tu propia API, y que el error de CORS ha desaparecido.

Es el momento en que las cuatro unidades anteriores se juntan: tu HTML, tu CSS, tu JavaScript de navegador y tu servidor, todo funcionando desde un solo proceso que has escrito tú.

Paso 4 · Cada caso, su código · 15 min

Un servidor que devuelve 200 para todo no es un servidor, es un eco. Comprueba los nueve casos con tu cliente HTTP:

Petición Código esperado Código real Cuerpo
GET /api/productos 200
GET /api/productos/1 200
GET /api/productos/9999 404
GET /api/productos/abc
POST /api/productos válido 201
POST /api/productos sin nombre 400
POST con cuerpo JSON roto 400
DELETE /api/productos 405
GET /api/inventado 404

La cuarta fila no tiene código escrito a propósito: decídelo tú y justifícalo. Un identificador que no es un número, ¿es una petición mal formada o un recurso que no existe? Las dos respuestas son defendibles.

Paso 5 · El fichero de peticiones · 15 min

Escribe peticiones.http con los nueve casos del paso anterior, ejecutables desde el editor.

  1. Una petición por caso, con su comentario encima diciendo qué comprueba.
  2. Incluye los cuerpos JSON de los POST, el válido y los dos inválidos.
  3. Comprueba que se pueden ejecutar todas seguidas sin dejar el almacén en un estado raro.
  4. Súbelo al repositorio: es la documentación ejecutable de tu API, y el punto de partida de las pruebas automáticas del módulo de servidor.

Ampliación si has completado el trabajo

Primero termina y comprueba los cinco pasos. El primer reto es de seguridad y se hace contra tu propio servidor, en tu máquina.

Reto 1 · Demuestra el agujero que acabas de tapar

Una defensa que no se ha visto fallar no se entiende. Haz una copia de tu servidor y quítale la comprobación de la ruta.

  1. Contra esa copia, construye una petición que salga de la carpeta publico/ y consiga leer un fichero que no debería servirse. Empieza por tu propio package.json.
  2. Consigue leer el .env. Ese es el momento en el que la vulnerabilidad deja de ser teórica: acabas de exponer tus claves.
  3. Anota qué tuviste que escribir exactamente en la URL, y por qué el navegador quizá te lo normaliza. Pruébalo también con tu cliente HTTP, que no normaliza.
  4. Vuelve a poner la comprobación y repite los tres intentos. Comprueba que ahora devuelven 403.
  5. Busca si tu comprobación tiene algún hueco: prueba con la ruta codificada en porcentajes, con barras invertidas y con una ruta absoluta. Si alguno pasa, arréglalo.
  6. Escribe en tres líneas por qué la comprobación va después de resolver la ruta y no sobre el texto que llega del cliente. Es el error que convierte una defensa en un adorno.

Reto 2 · Lo que el cliente hace mal no es un 500

Tu servidor ya distingue 400 de 500. Ponlo a prueba con peticiones hostiles, que es como llegan las reales.

  1. Envía un POST con el cuerpo {esto no es json. Sin captura, tu servidor devuelve 500; consigue que devuelva 400 con un mensaje útil.
  2. Envía un cuerpo JSON válido pero que no es un objeto: un número, una cadena, un array. Decide qué responder a cada uno.
  3. Envía un objeto con campos de más que no esperabas. Decide si los ignoras o los rechazas, y justifica la decisión: las dos son defendibles y tienen consecuencias distintas.
  4. Envía un Content-Type que no sea JSON con un cuerpo que sí lo es. Averigua qué código existe para eso.
  5. Envía un cuerpo enorme, de varios megabytes. Anota qué hace tu servidor. Un servidor real pone un límite; propón dónde lo pondrías tú.
  6. Como segunda parte, añade paginación a GET /api/productos con parámetros de consulta, y trata los casos raros: página cero, página negativa, tamaño gigante, tamaño no numérico. Cada uno tiene una respuesta razonable, y ninguna es un 500.
Objetivo mínimoServidor levantado con tres rutas de API, sus códigos correctos, la web servida con tipos resueltos y las rutas protegidas.
Si lo tienesLa tabla de los nueve casos contestada y el peticiones.http ejecutable en el repositorio.
RetoEl path traversal demostrado y tapado con sus variantes, o las peticiones hostiles respondidas con 4xx y la paginación con sus casos raros.

Cierre

5 minutos · comprobación y recuerdo

Lista de verificación de la sesión

  • Tienes un servidor propio que sirve web y API desde un solo proceso.
  • Lees método, ruta, parámetros y cuerpo.
  • Cierras siempre la respuesta, y solo una vez.
  • Cada situación devuelve su código de estado, y todos los errores tienen la misma forma.
  • Los ficheros se sirven con su tipo correcto.
  • No se puede salir de la carpeta pública.

Antes de cerrar · 3 minutos, sin mirar

  1. ¿Qué tres partes escribes en una respuesta?
  2. ¿Qué pasa si no cierras la respuesta? ¿Y si la cierras dos veces?
  3. ¿Qué diferencia hay entre un 400 y un 500?
  4. ¿Qué se devuelve al crear un recurso?
  5. ¿Por qué el cuerpo se lee por trozos?
  6. ¿Qué comprobación evita el path traversal, y en qué momento se hace?
Ver respuestas

1 · El código de estado, las cabeceras y el cuerpo.

2 · Sin cerrarla, el cliente espera hasta agotar el tiempo; cerrándola dos veces, Node lanza un error de cabeceras ya enviadas.

3 · El 400 dice que la petición estaba mal; el 500, que ha fallado el servidor.

4 · Un 201 con la cabecera Location apuntando al recurso creado.

5 · Porque llega como un flujo, no como un valor ya disponible.

6 · Comprobar que la ruta ya resuelta sigue dentro de la carpeta pública, después de resolverla y nunca sobre el texto recibido.

Microprueba semanal 4 · 5–10 minutos

Individual, sin IA y sin apuntes.

  1. Nombra las tres cosas que se escriben en una respuesta HTTP, con un ejemplo de cada una.
  2. Di qué código de estado devolverías al crear, al no encontrar, ante datos inválidos y ante un fallo interno.
  3. ¿Qué comprobación evita que alguien pida un fichero fuera de la carpeta pública?