Building a chat with Hanami and Hotwire

by Paweł Świątkowski
15 Jun 2022

This is a story of how I built a simple chat application with Hanami and Hotwire in a few hours. But why? - you may ask. I wanted to check a claim made by the Hotwire creators that it’s framework-agnostic and may be used outside of Rails. Read on to know what I have found.

tl;dr: the repository is here

But what in what?

In short, for those who might not know:

  • Hanami is a full-featured web framework written in Ruby. It is currently under a major overhaul… or rather a total rewrite - some things stay, but mostly it’s everything new before releasing version 2.0, but alphas are already available to public. To start developing Hanami application today you should use the template.
  • Hotwire is an implementation of HTML-over-the-wire by Ruby on Rails team. It was preceded by Phoenix LiveView, Laravel’s Livewire, but might be most popular one nowadays.

How it went

The idea was to have an “ephemeral chat”, i.e. one where messages are not persisted on the server. When you join the room, you receive messages and can participate in the conversation. But at all time communication in only available to people who are actually in the chat. In that regard, it works more or less like IRC.

To build such things, we need some kind of a PubSub solution. We could use something like PostgreSQL’s LISTEN/NOTIFY or Redis, but I wanted to keep the project free of such dependencies. This is why I decided to employ Iodine. Iodine is a Ruby application server built with WebSockets in mind (making it perfect for endavours like Hotwire, which uses WebSockets a lot) and with built-in PubSub supportOf course, this only works within boundaries of a single sesrver instance - which is perfect for development, but not good for production. However, Iodine also supports switching its PubSub to be backed by Redis when you want to go multiple instances. Easy for dev, ready for prod. This is what I like.. I know it is uncommon for Ruby developers to rely on an application webserver. We are used to think that no matter if you use Puma or Unicorn or Passenger - it should not affect the application. There is some truth in it, but when using technologies like WebSockets these boundaries are blurred anyway. For me, using a right tool for the job (Iodine in this case) saved probably hours of trying to come of with alternative, server-agnostic solution.

But back to the chat application…

First and important thing is that I had to create a custom web component handling a Turbo Stream over WebSockets. This was largely inpired copied from the implementation from turbo-rails. Hotwire at its core is a pretty simple set of tools, but it’s turbo-rails that makes it powerful. Unsurprising, turbo-rails is very coupled to Rails The component looks like this:

import { connectStreamSource, disconnectStreamSource } from "@hotwired/turbo"

class TurboStreamChatElement extends HTMLElement {
  async connectedCallback() {
    connectStreamSource(this)
    this.socket = this.createSocket(this.channel, { received: this.dispatchMessageEvent.bind(this) })
  }

  disconnectedCallback() {
    disconnectStreamSource(this)
    if (this.subscription) this.subscription.unsubscribe()
  }

  dispatchMessageEvent(data) {
    const event = new MessageEvent("message", { data: data.data })
    return this.dispatchEvent(event)
  }

  createSocket(_channel, callbacks) {
    const room = this.getAttribute("room");
    const user = this.getAttribute("username");
    let socket = new WebSocket(`ws://${window.location.host}/ws?user=${user}&room=${room}`);
    socket.onmessage = callbacks.received;
    return socket
  }
}

customElements.define("turbo-stream-chat", TurboStreamChatElement)

There is a few things going on here, but rest assured - this is the only piece of JavaScript in this post and in this project. And in “real life” it would be hidden from you by something like turbo-hanami library. With that, we just need to put a correct code in one of our templates:

<turbo-stream-chat room="<%= room %>" username="<%= username %>"></turbo-stream-chat>

This connects to a given room using a given username - by passing params to generic /ws endpoint.

Actual WebSocket implementation

The whole WebSocket handler is just a one class:

module EphemeralChat
  class WebsocketChat
    attr_reader :room_name

    def self.handle(env)
      if env['rack.upgrade?'.freeze] == :websocket 
        req = Rack::Request.new(env)
        env['rack.upgrade'.freeze] = new(req.params["room"], req.params["user"])
        [0,{}, []]
      end
    end

    def initialize(room_name, user)
      @room_name = room_name || "default"
      @user = Hanami::Utils::Escape.html(user)
    end

    def on_open(client)
      client.subscribe(room_name) do |source, data|
        data = JSON.parse(data)
        response = Main::Views::Chat::Message.new.(message: data["message"], user: data["user"], is_system: data["system"]).to_s
        client.write(response)
      end

      client.publish(room_name, %[{"user": "", "system": true, "message": "#{@user} has joined"}])
    end

    def on_message(client, message)
      data = JSON.dump({ user: @user, message: message})
      client.publish(room_name, data)
    end
    
    def on_close(client)
      client.publish(room_name, %[{"user": "", "system": true, "message": "#{@user} has left"}])
    end
  end
