Ruby on Rails no WebAssembly: a jornada completa no navegador

Vladimir Dementyev
Vladimir Dementyev

Publicado em 31 de janeiro de 2025

Imagine executar um blog totalmente funcional no navegador, não apenas o front-end, mas também o back-end. Sem servidores ou nuvens envolvidos, apenas você, seu navegador e o WebAssembly. Ao permitir que os frameworks do lado do servidor sejam executados localmente, o WebAssembly está desfocando os limites do desenvolvimento clássico da Web e abrindo novas possibilidades. Neste post, Vladimir Dementyev (chefe de back-end da Evil Martians) compartilha o progresso na preparação do Ruby on Rails para Wasm e navegadores:

  • Como trazer o Rails para o navegador em 15 minutos.
  • Bastidores da Rails wasmification.
  • Futuro do Rails e do Wasm.

O famoso "blog em 15 minutos" do Ruby on Rails agora está disponível no seu navegador

O Ruby on Rails é um framework da Web focado na produtividade do desenvolvedor e na entrega rápida de coisas. É a tecnologia usada por líderes do setor, como GitHub e Shopify. A popularidade do framework começou há muitos anos com o lançamento do famoso "Como criar um blog em 15 minutos" publicado por David Heinemeier Hansson (ou DHH). Em 2005, era impossível criar um aplicativo da Web totalmente funcional em tão pouco tempo. Foi como mágica.

Hoje, gostaria de trazer essa sensação mágica de volta criando um aplicativo Rails que seja executado totalmente no navegador. Sua jornada começa com a criação de um aplicativo Rails básico da maneira usual e, em seguida, o empacotamento para Wasm.

Contexto: um "blog em 15 minutos" na linha de comando

