← Volver al listado de tecnologías

Capítulo 10: Setup de Leptos y Compilación WASM

Por: SiempreListo
rustwasmaxumleptos

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:

CSR vs SSR

Leptos soporta ambos modos. Elegimos CSR (Client-Side Rendering) porque:

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:

CratePropósito
leptosFramework reactivo. features = ["csr"] activa Client-Side Rendering
serde / serde_jsonSerialización JSON para comunicación con la API
uuidGeneración de IDs. features = ["js"] usa crypto.getRandomValues del navegador
gloo-netCliente HTTP para WASM (wrapper sobre Fetch API del navegador)
web-sysBindings a APIs del navegador (DOM, eventos Drag & Drop)
wasm-bindgenPuente 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:

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);
}

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érminoDefinición
CSRClient-Side Rendering: la app se renderiza en el navegador
TrunkBundler/dev-server para proyectos Rust WASM
mount_to_bodyMonta un componente Leptos en el <body> del DOM
gloo-netCrate que wrappea Fetch API para uso desde Rust WASM
web-sysBindings de Rust a las APIs del navegador (DOM, eventos)
wasm-bindgenHerramienta que genera el código glue entre Rust WASM y JS
data-trunkAtributo HTML que indica a Trunk cómo procesar assets