Saltar al contenido

WASM vs binario nativo: cómo se distribuye

Cómo llega el compilador a tu máquina y por qué eso decide su velocidad. La era del compilador en Go compilado a WebAssembly y el peaje de cruzar la frontera entre JavaScript y WASM en cada archivo. El modelo de Astro 7 con Rust: binarios nativos por plataforma vía napi-rs, con WASM como red de seguridad. Y por qué el motor nativo rinde más que la versión anterior.

La era del compilador en Go sobre WASM

Durante casi toda su historia, el compilador de Astro fue un programa en Go que no se distribuía como binario suelto sino compilado a WebAssembly. El paquete @astrojs/compiler traía un archivo .wasm y un envoltorio de JavaScript que lo instanciaba y le hablaba. La elección tenía una virtud enorme: un solo .wasm corría igual en macOS, Linux y Windows, en cualquier arquitectura, sin compilar nada por plataforma. Instalabas Astro y el compilador funcionaba, fuera cual fuera tu máquina.

TypeScript
// modelo mental de la era WASM: instanciar el modulo y hablarleconst wasm = await WebAssembly.instantiate(bytesDelWasm, imports);// cada llamada cruza la frontera JS <-> WASM llevando y trayendo textoconst resultado = wasm.exports.transform(fuenteSerializada);

La razón de elegir Go y WASM fue sensata en su momento. Go compila a WebAssembly con facilidad y produce un binario autocontenido, y WASM daba lo más difícil de conseguir de otro modo: un solo artefacto que corre en todas partes, sin pedir a nadie un compilador de C ni herramientas nativas por sistema. Para un proyecto que quería instalarse sin fricción en cualquier máquina, esa universalidad valía su precio. La decisión no fue un error que Astro 7 corrige; fue un equilibrio correcto para su época que las circunstancias han desplazado.

Esa portabilidad tenía contrapartida. Un módulo WASM vive en un espacio de memoria propio, aislado del de JavaScript, y no comparte objetos con él: solo puede intercambiar números y bloques de bytes. Para pasarle tu .astro había que serializar la cadena a esa memoria, y para recibir el resultado había que copiarlo de vuelta. Además, el runtime de Go arrastraba su propio recolector de basura dentro del .wasm, con su peso de arranque. Nada de esto se notaba en un archivo; multiplicado por miles, se convertía en una porción real del tiempo de build.

El peaje de la frontera

Conviene nombrar con precisión el coste, porque es el villano de esta historia. Cada vez que JavaScript llama a una función dentro de WASM y le pasa una cadena, ocurre un cruce de frontera: los datos se copian del montón de JavaScript a la memoria lineal del módulo, se procesan allí, y el resultado se copia de vuelta. Ese ida y vuelta no es gratis, y su coste crece con el tamaño de lo que se transfiere —y una fuente .astro con sus estilos y su marcado no es precisamente pequeña—.

La causa técnica es concreta y vale entenderla. WebAssembly, en su forma clásica, no tiene un tipo string: sus únicas monedas son enteros y flotantes, y toda estructura más rica —una cadena de texto, un objeto de resultado— hay que codificarla como bytes en una región de memoria que ambos lados acuerdan leer. Pasar tu componente al compilador significaba, literalmente, escribir sus caracteres byte a byte en esa región y decirle al módulo dónde empiezan y cuántos son; recuperar el resultado significaba hacer el mismo baile a la inversa. Ese protocolo de codificación y copia es el marshaling, y su coste es proporcional al texto que cruza, no al trabajo útil que se hace con él.

El peaje tiene dos componentes que conviene separar porque escalan distinto:

  • El arranque: instanciar el módulo y levantar el runtime de Go cuesta un tiempo fijo cada vez que el proceso de build empieza. Se paga una vez por build.
  • El marshaling: la copia de datos de ida y vuelta en cada llamada. Se paga una vez por archivo, así que crece con el tamaño del proyecto.

En un sitio de diez páginas ninguno de los dos importa; en uno de diez mil, el marshaling se multiplica hasta dominar la percepción de lentitud. La frontera WASM era el precio de la portabilidad —un único binario para todas las plataformas— y en proyectos grandes ese precio se hacía visible en cada build y cada arranque del dev server.

El modelo nativo de Astro 7