Supondo que você tenha Ruby e Ruby on Rails instalados na sua máquina, começe criando um novo aplicativo Ruby on Rails e criando um esqueleto de algumas funcionalidades (como no vídeo original "blog em 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) ===========

Sem nem mesmo tocar na base de código, agora você pode executar o aplicativo e conferir em ação:

$ bin/dev

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

Agora você pode abrir seu blog em http://localhost:3000/posts e começar a escrever postagens.

Um blog Ruby on Rails iniciado na linha de comando em execução no navegador.

Você tem um aplicativo de blog muito básico, mas funcional, criado em minutos. É um aplicativo completo controlado pelo servidor: você tem um banco de dados (SQLite) para manter seus dados, um servidor da Web para processar solicitações HTTP (Puma) e um programa Ruby para manter sua lógica de negócios, fornecer a interface e processar as interações do usuário. Por fim, há uma camada fina de JavaScript (Turbo) para agilizar a experiência de navegação.

A demonstração oficial do Rails continua na direção de implantar este aplicativo em um servidor bare metal e, assim, deixá-lo pronto para produção. Sua jornada vai continuar na direção oposta: em vez de colocar o aplicativo em algum lugar distante, você vai "implantá-lo" localmente.

Avanço: um "blog em 15 minutos" no Wasm

Desde a adição do WebAssembly, os navegadores passaram a executar não apenas códigos JavaScript, mas também qualquer código combinável em Wasm. E o Ruby não é uma exceção. O Rails é mais do que Ruby, mas antes de entrar nas diferenças, vamos continuar a demonstração e wasmify (um verbo criado pela biblioteca wasmify-rails) o aplicativo Rails.

Você só precisa executar alguns comandos para compilar o aplicativo do blog em um módulo Wasm e executá-lo no navegador.

Primeiro, instale a biblioteca wasmify-rails usando o Bundler (o npm do Ruby) e execute o gerador usando o Rails CLI:

$ 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!

O comando wasmify:rails configura um ambiente de execução "wasm" dedicado, além dos ambientes padrão de "desenvolvimento", "teste" e "produção", e instala as dependências necessárias. Para um aplicativo Rails novo, isso é suficiente para torná-lo pronto para Wasm.

Em seguida, crie o módulo Wasm principal que contém o ambiente de execução do Ruby, a biblioteca padrão e todas as dependências do aplicativo:

$ 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

Essa etapa pode levar algum tempo: você precisa criar o Ruby a partir da origem para vincular corretamente as extensões nativas (escritas em C) das bibliotecas de terceiros. Essa desvantagem (temporária) será abordada mais adiante na postagem.

O módulo Wasm compilado é apenas uma base para o aplicativo. Você também precisa empacotar o código do aplicativo e todos os recursos (por exemplo, imagens, CSS, JavaScript). Antes de fazer o empacotamento, crie um aplicativo de inicialização básico que possa ser usado para executar o Rails wasmificado no navegador. Para isso, há também um comando de geração:

$ bin/rails wasmify:pwa

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

O comando anterior gera um aplicativo PWA mínimo criado com o Vite, que pode ser usado localmente para testar o módulo Rails Wasm compilado ou ser implantado de forma estática para distribuir o app.

Agora, com o iniciador, tudo o que você precisa é empacotar todo o aplicativo em um único binário Wasm:

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

Pronto! Execute o app de inicialização e confira o aplicativo de blog Rails em execução no navegador:

$ cd pwa/

$ yarn dev

  VITE v4.5.5  ready in 290 ms

    Local:   http://localhost:5173/

Acesse http://localhost:5173, aguarde um pouco até que o botão "Launch" fique ativo e clique nele. Aproveite o app Rails em execução localmente no seu navegador.

Um blog Ruby on Rails iniciado em uma guia do navegador em execução em outra guia do navegador.

Não é como mágica executar um aplicativo monolítico do lado do servidor não apenas na sua máquina, mas no sandbox do navegador? Para mim, mesmo sendo o "feiticeiro", ainda parece uma fantasia. Mas não há magia envolvida, apenas o progresso da tecnologia.

Demonstração

Você pode conferir a demonstração incorporada no artigo ou iniciar a demonstração em uma janela independente. Confira o código-fonte no GitHub.

Bastidores do Rails no Wasm

Para entender melhor os desafios (e soluções) de empacotar um aplicativo do lado do servidor em um módulo Wasm, o restante desta postagem explica os componentes que fazem parte dessa arquitetura.

Um aplicativo da Web depende de muitas outras coisas além de uma linguagem de programação usada para escrever o código do aplicativo. Cada componente também precisa ser levado para seu_ ambiente de implantação local_, o navegador. O que é interessante na demonstração "blog em 15 minutos" é que isso pode ser feito sem reescrever o código do aplicativo. O mesmo código foi usado para executar o aplicativo em um modo clássico do lado do servidor e no navegador.

Os componentes que compõem um app Ruby on Rails: um servidor da Web, um banco de dados, uma fila e armazenamento. Além dos componentes principais do Ruby: as gems, extensões nativas, ferramentas do sistema e a VM do Ruby.

Um framework, como o Ruby on Rails, oferece uma interface, uma abstração para se comunicar com os componentes de infraestrutura. A seção a seguir discute como usar a arquitetura do framework para atender às necessidades de veiculação local um tanto esotéricas.

A base: ruby.wasm

O Ruby se tornou oficialmente compatível com Wasm em 2022 (desde a versão 3.2.0), o que significa que o código-fonte C pode ser compilado para Wasm e trazer uma VM Ruby para qualquer lugar. O projeto ruby.wasm envia módulos pré-compilados e vinculações JavaScript para executar o Ruby no navegador (ou qualquer outro ambiente de execução JavaScript). O projeto ruby:wasm também vem com as ferramentas de build que permitem criar uma versão personalizada do Ruby com dependências adicionais. Isso é muito importante para projetos que dependem de bibliotecas com extensões C. Sim, você também pode compilar extensões nativas no Wasm. Bem, ainda não há nenhuma extensão, mas a maioria delas.

Atualmente, o Ruby oferece suporte total à interface do sistema WebAssembly, WASI 0.1. A WASI 0.2, que inclui o modelo de componentes, já está no estado alfa e está a algumas etapas da conclusão.Quando a WASI 0.2 tiver suporte, ela eliminará a necessidade atual de recompilar todo o idioma sempre que você precisar adicionar novas dependências nativas: elas podem ser divididas em componentes.

Como efeito colateral, o modelo de componente também ajuda a reduzir o tamanho do pacote. Saiba mais sobre o desenvolvimento e o progresso do ruby.wasm na palestra O que você pode fazer com Ruby no WebAssembly.

Assim, a parte Ruby da equação Wasm foi resolvida. No entanto, o Rails como framework da Web precisa de todos os componentes mostrados no diagrama anterior. Continue lendo para saber como colocar outros componentes no navegador e vinculá-los no Rails.

Conectar-se a um banco de dados em execução no navegador

O SQLite3 vem com uma distribuição oficial do Wasm e um wrapper de JavaScript correspondente, portanto, está pronto para ser incorporado no navegador. O PostgreSQL para Wasm está disponível no projeto PGlite. Portanto, você só precisa descobrir como se conectar ao banco de dados no navegador do aplicativo Rails no Wasm.

Um componente, ou subframework, do Rails responsável pela modelagem de dados e interações de banco de dados é chamado de Active Record (sim, nomeado após o padrão de design ORM). O Active Record abstrai a implementação real do banco de dados SQL do código do aplicativo usando os adaptadores de banco de dados. O Rails oferece adaptadores SQLite3, PostgreSQL e MySQL. No entanto, todos eles assumem a conexão com bancos de dados reais disponíveis na rede. Para resolver esse problema, você pode escrever seus próprios adaptadores para se conectar a bancos de dados locais no navegador.

Confira como os adaptadores SQLite3 Wasm e PGlite implementados como parte do projeto Wasmify Rails são criados:

  • A classe do adaptador herda do adaptador integrado correspondente (por exemplo, class PGliteAdapter < PostgreSQLAdapter), para que você possa reutilizar a preparação de consulta e a lógica de análise de resultados.
  • Em vez da conexão de banco de dados de baixo nível, use um objeto de interface externa que fica no ambiente de execução do JavaScript, uma ponte entre um módulo Rails Wasm e um banco de dados.

Por exemplo, esta é a implementação da ponte para o 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();
    },
  };
}

