Ruby on Rails en WebAssembly, el recorrido de pila completa en el navegador

Vladimir Dementyev
Vladimir Dementyev

Fecha de publicación: 31 de enero de 2025

Imagina ejecutar un blog completamente funcional en tu navegador, no solo el frontend, sino también el backend. No hay servidores ni nubes involucrados, solo tú, tu navegador y… WebAssembly. Dado que permite que los frameworks del servidor se ejecuten de forma local, WebAssembly desdibuja los límites del desarrollo web clásico y abre nuevas posibilidades emocionantes. En esta publicación, Vladimir Dementyev (director de backend de Evil Martians) comparte el progreso en la preparación de Ruby on Rails para Wasm y navegadores:

  • Cómo llevar Rails al navegador en 15 minutos
  • Detrás de escena de la wasmificación de Rails
  • El futuro de Rails y Wasm.

El famoso “blog en 15 minutos” de Ruby on Rails ahora se ejecuta directamente en tu navegador

Ruby on Rails es un framework web enfocado en la productividad de los desarrolladores y en la entrega rápida de productos. Es la tecnología que usan los líderes de la industria, como GitHub y Shopify. La popularidad del framework comenzó hace muchos años con el lanzamiento del famoso video "How to build a blog in 15 minutes", publicado por David Heinemeier Hansson (o DHH). En 2005, era imposible compilar una aplicación web que funcionara completamente en tan poco tiempo. ¡Fue como magia!

Hoy, me gustaría volver a traer este sentimiento mágico creando una aplicación de Rails que se ejecute por completo en tu navegador. Tu recorrido comienza con la creación de una aplicación básica de Rails de la forma habitual y, luego, su empaquetado para Wasm.

Antecedentes: un “blog en 15 minutos” en la línea de comandos

Si tienes Ruby y Ruby on Rails instalados en tu máquina, comienzas por crear una nueva aplicación de Ruby on Rails y crear un andamiaje para algunas funciones (al igual que en el video original "Blog en 15 minutos"):


$ rails new --css=tailwind web_dev_blog

  create  .ruby-version
  ...

$ cd web_dev_blog

$ bin/rails generate scaffold Post title:string date:date body:text

  create    db/migrate/20241217183624_create_posts.rb
  create    app/models/post.rb
  ...

$ bin/rails db:migrate

== 20241217183624 CreatePosts: migrating ====================
-- create_table(:posts)
   -> 0.0017s
== 20241217183624 CreatePosts: migrated (0.0018s) ===========

Sin siquiera tocar la base de código, ahora puedes ejecutar la aplicación y verla en acción:

$ bin/dev

=> Booting Puma
=> Rails 8.0.1 application starting in development
...
* Listening on http://127.0.0.1:3000

Ahora, puedes abrir tu blog en http://localhost:3000/posts y comenzar a escribir entradas.

Un blog de Ruby on Rails iniciado desde la línea de comandos que se ejecuta en el navegador.

Tienes una aplicación de blog muy básica, pero funcional, compilada en minutos. Es una aplicación de pila completa controlada por el servidor: tienes una base de datos (SQLite) para conservar tus datos, un servidor web para controlar las solicitudes HTTP (Puma) y un programa Ruby para conservar tu lógica empresarial, proporcionar una IU y procesar las interacciones del usuario. Por último, hay una capa delgada de JavaScript (Turbo) para optimizar la experiencia de navegación.

La demostración oficial de Rails continúa en la dirección de implementar esta aplicación en un servidor bare metal y, por lo tanto, prepararla para la producción. Tu recorrido continuará en la dirección opuesta: en lugar de colocar tu aplicación en un lugar lejano, la “implementarás” de forma local.

Próximo nivel: un "blog en 15 minutos" en Wasm

Desde que se agregó WebAssembly, los navegadores pudieron ejecutar no solo código JavaScript, sino cualquier código que se pudiera compilar en Wasm. Y Ruby no es una excepción. Sin duda, Rails es más que Ruby, pero antes de analizar las diferencias, continuemos con la demostración y wasmify (un verbo acuñado por la biblioteca wasmify-rails) la aplicación de Rails.

Solo debes ejecutar algunos comandos para compilar tu aplicación de blog en un módulo Wasm y ejecutarla en el navegador.

