Definición del Component Model

Cortemos con el marketing. Un WASM Component es:

  1. Un binario .wasm que sigue la spec del Component Model (no un módulo wasm "core")
  2. Declara imports tipados (lo que necesita) y exports (lo que provee)
  3. Estos tipos se definen en archivos WIT (WebAssembly Interface Type)
  4. Los componentes se pueden componer, conectar en sus boundaries de import/export
  5. Cada componente tiene su propia memoria lineal (sandboxeado por default)

Acá va una definición WIT mínima para un componente de autenticación:

// auth.wit
package myorg:auth@1.0.0;

interface authenticate {
 record auth-request {
 token: string,
 required-scopes: list<string>,
 }

 record auth-result {
 authenticated: bool,
 user-id: option<string>,
 granted-scopes: list<string>,
 error: option<string>,
 }

 verify: func(req: auth-request) -> auth-result;
}

world auth-component {
 export authenticate;
}

Y la implementación en Rust:

// src/lib.rs
wit_bindgen::generate!({
 world: "auth-component",
 path: "../wit/auth.wit",
});

struct AuthComponent;

impl Guest for AuthComponent {
 fn verify(req: AuthRequest) -> AuthResult {
 let claims = match decode_jwt(&req.token) {
 Ok(c) => c,
 Err(e) => return AuthResult {
 authenticated: false,
 user_id: None,
 granted_scopes: vec![],
 error: Some(format!("JWT decode failed: {e}")),
 },
 };

 let granted: Vec<String> = req.required_scopes
 .iter()
 .filter(|s| claims.scopes.contains(s))
 .cloned()
 .collect();

 let all_granted = granted.len() == req.required_scopes.len();

 AuthResult {
 authenticated: all_granted,
 user_id: Some(claims.sub.clone()),
 granted_scopes: granted,
 error: if all_granted { None } else {
 Some("Insufficient scopes".into())
 },
 }
 }
}

export!(AuthComponent);

La macro wit_bindgen::generate! lee el archivo WIT y genera todas las definiciones de tipos y el código glue. Se implementa el trait, se exporta el struct, lo buildeas:

cargo component build --release

Eso produce un componente .wasm (no un módulo core) que cualquier runtime del Component Model puede cargar y llamar.

Composición Cross-Language: El Poder Real

Acá resulta interesante. Supongamos que tu componente de auth está en Rust, pero tu API gateway está en Go, y tu lógica de negocio está en Python (usando componentize-py). Cada uno se compila a un componente WASM separado.

Primero, el WIT del componente gateway en Go:

// gateway.wit
package myorg:gateway@1.0.0;

interface handle-request {
 record http-request {
 method: string,
 path: string,
 headers: list<tuple<string, string>>,
 body: option<list<u8>>,
 }

 record http-response {
 status: u16,
 headers: list<tuple<string, string>>,
 body: list<u8>,
 }

 handle: func(req: http-request) -> http-response;
}

world gateway-component {
 import myorg:auth/authenticate@1.0.0;
 import myorg:bizlogic/process@1.0.0;
 export handle-request;
}

La implementación en Go importa la interfaz de auth y la llama como una función local, sin HTTP, sin serialización:

package main

import (
 "myorg/gateway/gen"
 auth "myorg/auth/gen"
 bizlogic "myorg/bizlogic/gen"
)

type Gateway struct{}

func (g *Gateway) Handle(req gen.HttpRequest) gen.HttpResponse {
 token := extractBearerToken(req.Headers)

 // Llamar al componente auth de Rust, mismo proceso, sin red
 authResult := auth.Verify(auth.AuthRequest{
 Token: token,
 RequiredScopes: []string{"read:users"},
 })

 if !authResult.Authenticated {
 return gen.HttpResponse{
 Status: 401,
 Body: []byte(`{"error": "unauthorized"}`),
 }
 }

 // Llamar al componente de lógica de negocio en Python
 result := bizlogic.Process(bizlogic.ProcessRequest{
 UserId: *authResult.UserId,
 Action: "list_users",
 Payload: req.Body,
 })

 return gen.HttpResponse{
 Status: 200,
 Headers: []gen.Header{{"content-type", "application/json"}},
 Body: result.Data,
 }
}

