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:
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
Actual WebSocket implementation
The whole WebSocket handler is just a one class:
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
on_messagehappens 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_closeserves the disconnection - it broadcasts the information that the user left the chat.
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.
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/:idvia GET - render a form asking a user to input their username and then taking them to the chat itself
/chat/:dvia 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/messageis 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
Seeing this, Hotwire replaces a current form element (a
<turbo-frame> with id
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.
- 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.