Do ponto de vista do aplicativo, a mudança de um banco de dados real para um no navegador é apenas uma questão de configuração:

# config/database.yml
development:
  adapter: sqlite3

production:
  adapter: sqlite3

wasm:
  adapter: sqlite3_wasm
  js_interface: "sqliteForRails"

Trabalhar com um banco de dados local não exige muito esforço. No entanto, se a sincronização de dados com alguma fonte de verdade central for necessária, você poderá enfrentar um desafio de nível mais alto. Essa pergunta está fora do escopo deste post (dica: confira a demonstração do Rails no PGlite e no ElectricSQL).

Service worker como um servidor da Web

Outro componente essencial de qualquer aplicativo da Web é um servidor da Web. Os usuários interajam com aplicativos da Web usando solicitações HTTP. Portanto, você precisa de uma maneira de encaminhar solicitações HTTP acionadas por navegação ou envios de formulários para o módulo Wasm. Felizmente, o navegador tem uma resposta para isso: service workers.

Um worker de serviço é um tipo especial de worker da Web que atua como um proxy entre o aplicativo JavaScript e a rede. Ele pode interceptar solicitações e manipular, por exemplo: fornecer dados em cache, redirecionar para outros URLs ou… para módulos Wasm. Este é um esboço de um serviço que processa solicitações de exibição usando um aplicativo Rails em execução no 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)
  );
});

O "fetch" é acionado sempre que uma solicitação é feita pelo navegador. Você pode extrair as informações da solicitação (URL, cabeçalhos HTTP, corpo) e criar seu próprio objeto de solicitação.

O Rails, como a maioria dos aplicativos da Web Ruby, depende da interface Rack para trabalhar com solicitações HTTP. A interface Rack descreve o formato dos objetos de solicitação e resposta, bem como a interface do gerenciador HTTP (aplicativo) subjacente. É possível expressar essas propriedades da seguinte maneira:

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, {...}, [...]]

Se o formato da solicitação parecer familiar, provavelmente você já trabalhou com CGI.

O objeto JavaScript RackHandler é responsável por converter solicitações e respostas entre os domínios JavaScript e Ruby. Como o Rack é usado pela maioria dos aplicativos da Web Ruby, a implementação se torna universal, não específica do Rails. A implementação real é muito longa para postar aqui.

Um worker de serviço é um dos pontos principais de um aplicativo da Web no navegador. Ele não é apenas um proxy HTTP, mas também uma camada de armazenamento em cache e um comutador de rede. Isso significa que você pode criar um aplicativo com foco local ou com capacidade off-line. Esse também é um componente que pode ajudar a enviar arquivos enviados pelo usuário.

Manter os uploads de arquivos no navegador

