Capítulo 10: Setup de Leptos y Compilación WASM
Capítulo 10: Setup de Leptos y Compilación WASM
Introducción
Dejamos el backend completo. Ahora construimos el frontend en Rust puro, compilado a WebAssembly. Usamos Leptos como framework reactivo y Trunk como bundler.
Qué es Leptos
Leptos es un framework reactivo para Rust que compila a WebAssembly. Similar a React o SolidJS, pero con las garantías de Rust: sin null, sin excepciones en runtime, tipado estricto.
Características principales:
- Signals: reactividad fine-grained (como SolidJS, no como React)
- Macro
view!: escribe HTML con sintaxis RSX (similar a JSX) - CSR y SSR: soporta Client-Side Rendering y Server-Side Rendering
- Sin JavaScript: el output es un binario
.wasm
CSR vs SSR
Leptos soporta ambos modos. Elegimos CSR (Client-Side Rendering) porque:
- El backend ya existe (Axum separado)
- No necesitamos SEO (es una aplicación interna)
- Menor complejidad de deploy (archivos estáticos)
- Separación clara: frontend WASM + backend API
En CSR, Trunk compila el Rust a .wasm, genera un .js loader, y sirve todo como archivos estáticos.
Trunk: el bundler WASM
Trunk es el equivalente a Vite/Webpack para proyectos Rust WASM. Lee index.html, compila el crate, y genera los assets.
# Desarrollo con hot-reload
trunk serve
# Build para producción
trunk build --release
Cargo.toml del frontend
[package]
name = "app-frontend"
version = "0.1.0"
edition = "2021"
[dependencies]
leptos = { version = "0.7", features = ["csr"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde", "js"] }
gloo-net = { version = "0.6", features = ["http"] }
web-sys = { version = "0.3", features = [
"DragEvent",
"DataTransfer",
"HtmlElement",
"Element",
] }
wasm-bindgen = "0.2"
Cada dependencia tiene un rol específico:
| Crate | Propósito |
|---|---|
| leptos | Framework reactivo. features = ["csr"] activa Client-Side Rendering |
| serde / serde_json | Serialización JSON para comunicación con la API |
| uuid | Generación de IDs. features = ["js"] usa crypto.getRandomValues del navegador |
| gloo-net | Cliente HTTP para WASM (wrapper sobre Fetch API del navegador) |
| web-sys | Bindings a APIs del navegador (DOM, eventos Drag & Drop) |
| wasm-bindgen | Puente entre Rust y JavaScript |
Nota sobre uuid con feature "js": en WASM no existe /dev/urandom. La feature "js" le dice a uuid que use la API criptográfica del navegador.
index.html: bootstrap WASM
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kanban Board</title>
<link data-trunk rel="css" href="style.css" />
</head>
<body></body>
</html>
El HTML es mínimo:
data-trunk rel="css": le dice a Trunk que procese e incluya el archivo CSS.<body></body>vacío: Leptos inyecta toda la UI al montar la aplicación.- Trunk agrega automáticamente el
<script>que carga el.wasm.
main.rs: punto de entrada WASM
mod domain;
mod infrastructure;
mod ui;
use leptos::prelude::*;
use ui::app::App;
fn main() {
mount_to_body(App);
}
mod domain / infrastructure / ui: los tres módulos del frontend.mount_to_body(App): monta el componente raízAppen<body>. Equivalente aReactDOM.createRoot(document.body).render(<App />).- No hay
#[tokio::main]porque en WASM no hay runtime async del sistema operativo. Leptos usa el event loop del navegador.
Estilos CSS del tablero
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, sans-serif;
background: #f0f2f5;
padding: 1rem;
}
h1 { text-align: center; margin-bottom: 1rem; color: #333; }
Reset básico y fuente del sistema.
.board {
display: flex;
gap: 1rem;
justify-content: center;
align-items: flex-start;
}
.lane {
flex: 1;
max-width: 320px;
min-height: 400px;
background: #e2e8f0;
border-radius: 8px;
padding: 1rem;
}
.lane-todo { border-top: 4px solid #f59e0b; }
.lane-doing { border-top: 4px solid #3b82f6; }
.lane-done { border-top: 4px solid #10b981; }
El tablero usa flexbox con 3 columnas. Cada lane tiene un color distintivo en el borde superior: amarillo (todo), azul (doing), verde (done).
.card-item {
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 6px;
padding: 0.75rem;
cursor: grab;
position: relative;
box-shadow: 2px 2px 4px rgba(0,0,0,0.1);
transition: transform 0.15s;
}
.card-item:hover { transform: rotate(-1deg); }
.card-item:active { cursor: grabbing; }
Las tarjetas tienen un efecto visual de post-it: fondo amarillo claro, sombra, y rotación sutil al hover. El cursor: grab/grabbing indica que son arrastrables.
.card-item.completed {
background: #f0fdf4;
border-color: #86efac;
}
.card-item.completed .card-title {
text-decoration: line-through;
color: #6b7280;
}
Las tarjetas completadas cambian a fondo verde y texto tachado.
Arquitectura hexagonal en el frontend
Aplicamos la misma arquitectura del backend:
apps/app-frontend/src/
├── domain/
│ ├── entities/
│ │ └── card.rs # Struct Card (frontend)
│ └── ports/
│ └── card_gateway.rs # Trait CardGateway
├── infrastructure/
│ └── http_card_gateway.rs # Implementación HTTP
├── ui/
│ ├── app.rs
│ ├── pages/
│ │ └── board.rs
│ └── components/
│ ├── lane.rs
│ ├── card_item.rs
│ └── create_card_form.rs
└── main.rs
Card del frontend
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Card {
pub id: String,
pub title: String,
pub description: String,
pub lane: String,
pub position: i32,
pub completed: bool,
pub created_at: String,
pub updated_at: String,
}
A diferencia del backend, aquí Card usa tipos simples (String para id, lane, timestamps). No necesitamos value objects porque el frontend no valida reglas de negocio, solo muestra datos.
Port: CardGateway
use crate::domain::entities::Card;
pub trait CardGateway {
fn fetch_all(
&self,
) -> impl std::future::Future<Output = Result<Vec<Card>, String>>;
fn create(
&self,
title: String,
description: String,
) -> impl std::future::Future<Output = Result<Card, String>>;
fn move_card(
&self,
id: &str,
lane: &str,
position: i32,
) -> impl std::future::Future<Output = Result<(), String>>;
fn delete(
&self,
id: &str,
) -> impl std::future::Future<Output = Result<(), String>>;
}
El trait define las operaciones que la UI necesita, sin importar cómo se implementen. Usamos impl Future en lugar de async fn en traits (limitación de Rust con traits async en WASM).
Adaptador: HttpCardGateway
use gloo_net::http::Request;
use serde_json::json;
use crate::domain::entities::Card;
use crate::domain::ports::CardGateway;
const BASE_URL: &str = "http://localhost:22000/api";
pub struct HttpCardGateway;
impl CardGateway for HttpCardGateway {
async fn fetch_all(&self) -> Result<Vec<Card>, String> {
Request::get(&format!("{BASE_URL}/cards"))
.send()
.await
.map_err(|e| e.to_string())?
.json::<Vec<Card>>()
.await
.map_err(|e| e.to_string())
}
async fn create(&self, title: String, description: String) -> Result<Card, String> {
Request::post(&format!("{BASE_URL}/cards"))
.json(&json!({ "title": title, "description": description }))
.map_err(|e| e.to_string())?
.send()
.await
.map_err(|e| e.to_string())?
.json::<Card>()
.await
.map_err(|e| e.to_string())
}
async fn move_card(&self, id: &str, lane: &str, position: i32) -> Result<(), String> {
Request::patch(&format!("{BASE_URL}/cards/{id}/move"))
.json(&json!({ "lane": lane, "position": position }))
.map_err(|e| e.to_string())?
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
async fn delete(&self, id: &str) -> Result<(), String> {
Request::delete(&format!("{BASE_URL}/cards/{id}"))
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
}
gloo_net::http::Request es un wrapper sobre la Fetch API del navegador. El patrón es consistente: construir request, enviar, parsear respuesta, mapear errores.
HttpCardGateway es un struct unitario (sin campos). Funciona como namespace para los métodos. Si necesitáramos configuración (ej. base URL dinámica), agregaríamos campos.
Glosario
| Término | Definición |
|---|---|
| CSR | Client-Side Rendering: la app se renderiza en el navegador |
| Trunk | Bundler/dev-server para proyectos Rust WASM |
| mount_to_body | Monta un componente Leptos en el <body> del DOM |
| gloo-net | Crate que wrappea Fetch API para uso desde Rust WASM |
| web-sys | Bindings de Rust a las APIs del navegador (DOM, eventos) |
| wasm-bindgen | Herramienta que genera el código glue entre Rust WASM y JS |
| data-trunk | Atributo HTML que indica a Trunk cómo procesar assets |