Primero, instala la biblioteca wasmify-rails con Bundler (el npm de Ruby) y ejecuta su generador con la CLI de Rails:

$ bundle add wasmify-rails

$ bin/rails wasmify:install

  create  config/wasmify.yml
  create  config/environments/wasm.rb
  ...
  info   The application is prepared for Wasm-ificaiton!

El comando wasmify:rails configura un entorno de ejecución "wasm" dedicado (además de los entornos predeterminados de "desarrollo", "prueba" y "producción") e instala las dependencias requeridas. Para una aplicación de Rails de campo nuevo, esto es suficiente para que esté lista para Wasm.

A continuación, compila el módulo principal de Wasm que contiene el entorno de ejecución de Ruby, la biblioteca estándar y todas las dependencias de la aplicación:

$ bin/rails wasmify:build

==> RubyWasm::BuildSource(3.3) -- Building
...
==> RubyWasm::CrossRubyProduct(ruby-3.3-wasm32-unknown-wasip1-full-4aaed4fbda7afe0bdf4e22167afd101e) -- done in 47.37s
INFO: Packaging gem: rake-13.2.1
...
INFO: Packaging gem: wasmify-rails-0.2.0
INFO: Packaging setup.rb: bundle/setup.rb
INFO: Size: 73.77 MB

Este paso puede demorar un poco: debes compilar Ruby desde la fuente para vincular correctamente las extensiones nativas (escritas en C) desde las bibliotecas de terceros. Más adelante en la publicación, se explica esta desventaja (temporal).

El módulo de Wasm compilado es solo una base para tu aplicación. También debes empaquetar el código de la aplicación y todos los recursos (por ejemplo, imágenes, CSS y JavaScript). Antes de empaquetar, crea una aplicación de selector básica que se pueda usar para ejecutar Rails wasmificado en el navegador. Para ello, también hay un comando generador:

$ bin/rails wasmify:pwa

  create  pwa
  create  pwa/boot.html
  create  pwa/boot.js
  ...
  prepend  config/wasmify.yml

El comando anterior genera una aplicación de PWA mínima compilada con Vite que se puede usar de forma local para probar el módulo Wasm compilado de Rails o implementarse de forma estática para distribuir la app.

Ahora, con el selector, todo lo que necesitas es empaquetar toda la aplicación en un solo objeto binario de Wasm:

$ bin/rails wasmify:pack
...
Packed the application to pwa/app.wasm
Size: 76.2 MB

Eso es todo. Ejecuta la app del selector y observa cómo se ejecuta completamente tu aplicación de blog de Rails en el navegador:

$ cd pwa/

$ yarn dev

  VITE v4.5.5  ready in 290 ms

    Local:   http://localhost:5173/

Ve a http://localhost:5173, espera un poco a que el botón “Launch” se active y haz clic en él. Disfruta de trabajar con la app de Rails que se ejecuta de forma local en tu navegador.

Un blog de Ruby on Rails que se inicia desde una pestaña del navegador que se ejecuta en otra pestaña del navegador.

¿No te parece mágico ejecutar una aplicación monolítica del servidor no solo en tu máquina, sino dentro de la zona de pruebas del navegador? Para mí (aunque sea el “mago”), sigue pareciendo una fantasía. Pero no hay magia, solo el progreso de la tecnología.

Demostración

Puedes experimentar la demostración incorporada en el artículo o iniciar la demostración en una ventana independiente. Consulta el código fuente en GitHub.

Detrás de escena de Rails en Wasm

Para comprender mejor los desafíos (y las soluciones) de empaquetar una aplicación del servidor en un módulo Wasm, en el resto de esta publicación, se explican los componentes que forman parte de esta arquitectura.

Una aplicación web depende de muchos más elementos que solo un lenguaje de programación que se usa para escribir el código de la aplicación. Cada componente también debe llevarse a tu _entorno de implementación local_: el navegador. Lo interesante de la demostración “blog en 15 minutos” es que se puede lograr sin reescribir el código de la aplicación. Se usó el mismo código para ejecutar la aplicación en un modo clásico del servidor y en el navegador.