Ahora los componemos:

# Buildear cada componente
cargo component build --release -p auth-component
tinygo build -target=wasip2 -o gateway.wasm ./gateway
componentize-py -d ./wit -w bizlogic-component ./bizlogic.py -o bizlogic.wasm

# Componer en un solo componente linkeado
wasm-tools compose \
 --definitions auth-component.wasm \
 --definitions bizlogic.wasm \
 -o composed-service.wasm \
 gateway.wasm

El composed-service.wasm es un solo binario conteniendo los tres componentes, con sus imports/exports conectados. Lo es posible correr en wasmtime, Cloudflare Workers, Fermyon Spin, o cualquier runtime del Component Model.

El Pipeline de Build en Detalle

Hacer funcionar esto en CI toma más esfuerzo del esperado. Acá está un Makefile que realmente funciona:

WIT_DIR := ./wit
BUILD_DIR := ./build
WASI_ADAPTER := ./adapters/wasi_snapshot_preview1.reactor.wasm

.PHONY: all clean validate compose

all: validate compose

validate:
	wasm-tools component wit $(WIT_DIR) --check

$(BUILD_DIR)/auth.wasm: auth/src/lib.rs $(WIT_DIR)/auth.wit
	cd auth && cargo component build --release
	cp auth/target/wasm32-wasip2/release/auth_component.wasm $@
	wasm-opt -Oz $@ -o $@

$(BUILD_DIR)/gateway.wasm: gateway/main.go $(WIT_DIR)/gateway.wit
	cd gateway && tinygo build -target=wasip2 -o ../$@
	wasm-tools component new $@ --adapt $(WASI_ADAPTER) -o $@

$(BUILD_DIR)/bizlogic.wasm: bizlogic/app.py $(WIT_DIR)/bizlogic.wit
	componentize-py -d $(WIT_DIR) -w bizlogic-component bizlogic/app.py -o $@

compose: $(BUILD_DIR)/auth.wasm $(BUILD_DIR)/gateway.wasm $(BUILD_DIR)/bizlogic.wasm
	wasm-tools compose \
		-d $(BUILD_DIR)/auth.wasm \
		-d $(BUILD_DIR)/bizlogic.wasm \
		-o $(BUILD_DIR)/composed-service.wasm \
		$(BUILD_DIR)/gateway.wasm
	@echo "Binario compuesto: $$(du -h $(BUILD_DIR)/composed-service.wasm | cut -f1)"

