Servidor HTTP desde cero sobre TCP en Rust: sin frameworks y sin dependencias
Inicia sesión para descargarCrear un servidor HTTP desde cero en Rust usando TcpListener y TcpStream
Contenido del tutorial ⌄
- 1. Qué vamos a construir
- 2. Requisitos antes de empezar
- 3. Crear el proyecto con Cargo
- 4. Revisar Cargo.toml: un proyecto sin dependencias
- 5. HTTP como texto
- 7. Leer una petición HTTP
- 8. Responder HTTP manualmente
- 9. Rutas y archivos externos
- 10. Errores 404 y 500
- 11. Concurrencia básica con hilos
- 12. Código final de src/main.rs
- 13. Archivos HTML finales
🦀 Rust · TCP · HTTP desde cero · Sin frameworks
En este tutorial vamos a construir un servidor HTTP básico usando Rust y únicamente la librería estándar. No usaremos Axum, Actix, Rocket ni dependencias externas. La idea es mirar debajo de la capa de los frameworks y entender qué ocurre cuando un navegador se conecta a un servidor, envía una petición HTTP y recibe una respuesta.
Un servidor web no empieza siendo un framework. Antes de que existan routers avanzados, middlewares, extractores, respuestas JSON o motores de plantillas, hay una idea mucho más simple: un programa abre un puerto, espera conexiones TCP, lee bytes y responde texto con un formato que el navegador entiende.
Esa es la idea central de este tutorial. Vamos a construir el camino completo desde abajo: primero una conexión TCP, luego una petición HTTP real enviada por el navegador, después una respuesta escrita manualmente, más adelante rutas simples, archivos HTML externos, manejo de errores y, finalmente, concurrencia básica usando hilos.
HTTP no es magia. HTTP es texto con formato viajando sobre una conexión TCP.
Al final tendrás un servidor local ejecutándose en 127.0.0.1:7878.
Ese servidor podrá aceptar conexiones, leer peticiones reales del navegador, detectar
rutas como /, /saludo, /contacto y
/lento, cargar archivos HTML desde la carpeta public,
devolver 404 NOT FOUND cuando una ruta no exista y responder
500 INTERNAL SERVER ERROR si ocurre un problema leyendo un archivo.
Objetivo práctico del tutorial
Al terminar, podrás ejecutar cargo run, abrir
http://127.0.0.1:7878/ en el navegador y comprobar que tu propio
programa en Rust está actuando como servidor HTTP. No como una caja negra,
sino como código que tú puedes leer, modificar y entender.
Este tutorial es educativo. No pretende reemplazar un servidor web profesional ni competir con frameworks reales. Su valor está en otra parte: después de construirlo, herramientas como Axum, Actix, Rocket, Nginx o cualquier framework backend dejan de parecer magia, porque ya habrás visto las piezas fundamentales funcionando una por una.
1. Qué vamos a construir
Antes de escribir el código final, conviene mirar el resultado que vamos a construir. La meta no es crear un framework web completo ni imitar toda la complejidad de un servidor profesional. La meta es mucho más concreta: construir un servidor pequeño, ejecutable y fácil de entender que muestre las piezas mínimas de HTTP funcionando sobre TCP.
Nuestro servidor escuchará conexiones en 127.0.0.1:7878. Cuando el navegador visite una dirección, Rust recibirá una conexión TCP, leerá la petición HTTP como texto, extraerá la primera línea y decidirá qué archivo HTML debe devolver.
El servidor tendrá cinco rutas principales. Algunas responderán páginas normales, una servirá para demostrar concurrencia y otra nos permitirá comprobar qué ocurre cuando el navegador pide una ruta que no existe.
/
/saludo
/contacto
/lento
/no-existe
Cada una de esas rutas tendrá una responsabilidad sencilla. La ruta / mostrará la página principal, /saludo devolverá una página de saludo, /contacto cargará una página informativa y /lento simulará una petición que tarda varios segundos.
La ruta /no-existe no estará registrada como página normal. La usaremos para demostrar una respuesta 404 NOT FOUND. Esto es importante porque un servidor no solo debe responder cuando todo sale bien; también debe responder de forma clara cuando el cliente pide algo que no existe.
Además de las rutas, el tutorial también mostrará tres tipos de respuesta HTTP que son muy útiles para entender cómo se comunica un servidor con el navegador.
200 OK
404 NOT FOUND
500 INTERNAL SERVER ERROR
Resultado esperado
Al terminar esta construcción, podrás abrir el navegador, visitar las rutas del servidor, ver respuestas HTML reales, provocar un 404 NOT FOUND, simular un error interno 500 INTERNAL SERVER ERROR y comprobar que una ruta lenta no bloquea completamente a las demás gracias al uso de hilos.
Todo esto se hará sin dependencias externas. No vamos a usar Axum, Actix, Rocket ni ningún framework. Cada parte saldrá de la librería estándar de Rust: TcpListener, TcpStream, lectura de archivos con std::fs, escritura en la conexión y creación de hilos con std::thread::spawn.
Esta restricción es justamente lo que hace valioso el tutorial. Al quitar las capas cómodas del framework, queda visible la mecánica real: aceptar una conexión, leer bytes, interpretar una petición, decidir una ruta, construir una respuesta y enviarla de vuelta al navegador.
2. Requisitos antes de empezar
Antes de construir el servidor, necesitamos preparar un entorno mínimo. La buena noticia es que este tutorial no requiere bases de datos, Docker, frameworks, servidores externos ni dependencias adicionales. Solo necesitamos Rust, Cargo, una terminal, un navegador y un editor de código.
También es importante aclarar algo desde el principio: todo el servidor se construirá usando la librería estándar de Rust. Eso significa que no vamos a instalar Axum, Actix, Rocket ni ninguna dependencia externa. La intención es ver la mecánica base: TCP, lectura de bytes, texto HTTP y respuestas construidas manualmente.
- Rust instalado: necesario para compilar y ejecutar el proyecto.
- Cargo disponible: es la herramienta que crea, compila y ejecuta proyectos Rust.
- PowerShell, CMD o terminal: sirve para ejecutar los comandos del tutorial.
- Un navegador: Chrome, Edge, Firefox o cualquier navegador moderno.
- Un editor de código: Visual Studio Code, Zed, IntelliJ, Vim o el que prefieras.
- Windows, Linux o macOS: el ejemplo usa una dirección local, por lo que funciona en cualquier sistema con Rust instalado.
Verificación rápida
Si tu terminal reconoce cargo, ya tienes la parte más importante lista. En Windows puedes comprobarlo desde PowerShell ejecutando estos comandos.
cargo --version
rustc --version
Si ambos comandos muestran una versión, puedes continuar. Por ejemplo, podrías ver una salida parecida a cargo 1.x.x y rustc 1.x.x. No necesitas memorizar esos números; lo importante es que la terminal no diga que cargo o rustc no existen.
Durante el tutorial usaremos principalmente dos comandos. El primero sirve para revisar que el proyecto compila sin generar un ejecutable final optimizado. El segundo compila y ejecuta el servidor.
cargo check
cargo run
cargo aparece un mensaje como
cargo no se reconoce como nombre de un cmdlet, significa que Rust no está instalado
o que Cargo no quedó agregado al PATH. En ese caso conviene cerrar y abrir de nuevo
PowerShell después de instalar Rust, o revisar la instalación antes de continuar.
Para probar el servidor usaremos la dirección local 127.0.0.1:7878. Esa dirección apunta a tu propio computador. No estás publicando nada en internet todavía; simplemente estás levantando un programa local que el navegador puede visitar.
Cuando el servidor esté encendido, podrás abrir esta dirección en el navegador:
http://127.0.0.1:7878/
Con eso ya tenemos el terreno listo. A partir del siguiente bloque vamos a crear el proyecto con Cargo y a empezar desde lo más básico: un programa Rust que luego se convertirá paso a paso en un servidor HTTP real.
3. Crear el proyecto con Cargo
Ahora sí vamos a crear el proyecto. En Rust, lo normal es usar Cargo,
que es la herramienta encargada de crear la estructura inicial, compilar el código,
ejecutar el programa y administrar dependencias cuando el proyecto las necesita.
En este tutorial usaremos Cargo para generar una carpeta llamada
servidor_http. Esa carpeta contendrá el archivo Cargo.toml
y una carpeta src con el archivo principal main.rs.
Abre PowerShell, CMD o tu terminal favorita, ubícate en la carpeta donde quieras guardar el proyecto y ejecuta estos comandos:
cargo new servidor_http
cd servidor_http
cargo run
El primer comando crea el proyecto. El segundo entra a la carpeta recién creada. El tercero compila y ejecuta el programa inicial que Cargo genera automáticamente.
Si todo salió bien, deberías ver una salida parecida a esta:
Compiling servidor_http v0.1.0
Finished dev [unoptimized + debuginfo] target(s) in 1.23s
Running `target\debug\servidor_http.exe`
Hello, world!
Esa salida todavía no tiene nada que ver con HTTP. Es simplemente la prueba de que el proyecto Rust fue creado correctamente, que Cargo puede compilarlo y que el programa inicial se ejecuta sin errores.
La estructura inicial del proyecto debe verse más o menos así:
servidor_http/
├── Cargo.toml
└── src/
└── main.rs
Punto de partida confirmado
Si ves Hello, world!, ya tienes un proyecto Rust funcional.
A partir de aquí vamos a reemplazar ese programa inicial por nuestro propio
servidor HTTP construido paso a paso.
En el siguiente bloque revisaremos el archivo Cargo.toml. Ese archivo es
importante porque declara el nombre del proyecto, la versión, la edición de Rust y las
dependencias. En nuestro caso, la parte más interesante será justamente que no agregaremos
ninguna dependencia externa.
4. Revisar Cargo.toml: un proyecto sin dependencias
Antes de empezar a modificar src/main.rs, revisemos el archivo
Cargo.toml. Este archivo describe el proyecto Rust: su nombre,
su versión, la edición del lenguaje que usará y las dependencias externas
que necesita.
En este tutorial la parte más importante está al final del archivo:
la sección [dependencies] quedará vacía. Eso significa que no vamos
a usar frameworks, crates externas ni librerías adicionales. Todo el servidor
saldrá de la librería estándar de Rust.
Archivo del proyecto
Cargo.toml
Este archivo debe quedar en la raíz del proyecto, al mismo nivel que la carpeta
src.
[package]
name = "servidor_http"
version = "0.1.0"
edition = "2021"
# Sin dependencias: todo se hace con la librería estándar de Rust.
# Esa es la promesa de este tutorial: un servidor HTTP desde cero.
[dependencies]
La sección [package] contiene la información básica del proyecto.
El campo name define el nombre del paquete, version
indica la versión del proyecto y edition define la edición de Rust
que usará el compilador.
Para este tutorial usaremos edition = "2021". Es una elección estable,
compatible y suficiente para construir el servidor HTTP que queremos. Si al crear
el proyecto Cargo genera otra edición más reciente, puedes dejarla si tu instalación
la soporta, pero para seguir exactamente este tutorial conviene usar la edición
2021.
La sección [dependencies] queda vacía a propósito. En un proyecto web
normal aquí podrías encontrar crates como axum, actix-web,
rocket, tokio o serde. En este caso no las
necesitamos porque queremos construir la base manualmente.
[dependencies]
Idea clave de esta sección
Si [dependencies] está vacío, el proyecto no depende de ningún
framework externo. Eso nos obliga a trabajar con las piezas fundamentales:
TcpListener, TcpStream, lectura de bytes, escritura
de respuestas HTTP y manejo básico de archivos.
Esta decisión hace que el tutorial sea más transparente. En lugar de pedirle a un framework que reciba la petición, resuelva la ruta y construya la respuesta, vamos a escribir nosotros esa lógica para entender qué ocurre por debajo.
Con el proyecto creado y el archivo Cargo.toml listo, ya podemos pasar
a la idea central del tutorial: entender que HTTP no es un objeto mágico que aparece
en Rust, sino texto con formato que viaja sobre una conexión TCP.
5. HTTP como texto
Antes de abrir un servidor TCP en Rust, necesitamos entender la idea más importante del tutorial: HTTP es texto con formato. El navegador no le envía al servidor un objeto mágico llamado “petición”. Lo que viaja por la conexión son bytes que, en este caso, podemos interpretar como texto.
Cuando escribes una dirección en el navegador, por ejemplo
http://127.0.0.1:7878/, el navegador abre una conexión TCP contra esa
dirección y envía una petición HTTP. Una versión simplificada de esa petición se ve así:
GET / HTTP/1.1
Host: 127.0.0.1:7878
Accept: text/html
La primera línea es la línea inicial de la petición. En este ejemplo,
GET es el método, / es la ruta solicitada y
HTTP/1.1 es la versión del protocolo. Esa línea será una de las piezas
más importantes de nuestro servidor, porque la usaremos para decidir qué página debe
responder Rust.
Después aparecen las cabeceras. Una cabecera es una línea con información adicional.
Por ejemplo, Host indica a qué servidor quiere conectarse el navegador,
y Accept puede indicar qué tipo de contenido espera recibir.
Primera idea clave
Para este servidor educativo no necesitamos entender todas las cabeceras del navegador. Nos concentraremos en leer la petición, extraer la primera línea y responder algo que el navegador pueda interpretar.
Ahora miremos el otro lado de la comunicación. Si el navegador envía una petición, el servidor debe devolver una respuesta HTTP. Una respuesta mínima puede verse así:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 13
Connection: close
<h1>Hola</h1>
La primera línea de la respuesta es la línea de estado. En el ejemplo anterior,
HTTP/1.1 200 OK significa que el servidor entendió la petición y pudo
responder correctamente.
Luego vienen las cabeceras de respuesta. Content-Type le dice al navegador
qué tipo de contenido está recibiendo. En este tutorial responderemos HTML, por eso
usaremos text/html; charset=utf-8. El charset=utf-8 ayuda
a que caracteres como tildes, eñes y símbolos se interpreten correctamente.
La cabecera Content-Length indica cuántos bytes tiene el cuerpo de la
respuesta. Esto es importante porque HTTP no cuenta “letras bonitas” sino bytes.
En Rust usaremos cuerpo.as_bytes().len() para calcular esa longitud.
Después de las cabeceras aparece una línea en blanco. Esa línea separa las cabeceras del cuerpo. Todo lo que viene después de esa separación es el contenido que el navegador debe mostrar o procesar.
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 13
Connection: close
<h1>Hola</h1>
Aunque visualmente vemos saltos de línea normales, HTTP usa una secuencia especial
para terminar cada línea: \r\n. Además, para separar las cabeceras del
cuerpo se usa una línea vacía, es decir: \r\n\r\n.
En Rust construiremos respuestas HTTP manualmente usando cadenas de texto. Por eso verás código con esta forma:
let respuesta = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: 13\r\nConnection: close\r\n\r\n<h1>Hola</h1>";
Entonces, antes de escribir el servidor, ya tenemos la idea base: el navegador enviará texto con formato HTTP, Rust leerá ese texto desde una conexión TCP y luego responderá con otro texto que también sigue el formato HTTP.
El servidor que construiremos no empieza con rutas, controladores ni frameworks. Empieza leyendo bytes y respondiendo texto.
Con esta base conceptual clara, ahora podemos pasar al primer paso real del servidor:
abrir un puerto TCP con TcpListener y esperar conexiones del navegador.
7. Leer una petición HTTP
Después de aceptar una conexión TCP, el siguiente paso es leer lo que el navegador envía por esa conexión. Esta es una de las partes más importantes del tutorial, porque aquí se ve con claridad que HTTP no llega a Rust como un objeto especial, sino como una secuencia de bytes.
En un framework web normalmente recibes algo como una petición ya procesada:
método, ruta, cabeceras, cuerpo, parámetros y muchas comodidades más. Pero en este
servidor educativo estamos trabajando debajo de esa capa. Lo que tenemos realmente
es un TcpStream, y desde ahí debemos leer datos manualmente.
El navegador no le entrega a Rust un objeto HTTP bonito. Le entrega bytes. Nosotros decidimos interpretarlos como texto.
let mut buffer = [0; 2048];
let bytes_leidos = stream.read(&mut buffer)?;
La variable buffer es un arreglo de bytes. En este caso reservamos
espacio para leer hasta 2048 bytes desde la conexión. Para este tutorial
es suficiente, porque las peticiones que enviaremos desde el navegador serán pequeñas.
La llamada stream.read(&mut buffer) intenta leer datos desde la conexión TCP.
Si el navegador envió una petición HTTP, esos bytes quedan guardados dentro del
buffer. El valor bytes_leidos nos dice cuántos bytes llegaron
realmente.
Idea clave
TCP no entrega texto listo para usar. TCP entrega bytes. Si queremos ver una petición HTTP, debemos tomar esos bytes y convertirlos a una representación de texto.
Para convertir esos bytes a texto usaremos String::from_utf8_lossy.
Esta función toma una porción de bytes y devuelve una cadena que podemos imprimir,
analizar y recorrer línea por línea.
let peticion = String::from_utf8_lossy(&buffer[..bytes_leidos]);
Observa que no convertimos todo el buffer, sino solamente esta parte:
&buffer[..bytes_leidos]. Eso significa: “usa únicamente los bytes que
realmente llegaron desde la conexión”.
Si convirtiéramos todo el arreglo, también estaríamos incluyendo espacios vacíos que no pertenecen a la petición real. Por eso el número de bytes leídos es tan importante.
Una vez convertida la petición a texto, podemos imprimirla en consola. Al abrir
http://127.0.0.1:7878/ desde el navegador, podríamos ver algo parecido
a esto:
GET / HTTP/1.1
Host: 127.0.0.1:7878
Connection: keep-alive
sec-ch-ua: "Chromium";v="124"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Accept-Language: es-ES,es;q=0.9
Esa salida puede variar según el navegador, el sistema operativo y las extensiones instaladas. No importa si en tu caso aparecen más o menos cabeceras. Lo importante es reconocer la primera línea:
GET / HTTP/1.1
Esa línea contiene las tres piezas mínimas que necesitamos para nuestro router:
el método GET, la ruta / y la versión HTTP/1.1.
Más adelante usaremos esa línea para decidir si debemos responder
public/index.html, public/saludo.html,
public/contacto.html, public/lento.html o
public/404.html.
En el código final del proyecto, esta lectura quedará encapsulada en una función
llamada leer_peticion_http. Esa función recibe un TcpStream,
lee bytes desde la conexión, los convierte a texto y devuelve un String
con la petición HTTP recibida.
fn leer_peticion_http(stream: &mut TcpStream) -> Result<String, String> {
let mut buffer = [0; TAMANO_BUFFER];
match stream.read(&mut buffer) {
Ok(0) => Err("la conexión se cerró sin enviar datos".to_string()),
Ok(bytes_leidos) => {
println!("Bytes leídos desde TCP: {}", bytes_leidos);
let peticion = String::from_utf8_lossy(&buffer[..bytes_leidos]).to_string();
println!("\n== Petición HTTP recibida ==");
println!("{}", hacer_visible(&peticion));
println!("== Fin de la petición recibida ==\n");
Ok(peticion)
}
Err(error) => Err(format!("error al leer desde la conexión TCP: {}", error)),
}
}
La función devuelve un Result<String, String> porque leer desde una
conexión puede fallar. Por ejemplo, el cliente puede cerrar la conexión antes de enviar
datos, puede ocurrir un error de red o puede llegar una petición vacía.
El caso Ok(0) significa que la conexión se cerró sin enviar datos.
El caso Ok(bytes_leidos) significa que sí llegaron bytes desde TCP.
El caso Err(error) representa un error al intentar leer desde la conexión.
Para este tutorial, la lectura básica es suficiente porque queremos entender el flujo fundamental: el navegador envía bytes, Rust los lee, los interpreta como texto HTTP y luego usa esa información para construir una respuesta.
fn obtener_linea_inicial(peticion: &str) -> &str {
match peticion.lines().next() {
Some(linea) => linea,
None => "",
}
}
Después de leer la petición, solo necesitamos tomar la primera línea. Para eso usamos
peticion.lines().next(). Esa primera línea será el punto de entrada para
el router simple que construiremos más adelante.
Lo que ya conseguimos con este bloque
Ya sabemos leer bytes desde una conexión TCP, convertirlos a texto, imprimir la petición HTTP recibida y extraer su línea inicial. Con eso tenemos la base para que el servidor pueda decidir qué responder.
En el siguiente bloque veremos el otro lado del intercambio: construir una respuesta HTTP manualmente y enviarla de vuelta al navegador.
8. Responder HTTP manualmente
Ya vimos cómo leer una petición HTTP desde una conexión TCP. Ahora falta el otro lado del intercambio: enviar una respuesta al navegador. Esta parte es clave porque aquí se entiende que un servidor HTTP no solo recibe texto con formato, sino que también responde texto con formato.
En un framework web normalmente escribes algo como “devuelve esta página” o “responde este JSON”, y el framework se encarga de construir la respuesta HTTP completa. En este tutorial haremos esa construcción manualmente para ver qué necesita realmente el navegador.
Una respuesta HTTP mínima no es magia: es una línea de estado, varias cabeceras, una línea en blanco y un cuerpo.
Una respuesta HTTP simple puede verse así:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 46
Connection: close
<!DOCTYPE html>
<h1>Hola desde Rust</h1>
La primera línea es la línea de estado. En este caso,
HTTP/1.1 200 OK le dice al navegador que la petición fue procesada
correctamente. Más adelante también usaremos líneas como HTTP/1.1 404 NOT FOUND
y HTTP/1.1 500 INTERNAL SERVER ERROR.
Después vienen las cabeceras. La cabecera Content-Type indica qué tipo
de contenido recibirá el navegador. Como vamos a responder HTML, usaremos
text/html; charset=utf-8. El charset=utf-8 ayuda a que
caracteres como tildes, eñes y símbolos se muestren correctamente.
La cabecera Content-Length indica el tamaño del cuerpo de la respuesta
en bytes. Este detalle importa mucho: el navegador necesita saber cuántos bytes
pertenecen al contenido que está recibiendo.
Estructura mínima de una respuesta HTTP
Línea de estado, cabeceras, línea en blanco y cuerpo. Si falta la línea en blanco, el navegador puede confundir las cabeceras con el contenido.
En Rust construiremos esa respuesta con una cadena de texto. Una primera versión escrita directamente podría verse así:
let respuesta = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: 46\r\nConnection: close\r\n\r\n<!DOCTYPE html>\n<h1>Hola desde Rust</h1>";
Observa que la respuesta usa \r\n para separar líneas. Esa secuencia
representa el salto de línea esperado por HTTP. También aparece \r\n\r\n,
que marca la separación entre las cabeceras y el cuerpo.
Aunque esta cadena funciona como ejemplo, escribir la respuesta completa a mano cada
vez sería incómodo y fácil de romper. Por eso en el código final usaremos una función
que reciba la línea de estado y el cuerpo HTML, calcule automáticamente
Content-Length y devuelva la respuesta completa.
fn crear_respuesta_html(linea_estado: &str, cuerpo: &str) -> String {
let longitud = cuerpo.as_bytes().len();
format!(
"{}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
linea_estado, longitud, cuerpo
)
}
Esta función recibe dos datos. El primero es la línea de estado, por ejemplo
HTTP/1.1 200 OK. El segundo es el cuerpo de la respuesta, que en este
tutorial será HTML.
La línea let longitud = cuerpo.as_bytes().len(); calcula el tamaño del
cuerpo en bytes. Esto es mejor que contar caracteres, porque HTTP espera una longitud
en bytes, no una longitud visual de texto.
Luego usamos format! para construir la respuesta completa. Esa respuesta
queda lista para enviarse por el TcpStream.
Content-Length no coincide con el
tamaño real del cuerpo, algunos clientes pueden interpretar mal la respuesta. Por eso
conviene calcularlo automáticamente con cuerpo.as_bytes().len().
Crear la respuesta no basta. Después debemos escribirla en la conexión TCP para que
llegue al navegador. En Rust eso se hace con write_all.
fn enviar_respuesta(stream: &mut TcpStream, respuesta: &str) {
match stream.write_all(respuesta.as_bytes()) {
Ok(_) => {
println!("Respuesta HTTP enviada al navegador:");
println!("{}", respuesta);
}
Err(error) => {
println!("Error al enviar la respuesta HTTP: {}", error);
}
}
}
La función enviar_respuesta recibe una referencia mutable al
TcpStream y la respuesta HTTP como texto. Luego convierte esa respuesta
a bytes con respuesta.as_bytes() y la envía usando
stream.write_all(...).
La razón por la que volvemos a bytes es sencilla: TCP no envía objetos de Rust ni cadenas abstractas. TCP envía bytes. Nosotros construimos una respuesta como texto, pero antes de mandarla por la conexión debemos convertirla a bytes.
Lo que logramos en este bloque
Ya sabemos construir una respuesta HTTP manual, calcular Content-Length,
indicar que el contenido es HTML y enviar esa respuesta al navegador usando
write_all.
Con esto ya tenemos las dos mitades fundamentales del servidor: leer una petición HTTP y enviar una respuesta HTTP. El siguiente paso será conectar esa respuesta con rutas y archivos externos para que el servidor pueda devolver páginas distintas según la URL solicitada.
9. Rutas y archivos externos
Hasta ahora ya entendimos dos piezas fundamentales: leer una petición HTTP y construir una respuesta HTTP manualmente. Pero todavía falta algo importante para que el servidor se sienta como un servidor web real: responder páginas diferentes según la ruta que el navegador solicite.
Cuando el navegador pide /, no queremos responder lo mismo que cuando pide
/saludo o /contacto. Por eso vamos a crear un router muy pequeño.
Ese router mirará la ruta solicitada y decidirá qué archivo HTML debe cargar desde la
carpeta public.
La relación entre rutas y archivos será esta:
/ -> public/index.html
/saludo -> public/saludo.html
/contacto -> public/contacto.html
/lento -> public/lento.html
otra ruta -> public/404.html
Esta tabla mental es la base del router. El navegador no pide directamente
public/index.html. El navegador pide una ruta, por ejemplo /saludo.
Nuestro servidor traduce esa ruta a un archivo concreto dentro de la carpeta
public.
En un framework web profesional, esta tarea la hace un sistema de rutas más avanzado.
Aquí lo haremos con un match de Rust para que la idea quede completamente
visible.
Idea clave
Un router, en su forma más simple, mira una ruta como /contacto y decide
qué respuesta debe devolver. En nuestro caso, decidirá qué archivo HTML cargar.
En el código final, esa decisión quedará dentro de una función llamada
responder_ruta.
fn responder_ruta(ruta: &str) -> String {
match ruta {
"/" => crear_respuesta_desde_archivo("HTTP/1.1 200 OK", "public/index.html"),
"/saludo" => crear_respuesta_desde_archivo("HTTP/1.1 200 OK", "public/saludo.html"),
"/contacto" => crear_respuesta_desde_archivo("HTTP/1.1 200 OK", "public/contacto.html"),
"/lento" => responder_ruta_lenta(),
_ => crear_respuesta_desde_archivo("HTTP/1.1 404 NOT FOUND", "public/404.html"),
}
}
Esta función recibe la ruta como texto. Si la ruta es /, responde
public/index.html. Si la ruta es /saludo, responde
public/saludo.html. Si la ruta es /contacto, responde
public/contacto.html.
La ruta /lento es especial. No carga el archivo directamente desde el
match, sino que llama a responder_ruta_lenta(). Más adelante
usaremos esa ruta para demostrar concurrencia: una petición lenta no debería bloquear
todas las demás conexiones.
La parte final del match, marcada con _, captura cualquier
ruta que no hayamos definido. Esa será nuestra respuesta 404 NOT FOUND.
Por ejemplo, si el navegador pide /no-existe, el servidor cargará
public/404.html.
Ahora necesitamos una función que realmente lea el archivo HTML desde disco y lo convierta
en una respuesta HTTP completa. Para eso usaremos std::fs::read_to_string.
fn crear_respuesta_desde_archivo(linea_estado: &str, ruta_archivo: &str) -> String {
match fs::read_to_string(ruta_archivo) {
Ok(cuerpo) => {
println!("Archivo cargado correctamente: {}", ruta_archivo);
crear_respuesta_html(linea_estado, &cuerpo)
}
Err(error) => {
println!("No se pudo leer el archivo {}.", ruta_archivo);
println!("Causa: {}", error);
println!("Respondiendo con 500 Internal Server Error.");
crear_respuesta_500()
}
}
}
La función crear_respuesta_desde_archivo recibe dos datos. El primero es
la línea de estado HTTP, por ejemplo HTTP/1.1 200 OK o
HTTP/1.1 404 NOT FOUND. El segundo es la ruta del archivo que queremos
cargar desde disco.
La llamada fs::read_to_string(ruta_archivo) intenta leer el archivo como
texto. Si todo sale bien, Rust obtiene el contenido HTML en la variable
cuerpo. Luego ese cuerpo se pasa a crear_respuesta_html,
que ya sabe construir la respuesta HTTP con Content-Type,
Content-Length y la línea en blanco antes del cuerpo.
Si el archivo no existe o no se puede leer, entramos al caso Err(error).
En ese escenario, el problema ya no es que el cliente pidió una ruta inexistente.
El problema es que el servidor no pudo construir la respuesta. Por eso más adelante
responderemos con 500 INTERNAL SERVER ERROR.
404 y 500 no significan
lo mismo. 404 quiere decir que el cliente pidió una ruta que no existe.
500 quiere decir que el servidor falló intentando responder.
Para que este bloque funcione, el proyecto tendrá una carpeta public en
la raíz. Esa carpeta estará al mismo nivel que Cargo.toml y contendrá
los archivos HTML que el servidor podrá devolver.
servidor_http/
├── Cargo.toml
├── public/
│ ├── index.html
│ ├── saludo.html
│ ├── contacto.html
│ ├── lento.html
│ ├── 404.html
│ └── 500.html
└── src/
└── main.rs
Esta estructura separa el código Rust de las páginas HTML. El archivo
src/main.rs contiene la lógica del servidor, mientras que la carpeta
public contiene el contenido que será enviado al navegador.
Esa separación es importante porque nos permite cambiar el contenido HTML sin mezclarlo directamente con toda la lógica de red, lectura de peticiones y construcción de respuestas. Aunque el servidor sigue siendo pequeño, ya empieza a parecerse a la idea básica de servir archivos estáticos.
Cuando el navegador pida una ruta, el recorrido será este:
GET /saludo HTTP/1.1
↓
ruta detectada: /saludo
↓
archivo seleccionado: public/saludo.html
↓
respuesta construida: HTTP/1.1 200 OK
↓
HTML enviado al navegador
Lo que logramos en este bloque
Ya conectamos las rutas del navegador con archivos HTML reales. Ahora el servidor
no responde siempre lo mismo: puede mirar la ruta solicitada, elegir un archivo
desde public, construir una respuesta HTTP y enviarla al navegador.
Con esto ya tenemos un router básico y una forma de servir archivos externos. El siguiente
paso será profundizar en los errores 404 NOT FOUND y
500 INTERNAL SERVER ERROR, porque ambos aparecen en este flujo pero significan
cosas diferentes.
10. Errores 404 y 500
Ahora que el servidor ya puede conectar rutas con archivos HTML, necesitamos aclarar
una diferencia fundamental: no todos los errores HTTP significan lo mismo. En esta
sección vamos a distinguir dos respuestas muy comunes: 404 NOT FOUND y
500 INTERNAL SERVER ERROR.
Un 404 aparece cuando el cliente pide una ruta que el servidor no tiene
registrada. En cambio, un 500 aparece cuando el servidor sí intenta
responder, pero falla internamente mientras construye la respuesta.
Esta diferencia ayuda a diagnosticar problemas. No es lo mismo que una persona visite
una URL inexistente, a que el servidor no pueda leer un archivo que debería estar
disponible en la carpeta public.
Diferencia clave
404 significa: “la ruta solicitada no existe”.
500 significa: “el servidor falló intentando construir la respuesta”.
Podemos resumirlo así:
404 -> el cliente pidió una ruta que no existe.
500 -> el servidor falló intentando construir la respuesta.
En nuestro servidor, el caso 404 aparece dentro del router. Si la ruta
solicitada no coincide con /, /saludo, /contacto
o /lento, entonces el servidor responde con el archivo
public/404.html.
Esa decisión está en la última rama del match. El símbolo _
representa “cualquier otro caso”. Es decir, cualquier ruta no registrada terminará
devolviendo una respuesta 404 NOT FOUND.
fn responder_ruta(ruta: &str) -> String {
match ruta {
"/" => crear_respuesta_desde_archivo("HTTP/1.1 200 OK", "public/index.html"),
"/saludo" => crear_respuesta_desde_archivo("HTTP/1.1 200 OK", "public/saludo.html"),
"/contacto" => crear_respuesta_desde_archivo("HTTP/1.1 200 OK", "public/contacto.html"),
"/lento" => responder_ruta_lenta(),
_ => crear_respuesta_desde_archivo("HTTP/1.1 404 NOT FOUND", "public/404.html"),
}
}
Por ejemplo, si el navegador visita http://127.0.0.1:7878/no-existe,
la ruta recibida será /no-existe. Como esa ruta no está registrada en
las ramas principales del match, caerá en _ y responderá
con el archivo public/404.html.
Eso no significa que el servidor esté roto. Al contrario: significa que el servidor entendió la petición y respondió correctamente que esa página no existe.
El flujo se puede leer así:
GET /no-existe HTTP/1.1
↓
ruta detectada: /no-existe
↓
no coincide con ninguna ruta registrada
↓
respuesta: HTTP/1.1 404 NOT FOUND
↓
archivo enviado: public/404.html
El caso 500 es diferente. Aquí el problema no está en la ruta que pidió
el navegador, sino en el servidor. Por ejemplo, el navegador puede pedir /,
el router puede decidir correctamente que debe cargar public/index.html,
pero si ese archivo no existe o no se puede leer, el servidor ya no puede construir
la respuesta esperada.
En ese escenario usamos crear_respuesta_desde_archivo. Esta función intenta
leer el archivo con fs::read_to_string. Si la lectura funciona, construye
una respuesta normal. Si la lectura falla, responde con un error interno del servidor.
fn crear_respuesta_desde_archivo(linea_estado: &str, ruta_archivo: &str) -> String {
match fs::read_to_string(ruta_archivo) {
Ok(cuerpo) => {
println!("Archivo cargado correctamente: {}", ruta_archivo);
crear_respuesta_html(linea_estado, &cuerpo)
}
Err(error) => {
println!("No se pudo leer el archivo {}.", ruta_archivo);
println!("Causa: {}", error);
println!("Respondiendo con 500 Internal Server Error.");
crear_respuesta_500()
}
}
}
La parte importante está en el caso Err(error). Si Rust no puede leer
el archivo solicitado, el servidor imprime el error en consola y llama a
crear_respuesta_500().
Esa función intenta cargar una página especial: public/500.html. Así el
navegador recibe una respuesta clara, en lugar de quedar esperando o recibir una conexión
cerrada sin explicación.
fn crear_respuesta_500() -> String {
match fs::read_to_string("public/500.html") {
Ok(cuerpo) => {
println!("Archivo cargado correctamente: public/500.html");
crear_respuesta_html("HTTP/1.1 500 INTERNAL SERVER ERROR", &cuerpo)
}
Err(error) => {
println!("No se pudo leer public/500.html.");
println!("Causa: {}", error);
println!("Usando página 500 mínima generada desde Rust.");
crear_respuesta_error_simple(
"HTTP/1.1 500 INTERNAL SERVER ERROR",
"500",
"Error interno del servidor",
"No se pudo leer el archivo HTML solicitado.",
)
}
}
}
Esta función tiene dos niveles de defensa. Primero intenta leer
public/500.html. Si ese archivo existe, lo usa como página de error.
Pero si incluso public/500.html falla, el servidor genera una página
mínima desde Rust.
Esto evita que el servidor se quede sin respuesta justo cuando ocurre un error. Aunque algo falle en el sistema de archivos, el navegador recibirá una página HTML simple explicando que ocurrió un error interno.
public/index.html, eso no es
un 404. La ruta / sí existe. El problema es que el servidor
no pudo leer el archivo que debía responder. Por eso corresponde un
500 INTERNAL SERVER ERROR.
El recorrido de ese error sería este:
GET / HTTP/1.1
↓
ruta detectada: /
↓
archivo esperado: public/index.html
↓
public/index.html no existe o no se puede leer
↓
respuesta: HTTP/1.1 500 INTERNAL SERVER ERROR
↓
archivo enviado: public/500.html
Para completar el respaldo, el servidor también tiene una función que genera una página HTML mínima desde Rust. Esta función sirve cuando queremos responder un error sin depender de un archivo externo.
fn crear_respuesta_error_simple(
linea_estado: &str,
codigo: &str,
titulo: &str,
mensaje: &str,
) -> String {
let cuerpo = pagina_error_simple(codigo, titulo, mensaje);
crear_respuesta_html(linea_estado, &cuerpo)
}
fn pagina_error_simple(codigo: &str, titulo: &str, mensaje: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>{codigo} | {titulo}</title>
</head>
<body>
<h1>{codigo}</h1>
<p>{titulo}</p>
<p>{mensaje}</p>
<nav>
<a href="/">Volver al inicio</a>
</nav>
</body>
</html>"#
)
}
Esta función no reemplaza nuestros archivos 404.html y
500.html. Es un respaldo. Su objetivo es garantizar que el servidor pueda
devolver una respuesta HTML incluso si algún archivo de error falta.
Para probar manualmente estos casos, puedes hacer dos experimentos. Primero visita una
ruta que no exista. Eso debe producir un 404 NOT FOUND. Después, renombra
temporalmente public/index.html y visita /. Eso debe producir
un 500 INTERNAL SERVER ERROR.
Prueba 404:
http://127.0.0.1:7878/no-existe
Prueba 500:
1. Renombra public/index.html temporalmente.
2. Ejecuta cargo run.
3. Abre http://127.0.0.1:7878/
4. El servidor debe responder con 500 INTERNAL SERVER ERROR.
5. Vuelve a dejar el archivo como public/index.html.
Lo que logramos en esta sección
Ya diferenciamos errores del cliente y errores del servidor. Un 404
aparece cuando la ruta no existe. Un 500 aparece cuando el servidor
falla intentando construir la respuesta, por ejemplo porque no puede leer un archivo
HTML desde public.
Con esto el servidor ya responde mejor ante situaciones reales. No solo sabe devolver páginas exitosas, sino también explicar cuándo una ruta no existe y cuándo el problema ocurrió dentro del propio servidor.
El siguiente paso será introducir concurrencia básica con hilos. Así podremos demostrar
que una ruta lenta, como /lento, no tiene por qué bloquear completamente
las demás conexiones.
11. Concurrencia básica con hilos
Hasta ahora nuestro servidor ya puede leer peticiones HTTP, responder HTML, servir
archivos desde public y diferenciar errores 404 y
500. Pero todavía falta una pieza importante: atender más de una conexión
sin que una petición lenta bloquee completamente a las demás.
Imagina que una persona abre la ruta /lento y esa ruta tarda varios
segundos en responder. Si el servidor atiende todo en un solo hilo, mientras esa
petición está esperando, las demás conexiones quedan detenidas. Eso no es lo ideal,
ni siquiera para una demostración pequeña.
Para visualizar el problema, pensemos en este flujo secuencial:
1. Llega una petición a /lento
2. El servidor empieza a esperar 5 segundos
3. Llega otra petición a /contacto
4. /contacto debe esperar a que /lento termine
5. El servidor responde tarde aunque /contacto era una ruta rápida
Ese comportamiento sirve para entender el problema, pero no es lo que queremos demostrar al final. La idea es que una ruta lenta pueda tardar, pero que otra conexión diferente siga siendo atendida.
Para conseguirlo usaremos std::thread::spawn. Esta función permite crear
un hilo nuevo y ejecutar una tarea dentro de ese hilo. En nuestro caso, cada vez que
llegue una conexión TCP, crearemos un hilo para atenderla.
La idea no es construir el servidor concurrente perfecto. La idea es ver, de forma simple, que cada conexión puede ser atendida de manera independiente.
use std::thread;
Primero necesitamos importar std::thread. Esta parte viene de la librería
estándar de Rust, así que seguimos cumpliendo la promesa del tutorial: no usar frameworks
ni dependencias externas.
La parte central está en el ciclo que acepta conexiones. Antes podríamos llamar
directamente a manejar_conexion(stream). Ahora, en cambio, moveremos cada
conexión a un hilo separado.
for conexion in listener.incoming() {
match conexion {
Ok(stream) => {
println!("Conexión aceptada. Creando un hilo para atenderla...\n");
thread::spawn(move || {
manejar_conexion(stream);
});
}
Err(error) => {
println!("Error al aceptar una conexión TCP: {}", error);
}
}
}
La llamada listener.incoming() sigue entregando conexiones nuevas. Cada
conexión aceptada llega como un TcpStream. La diferencia está en que ahora
no atendemos esa conexión directamente en el hilo principal, sino dentro de
thread::spawn.
La palabra move es importante porque le indica a Rust que el hilo nuevo
tomará posesión de stream. Eso tiene sentido: una conexión específica debe
ser atendida por un hilo específico, y ese hilo necesita ser dueño del TcpStream
para poder leer la petición y escribir la respuesta.
Idea clave
El hilo principal sigue aceptando conexiones. Cada conexión aceptada se mueve a un
hilo nuevo, donde se ejecuta manejar_conexion(stream).
Hilo principal
↓
acepta conexión TCP
↓
crea un hilo nuevo
↓
ese hilo ejecuta manejar_conexion(stream)
↓
el hilo principal vuelve a esperar más conexiones
Para demostrar que esto realmente importa, agregamos una ruta especial:
/lento. Esa ruta simula una operación que tarda varios segundos. No hace
nada complejo: simplemente espera usando thread::sleep.
También necesitamos importar Duration, que nos permite expresar tiempos
como segundos.
use std::time::Duration;
En el código final definimos una constante para controlar cuántos segundos debe tardar la ruta lenta. Así el valor queda claro y fácil de modificar.
const SEGUNDOS_RUTA_LENTA: u64 = 5;
La función encargada de simular esa espera será responder_ruta_lenta.
Esta función duerme el hilo durante algunos segundos y luego carga
public/lento.html como una respuesta normal.
fn responder_ruta_lenta() -> String {
println!(
"Ruta /lento detectada. Simulando trabajo lento durante {} segundos...",
SEGUNDOS_RUTA_LENTA
);
thread::sleep(Duration::from_secs(SEGUNDOS_RUTA_LENTA));
println!("Trabajo lento terminado. Enviando respuesta de /lento.");
crear_respuesta_desde_archivo("HTTP/1.1 200 OK", "public/lento.html")
}
Lo interesante es que thread::sleep duerme el hilo actual, no todo el
programa. Como cada conexión se atiende en su propio hilo, una petición a
/lento puede estar esperando mientras otra petición a /contacto
se responde en otro hilo.
Por eso la ruta /lento también aparece en el router:
fn responder_ruta(ruta: &str) -> String {
match ruta {
"/" => crear_respuesta_desde_archivo("HTTP/1.1 200 OK", "public/index.html"),
"/saludo" => crear_respuesta_desde_archivo("HTTP/1.1 200 OK", "public/saludo.html"),
"/contacto" => crear_respuesta_desde_archivo("HTTP/1.1 200 OK", "public/contacto.html"),
"/lento" => responder_ruta_lenta(),
_ => crear_respuesta_desde_archivo("HTTP/1.1 404 NOT FOUND", "public/404.html"),
}
}
Ahora el recorrido de una petición lenta se puede leer así:
GET /lento HTTP/1.1
↓
ruta detectada: /lento
↓
se crea o se usa un hilo para esa conexión
↓
ese hilo espera 5 segundos
↓
se carga public/lento.html
↓
se responde HTTP/1.1 200 OK
La prueba más clara consiste en abrir dos rutas casi al mismo tiempo. Primero abre
/lento. Mientras esa página está cargando, abre /contacto
en otra pestaña. Si la concurrencia está funcionando, /contacto debe
responder sin esperar a que termine /lento.
Prueba de concurrencia:
1. Abre esta ruta:
http://127.0.0.1:7878/lento
2. Mientras carga, abre esta otra ruta:
http://127.0.0.1:7878/contacto
3. Resultado esperado:
/contacto debe responder sin esperar a que /lento termine.
En consola también podrás notar la diferencia. Cada vez que llega una conexión, el servidor imprime un mensaje indicando que aceptó la conexión y que creó un hilo para atenderla.
Conexión aceptada. Creando un hilo para atenderla...
Hilo ThreadId(2) atendiendo conexión desde 127.0.0.1:54321
Hilo ThreadId(2): línea inicial detectada: GET /lento HTTP/1.1
Conexión aceptada. Creando un hilo para atenderla...
Hilo ThreadId(3) atendiendo conexión desde 127.0.0.1:54322
Hilo ThreadId(3): línea inicial detectada: GET /contacto HTTP/1.1
Los números de ThreadId pueden cambiar en tu computador. No importa si
aparecen otros valores. Lo importante es ver que distintas conexiones pueden ser
atendidas por hilos diferentes.
Aun así, para este tutorial el enfoque es perfecto: permite ver la idea de concurrencia sin ocultarla detrás de un framework ni de un runtime externo.
Sin hilos:
/lento bloquea a /contacto
Con hilos:
/lento espera en un hilo
/contacto responde en otro hilo
Lo que logramos en esta sección
Ya vimos cómo usar std::thread::spawn para atender cada conexión en un
hilo separado. También usamos /lento para demostrar que una petición
lenta no tiene por qué bloquear completamente a las demás.
Con esto ya tenemos todas las ideas principales del servidor: TCP, lectura de peticiones,
respuestas HTTP manuales, rutas, archivos externos, errores y concurrencia básica.
Ahora sí podemos reunir todo en el código final de src/main.rs.
12. Código final de src/main.rs
Ya tenemos todas las piezas principales: abrir un puerto TCP, aceptar conexiones,
leer peticiones HTTP, extraer la línea inicial, construir respuestas, servir archivos
desde public, manejar errores 404 y 500, y atender
conexiones usando hilos.
Ahora vamos a reunir todo en el archivo final src/main.rs. Este será el
corazón del servidor. La idea es que puedas copiarlo completo, reemplazar el contenido
actual de src/main.rs y luego ejecutar el proyecto con cargo run.
Archivo final
src/main.rs
Este archivo contiene el servidor HTTP completo: TCP, lectura de peticiones, rutas, respuestas HTML, errores y concurrencia básica con hilos.
// Para el lector del blog:
//
// Este archivo reúne lo aprendido hasta ahora:
//
// 1. Abrir un servidor TCP con TcpListener.
// 2. Aceptar conexiones entrantes.
// 3. Leer una petición HTTP como texto.
// 4. Extraer la primera línea de la petición.
// 5. Decidir qué archivo HTML responder según la ruta.
// 6. Construir una respuesta HTTP manual.
// 7. Servir archivos desde la carpeta public.
// 8. Responder 404 cuando la ruta no existe.
// 9. Responder 500 cuando el servidor no puede leer un archivo.
// 10. Atender cada conexión en un hilo separado.
//
// El objetivo sigue siendo educativo.
// Este servidor ayuda a entender cómo HTTP viaja sobre TCP,
// pero no pretende reemplazar un servidor web profesional.
use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
use std::time::Duration;
const DIRECCION: &str = "127.0.0.1:7878";
const TAMANO_BUFFER: usize = 2048;
const SEGUNDOS_RUTA_LENTA: u64 = 5;
fn main() {
println!("Servidor HTTP desde cero sobre TCP en Rust");
println!("Código final del tutorial\n");
let listener = match iniciar_servidor(DIRECCION) {
Some(listener) => listener,
None => return,
};
mostrar_rutas_disponibles();
for conexion in listener.incoming() {
match conexion {
Ok(stream) => {
println!("Conexión aceptada. Creando un hilo para atenderla...\n");
thread::spawn(move || {
manejar_conexion(stream);
});
}
Err(error) => {
println!("Error al aceptar una conexión TCP: {}", error);
}
}
}
}
/// Intenta abrir el servidor TCP.
///
/// Para el lector del blog:
///
/// TcpListener::bind intenta reservar una dirección IP y un puerto.
/// Si otro programa ya está usando el puerto 7878, esta operación falla.
fn iniciar_servidor(direccion: &str) -> Option<TcpListener> {
match TcpListener::bind(direccion) {
Ok(listener) => {
println!("Servidor escuchando en http://{}", direccion);
Some(listener)
}
Err(error) => {
println!("No se pudo abrir el servidor TCP en {}.", direccion);
println!("Causa: {}", error);
println!("\nPosibles soluciones:");
println!("1. Verifica que no tengas otro servidor usando el puerto 7878.");
println!("2. Detén el servidor anterior con Ctrl + C.");
println!("3. Vuelve a ejecutar cargo run.");
None
}
}
}
/// Muestra rutas útiles para probar el servidor.
fn mostrar_rutas_disponibles() {
println!("\nRutas disponibles:");
println!(" http://127.0.0.1:7878/");
println!(" http://127.0.0.1:7878/saludo");
println!(" http://127.0.0.1:7878/contacto");
println!(" http://127.0.0.1:7878/lento");
println!(" http://127.0.0.1:7878/no-existe");
println!("\nPrueba de concurrencia:");
println!(" 1. Abre http://127.0.0.1:7878/lento");
println!(" 2. Mientras carga, abre http://127.0.0.1:7878/contacto");
println!(" 3. /contacto debe responder sin esperar a que /lento termine.");
println!("\nPresiona Ctrl + C para detener el servidor.\n");
}
/// Atiende una única conexión TCP.
///
/// Para el lector del blog:
///
/// Cada vez que el navegador visita una ruta, se abre una conexión TCP.
/// En esta versión, esa conexión se atiende dentro de un hilo separado.
fn manejar_conexion(mut stream: TcpStream) {
let id_hilo = thread::current().id();
let origen = obtener_origen(&stream);
println!("Hilo {:?} atendiendo conexión desde {}", id_hilo, origen);
let peticion = match leer_peticion_http(&mut stream) {
Ok(peticion) => peticion,
Err(mensaje_error) => {
println!("Hilo {:?}: {}", id_hilo, mensaje_error);
let respuesta = crear_respuesta_error_simple(
"HTTP/1.1 500 INTERNAL SERVER ERROR",
"500",
"Error interno del servidor",
"No se pudo leer la petición enviada por el cliente.",
);
enviar_respuesta(&mut stream, &respuesta);
return;
}
};
let linea_inicial = obtener_linea_inicial(&peticion);
if linea_inicial.is_empty() {
println!("Hilo {:?}: petición HTTP sin línea inicial.", id_hilo);
let respuesta = crear_respuesta_error_simple(
"HTTP/1.1 400 BAD REQUEST",
"400",
"Petición inválida",
"El servidor no pudo encontrar una línea inicial HTTP válida.",
);
enviar_respuesta(&mut stream, &respuesta);
return;
}
println!("Hilo {:?}: línea inicial detectada: {}", id_hilo, linea_inicial);
let respuesta = responder_peticion(linea_inicial);
enviar_respuesta(&mut stream, &respuesta);
println!("Hilo {:?}: conexión atendida.\n", id_hilo);
}
/// Lee bytes desde la conexión TCP y los convierte a texto.
///
/// Para el lector del blog:
///
/// TCP entrega bytes. HTTP, en este ejemplo, lo interpretamos como texto.
/// Por eso usamos String::from_utf8_lossy.
fn leer_peticion_http(stream: &mut TcpStream) -> Result<String, String> {
let mut buffer = [0; TAMANO_BUFFER];
match stream.read(&mut buffer) {
Ok(0) => Err("la conexión se cerró sin enviar datos".to_string()),
Ok(bytes_leidos) => {
println!("Bytes leídos desde TCP: {}", bytes_leidos);
let peticion = String::from_utf8_lossy(&buffer[..bytes_leidos]).to_string();
println!("\n== Petición HTTP recibida ==");
println!("{}", hacer_visible(&peticion));
println!("== Fin de la petición recibida ==\n");
Ok(peticion)
}
Err(error) => Err(format!("error al leer desde la conexión TCP: {}", error)),
}
}
/// Obtiene la dirección de origen de la conexión.
///
/// Esta información solo se imprime para que el lector vea
/// desde dónde llegó la conexión.
fn obtener_origen(stream: &TcpStream) -> String {
match stream.peer_addr() {
Ok(addr) => addr.to_string(),
Err(_) => "origen desconocido".to_string(),
}
}
/// Obtiene la primera línea de la petición HTTP.
///
/// Ejemplo:
///
/// GET / HTTP/1.1
fn obtener_linea_inicial(peticion: &str) -> &str {
match peticion.lines().next() {
Some(linea) => linea,
None => "",
}
}
/// Responde una petición HTTP a partir de su primera línea.
///
/// Para el lector del blog:
///
/// La primera línea contiene tres piezas importantes:
///
/// método ruta versión
///
/// Por ejemplo:
///
/// GET /saludo HTTP/1.1
///
/// En este servidor educativo solo manejamos el método GET.
fn responder_peticion(linea_inicial: &str) -> String {
let partes = extraer_partes_linea_inicial(linea_inicial);
let (metodo, ruta, version) = match partes {
Some(partes) => partes,
None => {
return crear_respuesta_error_simple(
"HTTP/1.1 400 BAD REQUEST",
"400",
"Petición inválida",
"La línea inicial de la petición HTTP no tiene el formato esperado.",
);
}
};
if !version.starts_with("HTTP/") {
return crear_respuesta_error_simple(
"HTTP/1.1 400 BAD REQUEST",
"400",
"Versión HTTP inválida",
"La petición no indica una versión HTTP válida.",
);
}
if metodo != "GET" {
return crear_respuesta_error_simple(
"HTTP/1.1 405 METHOD NOT ALLOWED",
"405",
"Método no permitido",
"Este servidor educativo solo responde peticiones GET.",
);
}
responder_ruta(ruta)
}
/// Divide la línea inicial en método, ruta y versión.
///
/// Si la línea no tiene exactamente tres partes, devolvemos None.
fn extraer_partes_linea_inicial(linea_inicial: &str) -> Option<(&str, &str, &str)> {
let mut partes = linea_inicial.split_whitespace();
let metodo = partes.next()?;
let ruta = partes.next()?;
let version = partes.next()?;
if partes.next().is_some() {
return None;
}
Some((metodo, ruta, version))
}
/// Decide qué archivo HTML responder según la ruta.
///
/// Para el lector del blog:
///
/// Esta función es un router muy pequeño.
/// Un framework web profesional tiene routers mucho más potentes,
/// pero la idea base empieza aquí: mirar la ruta y decidir una respuesta.
fn responder_ruta(ruta: &str) -> String {
match ruta {
"/" => crear_respuesta_desde_archivo("HTTP/1.1 200 OK", "public/index.html"),
"/saludo" => crear_respuesta_desde_archivo("HTTP/1.1 200 OK", "public/saludo.html"),
"/contacto" => crear_respuesta_desde_archivo("HTTP/1.1 200 OK", "public/contacto.html"),
"/lento" => responder_ruta_lenta(),
_ => crear_respuesta_desde_archivo("HTTP/1.1 404 NOT FOUND", "public/404.html"),
}
}
/// Simula una ruta lenta.
///
/// Para el lector del blog:
///
/// Esta función existe para demostrar la concurrencia.
/// Si el servidor no usara hilos, una ruta lenta bloquearía a las demás.
fn responder_ruta_lenta() -> String {
println!(
"Ruta /lento detectada. Simulando trabajo lento durante {} segundos...",
SEGUNDOS_RUTA_LENTA
);
thread::sleep(Duration::from_secs(SEGUNDOS_RUTA_LENTA));
println!("Trabajo lento terminado. Enviando respuesta de /lento.");
crear_respuesta_desde_archivo("HTTP/1.1 200 OK", "public/lento.html")
}
/// Lee un archivo HTML desde disco y construye la respuesta HTTP.
fn crear_respuesta_desde_archivo(linea_estado: &str, ruta_archivo: &str) -> String {
match fs::read_to_string(ruta_archivo) {
Ok(cuerpo) => {
println!("Archivo cargado correctamente: {}", ruta_archivo);
crear_respuesta_html(linea_estado, &cuerpo)
}
Err(error) => {
println!("No se pudo leer el archivo {}.", ruta_archivo);
println!("Causa: {}", error);
println!("Respondiendo con 500 Internal Server Error.");
crear_respuesta_500()
}
}
}
/// Crea una respuesta 500.
///
/// Primero intenta cargar public/500.html.
/// Si ese archivo también falla, genera una página mínima desde Rust.
fn crear_respuesta_500() -> String {
match fs::read_to_string("public/500.html") {
Ok(cuerpo) => {
println!("Archivo cargado correctamente: public/500.html");
crear_respuesta_html("HTTP/1.1 500 INTERNAL SERVER ERROR", &cuerpo)
}
Err(error) => {
println!("No se pudo leer public/500.html.");
println!("Causa: {}", error);
println!("Usando página 500 mínima generada desde Rust.");
crear_respuesta_error_simple(
"HTTP/1.1 500 INTERNAL SERVER ERROR",
"500",
"Error interno del servidor",
"No se pudo leer el archivo HTML solicitado.",
)
}
}
}
/// Crea una respuesta HTTP de error con una página HTML simple.
fn crear_respuesta_error_simple(
linea_estado: &str,
codigo: &str,
titulo: &str,
mensaje: &str,
) -> String {
let cuerpo = pagina_error_simple(codigo, titulo, mensaje);
crear_respuesta_html(linea_estado, &cuerpo)
}
/// Genera una página HTML simple para errores.
///
/// Esta función es un respaldo.
/// Sirve cuando el servidor necesita responder algo claro
/// sin depender de un archivo externo.
fn pagina_error_simple(codigo: &str, titulo: &str, mensaje: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>{codigo} | {titulo}</title>
</head>
<body>
<h1>{codigo}</h1>
<p>{titulo}</p>
<p>{mensaje}</p>
<nav>
<a href="/">Volver al inicio</a>
</nav>
</body>
</html>"#
)
}
/// Construye una respuesta HTTP con cuerpo HTML.
///
/// Para el lector del blog:
///
/// Una respuesta HTTP mínima necesita:
///
/// 1. Línea de estado.
/// 2. Cabeceras.
/// 3. Una línea en blanco.
/// 4. Cuerpo.
///
/// Content-Length debe medirse en bytes.
fn crear_respuesta_html(linea_estado: &str, cuerpo: &str) -> String {
let longitud = cuerpo.as_bytes().len();
format!(
"{}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
linea_estado, longitud, cuerpo
)
}
/// Envía la respuesta HTTP al navegador.
fn enviar_respuesta(stream: &mut TcpStream, respuesta: &str) {
match stream.write_all(respuesta.as_bytes()) {
Ok(_) => {
println!("Respuesta HTTP enviada al navegador:");
println!("{}\n", hacer_visible(respuesta));
}
Err(error) => {
println!("Error al enviar la respuesta HTTP: {}", error);
}
}
}
/// Hace visibles caracteres especiales usados por HTTP.
///
/// Para el lector del blog:
///
/// HTTP separa líneas usando \r\n.
/// Esta función permite ver esos caracteres en consola.
fn hacer_visible(texto: &str) -> String {
texto.replace('\r', "\\r").replace('\n', "\\n\n")
}
Este archivo ya contiene la versión completa del servidor. Si copiaste el código en
src/main.rs, puedes comprobar que compila ejecutando cargo check
desde la raíz del proyecto.
cargo check
Si no aparece ningún error de compilación, puedes ejecutar el servidor con
cargo run.
cargo run
Cuando el servidor esté encendido, la terminal debe mostrar las rutas disponibles.
Desde ese momento puedes abrir el navegador y probar /,
/saludo, /contacto, /lento y
/no-existe.
Punto importante
Este código espera encontrar una carpeta public junto a
Cargo.toml. En la siguiente sección crearemos los archivos HTML finales
que vivirán dentro de esa carpeta.
Ahora que el servidor Rust está completo, falta preparar el contenido que va a servir:
las páginas index.html, saludo.html,
contacto.html, lento.html, 404.html y
500.html.
13. Archivos HTML finales
El servidor Rust ya está completo, pero todavía necesita contenido para responder.
Como la función crear_respuesta_desde_archivo carga páginas desde la carpeta
public, ahora vamos a crear los archivos HTML que vivirán allí.
Esta carpeta debe estar en la raíz del proyecto, al mismo nivel que
Cargo.toml. No debe quedar dentro de src, porque el servidor
buscará rutas como public/index.html, public/saludo.html y
public/404.html.
La estructura final debe quedar así:
servidor_http/
├── Cargo.toml
├── public/
│ ├── index.html
│ ├── saludo.html
│ ├── contacto.html
│ ├── lento.html
│ ├── 404.html
│ └── 500.html
└── src/
└── main.rs
Si todavía no tienes la carpeta public, puedes crearla desde la terminal
estando ubicado en la raíz del proyecto.
mkdir public
El primer archivo será public/index.html. Esta será la página principal,
la que se enviará cuando el navegador visite http://127.0.0.1:7878/.
Archivo HTML
public/index.html
Página principal del servidor. Se responde cuando la ruta solicitada es
/.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Servidor HTTP desde cero en Rust</title>
</head>
<body>
<h1>Servidor HTTP desde cero</h1>
<p>
Esta página fue enviada desde un servidor escrito en Rust usando
una conexión TCP.
</p>
<p>
El servidor lee una petición HTTP, detecta la ruta solicitada,
carga un archivo HTML desde la carpeta <strong>public</strong>
y construye manualmente la respuesta HTTP.
</p>
<p>
Esta versión también atiende cada conexión en un hilo separado
usando <strong>std::thread::spawn</strong>.
</p>
<nav>
<a href="/">Inicio</a> |
<a href="/saludo">Saludo</a> |
<a href="/contacto">Contacto</a> |
<a href="/lento">Ruta lenta</a> |
<a href="/no-existe">Ruta 404</a>
</nav>
</body>
</html>
El siguiente archivo será public/saludo.html. Esta página se enviará
cuando el navegador visite la ruta /saludo.
Archivo HTML
public/saludo.html
Página sencilla para comprobar que el router puede responder una ruta diferente a la principal.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Saludo | Servidor Rust</title>
</head>
<body>
<h1>Hola desde Rust</h1>
<p>
Esta página viene desde la ruta <strong>/saludo</strong>.
</p>
<p>
El servidor recibió una petición HTTP, leyó su primera línea,
detectó la ruta y cargó el archivo <strong>public/saludo.html</strong>.
</p>
<nav>
<a href="/">Volver al inicio</a> |
<a href="/contacto">Ir a contacto</a> |
<a href="/lento">Probar ruta lenta</a>
</nav>
</body>
</html>
Ahora crearemos public/contacto.html. Esta página ayuda a comprobar
que el servidor puede cargar otro archivo HTML normal desde la carpeta
public.
Archivo HTML
public/contacto.html
Página de contacto usada para probar una ruta rápida, especialmente mientras
/lento está esperando.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Contacto | Servidor Rust</title>
</head>
<body>
<h1>Contacto</h1>
<p>
Tutorial creado para <strong>TuCodigoCotidiano</strong>.
</p>
<p>
Este proyecto construye un servidor HTTP desde cero sobre TCP,
usando solamente la librería estándar de Rust.
</p>
<p>
La respuesta de esta página fue generada leyendo el archivo
<strong>public/contacto.html</strong>.
</p>
<nav>
<a href="/">Volver al inicio</a> |
<a href="/saludo">Ir al saludo</a> |
<a href="/lento">Probar ruta lenta</a>
</nav>
</body>
</html>
El archivo public/lento.html será la respuesta de la ruta
/lento. Esta ruta no es lenta por el HTML, sino porque el servidor
espera unos segundos antes de cargar el archivo.
Archivo HTML
public/lento.html
Página usada para demostrar concurrencia básica. El servidor espera antes de responder, pero otras rutas pueden seguir siendo atendidas.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Ruta lenta | Servidor Rust</title>
</head>
<body>
<h1>Ruta lenta</h1>
<p>
Esta página fue enviada después de simular un trabajo lento de 5 segundos.
</p>
<p>
La ruta <strong>/lento</strong> sirve para demostrar que el servidor
puede atender otras conexiones mientras una petición sigue trabajando
en otro hilo.
</p>
<p>
Esta es una forma simple y educativa de introducir concurrencia.
Para servidores reales con muchos usuarios se usan estrategias más avanzadas.
</p>
<nav>
<a href="/">Volver al inicio</a> |
<a href="/contacto">Ir a contacto</a>
</nav>
</body>
</html>
El archivo public/404.html se enviará cuando el navegador pida una ruta
que el router no tenga registrada. Por ejemplo: /no-existe.
Archivo HTML
public/404.html
Página de error para rutas inexistentes. Se responde junto con el estado
HTTP/1.1 404 NOT FOUND.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>404 | Página no encontrada</title>
</head>
<body>
<h1>404</h1>
<p>
Página no encontrada.
</p>
<p>
El servidor recibió una ruta que no está registrada en el router básico.
Por eso responde con el estado HTTP <strong>404 NOT FOUND</strong>.
</p>
<nav>
<a href="/">Volver al inicio</a>
</nav>
</body>
</html>
Finalmente crearemos public/500.html. Esta página se usará cuando el
servidor falle intentando leer un archivo que debería existir.
Archivo HTML
public/500.html
Página de error interno. Se responde cuando el servidor no puede construir la respuesta esperada.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>500 | Error interno del servidor</title>
</head>
<body>
<h1>500</h1>
<p>
Error interno del servidor.
</p>
<p>
El servidor intentó cargar un archivo HTML, pero ocurrió un problema.
</p>
<p>
Este error no significa necesariamente que la ruta no exista.
Significa que el servidor falló intentando construir la respuesta.
</p>
<nav>
<a href="/">Volver al inicio</a>
</nav>
</body>
</html>
Comentarios y valoraciones
No hay comentarios aún. ¡Sé el primero en opinar!