Los componentes que conforman una app de Ruby on Rails: un servidor web, una base de datos, una cola y almacenamiento. Además, incluye los componentes principales de Ruby: las gemas, las extensiones nativas, las herramientas del sistema y la VM de Ruby.

Un framework, como Ruby on Rails, te brinda una interfaz, una abstracción para comunicarte con los componentes de la infraestructura. En la siguiente sección, se analiza cómo puedes emplear la arquitectura del framework para satisfacer las necesidades de entrega locales un tanto esotéricas.

La base: ruby.wasm

Ruby se convirtió oficialmente en compatible con Wasm en 2022 (desde la versión 3.2.0), lo que significa que el código fuente C se puede compilar en Wasm y llevar una VM de Ruby a cualquier lugar que desees. El proyecto ruby.wasm envía módulos precompilados y vinculaciones de JavaScript para ejecutar Ruby en el navegador (o cualquier otro entorno de ejecución de JavaScript). El proyecto ruby:wasm también incluye las herramientas de compilación que te permiten compilar una versión personalizada de Ruby con dependencias adicionales, lo que es muy importante para los proyectos que dependen de bibliotecas con extensiones de C. Sí, también puedes compilar extensiones nativas en Wasm. (bueno, aún no hay ninguna extensión, pero la mayoría de ellas).

Actualmente, Ruby es totalmente compatible con la interfaz del sistema de WebAssembly, WASI 0.1. WASI 0.2, que incluye el modelo de componentes, ya está en estado alfa y a pocos pasos de completarse.Una vez que se admita WASI 0.2, se eliminará la necesidad actual de volver a compilar todo el lenguaje cada vez que necesites agregar nuevas dependencias nativas: podrían estar en componentes.

Como efecto secundario, el modelo de componentes también debería ayudar a reducir el tamaño del paquete. Puedes obtener más información sobre el desarrollo y el progreso de ruby.wasm en la charla What you can do with Ruby on WebAssembly.

Por lo tanto, se resuelve la parte de Ruby de la ecuación de Wasm. Sin embargo, Rails, como framework web, necesita todos los componentes que se muestran en el diagrama anterior. Continúa leyendo para aprender a colocar otros componentes en el navegador y vincularlos en Rails.

Cómo conectarse a una base de datos que se ejecuta en el navegador

SQLite3 viene con una distribución de Wasm oficial y un wrapper de JavaScript correspondiente, por lo que está listo para incorporarse en el navegador. PostgreSQL para Wasm está disponible a través del proyecto PGlite. Por lo tanto, solo debes averiguar cómo conectarte a la base de datos en el navegador desde la aplicación de Rails en Wasm.

Un componente, o subframework, de Rails responsable del modelado de datos y las interacciones con la base de datos se denomina Active Record (sí, se llama así por el patrón de diseño de ORM). Active Record abstrae la implementación real de la base de datos que habla SQL del código de la aplicación a través de los adaptadores de la base de datos. De forma predeterminada, Rails te brinda adaptadores para SQLite3, PostgreSQL y MySQL. Sin embargo, todos suponen que se conectan a bases de datos reales disponibles en la red. Para superar esto, puedes escribir tus propios adaptadores para conectarte a bases de datos locales en el navegador.

De esta manera, se crean los adaptadores SQLite3 Wasm y PGlite implementados como parte del proyecto Wasmify Rails:

  • La clase del adaptador hereda del adaptador integrado correspondiente (por ejemplo, class PGliteAdapter < PostgreSQLAdapter), por lo que puedes volver a usar la lógica real de preparación de consultas y análisis de resultados.
  • En lugar de la conexión de base de datos de bajo nivel, usas un objeto de interfaz externa que reside en el entorno de ejecución de JavaScript, un puente entre un módulo de Wasm de Rails y una base de datos.

Por ejemplo, esta es la implementación del puente para SQLite3 Wasm:

export function registerSQLiteWasmInterface(worker, db, opts = {}) {
  const name = opts.name || "sqliteForRails";

  worker[name] = {
    exec: function (sql) {
      let cols = [];
      let rows = db.exec(sql, { columnNames: cols, returnValue: "resultRows" });

      return {
        cols,
        rows,
      };
    },

    changes: function () {
      return db.changes();
    },
  };
}

