WebAssembly fuera del navegador: WASI y Wasm del lado del servidor
1. Descripción general
WebAssembly (Wasm) nació para ejecutar código de alto rendimiento en el navegador. Pero sus características (tamaño compacto, sandbox estricto, independencia de la arquitectura CPU) también son muy atractivas en el servidor.
En este artículo, entenderás qué es WASI y por qué Wasm del lado del servidor está ganando atención. Construirás un módulo Wasm, lo ejecutarás con un runtime de línea de comandos, lo incrustarás en una aplicación Java 21 y escribirás pruebas. Al final del artículo, conocerás los casos en los que no deberías usar Wasm.
2. ¿Por qué WebAssembly necesita WASI?
2.1. Wasm “puro” no puede hacer nada con el mundo exterior
Un módulo Wasm puro solo sabe hacer cálculos en su memoria lineal (linear memory). No puede abrir archivos por sí solo, leer variables de entorno, obtener la hora del sistema u abrir sockets. Toda interacción con el exterior debe pasar por funciones que el host (el entorno que ejecuta el módulo) proporciona como imports.
En el navegador, el host es JavaScript. Fuera del navegador, aún no hay un host estandarizado. Si cada runtime define su propio conjunto de APIs, un módulo compilado para uno no funcionará en otro.
2.2. WASI: interfaz de sistema estándar para Wasm
WASI (WebAssembly System Interface) es un conjunto de APIs estándar para operaciones de tipo sistema operativo: archivos, reloj, números aleatorios, variables de entorno, parámetros de línea de comandos, y en versiones más recientes, red. Un módulo compilado para WASI puede ejecutarse en cualquier runtime que soporte WASI, como Wasmtime, WasmEdge, Wasmer, o un runtime embebido en tu aplicación.
La diferencia más importante es el modelo de capability. El módulo no tiene permisos por defecto. El host debe conceder permisos explícitamente, por ejemplo, “puede leer el directorio ./data”. Sin permisos, la llamada falla. Esto es lo opuesto a un proceso normal, que hereda todos los permisos del usuario que lo ejecuta.
2.3. WASI Preview 1, Preview 2 y Component Model
WASI ha pasado por varias etapas. Encontrarás dos nombres comunes:
- Preview 1 (wasip1): APIs estilo función C, con el soporte más amplio.
- Preview 2 (wasip2): construida sobre el Component Model. Las interfaces se describen con un lenguaje IDL llamado WIT. Los componentes escritos en diferentes lenguajes pueden llamarse entre sí a través de tipos de datos más ricos como string, record, list, en lugar de solo números enteros.
El nivel de soporte varía según el runtime y toolchain. Antes de elegir, debes revisar la documentación del runtime que planeas usar.
3. ¿Por qué Wasm del lado del servidor está ganando atención?
Hay cuatro razones principales:
- Sandbox por defecto: El módulo no puede acceder a memoria fuera de su espacio ni tiene permisos del sistema a menos que se le concedan. Esto es perfecto para ejecutar código no confiable, como plugins de clientes o scripts cargados por usuarios.
- Inicio rápido, huella pequeña: Los módulos suelen ser ligeros e inicializarse mucho más rápido que un contenedor o una JVM nueva. Por eso Wasm es adecuado para serverless y edge computing, donde cada solicitud podría necesitar su propia instancia.
- Portabilidad: Un archivo
.wasmse ejecuta en x86 o ARM, Linux o macOS, siempre que haya un runtime disponible. - Multilenguaje: Rust, C/C++, Go, Zig, AssemblyScript… todos pueden compilarse a Wasm. Tu aplicación Java puede cargar lógica escrita en otros lenguajes sin necesidad de JNI.
Comparación rápida con contenedores:
| Criterio | Contenedor | Wasm + WASI |
|---|---|---|
| Unidad de empaque | Imagen que contiene el SO completo | Un archivo .wasm |
| Aislamiento | Namespace/cgroup del kernel | Sandbox del runtime + capability |
| Dependencia de arquitectura | Requiere imagen por arquitectura | No |
| Ecosistema | Muy maduro | En desarrollo |
Wasm no reemplaza los contenedores. Los dos suelen complementarse: aún puedes ejecutar un runtime Wasm dentro de un contenedor.
4. Compilar y ejecutar un programa WASI
El ejemplo usa Rust porque su toolchain Wasm es bastante conveniente. La idea se aplica de manera similar a otros lenguajes.
4.1. Programa que lee un archivo de órdenes
// src/main.rs
use std::fs;
fn main() {
match fs::read_to_string("orders.csv") {
Ok(content) => {
let total: f64 = content
.lines()
.filter_map(|line| line.split(',').nth(1))
.filter_map(|amount| amount.trim().parse::<f64>().ok())
.sum();
println!("Valor total de órdenes: {total}");
}
Err(e) => eprintln!("No se pudo leer el archivo: {e}"),
}
}
El programa lee orders.csv, toma la segunda columna (cantidad) y la suma. El código no tiene nada especial para Wasm: std::fs se mapeará a llamadas WASI cuando compile.
Agrega el target y compila:
rustup target add wasm32-wasip1
cargo build --release --target wasm32-wasip1
El resultado es el archivo target/wasm32-wasip1/release/order-report.wasm (el nombre depende de tu package).
4.2. Ejecutar con Wasmtime y ver cómo funciona la capability
Crea un archivo de datos:
ORD-1,120.5
ORD-2,79.5
ORD-3,300
Ejecuta sin conceder permiso de directorio:
wasmtime run target/wasm32-wasip1/release/order-report.wasm
No se pudo leer el archivo: failed to find a pre-opened file descriptor through which "orders.csv" could be opened
El módulo se ejecuta pero no ve ningún archivo. No tiene capability con el filesystem. Ahora concede permiso al directorio actual:
wasmtime run --dir=. target/wasm32-wasip1/release/order-report.wasm
Valor total de órdenes: 500
La opción --dir hace un “pre-open” del directorio para el módulo. El módulo solo ve ese directorio específico, no ve /etc ni tu directorio home. El contenido del mensaje de error puede variar entre versiones de runtime, pero el comportamiento es el mismo.
Nota: La sintaxis de opciones (
--dir,--env, mapeo de directorios entre host y guest) puede cambiar entre versiones de Wasmtime. Verificawasmtime run --helpen tu máquina.
5. Embeber Wasm en una aplicación Java
Wasm del lado del servidor no es solo ejecutar un archivo .wasm desde CLI. Una forma muy práctica es embeber el runtime en una aplicación existente para ejecutar plugins o lógica proporcionada por terceros.
Con Java, hay dos enfoques:
- Bindings a runtimes nativos (Wasmtime, WasmEdge) a través de JNI o FFM.
- Usar un runtime escrito completamente en Java, como Chicory. No requiere bibliotecas nativas, por lo que es fácil empaquetarlo y ejecutarlo en cualquier lugar que tenga JVM.
El ejemplo siguiente usa Chicory.
5.1. Módulo para calcular costo de envío
Supongamos que un socio proporciona la lógica de cálculo de envío como Wasm:
// src/lib.rs, Cargo.toml tiene: [lib] crate-type = ["cdylib"]
#[no_mangle]
pub extern "C" fn shipping_fee(weight_grams: i32, distance_km: i32) -> i32 {
let base = 15_000;
let by_weight = (weight_grams / 500) * 5_000;
let by_distance = distance_km * 1_000;
base + by_weight + by_distance
}
rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown
cp target/wasm32-unknown-unknown/release/shipping.wasm src/main/resources/
Este módulo no usa WASI. Solo hace cálculos, por lo que el target wasm32-unknown-unknown es suficiente y el módulo no necesita ningún import.
5.2. Dependencia Maven
<properties>
<java.version>21</java.version>
<!-- Verifica la versión más reciente en Maven Central -->
<chicory.version>REPLACE_WITH_LATEST</chicory.version>
</properties>
<dependencies>
<dependency>
<groupId>com.dylibso.chicory</groupId>
<artifactId>runtime</artifactId>
<version>${chicory.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Si usas Spring Boot 3.x, las versiones de JUnit y AssertJ ya están gestionadas a través de spring-boot-starter-test.
5.3. Llamar a funciones Wasm desde Java
package dev.nonla.shipping;
import com.dylibso.chicory.runtime.ExportFunction;
import com.dylibso.chicory.runtime.Instance;
import com.dylibso.chicory.wasm.Parser;
import java.io.InputStream;
public class WasmShippingCalculator {
private final ExportFunction shippingFee;
public WasmShippingCalculator(String resource) {
try (InputStream wasm = getClass().getResourceAsStream(resource)) {
var module = Parser.parse(wasm);
Instance instance = Instance.builder(module).build();
this.shippingFee = instance.export("shipping_fee");
} catch (Exception e) {
throw new IllegalStateException("No se pudo cargar el módulo " + resource, e);
}
}
public int fee(int weightGrams, int distanceKm) {
long[] result = shippingFee.apply(weightGrams, distanceKm);
return (int) result[0];
}
}
Explicación:
Parser.parse(...)lee el bytecode Wasm en un modelo de módulo.Instance.builder(module).build()crea una instancia con su propia memoria. El módulo no importa nada, por lo que no necesitas proporcionar host functions.instance.export("shipping_fee")obtiene la función exportada. El nombre debe coincidir con#[no_mangle]en Rust.apply(...)toma y devuelvelong[], porque los tipos numéricos de Wasm (i32,i64…) se representan comolong. Tú mismo haces cast aint.
Prueba ejecutar:
var calculator = new WasmShippingCalculator("/shipping.wasm");
System.out.println(calculator.fee(1_200, 10));
35000
El resultado consta de 15.000 en tarifa base, más 2 × 5.000 por peso (1.200 / 500 = 2), más 10 × 1.000 por distancia.
5.4. Pruebas de comportamiento
package dev.nonla.shipping;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class WasmShippingCalculatorTest {
static WasmShippingCalculator calculator;
@BeforeAll
static void setUp() {
calculator = new WasmShippingCalculator("/shipping.wasm");
}
@Test
void dadoPaqueteLigeroCerca_cuandoCosto_entoncesApenasTarifaYDistancia() {
assertThat(calculator.fee(300, 2)).isEqualTo(17_000);
}
@Test
void dadoPaquetePesadoLejano_cuandoCosto_entoncesTodosLosComponentesSumados() {
assertThat(calculator.fee(2_000, 50)).isEqualTo(15_000 + 20_000 + 50_000);
}
@Test
void dadoModuloFaltante_cuandoCrear_entoncesErrorRapido() {
assertThatThrownBy(() -> new WasmShippingCalculator("/not-found.wasm"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("not-found.wasm");
}
}
La tercera prueba asegura que la aplicación falle inmediatamente durante la inicialización si falta el módulo, en lugar de esperar hasta la primera solicitud.
Nota: Una
Instancetiene su propia memoria y estado. No asumas que sea seguro para usar entre múltiples threads. Para módulos que solo hacen cálculos, la forma más simple es crear una instancia por solicitud o usar un pool de instancias. El módulo ya parseado se puede reutilizar para crear múltiples instancias.
5.5. Cuando el módulo necesita WASI
Si un plugin necesita leer archivos o escribir en stdout, importará funciones WASI. Debes proporcionar estas funciones al crear la instancia. Chicory tiene un módulo wasi separado para esto. El principio sigue siendo capability: solo proporciona los directorios, variables de entorno y stdout/stderr que el plugin realmente necesita. No “heredes” todo el entorno del host por conveniencia.
6. Patrones de aplicación práctica
- Sistema de plugins: Permite que los clientes o socios personalicen la lógica (cálculo de precios, verificación de reglas, transformación de datos) sin ejecutar su código directamente en la JVM.
- Motor de políticas: Empaqueta reglas de autorización como módulos Wasm para compartir entre múltiples servicios escritos en diferentes lenguajes.
- Serverless y edge functions: Cada solicitud se ejecuta en una instancia aislada que se inicia rápidamente.
- Reutilizar bibliotecas nativas: Compila una biblioteca C/Rust (parser, codec, algoritmo) a Wasm y llámala desde Java, evitando JNI y el riesgo de crashes en toda la JVM.
- Extensión de infraestructura: Algunos proxies, bases de datos y brokers de mensajes permiten extensiones con Wasm.
7. Cuándo NO debes usar Wasm
Wasm del lado del servidor es atractivo, pero no siempre es la opción correcta:
- Lógica ya bien escrita en Java y que no necesita aislamiento: Agregar una capa Wasm solo aumenta la complejidad, el costo de transferencia de datos y la dificultad de depuración.
- Cargas de trabajo que requieren multithreading pesado, GPU o SIMD especializados: El soporte de threads y características de hardware en Wasm aún es limitado e inconsistente entre runtimes.
- Aplicaciones que dependen mucho de red y el ecosistema de bibliotecas: Sockets, TLS, controladores de base de datos a través de WASI aún no son tan completos como en un OS real. Muchas bibliotecas aún no se pueden compilar a Wasm.
- Llamadas frecuentes con grandes volúmenes de datos: Cada transferencia de string o array entre host y guest debe copiarse en la memoria lineal. Si llamas a funciones Wasm millones de veces con payloads grandes, este costo es significativo. Agrupa el trabajo en llamadas grandes y menos frecuentes.
- Creer que sandbox equivale a seguridad absoluta: Wasm aísla la memoria, pero no bloquea automáticamente bucles infinitos ni consumo excesivo de CPU. Aún necesitas timeouts, límites de memoria y mecanismos de interrupción si el runtime lo soporta (fuel, epoch interruption…).
- Equipo no preparado: Toolchains, debuggers y observabilidad para Wasm aún están en desarrollo. No lo introduzcas en sistemas críticos solo porque está “de moda”.
Algunos errores comunes:
- Compilar con el target incorrecto. Módulos que usan
std::fsoprintln!necesitan el target WASI. Módulos que solo hacen cálculos usanwasm32-unknown-unknown. - Olvidar
#[no_mangle]oextern "C", haciendo que la función no aparezca en la lista de exportaciones. - Confusión de tipos: Wasm solo tiene tipos numéricos primitivos. Para pasar strings, debes gestionar manualmente la asignación en memoria lineal o usar Component Model con WIT.
- Conceder permisos demasiado amplios (
--dir=/, heredar todo el entorno), perdiendo el mayor beneficio de WASI.
8. Conclusión
WASI transforma WebAssembly de una tecnología del navegador en un formato de ejecución portable, ligero y con sandbox estricto para el lado del servidor. El modelo de capability (sin permisos a menos que se concedan) es la diferencia fundamental respecto a procesos y contenedores tradicionales.
En este artículo, compilaste un programa WASI y viste cómo se controlan los permisos del filesystem. También incrustaste un módulo Wasm en una aplicación Java 21 usando un runtime puro de Java y escribiste pruebas para él.
Considera Wasm cuando necesites ejecutar código no confiable, quieras plugins multilenguaje o requieras inicio ultra rápido. Pero si el problema es solo lógica empresarial común en un servicio Java, la solución más simple sigue siendo la mejor.