Um dos primeiros recursos adicionais a serem implementados no seu aplicativo de blog provavelmente será o suporte a uploads de arquivos ou, mais especificamente, a anexar imagens a postagens. Para isso, você precisa de uma maneira de armazenar e exibir arquivos.

No Rails, a parte do framework responsável por lidar com uploads de arquivos é chamada de Active Storage. O Active Storage oferece abstrações e interfaces aos desenvolvedores para trabalhar com arquivos sem pensar no mecanismo de armazenamento de baixo nível. Não importa onde você armazene seus arquivos, em um disco rígido ou na nuvem, o código do aplicativo não tem conhecimento disso.

Assim como no Active Record, para oferecer suporte a um mecanismo de armazenamento personalizado, basta implementar um adaptador de serviço de armazenamento correspondente. Onde armazenar arquivos no navegador?

A opção tradicional é usar um banco de dados. Sim, é possível armazenar arquivos como blobs no banco de dados, sem a necessidade de componentes de infraestrutura adicionais. E já existe um plug-in pronto para isso no Rails, Active Storage Database. No entanto, o envio de arquivos armazenados em um banco de dados pelo aplicativo Rails em execução no WebAssembly não é ideal, porque envolve rodadas de (des-)serialização que não são sem custo financeiro.

Uma solução melhor e mais otimizada para navegadores seria usar APIs do sistema de arquivos e processar uploads de arquivos e arquivos enviados pelo servidor diretamente do worker de serviço. Um candidato perfeito para essa infraestrutura é o OPFS (origin private file system, ou sistema de arquivos particular de origem, em tradução livre), uma API de navegador muito recente que com certeza vai desempenhar um papel importante para os aplicativos no navegador do futuro.

O que Rails e Wasm podem alcançar juntos

Tenho certeza de que você já se fez essa pergunta ao começar a ler o artigo: por que executar uma estrutura do lado do servidor no navegador? A ideia de que um framework ou uma biblioteca é do lado do servidor (ou do cliente) é apenas um rótulo. Um bom código e, principalmente, uma boa abstração funcionam em qualquer lugar. Os rótulos não devem impedir você de explorar novas possibilidades e ultrapassar os limites do framework (por exemplo, Ruby on Rails), bem como os limites do ambiente de execução (WebAssembly). Ambos podem se beneficiar desses casos de uso não convencionais.

Há muitos casos de uso convencionais ou práticos.

Primeiro, trazer o framework para o navegador abre enormes oportunidades de aprendizado e prototipagem. Imagine poder brincar com bibliotecas, plug-ins e padrões diretamente no seu navegador e com outras pessoas. O Stackblitz tornou isso possível para frameworks JavaScript. Outro exemplo é um WordPress Playground que possibilitou brincar com temas do WordPress sem sair da página da Web. O Wasm pode permitir algo semelhante para Ruby e o ecossistema dele.

Há um caso especial de programação no navegador que é especialmente útil para desenvolvedores de código aberto: priorizar e depurar problemas. Novamente, o StackBlitz fez isso para projetos JavaScript: você cria um script de reprodução mínimo, aponta para o link em um problema do GitHub e poupa o tempo dos mantenedores na reprodução do cenário. E, na verdade, isso já começou a acontecer no Ruby graças ao projeto RunRuby.dev (aqui está um exemplo de problema resolvido com a reprodução no navegador).

Outro caso de uso são os aplicativos com suporte off-line (ou que reconhecem o modo off-line). Aplicativos com capacidade off-line que geralmente funcionam usando a rede, mas que continuam utilizáveis quando não há conexão. Por exemplo, um cliente de e-mail que permite procurar na caixa de entrada enquanto estiver off-line. Ou um aplicativo de biblioteca de músicas com o recurso "Armazenar no dispositivo", para que suas músicas favoritas continuem tocando mesmo que não haja conexão de rede. Ambos os exemplos dependem dos dados armazenados localmente, não apenas usando um cache, como nos PWAs clássicos.

Por fim, criar aplicativos locais (ou para computadores) com o Rails também faz sentido, porque a produtividade que o framework oferece não depende do ambiente de execução. Os frameworks completos são adequados para criar aplicativos com dados pessoais e muita lógica. O uso do Wasm como um formato de distribuição portátil também é uma opção viável.

Este é apenas o começo da jornada do Rails no Wasm. Saiba mais sobre os desafios e as soluções no ebook Ruby on Rails na WebAssembly, que é um aplicativo Rails compatível com modo off-line.