Ruby on Rails su WebAssembly, il percorso full-stack in-browser

Vladimir Dementyev
Vladimir Dementyev

Data di pubblicazione: 31 gennaio 2025

Immagina di gestire un blog completamente funzionale nel browser, non solo il frontend, ma anche il backend. Nessun server o cloud coinvolti: solo tu, il tuo browser e… WebAssembly. Consentendo ai framework lato server di funzionare localmente, WebAssembly sta sfumando i confini dello sviluppo web classico e aprendo nuove interessanti possibilità. In questo post, Vladimir Dementyev (responsabile del backend di Evil Martians) condivide i progressi compiuti per rendere Ruby on Rails compatibile con Wasm e i browser:

  • Come portare Rails nel browser in 15 minuti.
  • Dietro le quinte della conversione di Rails in wasm.
  • Il futuro di Rails e Wasm.

Il famoso "blog in 15 minuti" di Ruby on Rails ora funziona direttamente nel browser

Ruby on Rails è un framework web incentrato sulla produttività degli sviluppatori e sulla rapidità di esecuzione. È la tecnologia utilizzata dai leader del settore come GitHub e Shopify. La popolarità del framework è iniziata molti anni fa con la pubblicazione del famoso video "Come creare un blog in 15 minuti" di David Heinemeier Hansson (o DHH). Nel 2005 era impensabile creare un'applicazione web completamente funzionante in così poco tempo. È stato come una magia.

Oggi vorrei ricreare questa sensazione magica creando un'applicazione Rails che funzioni completamente nel browser. Il tuo percorso inizia con la creazione di un'applicazione Rails di base nel solito modo, quindi con il relativo packaging per Wasm.

Informazioni di base: un "blog in 15 minuti" sulla riga di comando

Supponendo che tu abbia installato Ruby e Ruby on Rails sulla tua macchina, inizia creando una nuova applicazione Ruby on Rails e creando lo scaffolding di alcune funzionalità (proprio come nel video originale "Blog in 15 minuti"):


$ 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) ===========

Ora puoi eseguire l'applicazione e visualizzarla in azione senza nemmeno toccare la base di codice:

$ bin/dev

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

Ora puoi aprire il tuo blog all'indirizzo http://localhost:3000/posts e iniziare a scrivere post.

Un blog Ruby on Rails lanciato dalla riga di comando in esecuzione nel browser.

Hai creato un'applicazione di blog molto essenziale, ma funzionale, in pochi minuti. Si tratta di un'applicazione full-stack controllata dal server: hai un database (SQLite) per conservare i dati, un server web per gestire le richieste HTTP (Puma) e un programma Ruby per gestire la logica di business, fornire l'interfaccia utente ed elaborare le interazioni degli utenti. Infine, è presente un sottile livello di JavaScript (Turbo) per semplificare l'esperienza di navigazione.

La demo ufficiale di Rails continua nella direzione del deployment di questa applicazione su un server bare metal e, di conseguenza, la rende pronta per la produzione. Il tuo percorso continuerà in direzione opposta: anziché mettere la tua applicazione da qualche parte lontano, la "implementerai" localmente.

Livello successivo: un "blog in 15 minuti" in Wasm

Con l'aggiunta di WebAssembly, i browser sono diventati in grado di eseguire non solo il codice JavaScript, ma qualsiasi codice compilabile in Wasm. E Ruby non è un'eccezione. Sicuramente Rails è più di Ruby, ma prima di approfondire le differenze, continuiamo la demo e wasmify (un verbo coniato dalla libreria wasmify-rails) l'applicazione Rails.

Devi solo eseguire alcuni comandi per compilare l'applicazione del blog in un modulo Wasm ed eseguirla nel browser.

Innanzitutto, installa la libreria wasmify-rails utilizzando Bundler (il npm di Ruby) e esegui il relativo generatore utilizzando l'interfaccia a riga di comando 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!

Il comando wasmify:rails configura un ambiente di esecuzione "wasm" dedicato (oltre agli ambienti "sviluppo", "test" e "produzione" predefiniti) e installa le dipendenze richieste. Per un'applicazione Rails nuova, è sufficiente per renderla compatibile con Wasm.

Successivamente, crea il modulo Wasm principale contenente il runtime Ruby, la libreria standard e tutte le dipendenze dell'applicazione:

$ 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

Questo passaggio può richiedere del tempo: devi compilare Ruby dal codice sorgente per collegare correttamente le estensioni native (scritte in C) dalle librerie di terze parti. Questo svantaggio (temporaneo) viene trattato più avanti nel post.

