wandres.dev
SWIFT MULTIPLATAFORMA · Linux, Windows, Wasm

SwiftWasm: compilar a WebAssembly y pagar su precio

Cómo se compila Swift al objetivo `wasm32-unknown-wasi` con un SDK oficial, qué ofrece y qué niega el entorno WASI, por qué el binario resultante pesa lo que pesa y qué se puede hacer para reducirlo, y cuáles son los casos de uso donde este camino compensa de verdad.

⏱ 20 min

WebAssembly es la plataforma más extraña a la que Swift ha llegado, porque no es un sistema operativo sino una máquina abstracta con una superficie de sistema deliberadamente mínima y un anfitrión que decide qué se le concede. Compilar hacia allí obliga a enfrentarse a tres realidades a la vez: un entorno sin hilos y sin sockets, un runtime de lenguaje que hay que empaquetar entero dentro del artefacto, y un canal de distribución —la red— donde cada kilobyte se paga en tiempo de carga. Nada de eso convierte el objetivo en un juguete; lo convierte en una plataforma con un perfil de coste distinto, en la que las decisiones que en un servidor eran gratuitas dejan de serlo y donde el criterio de qué merece la pena portar cambia por completo.

🎯 Al terminar esta lección sabrás
  • Instalar el SDK de WebAssembly, compilar y ejecutar un artefacto para wasm32-unknown-wasi.
  • Delimitar qué concede WASI y qué hay que pedirle al anfitrión a través del puente con JavaScript.
  • Explicar de dónde sale el tamaño del binario y aplicar la secuencia de reducción con criterio.
  • Identificar los casos de uso en los que este objetivo aporta valor real frente a los que solo aportan novedad.

Compilar hacia la máquina abstracta

El camino es hoy el mismo que para cualquier otra compilación cruzada de Swift: instalar un SDK y pedirle a SwiftPM que lo use. No hace falta un compilador distinto ni una bifurcación del proyecto.

swift sdk install <url-del-sdk-de-webassembly>
swift sdk list
swift build --swift-sdk wasm32-unknown-wasi -c release
wasmtime .build/release/mi-programa.wasm

El artefacto resultante es un módulo que cualquier anfitrión conforme puede cargar: un runtime independiente en la consola, un servidor de funciones en el borde de la red o el motor del navegador. Para el caso del navegador, el paquete JavaScriptKit proporciona el puente en las dos direcciones, y hay herramientas de desarrollo que empaquetan el módulo, sirven la página y recargan al guardar, además de ejecutar las pruebas dentro del propio navegador.

import JavaScriptKit

let documento = JSObject.global.document
var boton = documento.createElement("button")
boton.innerText = "Pulsa"
boton.onclick = JSClosure { _ in
    JSObject.global.console.log("hola desde Swift")
    return .undefined
}
_ = documento.body.appendChild(boton)

Ese fragmento revela la naturaleza del puente: no hay tipos del navegador expuestos como tipos de Swift, hay un valor dinámico que representa cualquier valor de JavaScript y sobre el que se opera por nombre. Es potente, es completamente general y tiene un coste que conviene medir, porque cada cruce de la frontera implica convertir representaciones y, en el caso de las cadenas, copiarlas y recodificarlas.

ℹ️
Dos anfitriones, dos conjuntos de reglas

En un runtime de consola dispones de la superficie WASI: ficheros, reloj, argumentos, variables de entorno y generación de aleatorios, sujetos a lo que el anfitrión conceda explícitamente. En el navegador no hay nada de eso salvo lo que se emule, y todo lo demás —red, almacenamiento, temporizadores, interfaz— se pide a través del puente con JavaScript. El mismo binario puede necesitar dos capas de adaptación distintas.

Lo que hay y lo que falta

La biblioteca estándar funciona completa y ese es el punto de partida sólido: colecciones, cadenas con su segmentación Unicode, genéricos, protocolos y conteo de referencias se comportan igual que en cualquier otro objetivo. FoundationEssentials está disponible, con sus tipos de valor.