end

The self.handle(env) is a handler for WebSocket endpoint, as described in Iodine’s README. Inside we instantiate a new WebsocketChat object, which holds room name and current username. This object implements three methods:

  • on_open – this happens when a new connection to a WebSocket endpoint is created. It subscribes the client to a channel with a name of the room. Inside the subscription is the code executed upon each received message, which is: parse the data (is must be a string in Iodine) and render a response. It also broadcasts a message that a new user has joined the room, using client.publish method.
  • on_message happens on each message received by the WebSocket, i.e. when a user sends a message to the chat. It is really simple: just craete a JSON message and broadcast it to the channel.
  • on_close serves the disconnection - it broadcasts the information that the user left the chat.

The Main::Views::Chat::Message view just renders Turbo-specific HTML template:

<turbo-stream action="append" target="messages">
  <template>
    <div class="row" id="<%= SecureRandom.uuid %>">
      <div class="column column-80">
        <% if is_system %>
        <em><%= message %></em>
        <% else %>
        [<strong><%= user %></strong>]
        <%= message %>
        <% end %>
      </div>
      <div class="column column-20">
        <%= time %>
      </div>
    </div>
  </template>
</turbo-stream>

It instructs frontend Hotwire lib to append the content (the inner HTML of <template> tag to the element with id messages). That’s it. We don’t have to do anything more.

Routes etc.

Finally, we have to add some routes and other standard Hanami things, like other views and actions. The routing part looks like this:

module EphemeralChat
  class Routes < Hanami::Application::Routes
    define do
      slice :main, at: "/" do
        get "/ws", to: ->(env) { EphemeralChat::WebsocketChat.handle(env) }
        get "/chat/:id", to: "chat.join"
        post "/chat/:id", to: "chat.show"
        post "/chat/:id/message", to: "chat.add_message"
        root to: "home.show"
      end
    end
  end
end
  • /ws - we have already covered, it’s the whole WebSockets machinery
  • /chat/:id via GET - render a form asking a user to input their username and then taking them to the chat itself
  • /chat/:d via POST - the actual chat. POST is a hack so that the URL can be copied from the browser address bar without containing a room name and a user name, also forcing the user to go through the username-setting step.
  • /chat/:id/message is hit when a user inputs a message in a input on a chat view and hits enter. By the power of Turbo Drive (which we got for free when including Hotwire, whether we want it or not) takes care of sending it asynchronously, without reloading the page, so in the controller part we can just treat it as a regular endpoint. Here’s the code for this action:
def handle(req, res)
  message = req.params[:message]
  user = req.params[:user]
  
  data = JSON.dump({ user: user, message: message})
  Iodine.publish(req.params[:id], data)

  res[:room] = req.params[:id]
  res[:username] = req.params[:user]
end

Note that we are calling Iodine.publish here, again coupling with the server, but doing it only at controller level - which is kind of infrastructural anyway.

Another trick that happens here is that we are rendering… template of an empty chatroom’s form at the end. Why? Because it contains a turbo-frame:

<turbo-frame id="chat-controls">
  <form class="chat-controls" data-turbo="true"
      method="POST" action="/chat/<%= room %>/message">
    <input type="text" name="message" autocomplete="false" autofocus />
    <input type="hidden" name="user" value="<%= username %>" />
  </form>
</turbo-frame>

Seeing this, Hotwire replaces a current form element (a <turbo-frame> with id chat-controls) with an empty one - so we have reset the input (that contained the message we just sent) with it and have form clearing without any JavaScript coding involved.

Summary

This exercise required a lot if investigating how turbo-rails and Hotwire work under the hood. However, after having done this work it is relatively simple glue code. I think the choice of the “bricks” was pretty smart in this case:

  • Hanami - gave the router and full-featured view rendering, with layouts etc. Of course, this could be replaced with something simpler, like Rack App, but with that we’ll be on our own with assets and templates.
  • Hotwire - aside from the web component, this app does not contain a single line of JavaScript code, while still behaves like a SPA application.
  • Iodine - not having to reimplement WebSocket support and PubSub is cool, even if you have to rely on the choice of the webserver a bit more than you are used to.

And yes, Hotwire can be used outside of Rails just fine. However, a lot of things associated with Hotwire in Rails actually come from turbo-rails integration. Bear that in mind.

end of the article

Tags: ruby hanami hotwire html-over-the-wire

This article was written by me – Paweł Świątkowski – on 15 Jun 2022. I'm on Fediverse (Ruby-flavoured account, Elixir-flavoured account). Let's talk.