Il modulo Wasm compilato è solo una base per la tua applicazione. Devi anche impacchettare il codice dell'applicazione stesso e tutte le risorse (ad esempio immagini, CSS, JavaScript). Prima di eseguire l'imballaggio, crea un'applicazione di avvio di base che possa essere utilizzata per eseguire Rails in formato wasm nel browser. Per questo, esiste anche un comando generatore:

$ bin/rails wasmify:pwa

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

Il comando precedente genera un'applicazione PWA minima creata con Vite che può essere utilizzata localmente per testare il modulo Rails Wasm compilato o essere eseguita in modo statico per distribuire l'app.

Ora, con il programma di avvio, devi solo pacchettizzare l'intera applicazione in un singolo file binario Wasm:

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

È tutto. Esegui l'app Avvio app e visualizza la tua applicazione di blogging Rails in esecuzione nel browser:

$ cd pwa/

$ yarn dev

  VITE v4.5.5  ready in 290 ms

    Local:   http://localhost:5173/

Vai alla pagina http://localhost:5173, attendi un po' che il pulsante "Avvia" diventi attivo e fai clic su di esso. Goditi il lavoro con l'app Rails in esecuzione localmente nel browser.

Un blog Ruby on Rails lanciato da una scheda del browser in esecuzione in un'altra scheda del browser.

Non è magico eseguire un'applicazione lato server monolitica non solo sul tuo computer, ma anche all'interno della sandbox del browser? Per me (anche se sono il "mago"), sembra ancora una fantasia. Ma non c'è nessuna magia, solo il progresso della tecnologia.

Demo

Puoi provare la demo incorporata nell'articolo o avviarla in una finestra autonoma. Consulta il codice sorgente su GitHub.

Dietro le quinte di Rails on Wasm

Per comprendere meglio le sfide (e le soluzioni) dell'imballaggio di un'applicazione lato server in un modulo Wasm, il resto di questo post illustra i componenti che fanno parte di questa architettura.

Un'applicazione web dipende da molti più elementi di un semplice linguaggio di programmazione utilizzato per scrivere il codice dell'applicazione. Ogni componente deve essere importato anche nel tuo_ ambiente di deployment locale_, ovvero nel browser. La demo "blog in 15 minuti " è entusiasmante perché può essere realizzata senza riscrivere il codice dell'applicazione. Lo stesso codice è stato utilizzato per eseguire l'applicazione in una modalità lato server classica e nel browser.

I componenti che compongono un'app Ruby on Rails: un server web, un database, una coda e uno spazio di archiviazione. Oltre ai componenti principali di Ruby: gem, estensioni native, strumenti di sistema e la VM Ruby.

Un framework, come Ruby on Rails, ti offre un'interfaccia, un'astrazione per comunicare con i componenti dell'infrastruttura. La sezione seguente illustra come utilizzare l'architettura del framework per soddisfare le esigenze di pubblicazione locale un po' esoteriche.

La base: ruby.wasm

Ruby è diventato ufficialmente supportato da Wasm nel 2022 (dalla versione 3.2.0), il che significa che il codice sorgente C può essere compilato in Wasm e puoi portare una VM Ruby ovunque vuoi. Il progetto ruby.wasm fornisce moduli precompilati e associazioni JavaScript per eseguire Ruby nel browser (o in qualsiasi altro ambiente di runtime JavaScript). Il progetto ruby:wasm include anche gli strumenti di compilazione che ti consentono di compilare una versione di Ruby personalizzata con dipendenze aggiuntive, il che è molto importante per i progetti che si basano su librerie con estensioni C. Sì, puoi anche compilare le estensioni native in Wasm. (Non ancora tutte le estensioni, ma la maggior parte).

Attualmente, Ruby supporta completamente l'interfaccia di sistema WebAssembly, WASI 0.1. WASI 0.2, che include il Modello di componenti, è già in stato alpha e manca poco al completamento.Una volta supportato WASI 0.2, verrà eliminata l'attuale necessità di ricompilare l'intero linguaggio ogni volta che è necessario aggiungere nuove dipendenze native: potrebbero essere componenti.

Come effetto collaterale, il modello di componenti dovrebbe contribuire anche a ridurre le dimensioni del bundle. Puoi scoprire di più sullo sviluppo e sull'avanzamento di ruby.wasm dal talk Cosa puoi fare con Ruby su WebAssembly.

Quindi, la parte Ruby dell'equazione Wasm è risolta. Tuttavia, Rails come framework web necessita di tutti i componenti mostrati nel diagramma precedente. Continua a leggere per scoprire come inserire altri componenti nel browser e collegarli in Rails.

