Building a simple room-based chat application in Nim (using HTMX)
This is going to be a bit of a weird tutorial - rather than walking you through the natural progression of how one would build an application, I’ll instead be explaining the code step by step of the existing application. This is done for two reasons: first, it’s easier to write a tutorial. Second, if you just want to see the final code, you can skip ahead and not have to bear with me as I explain things that you may already know.
A quick note - I use the word templating and template a bit in this explanation. A template
refers to a construct in Nim that allows for code substitution. Templating refers to translating our data into HTML, using Karax’s DSL in this instance.
With that out of the way, let’s get started! The final code can be found in this Github repo. Here are a few screenshots of what we’ll be building.
import std/[strutils, asyncdispatch, sets, hashes, json]
import karax/[karaxdsl, vdom], jester, ws, ws/jester_extra
On the first line we’ve got stdlib imports, and the second line has all of the external libraries we’ll be using. To install them, just run
nimble install karax jester ws
converter toString(x: VNode): string = $x
type User = object
name: string
socket: WebSocket
proc hash(x: User): Hash = hash(x.name)
var chatrooms = initTable[string, HashSet[User]]()
Now, we’ve got some real code. The first line is a converter. In Nim, converters are automatically ran to convert between types if needed. In our case, we are converting a “VNode” to a string whenever it is required. A VNode is just a DOM element, which is what the Karax DSL uses to represent HTML. However, Jester only responds with string. With this converter, we can simply resp vnode
and have it automatically get converted.
The next few lines simply set up the data structures for this chat application. A user consists of a username, and the websocket corresponding to their computer. We define a hash function for this type so that we can use it in a hashset. Then, we define the core in-memory variable that’ll be storing all of our users and rooms. It’s a map from string, to HashSet[User]. In other words, each room name maps to a set of users that are connected.
template index*(rest: untyped): untyped =
buildHtml(html(lang = "en")):
head:
meta(charset = "UTF-8", name="viewport", content="width=device-width, initial-scale=1")
link(rel = "stylesheet", href = "https://unpkg.com/@picocss/pico@latest/css/pico.min.css")
script(src = "https://unpkg.com/htmx.org@1.6.0")
title: text "Simple Chat"
body:
nav(class="container-fluid"):
ul: li: a(href = "/", class="secondary"): strong: text "Simple Chat"
main(class="container"): rest
Now we get into some of the templating/boilerplate. This snippet builds our “header” and takes in the rest of the page “rest” as input. Taking a closer look, we can see that we load a stylesheet (PicoCSS) and HTMX. We then create a quick navbar, all using the Karax DSL.
Note that this is a template
and not a proc
. So, anything that is passed in as the rest argument performs simple code substitution. We’ll see an example of that in a second.
proc chatInput(): VNode = buildHtml(input(name="message", id="clearinput", autofocus="", required=""))
proc sendAll(users: HashSet[User], msg: string) =
for user in users: discard user.socket.send(msg)
template buildMessage*(msg: untyped): untyped =
buildHtml(tdiv(id="content", hx-swap-oob="beforeend")):
tdiv: msg
We’ve got some helper procedures here. chatInput generates our input field using the Karax DSL that grabs focus. It’s also required, so that the user can’t just spam sending empty messages. sendAll
just sends a string to all of the users in a room by iterating over them.
buildMessage
is another template that we can use to help us reuse code for building HTML. You’ll also notice an interesting attribute: hx-swap-oob
. The documentation for that can be found here, but in a nutshell what this does is put the innerHTML of this <div>
into a new element, which is the last child of anything matching an id of “content”. If you’ve never used HTMX you may think that this is a very ugly way of doing things, but trust me: it works.
In other words, any time the client sees this div being returned, it appends the content into the end of the DOM element with an id of “content”.
routes:
get "/":
let html = index:
h1: text "Join a room!"
form(action="/chat", `method`="get"):
label:
text "Room"
input(type="text", name="room")
label:
text "Username"
input(type="text", name="name")
input(type="submit", value="Join")
resp html
Finally, some Jester code! This is the index page, where we can join a room. You’ll notice we use our index
template form earlier, to help us build the first part of the page, before we insert the rest of it. This code is pretty self explanatory as it’s just HTML, but we’re creating a form that asks for a room name and a username. Submitting this form will then send a GET
request to /chat
.
get "/chat":
let html = index:
h1: text @"room"
tdiv(hx-ws="connect:/chat/" & @"room" & "/" & @"name"):
p(id="content")
form(hx-ws="send", id="message"): chatInput()
resp html
After submitting that form, we end up in this bit of code. Based on the name
attributes from the earlier snippet, we can access the username and room the user submitted with @"name"
and @"room"
. You’ll notice the hx-ws
attribute here as well, from HTMX. The documentation for it says it will try and open a websocket connection to the URL it is given.
Moving on, we see a paragraph with the id “content”. This is where the earlier snippet can be used to append HTML inside of that tag. Within this div, we have a form with the attribute hx-ws
again, only this time it is set to “send”. Looking at the documentation from earlier shows that any time this form is submitted, a JSON response will be send to the closest opened websocket. In other words, submitting this form will send something like
{"message": "here is my message!"}
over the websocket connection to the server.
get "/chat/@room/@name":
var ws = await newWebSocket(request)
var user = User(name: @"name", socket: ws)
Here’s where we handle the websocket connection. We use treeform’s library to create a new websocket, and then we create a new User that corresponds to that websocket. The following code is a bit tricky, as it uses the helpers we defined earlier.
try:
chatrooms.mgetOrPut(@"room", initHashSet[User]()).incl(user)
let joined = buildMessage:
italic: text user.name
italic: text " has joined the room"
chatrooms[@"room"].sendAll(joined)
while user.socket.readyState == Open:
let sentMessage = (await user.socket.receiveStrPacket()).parseJson["message"]
discard user.socket.send(chatInput())
let reply = buildMessage:
bold: text user.name
text ": " & sentMessage.getStr()
chatrooms[@"room"].sendAll(reply)
Oh boy, here we go. Well, everything is wrapped in a try
block first of all. First, we create a new HashSet in that table we defined earlier if it didn’t already exist. We also add the user to the HashSet that holds the users for the given room. The following lines generate the message that is sent on join. We use the buildMessage
template to ensure it is appended to the end of the main content. Then, we use the sendAll
proc to ensure it is sent to every user connected to that room.
The following while loop only runs while the user is connected (readyState == Open). Remember, we are using Nim’s async module, so the loop is non-blocking. We wait for a message to be sent over websockets, parse the JSON, and grab the “message” key. I talked about the JSON format a bit earlier.
The following line is weird - we send the chatInput back to the client we just got a message from. If you remember from earlier, anything that is sent over the websocket will have it’s id attribute checked, and the content will affect the DOM. In this case, the element that is sent over will replace the input
tag that the user has on their machine. This is done for two reasons:
- The input tag that the user has on their machine has some text in it (the message that they sent). By sending over a blank one, we clear the message.
- Due to the
autofocus
attribute, we regain focus of the text input.
Normally, you’d probably clear the input with Javascript. Since we’re using HTMX however, this is the recommended way of doing things. Additionally, imagine that you had to filter the messages (for example, to check for slurs). In this way, you’d be able to quickly and easily clear the input and include a message along with the cleared input telling the user why the message wasn’t accepted.
After that somewhat lengthy explanation, we build the message itself in Karax’s DSL, using our buildMessage helper. Then, we send the message to all the connected users.
except:
chatrooms[@"room"].excl(user)
let left = buildMessage:
italic: text user.name
italic: text " has left the room"
chatrooms[@"room"].sendAll(left)
resp ""
Remember how all that code was wrapped in a try
block? Here’s the matching except
. An exception in this code will usually be thrown if there was some sort of error with the websocket. If you wanted to be more precise you could catch the specific exceptions, but as this is SIMPLE chat I didn’t bother.
If there’s an error with the websocket, we take that to mean that the user has disconnected. We remove them from the chat room that they were in. Then, we build a message to show that the user has left the room. Finally, we send that to all of the remaining clients in the room! The last resp
is there to make sure that Jester has something to terminate the connection with for the user that left.
Conclusion
And that’s it! Around 70 lines of code to build a room based chat using nothing but HTMX and Jester! The best part of this code? You don’t need to know or understand Javascript to work with this stack, it’s fantastic. The code surface for bugs on the client side is pretty much 0, but on the server side it does go up a bit with the hx-swap
logic. Still, at least the logic is only in one codebase.
This stack isn’t for everyone, and I’m not claiming that you can build every sort of application with it. I consider it to be very powerful for what it can accomplish without a whole lot of code. I hope that this was informational and helpful!