Astro 7 reescribe el compilador en Rust y cambia la estrategia de distribución. En lugar de un .wasm universal, publica binarios nativos por plataforma empaquetados como addons de Node mediante napi-rs, la tecnología que también usan herramientas como Rolldown, SWC u Oxc. Al instalar Astro, el gestor de paquetes descarga solo el binario .node que corresponde a tu sistema operativo y arquitectura, gracias a las dependencias opcionales que declaran cada variante.

JSON
// el paquete declara un binario por plataforma como dependencias opcionales{  "optionalDependencies": {    "@astrojs/compiler-darwin-arm64": "7.0.0",    "@astrojs/compiler-linux-x64-gnu": "7.0.0",    "@astrojs/compiler-win32-x64-msvc": "7.0.0"  }}

El nombre de cada dependencia codifica la plataforma exacta —sistema operativo, arquitectura y, en Linux, la librería C—, de modo que el gestor de paquetes resuelve sin ambigüedad cuál instalar y descarta el resto. Es el mismo esquema con el que se distribuyen los binarios de las herramientas nativas más usadas del ecosistema.

En tiempo de ejecución, el envoltorio de JavaScript del compilador prueba a cargar el binario nativo de tu plataforma y, si no existe, recae en el WASM. La lógica es tan simple como esto:

JS
// esquema del cargador: primero lo nativo, si no el fallback universallet compiler;try {  compiler = require('@astrojs/compiler-' + platform); // addon .node} catch {  compiler = await loadWasm(); // fallback universal}

Ese require de un .node es una carga de módulo nativo de Node: el binario se enlaza al proceso y sus funciones quedan disponibles como llamadas ordinarias, sin runtime intermedio. La try que lo envuelve es toda la ceremonia que hace falta para que el fallback sea automático e invisible.

La diferencia de fondo es que un addon nativo no cruza una frontera de memoria aislada: comparte el proceso de Node, recibe la cadena sin copiarla a un espacio separado y devuelve el resultado sin marshaling. No hay runtime de Go que levantar, no hay serialización por archivo. Y como es Rust, el compilador puede repartir el trabajo entre todos los núcleos con seguridad de memoria garantizada por el lenguaje. Para las plataformas exóticas que no tengan binario, el paquete conserva un fallback a WASM: más lento, pero universal, de modo que nadie se queda sin compilar.

Nada de esta maquinaria asoma a tu código. Sigues llamando a transform con la misma firma que en la era WASM; lo que cambia es qué se carga por debajo cuando lo invocas —un .node nativo o, en su defecto, un .wasm—. Esa estabilidad de la interfaz es lo que permite que un cambio tan radical en la distribución no rompa ni un solo proyecto: la fachada del compilador es la misma, y solo se ha sustituido el motor que hay detrás de ella. La portabilidad no se pierde, se degrada con elegancia: primero se intenta lo rápido, y solo si no existe se recurre a lo universal.

Por qué el nativo rinde más

La ganancia de Astro 7 sobre la versión anterior no viene de que Rust sea mágicamente más rápido que Go —ambos son lenguajes compilados y veloces—, sino de haber quitado la frontera. Tres costes desaparecen a la vez:

  • El arranque del runtime de Go dentro del .wasm, que ya no existe.
  • La serialización de cada .astro a la memoria del módulo y su copia de vuelta.
  • La traducción de estructuras entre dos mundos: el nativo comparte datos con el resto de la cadena de build.

Sumado al paralelismo que Rust permite con seguridad de memoria, el efecto se dispara justo donde antes dolía: los sitios con miles de componentes, donde el marshaling se pagaba archivo por archivo. La versión anterior no era lenta por Go; era lenta por la aduana que Go tenía que cruzar en cada llamada.

En el día a día, esa frontera eliminada se nota en tres momentos concretos, los mismos que más repites al desarrollar. El arranque del servidor de desarrollo ya no espera a instanciar un módulo WASM ni a levantar el runtime de Go. El HMR —recompilar el componente que acabas de tocar— corre a velocidad nativa, sin serializar nada a una memoria aislada. Y el build de producción empaqueta miles de módulos sin que el compilador sea el cuello de botella. No es una cifra abstracta de benchmark: es la diferencia entre una terminal que te hace esperar y una que responde antes de que apartes la vista.