Las ausencias son estructurales y no son fallos que alguien vaya a corregir mañana. No hay hilos en el modelo básico: la concurrencia de Swift funciona, pero sobre un ejecutor de un solo hilo que en el navegador se engancha al bucle de eventos de la página, de modo que async y await sirven para intercalar trabajo, no para paralelizarlo. No hay sockets en la superficie clásica de WASI, así que toda la red pasa por el anfitrión: en el navegador, por la API de peticiones de JavaScript. No hay procesos: nada de lanzar subprocesos ni bifurcar. Y URLSession no está, con lo que cualquier código de cliente HTTP escrito contra ella necesita una implementación alternativa detrás de la misma abstracción.

La ausencia de URLSession es un buen ejemplo de por qué la arquitectura de la lección anterior paga aquí de inmediato. Si el núcleo pide la red a través de un protocolo propio, WebAssembly solo necesita un adaptador más; si el núcleo llama a URLSession directamente, hay que reescribirlo.

protocol Transporte: Sendable {
    func pedir(_ ruta: String) async throws -> Data
}

#if arch(wasm32)
struct TransporteFetch: Transporte { /* llama a fetch por el puente */ }
#endif

Hay además un detalle aritmético fácil de olvidar y capaz de producir errores muy sutiles: wasm32 es una arquitectura de treinta y dos bits, así que Int mide treinta y dos bits. Un código que da por hecho el rango de sesenta y cuatro desbordará aquí, y una serialización binaria que escriba enteros del tamaño nativo producirá ficheros incompatibles entre plataformas.

flowchart TB
A[Codigo Swift portable] --> B[swift build con el SDK de WebAssembly]
B --> C[Modulo wasm mas runtime de Swift empotrado]
C --> D[Optimizar y podar]
D --> E[Comprimir para servir por red]
E --> F[Navegador con puente a JavaScript]
E --> G[Runtime de consola o funcion en el borde]
F --> H[Sin hilos y sin sockets nativos]
G --> I[Superficie WASI concedida por el anfitrion]
style C fill:#f9e2af,color:#11111b
style E fill:#a6e3a1,color:#11111b
style H fill:#f38ba8,color:#11111b

El tamaño, que es el precio de entrada

Un programa mínimo en Swift compilado a WebAssembly no pesa unos kilobytes: pesa megabytes. La razón es que el artefacto no contiene solo tu código, sino la biblioteca estándar, el runtime del lenguaje con su gestión de memoria y su despacho dinámico, los metadatos de tipo que sostienen los genéricos y los existenciales, y la información de reflexión que permite inspeccionar valores en tiempo de ejecución. En un servidor todo eso es invisible; servido por la red, es la primera impresión que se lleva el usuario.

La secuencia de reducción tiene un orden y conviene respetarlo, midiendo después de cada paso.

swift build --swift-sdk wasm32-unknown-wasi -c release \
  -Xswiftc -Osize \
  -Xswiftc -disable-reflection-metadata \
  -Xlinker --gc-sections
wasm-opt -Os --strip-debug --strip-producers entrada.wasm -o salida.wasm
brotli -q 11 salida.wasm

Primero se optimiza por tamaño en lugar de por velocidad. Después se poda: la recolección de secciones elimina lo que nadie referencia y desactivar los metadatos de reflexión recorta un porcentaje notable, con la contrapartida de que el tipo de inspección en tiempo de ejecución deja de funcionar, algo que conviene comprobar porque hay bibliotecas que dependen de él sin decirlo. Luego se pasa el optimizador específico de WebAssembly, que hace un trabajo que el compilador no puede hacer porque opera sobre el módulo ya enlazado. Y por último se comprime, que es donde se gana el factor más grande de todos: el formato es muy compresible y lo que viaja por la red es el resultado comprimido, no el fichero en disco.

Si aun así el tamaño no cabe en el presupuesto, existe la salida radical: Swift embebido. Renuncia a la reflexión, a los existenciales y a buena parte de la biblioteca estándar a cambio de artefactos de decenas de kilobytes. No sirve para portar una aplicación entera, y sirve muy bien para publicar una función pura y bien delimitada.

📉

Mide comprimido

El número que importa es el que viaja por la red. Comparar tamaños en disco lleva a optimizar lo que el usuario nunca descarga.