Connettiti a un database in esecuzione nel browser

SQLite3 è dotato di una distribuzione Wasm ufficiale e di un corrispondente wrapper JavaScript, pertanto è pronto per essere incorporato nel browser. PostgreSQL per Wasm è disponibile tramite il progetto PGlite. Pertanto, devi solo capire come collegarti al database in-browser dall'applicazione Rails on Wasm.

Un componente o sotto-framework di Rails responsabile della modellazione dei dati e delle interazioni con il database si chiama Active Record (sì, prende il nome dal pattern di progettazione ORM). Active Record rimuove l'implementazione effettiva del database SQL dal codice dell'applicazione tramite gli adattatori di database. Rails offre subito gli adattatori SQLite3, PostgreSQL e MySQL. Tuttavia, presuppongono tutti la connessione a database reali disponibili sulla rete. Per risolvere il problema, puoi scrivere i tuoi adattatori per connetterti a database locali in-browser.

Ecco come vengono creati gli adattatori SQLite3 Wasm e PGlite implementati nell'ambito del progetto Wasmify Rails:

  • La classe dell'adattatore eredita dall'adattatore integrato corrispondente (ad esempio class PGliteAdapter < PostgreSQLAdapter), quindi puoi riutilizzare la logica effettiva di preparazione delle query e di analisi dei risultati.
  • Anziché la connessione al database a basso livello, utilizzi un oggetto interfaccia esterna che si trova nel runtime di JavaScript, un ponte tra un modulo Rails Wasm e un database.

Ad esempio, di seguito è riportata l'implementazione del bridge per 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();
    },
  };
}

Dal punto di vista dell'applicazione, il passaggio da un database reale a uno in-browser è solo una questione di configurazione:

# config/database.yml
development:
  adapter: sqlite3

production:
  adapter: sqlite3

wasm:
  adapter: sqlite3_wasm
  js_interface: "sqliteForRails"

L'utilizzo di un database locale non richiede molto impegno. Tuttavia, se è necessaria la sincronizzazione dei dati con qualche fonte attendibile centrale, potresti trovarti di fronte a una sfida di livello superiore. Questa domanda non rientra nell'ambito di questo post (suggerimento: dai un'occhiata alla demo di Rails su PGlite ed ElectricSQL).

Service worker come server web

Un altro componente essenziale di qualsiasi applicazione web è un server web. Gli utenti interagiscono con le applicazioni web utilizzando richieste HTTP. Di conseguenza, devi trovare un modo per instradare le richieste HTTP attivate dalla navigazione o dall'invio di moduli al tuo modulo Wasm. Fortunatamente, il browser ha una risposta: i service worker.

Un worker di servizio è un tipo speciale di worker web che funge da proxy tra l'applicazione JavaScript e la rete. Può intercettare le richieste e manipularle, ad esempio: pubblicare dati memorizzati nella cache, reindirizzare ad altri URL o… a moduli Wasm. Ecco uno schizzo di un servizio che gestisce le richieste utilizzando un'applicazione Rails in esecuzione in 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)
  );
});

L'operazione "fetch" viene attivata ogni volta che il browser effettua una richiesta. Puoi ottenere le informazioni sulla richiesta (URL, intestazioni HTTP, corpo) e creare il tuo oggetto richiesta.

Rails, come la maggior parte delle applicazioni web Ruby, si basa sull'interfaccia Rack per lavorare con le richieste HTTP. L'interfaccia Rack descrive il formato degli oggetti richiesta e risposta, nonché l'interfaccia del gestore HTTP sottostante (applicazione). Puoi esprimere queste proprietà come segue:

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 il formato della richiesta ti è familiare, probabilmente hai già lavorato con il linguaggio CGI.

L'oggetto JavaScript RackHandler è responsabile della conversione delle richieste e delle risposte tra gli ambiti JavaScript e Ruby. Poiché Rack è utilizzato dalla maggior parte delle applicazioni web Ruby, l'implementazione diventa universale e non specifica di Rails. Tuttavia, la implementazione effettiva è troppo lunga per essere pubblicata qui.

Un worker di servizio è uno dei punti di intergrazione chiave di un'applicazione web in-browser. Non è solo un proxy HTTP, ma anche un livello di memorizzazione nella cache e un selettore di rete (ovvero puoi creare un'applicazione local-first o offline). Si tratta anche di un componente che può aiutarti a pubblicare i file caricati dagli utenti.

Mantenere i caricamenti di file nel browser