clean:
	rm -rf $(BUILD_DIR)/*

Dos lecciones importantes:

  1. La validación de WIT tiene que ir primero. Un typo en un archivo WIT produce errores inescrutables downstream. wasm-tools component wit --check los atrapa temprano.

  2. El adapter WASI es sensible a la versión. TinyGo, el target wasm32-wasip2 de Rust, y componentize-py cada uno espera una versión específica de WASI preview. El binario del adapter se debe pinar en el repo y referenciarlo explícitamente.

Rendimiento: Análisis de Compromisos

Acá van números reales de un deployment de producción. El setup tiene 12 componentes WASM (mezcla de Rust, Go y Python) compuestos en 3 servicios, corriendo en un orquestador custom basado en wasmtime 18 en bare metal.

Overhead de llamada entre componentes:

Tamaño del payload Latencia Notas
Vacío (sin args) 0.8μs Costo base de dispatch
Struct de 1KB 2.1μs Incluye serialización ABI canónica
Blob de 64KB 18μs Dominado por memcpy entre memorias lineales
Blob de 1MB 156μs Lineal con el tamaño, como se espera

Para contexto, una llamada gRPC por loopback es ~50μs para un mensaje vacío, y ~200μs para 1KB. Así que las llamadas entre componentes son ~25x más rápidas que gRPC para payloads chicos. La brecha se achica con payloads grandes porque ambos están bottlenecked en la copia de datos.

Overhead de CPU vs nativo (benchmark compute-bound, sin I/O):

Lenguaje del componente vs nativo Notas
Rust → WASM 1.15x Overhead mínimo, bounds checks
Go → WASM (TinyGo) 1.45x El GC de TinyGo es más simple, faltan algunas opts
Python → WASM 3.2x componentize-py incluye intérprete CPython

El número de Python se ve feo, pero recordar: este componente Python corre en el mismo proceso, sin overhead de container, sin cold start, y con sandboxing completo. Si la alternativa es un container Python con 800ms de cold start, 3.2x de overhead de CPU en una computación de 5ms es un trade fantástico.

Huella de memoria:

Deployment Memoria por instancia
12 containers Docker (1 por componente) ~3.2GB total
3 servicios WASM compuestos (4 componentes c/u) ~420MB total
Un solo WASM compuesto (los 12 componentes) ~180MB

Los ahorros de memoria son dramáticos porque los componentes WASM comparten el runtime (wasmtime) y solo alocan su propia memoria lineal. La memoria lineal de un componente Rust empieza en 64KB y crece bajo demanda.

La Historia del Sandboxing

Esta es la parte que convence a los equipos de seguridad. Cada componente WASM:

  • No puede acceder al filesystem a menos que se le dé explícitamente una capability wasi:filesystem
  • No puede hacer llamadas de red a menos que se le dé wasi:sockets
  • No puede leer la memoria de otro componente (memorias lineales separadas)
  • No puede ejecutar syscalls arbitrarios (modelo de capabilities de WASI)

Consideremos un vendor tercero que provee un componente de "enrichment de datos". En el mundo de containers, se necesitaría correrlo en un namespace de red separado, configurar políticas AppArmor/SELinux, armar filtrado de egress, etc. Con WASM, el componente se puede componer en el servicio con cero capabilities, puede computar sobre los datos que se le pasan y devolver resultados. Eso nomás. Si el código intenta llamar a casa, la llamada falla en el boundary de WASI, no en un firewall de red.

# Componer componente del vendor SIN capabilities WASI
wasm-tools compose \
 --definitions vendor-enrichment.wasm \
 --definitions our-service.wasm \
 -o final.wasm \
 orchestrator.wasm

Errores Comunes a Evitar

Error 1: Poner parsing de JSON en el boundary del componente. Un primer diseño común tiene componentes intercambiando strings JSON. Esto significaba que cada llamada cross-componente pagaba por serialize + deserialize + dos alocaciones de string. Cambiar a tipos record de WIT con el ABI canónico eliminó esto completamente.

Error 2: Un componente gigante en vez de composición. Un enfoque inicial común compila todo en un solo módulo WASM (no componente). Funcionaba pero perdía todos los beneficios de sandboxing y hacía imposible actualizar sin redeployar todo. La composición del Component Model es el punto, te deja actualizar un componente sin tocar los otros.

Error 3: Ignorar la versión del adapter. Es posible perder tres días debuggeando un segfault que resultó ser un mismatch entre el adapter WASI preview 1 y un componente buildeado para preview 2. Fijar las versiones de tu adapter. En serio.

Cuándo Usar Esto (y Cuándo No)

Utilizar el WASM Component Model cuando:

  • Se necesitan componer código de múltiples lenguajes en un solo proceso
  • El aislamiento de seguridad entre componentes es importante
  • Querés alta densidad de deployment (muchos servicios, memoria limitada)
  • Tus servicios son compute-heavy con intercambios de datos chicos

No lo uses cuando:

  • Todo tu código es un solo lenguaje (utilizar el tooling nativo de ese lenguaje)
  • Se necesitan intercambiar datasets grandes entre componentes (el overhead de copia se acumula)
  • Tu equipo no se siente cómodo con la complejidad del build toolchain
  • Se necesitan herramientas maduras de debugging/profiling (están mejorando, pero no llegaron todavía)

El Component Model no está reemplazando Kubernetes ni Docker. Es una herramienta nueva para una clase específica de problemas, y para esos problemas, es notablemente efectivo.