Writing web components and custom elements in pure ruby

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

Rahoul Baruah

Rahoul Baruah

Rubyist since 1.8.6. Freelancer since 2007, dedicated to building incredible, low-cost, bespoke software for tiny businesses. Also CTO at Collabor8Online.
Leeds, England