🧮

Int mide treinta y dos bits

La arquitectura es de treinta y dos bits. Fija los tamaños explícitamente en cualquier serialización que cruce plataformas.

🌉

La frontera cuesta

Cada llamada hacia JavaScript convierte representaciones. Diseña interfaces gruesas: pocas llamadas con mucha carga, no muchas con poca.

Dónde compensa de verdad

El caso fuerte es reutilizar lógica que ya existe y que es cara de reescribir: un validador de reglas de negocio compartido entre la aplicación de iOS y la web, un analizador de un formato propio, un motor de cálculo, un evaluador de expresiones, una implementación criptográfica auditada. En todos ellos el valor no está en que Swift corra en el navegador, sino en que deja de haber dos implementaciones de la misma regla que divergen con el tiempo.

El segundo caso es ejecutar las pruebas del núcleo portable en el navegador, que verifica de paso que el código no dependía en secreto de hilos, de red directa o del tamaño de puntero.

El tercero es el servidor: complementos aislados dentro de un anfitrión, funciones en el borde de la red y ejecución determinista de código no confiable, donde la caja de arena de WebAssembly aporta una garantía de aislamiento que un proceso normal no da.

Y el caso débil, que conviene nombrar para no perder meses en él, es sustituir un marco de trabajo de interfaz web. La manipulación intensiva del árbol del documento a través del puente paga la frontera en cada operación y compite contra herramientas que llevan una década puliendo exactamente ese problema.

WebAssembly no es otra plataforma más: es el examen final de tu arquitectura

Todo lo que en un servidor podía darse por supuesto aquí hay que declararlo, y por eso este objetivo funciona como un revelador implacable del diseño de un programa. No hay hilos, así que el código que confundía concurrencia con paralelismo se cae; no hay sockets, así que la lógica que hablaba de red directamente en lugar de a través de una abstracción se cae; el puntero mide la mitad, así que las suposiciones aritméticas no escritas se caen; y cada byte del runtime se paga en la descarga, así que las dependencias contraídas por comodidad dejan de ser gratuitas y pasan a tener un precio visible en un panel de métricas que alguien mira. Esa es la aportación intelectual del objetivo, más allá de su utilidad práctica: convierte en coste medible aquello que en las demás plataformas era un coste difuso, y al hacerlo pone a prueba si tu núcleo era de verdad un núcleo o solo un conjunto de archivos que nunca habían salido de casa. De ahí que compilar a WebAssembly merezca la pena incluso en proyectos que no piensan desplegar allí, aunque solo sea como ejercicio periódico de higiene: el día que el artefacto compila y las pruebas pasan, sabes con certeza que tu lógica de dominio no depende del sistema operativo, y esa certeza no la da ninguna revisión de código por minuciosa que sea. La conclusión útil no es que haya que portarlo todo, sino que el límite hasta donde llega tu compilación a WebAssembly es la medida honesta de cuánto de tu programa es realmente portable.

📝
Lo esencial

Se compila con un SDK de Swift para wasm32-unknown-wasi y se ejecuta en un runtime o en el navegador con JavaScriptKit como puente. No hay hilos, ni sockets nativos, ni procesos, y Int mide treinta y dos bits. El tamaño se reduce optimizando por tamaño, podando reflexión y secciones, pasando el optimizador de WebAssembly y comprimiendo. Y el valor está en compartir lógica cara de duplicar, no en sustituir la interfaz web.

⚔️ Del núcleo a la página
  1. Instala el SDK de WebAssembly y compila tu módulo de dominio; anota el primer error y qué supuesto de plataforma lo causó.
  2. Mide el tamaño del artefacto tras cada paso de la secuencia de reducción y construye una tabla con la ganancia de cada uno.
  3. Expón una función pura al navegador con JavaScriptKit y compara su tiempo con la versión equivalente en JavaScript.
  4. Diseña la misma funcionalidad con una interfaz gruesa y otra fina, y mide el coste de cruzar la frontera muchas veces.
  5. Ejecuta la batería de pruebas del núcleo en el navegador y explica cuáles fallan y por qué no fallaban en el servidor.