Desde la perspectiva de la aplicación, el cambio de una base de datos real a una en el navegador es solo una cuestión de configuración:

# config/database.yml
development:
  adapter: sqlite3

production:
  adapter: sqlite3

wasm:
  adapter: sqlite3_wasm
  js_interface: "sqliteForRails"

Trabajar con una base de datos local no requiere mucho esfuerzo. Sin embargo, si se requiere la sincronización de datos con alguna fuente de información central, es posible que te enfrentes a un desafío de un nivel superior. Esta pregunta está fuera del alcance de esta publicación (sugerencia: consulta la demo de Rails en PGlite y ElectricSQL).

Trabajador de servicio como servidor web

Otro componente esencial de cualquier aplicación web es un servidor web. Los usuarios interactúan con las aplicaciones web mediante solicitudes HTTP. Por lo tanto, necesitas una forma de enrutar las solicitudes HTTP activadas por la navegación o el envío de formularios a tu módulo Wasm. Afortunadamente, el navegador tiene una respuesta para eso: los trabajadores en segundo plano.

Un trabajador de servicio es un tipo especial de trabajador web que actúa como proxy entre la aplicación de JavaScript y la red. Puede interceptar solicitudes y manipularlas, por ejemplo, entregar datos almacenados en caché, redireccionar a otras URLs o a módulos Wasm. A continuación, se muestra un boceto de un servicio que funciona para entregar solicitudes con una aplicación de Rails que se ejecuta en Wasm:

// The vm variable holds a reference to the Wasm module with a
// Ruby VM initialized
let vm;
// The db variable holds a reference to the in-browser
// database interface
let db;

const initVM = async (progress, opts = {}) => {
  if (vm) return vm;
  if (!db) {
    await initDB(progress);
  }
  vm = await initRailsVM("/app.wasm");
  return vm;
};

const rackHandler = new RackHandler(initVM});

self.addEventListener("fetch", (event) => {
  // ...
  return event.respondWith(
    rackHandler.handle(event.request)
  );
});

La recuperación se activa cada vez que el navegador realiza una solicitud. Puedes obtener la información de la solicitud (URL, encabezados HTTP y cuerpo) y crear tu propio objeto de solicitud.

Rails, como la mayoría de las aplicaciones web de Ruby, depende de la interfaz de Rack para trabajar con solicitudes HTTP. La interfaz de Rack describe el formato de los objetos de solicitud y respuesta, así como la interfaz del controlador HTTP subyacente (aplicación). Puedes expresar estas propiedades de la siguiente manera:

request = {
   "REQUEST_METHOD" => "GET",
   "SCRIPT_NAME"    => "",
   "SERVER_NAME"  => "localhost",
   "SERVER_PORT" => "3000",
   "PATH_INFO"      => "/posts"
}

handler = proc do |env|
  [
    200,
    {"Content-Type" => "text/html"},
    ["<!doctype html><html><body>Hello Web!</body></html>"]
  ]
end

handler.call(request) #=> [200, {...}, [...]]

Si el formato de la solicitud te resulta familiar, es probable que hayas trabajado con CGI en el pasado.

El objeto JavaScript RackHandler es responsable de convertir solicitudes y respuestas entre los dominios de JavaScript y Ruby. Dado que la mayoría de las aplicaciones web de Ruby usan Rack, la implementación se vuelve universal, no específica de Rails. Sin embargo, la implementación real es demasiado larga para publicarla aquí.

Un trabajador de servicio es uno de los puntos clave de una aplicación web en el navegador. No solo es un proxy HTTP, sino también una capa de almacenamiento en caché y un conmutador de red (es decir, puedes compilar una aplicación en primer lugar local o con capacidad para funcionar sin conexión). Este también es un componente que puede ayudarte a entregar archivos subidos por los usuarios.

Cómo mantener las cargas de archivos en el navegador

Es probable que una de las primeras funciones adicionales que implementes en tu nueva aplicación de blog sea la compatibilidad con cargas de archivos o, más específicamente, la posibilidad de adjuntar imágenes a las publicaciones. Para lograrlo, necesitas una forma de almacenar y entregar archivos.

