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. 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. Haz: Levanta tu primer servidor, sirve el catálogo en
/api/productosy la web de las unidades anteriores en/. - 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
- Cuando en la UD4 hacías
fetch, ¿qué le llegaba al otro lado? - ¿Qué significan 200, 201, 400, 404 y 500?
- ¿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.
- El navegador abre una conexión al puerto 3000
- Envía la petición: método, ruta, cabeceras y quizá cuerpo
- Node ejecuta tu función
- Tu función escribe el estado, las cabeceras y el cuerpo
- 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
- Escribe
src/servidor.jsque responda texto en cualquier ruta. - Registra por consola método, ruta y momento de cada petición.
- Responde de forma distinta a
/y a/hola. - Lee un parámetro de la cadena de consulta y devuélvelo.
- Comprueba todo con el navegador y con tu cliente HTTP.
- 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.
- Implementa
GET /api/productoscon filtro por categoría en la consulta. - Implementa
GET /api/productos/:idcon su 404. - Implementa
POST /api/productoscon validación, 201 y cabeceraLocation. - Devuelve 400 con la lista de errores cuando la validación falle.
- 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.
- 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
- Copia el sitio de la UD4 a
publico/. - Sírvelo desde tu servidor, con
index.htmlpor defecto. - Resuelve el tipo de contenido por extensión.
- Protege contra rutas que salgan de la carpeta pública.
- Devuelve 404 con una página propia cuando el fichero no exista.
- Comprueba que el
fetchde 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.
- Una petición por caso, con su comentario encima diciendo qué comprueba.
- Incluye los cuerpos JSON de los
POST, el válido y los dos inválidos. - Comprueba que se pueden ejecutar todas seguidas sin dejar el almacén en un estado raro.
- 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.
- 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 propiopackage.json. - Consigue leer el
.env. Ese es el momento en el que la vulnerabilidad deja de ser teórica: acabas de exponer tus claves. - 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.
- Vuelve a poner la comprobación y repite los tres intentos. Comprueba que ahora devuelven 403.
- 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.
- 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.
- Envía un
POSTcon el cuerpo{esto no es json. Sin captura, tu servidor devuelve 500; consigue que devuelva 400 con un mensaje útil. - 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.
- 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.
- Envía un
Content-Typeque no sea JSON con un cuerpo que sí lo es. Averigua qué código existe para eso. - 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ú.
- Como segunda parte, añade paginación a
GET /api/productoscon 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.
peticiones.http ejecutable en el repositorio.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
- ¿Qué tres partes escribes en una respuesta?
- ¿Qué pasa si no cierras la respuesta? ¿Y si la cierras dos veces?
- ¿Qué diferencia hay entre un 400 y un 500?
- ¿Qué se devuelve al crear un recurso?
- ¿Por qué el cuerpo se lee por trozos?
- ¿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.
- Nombra las tres cosas que se escriben en una respuesta HTTP, con un ejemplo de cada una.
- Di qué código de estado devolverías al crear, al no encontrar, ante datos inválidos y ante un fallo interno.
- ¿Qué comprobación evita que alguien pida un fichero fuera de la carpeta pública?