Una delle prime funzionalità aggiuntive da implementare nella nuova applicazione del blog è probabilmente il supporto per i caricamenti di file o, più specificamente, l'allegazione di immagini ai post. Per farlo, devi avere un modo per archiviare e pubblicare i file.

In Rails, la parte del framework responsabile della gestione dei caricamenti di file si chiama Active Storage. Active Storage offre agli sviluppatori astrazioni e interfacce per lavorare con i file senza dover pensare al meccanismo di archiviazione a basso livello. Indipendentemente da dove archivi i file, su un disco rigido o nel cloud, il codice dell'applicazione non ne è a conoscenza.

Analogamente ad Active Record, per supportare un meccanismo di archiviazione personalizzato, è sufficiente implementare un'opzione di accoppiamento del servizio di archiviazione corrispondente. Dove memorizzare i file nel browser?

L'opzione tradizionale è utilizzare un database. Sì, puoi archiviare i file come blob nel database senza bisogno di componenti di infrastruttura aggiuntivi. Inoltre, in Rails esiste già un plug-in pronto per questo, Active Storage Database. Tuttavia, la pubblicazione di file archiviati in un database tramite l'applicazione Rails in esecuzione in WebAssembly non è l'ideale perché prevede cicli di (de)serializzazione non senza costi.

Una soluzione migliore e più ottimizzata per il browser sarebbe utilizzare le API del file system e elaborare i caricamenti dei file e i file caricati sul server direttamente dal servizio worker. Un candidato perfetto per questa infrastruttura è il OPFS (origin private file system), un'API del browser molto recente che avrà sicuramente un ruolo importante per le future applicazioni in-browser.

Cosa possono ottenere insieme Rails e Wasm

Sono abbastanza sicuro che ti stia ponendo questa domanda mentre inizi a leggere l'articolo: perché eseguire un framework lato server nel browser? L'idea che un framework o una libreria sia lato server (o lato client) è solo un'etichetta. Il codice di buona qualità e, in particolare, una buona astrazione funzionano ovunque. Le etichette non devono impedirti di esplorare nuove possibilità e di spingere i limiti del framework (ad esempio Ruby on Rails) e dell'ambiente di runtime (WebAssembly). Entrambi potrebbero trarre vantaggio da questi casi d'uso non convenzionali.

Esistono anche molti casi d'uso convenzionali o pratici.

Innanzitutto, il porting del framework nel browser apre enormi opportunità di apprendimento e prototipazione. Immagina di poter utilizzare librerie, plug-in e pattern direttamente nel browser e insieme ad altre persone. Stackblitz ha reso possibile questo per i framework JavaScript. Un altro esempio è un WordPress Playground che consentiva di giocare con i temi WordPress senza uscire dalla pagina web. Wasm potrebbe consentire qualcosa di simile per Ruby e il suo ecosistema.

Esiste un caso speciale di programmazione in-browser particolarmente utile per gli sviluppatori open source: la prioritizzazione e il debug dei problemi. Anche in questo caso, StackBlitz ha reso possibile questa operazione per i progetti JavaScript: puoi creare uno script di riproduzione minimo, indicare il link in un problema GitHub e risparmiare ai manutentori il tempo necessario per riprodurre il tuo scenario. E, in effetti, è già iniziato a succedere in Ruby grazie al progetto RunRuby.dev (qui è riportato un esempio di problema risolto con la riproduzione nel browser).

Un altro caso d'uso sono le app compatibili con l'offline (o che supportano l'offline). Applicazioni compatibili con l'utilizzo offline che in genere funzionano tramite la rete, ma rimangono utilizzabili anche in assenza di connessione. Ad esempio, un client di posta che ti consente di eseguire ricerche nella Posta in arrivo quando sei offline. In alternativa, un'applicazione di raccolta musicale con la funzionalità "Salva sul dispositivo", in modo che la tua musica preferita continui a suonare anche se non c'è connessione di rete. Entrambi gli esempi dipendono dai dati archiviati localmente, non solo dall'utilizzo di una cache come per le PWA classiche.

Infine, ha senso anche creare applicazioni locali (o desktop) con Rails, perché la produttività offerta dal framework non dipende dal runtime. I framework completi sono adatti per la creazione di applicazioni con un'elevata quantità di dati e logica personali. Anche l'utilizzo di Wasm come formato di distribuzione portatile è un'opzione valida.

Questo è solo l'inizio del viaggio di Rails on Wasm. Puoi scoprire di più sulle sfide e sulle soluzioni nell'ebook Ruby on Rails on WebAssembly (che, tra l'altro, è un'applicazione Rails stessa compatibile con l'offline).