En Rails, la parte del framework responsable de controlar las cargas de archivos se llama Active Storage. Active Storage les brinda a los desarrolladores abstracciones e interfaces para trabajar con archivos sin tener que pensar en el mecanismo de almacenamiento de bajo nivel. No importa dónde almacenes tus archivos, en un disco duro o en la nube, el código de la aplicación no lo sabrá.

De manera similar a Active Record, para admitir un mecanismo de almacenamiento personalizado, todo lo que necesitas es implementar un adaptador de servicio de almacenamiento correspondiente. ¿Dónde se almacenan los archivos en el navegador?

La opción tradicional es usar una base de datos. Sí, puedes almacenar archivos como BLOBs en la base de datos, sin necesidad de componentes de infraestructura adicionales. Y ya hay un complemento listo para eso en Rails, Active Storage Database. Sin embargo, entregar archivos almacenados en una base de datos a través de la aplicación de Rails que se ejecuta en WebAssembly no es lo ideal, ya que implica rondas de serialización (des)serialización que no son gratuitas.

Una solución mejor y más optimizada para el navegador sería usar las APIs del sistema de archivos y procesar las cargas de archivos y los archivos subidos por el servidor directamente desde el trabajador de servicio. Un candidato perfecto para esa infraestructura es el OPFS (sistema de archivos privados de origen), una API de navegador muy reciente que, sin duda, desempeñará un papel importante en las futuras aplicaciones en el navegador.

Qué pueden lograr Rails y Wasm juntos

Seguramente te hiciste esta pregunta cuando comenzaste a leer el artículo: ¿por qué ejecutar un framework del servidor en el navegador? La idea de que un framework o una biblioteca sean del servidor (o del cliente) es solo una etiqueta. Un buen código y, en especial, una buena abstracción funcionan en todas partes. Las etiquetas no deben impedirte explorar nuevas posibilidades y superar los límites del framework (por ejemplo, Ruby on Rails) y del entorno de ejecución (WebAssembly). Ambos podrían beneficiarse de estos casos de uso poco convencionales.

También hay muchos casos de uso convencionales o prácticos.

En primer lugar, llevar el framework al navegador abre enormes oportunidades de aprendizaje y prototipado. Imagina poder jugar con bibliotecas, complementos y patrones directamente en tu navegador y junto con otras personas. Stackblitz hizo esto posible para los frameworks de JavaScript. Otro ejemplo es un WordPress Playground que permitía jugar con temas de WordPress sin salir de la página web. Wasm podría permitir algo similar para Ruby y su ecosistema.

Hay un caso especial de programación en el navegador que es especialmente útil para los desarrolladores de código abierto: priorizar y depurar problemas. Una vez más, StackBlitz hizo esto posible para los proyectos de JavaScript: creas una secuencia de comandos de reproducción mínima, señalas el vínculo en un problema de GitHub y les ahorras a los encargados el tiempo de reproducir tu situación. De hecho, ya comenzó a suceder en Ruby gracias al proyecto RunRuby.dev (este es un problema de ejemplo resuelto con la reproducción en el navegador).

Otro caso de uso son las aplicaciones compatibles (o conscientes) sin conexión. Son aplicaciones compatibles con el modo sin conexión que suelen funcionar con la red, pero que, cuando no hay conexión, se pueden seguir usando. Por ejemplo, un cliente de correo electrónico que te permita buscar en tu carpeta Recibidos sin conexión. También puedes usar una aplicación de biblioteca de música con la función “Almacenar en el dispositivo” para que tu música favorita siga sonando, incluso si no hay conexión de red. Ambos ejemplos dependen de los datos almacenados de forma local, no solo de una caché como en los PWA clásicos.

Por último, compilar aplicaciones locales (o de escritorio) con Rails también tiene sentido, porque la productividad que te brinda el framework no depende del entorno de ejecución. Los frameworks con todas las funciones son adecuados para compilar aplicaciones con muchos datos personales y lógica. Además, usar Wasm como formato de distribución portátil también es una opción viable.

Este es solo el comienzo de este recorrido de Rails en Wasm. Puedes obtener más información sobre los desafíos y las soluciones en el libro electrónico Ruby on Rails on WebAssembly (que, por cierto, es una aplicación de Rails capaz de funcionar sin conexión).