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:
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:
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:
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, usingclient.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:
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:
/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:
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
:
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.