Writing web components and custom elements in pure ruby
So I've been playing around with Bridgetown, which started out as a static-site generator, but is turning into an environment for generating web-sites with interactive components. As I'm looking for something to replace Ruby on Rails in my development stack I'm starting to believe that Bridgetown can be an important part of that.
Bridgetown installs as a ruby gem, but it requires nodejs and yarn to handle its build process. You add your content into the src folder, then tell Bridgetown to build the site and it writes everything to the output folder. From there you can just rsync the lot up to your web-server.
Alternatively, you can install Bridgetown's Roda server to give your site dynamic routing and pages. In theory, this could work as a drop-in replacement for Rails. You define your routes, pull data from a database and generate content to show in the client.
But if we're replacing Rails because it's falling short of today's requirements, why copy the same architecture that Rails dictates to us?
So instead, I'm going to separate the front-end and back-end of my application. The back-end will be an HTTP/JSON API that controls access to the data. And the front-end will be a static HTML/CSS/JS site with all dynamic and interactive content handled by web components. This means I can use it as a web-application, load it into Neutralino to build desktop applications and into Capacitor to build mobile apps.
It turns out I've got the perfect opportunity to try this out. I have a client with a huge, complicated, legacy Ruby on Rails application. It's been in production for almost ten years and is ready for an overhaul as it's struggling to meet their requirements. But the code is a nightmare and incredibly hard to understand.
Some total idiot must have worked on this ....
Oops.
Anyway, I started building an API using Rails a few months ago, as one of their customers wanted to integrate with the system. And in doing so, I used the opportunity to start over. Rather than migrate all the data into a brand-new system, I wrote wrapper classes and renamed models in the existing database (as there is a lot of data that they do not want to lose), and put a simple API over the front. As I mentioned before, I will soon turn to rewriting this API in Grape and I'll do a write up of how that goes.
But in the meantime, I used this API to build a proof-of-concept front end.
Firstly, we install Bridgetown, then add in the Shoelace components and Lit renderer. This is important.
Normally custom-elements are rendered on the client side, after the HTML is delivered to the browser, which can cause a pause and flash-of-unstyled-content. But Bridgetown can perform server-side-rendering, so the Shoelace components are generated at build time and inserted straight into the HTML. Then, when the page is delivered to the client, they are hydrated (so the javascript behind the component is reattached), so it can behave correctly on the front-end.
Next we add in Ruby2JS and configure it in config/ruby2js.rb. We tell it to generate modern javascript (ES2022), load up its standard presets, as well as a filter1 for Lit (plus some other useful filters).
eslevel 2022
preset
filter :camelCase
filter :lit
filter :esm
filter :active_function
autoexports :default
Next, we build some components.
Your Bridgetown site lives in the src folder and your components in src/_components. Bridgetown has added a few for us to play with, but I started by adding a Login Panel. In my API, there is a POST /api/logins call that takes an email address and password, returning an access token, that is then used in the Authorization header of subsequent requests.
After a bit of work, I ended up with the following component.
import AQR, from: "../aqr.js.rb"
class AQR::LoginPanel < LitElement
custom_element "aqr-login-panel"
def app
window.app
end
def initialize
@logged_in = !app.token.blank?
@error_message = ""
@_on_login = ->(event) { @logged_in = true }
@_on_logout = ->(event) { @logged_in = false }
end
async def _do_login(event)
email = query("#email")&.value
password = query("#password")&.value
await app.log_in_as email, password
@error_message = ""
rescue AQR::InvalidLogin => ex
@error_message = ex.message
end
async def _do_logout(event)
app.log_out
end
def render
if !@logged_in
<<~HTML
<layout-cluster>
<sl-input id="email" type="email" required label="Email" name="email"></sl-input>
<sl-input id="password" type="password" required label="Password" name="password" autocomplete="current-password" password-toggle></sl-input>
#{@error_message}
<sl-button @click=#{_do_login}>Log in</sl-button>
</layout-cluster>
HTML
else
<<~HTML
<slot></slot>
<layout-cluster>
<sl-button @click=#{_do_logout}>Log out</sl-button>
</layout-cluster>
HTML
end
end
def connected_callback()
super
app.add_event_listener "log_in", @_on_login
app.add_event_listener "log_out", @_on_logout
end
def disconnected_callback()
super
app.remove_event_listener "log_in", @_on_login
app.remove_event_listener "log_out", @_on_logout
end
end
I'm pretty sure I can simplify this, but the key things are:
- initialize sets up the component. The Ruby2JS Lit filter takes any instance variables defined2 and makes them reactive properties. This means any time their value changes, it triggers a redraw of the component.
- connected_callback() and disconnected_callback() are called when the component is added onto or removed from the page. These attach themselves to an "application" object so they can listen for changes to the login/logout status. This is a bit messy - in Javascript you would pass a reference to a function, but it didn’t seem to work as expected with Ruby2JS, so I'm passing a proc until I figure it out.
- render() actually draws the component onto the page. It checks to see if we are logged in; if not it draws a simple form, attaching an on-click handler to the submit button. If we are logged in, it draws the slot3 inside a layout element4.
Also note that the _do_login() and _do_logout() methods are async - although it looks like ruby, we are actually writing javascript.
I also added in an "app" object for dispatching events and handling communication with the server. The client is called AQR, hence the name, and the object is attached as a global to the window (although this isn't ideal).
class AQR
class Event < CustomEvent
end
class InvalidLogin < Exception
end
attr_accessor :token
attr_accessor :asset_path
def initialize base_uri: "https://api.aqrtest.com/api"
@base_uri = base_uri
@asset_path = document.querySelector('meta[name="asset-path"]').content
@event_listeners = EventTarget.new
@token = window.local_storage.get_item("aqr_token")
end
async def log_in_as email, password
form_data = FormData.new
form_data.append "email", email
form_data.append "password", password
response = await fetch("logins", method: "POST", body: form_data)
if response.ok
json = await response.json()
@token = json["reference"]
window.local_storage.set_item "aqr_token", @token
dispatch_event AQR::Event.new("log_in", detail: @token)
else
message = await response.text()
raise AQR::InvalidLogin.new(message)
end
response
end
def log_out
@token = nil
window.local_storage.set_item "aqr_token", nil
dispatch_event AQR::Event.new("log_out")
end
def authentication_headers
if @token
credentials = btoa "USER:#{@token}"
{"Authorization" => "Basic #{credentials}"}
end
end
def fetch path, params = {}
params["mode"] = "cors"
params["headers"] = authentication_headers if @token
window.fetch "#{@base_uri}/#{path}", params
end
def add_event_listener event, handler
@event_listeners.add_event_listener(event, handler)
end
def remove_event_listener event, handler
@event_listeners.remove_event_listener(event, handler)
end
def dispatch_event event, details = {}
@event_listeners.dispatch_event(event, details)
end
end
window.app = AQR.new()
The key methods here are:
- log_in_as(username, password) which calls the API and retrieves a token, which it then puts into local storage, before sending a login event
- fetch which checks if we are logged in, if so it adds an Authorization header, before sending a request to the API
Finally, I added in a "Products List" component:
import AQR, from: "../aqr.js.rb"
class AQR::ProductsList < LitElement
custom_element "aqr-products-list"
def initialize
@products = []
end
def render_product(product)
<<~HTML
<li>#{product["code"]}: #{product["name"]}</li>
HTML
end
def render()
<<~HTML
<link rel="stylesheet" href="#{app.asset_path}" />
<h1>Products</h1>
<ul>
#{self.products.map { |p| render_product(p) }}
</ul>
HTML
end
async def load_products()
response = await window.app.fetch("products.json")
self.products = await response.json()
end
def connected_callback()
super
load_products()
end
end
This, I hope, is pretty self-explanatory - it asks the API for a list of products5, and then renders these into a list.
And then my index.erb6 file looks like this:
---
layout: default
---
<%= lit :aqr_login_panel do %>
<%= lit :aqr_products_list %>
<% end %>
This results in markup looking like this:
<aqr-login-panel>
<aqr-products-list></aqr-products-list>
</aqr-login-panel>
If we are not logged in, the login panel displays a login form. You enter your email and password and hit submit. On success, it stores the token and triggers an event which redraws the login panel. This time, instead of rendering the form, it renders its slot, which contains the products list. This fetches the products from the API and renders them as a list.
There's still a load of tidying up to do - I don't like how the events are handled, I really want a Markaby style render method, plus I want to add in an EventSource so the server can broadcast events to any connected clients.
But I think it's pretty simple, does the job really well, and is a decent first stab at a new way of building apps in ruby. What do you reckon?
––––––––––––––––––––––––––––––––
1 Filters are, in effect, custom translation modules for handling specific types of javascript
2 excluding those that are prefixed with an underscore
3 A slot is how you inject your own content into an existing web-component - similar to Rails views' content_for and yield . Or the "Some content" bit in <div>Some content</div>
4 I thoroughly recommend Every Layout - a book on how to build really simple but flexible CSS layouts. The cluster layout puts each element in a horizontal line, at its own natural width, breaking onto a new line where necessary.
5 The API filters the products based on the token supplied, only returning products you have permission to see
6 In Bridgetown you can choose your template language - for familiarity I'm using ERB