This year's entry to Sergey Tihon's F# advent calendar. Thanks again :)

HTMX, WebSockets, SignalR and you

This year was in interesting one in the web frontend space. The endless releases of framework of the week seem to have slowed down, and instead a completely different approach to building websites started gaining popularity. HTMX adds a few HTML attributes that make rendering partial HTML responses from the server incredibly easy, and gives your site the feel of a SPA without any of the complexity. If you haven't played around with it yet, I definitely recommend trying it out. The rest of this post will assume some familiarity with HTMX.

Something that I personally always keep an eye on is WebSockets. I'm always keen to find use cases for them because they add a significant wow factor to anything you're building. HTMX has an official extension package that adds WebSocket support to HTMX, and allows consuming messages sent by a WebSocket server. This means that we can easily send HTML snippets to any or all connected WebSocket clients, and have them render the HTML in any of the HTMX supported modes.

As a starting point, we're going to explore how to consume a raw WebSocket connection with a server implemented with Sauve. This is a super simple small and lightweight server library, and we can quickly get something up and running, including a WebSocket endpoint.

However, as most people use SignalR, we will also explore how to build a simply ASP.NET server and consume a SignalR Hub as there are some significant differences.

Code examples available on Codeberg, and on GitHub

Suave

Sauve is often overlooked when it comes to building web servers. It is a standalone web server framework, meaning it doesn't wrap ASP.NET. Many people think that this makes Suave somehow an inferior choice. I'm here to tell you it is fantastic. It's small and incredibly lightweight. In fact, the WebSocket demo with HTMX is only a single file, and can be simply run with dotnet fsi suave.fsx. It was also the main inspiration that eventually lead to the creation of Giraffe.

There are two WebSocket endpoints that the frontend will connect to. /chatroom mimics a super simple chatroom, and and this case will simply echo back the message sent by the single client that's connected. /notifications continually sends a "notification" every few seconds just to show how to update a specific section of HTML with messages coming it at unknown times.

To start things off, we need to pull in our dependency on HTMX and the HTMX-WS extension. You can simply include them as <script> tags in your HTML:

<script src="https://unpkg.com/[email protected]"></script>
<script src="https://unpkg.com/htmx.org/dist/ext/ws.js"></script>

With HTMX available, we can make use of all of its goodies, by defining just HTML snippets as responses for both WebSocket endpoints.

Notifications endpoint

First, lets take a look at the HTML snippet responsible for rendering a notification that was sent by the server:

let notificationResponse (receivedAt: DateTime) (message: string) =
    $"""
        <div id="notifications">
            Notification received at {receivedAt.ToString("hh:mm:ss.F")}: {message}
        </div>
    """

Not how it's simply a normal div, with no special attributes. It simply renders the two parameters passed in as string, and shows some text in HTML. Once again, this is the WebSocket response that is sent BY the server, to the client. All the client has to do is activate the WebSocket extension, and then connect to this specific endpoint. Within the index HTML, you'll find this section:

<div hx-ext="ws" ws-connect="/notifications">
    <div id="notifications"></div>
</div>

This div does two things. Firstly, it activates the WebSocket extension with the call to hx-ext="ws", and then it connects to the WebSocket endpoint that the server has exposed with ws-connect="/notifications". Any response it receives will be swapped in using the default HTMX out of band swap strategy. And that's all there really is to it.

Chat endpoint

The other interesting snippet is the chat room endpoint. In this case, we don't want to replace the response with a new response, but instead append it instead so that we can see the history of chat messages. If we take a look at the response from the server, there isn't much different than the previous example:

let chatResponse str =
    $"""
        <div id="chatRoom" hx-swap-oob="beforeend">
            <li>Message received: {str}</li>
        </div>
    """

The main difference here is the addition of the hx-swap-oob attribute. This is a standard HTMX attribute that defines the swap strategy that HTMX will use to include the HTML on the page. As for the client, we develop a simple list to show the chat messages, as well as a basic form with a single text input to submit data to the backend.

<div hx-ext="ws" ws-connect="/chatroom">
    <ul id="chatRoom">
    </ul>
    <form id="form" ws-send>
        <input name="chatMessage" />
    </form>
</div>

Same thing here as the previous snippet. First we need to activate the extension and define which endpoint to connect to. However, the form also needs to submit data to the WebSocket before it receives a response. This is accomplished using the ws-send attribute. It's essentially the same as an HTML form submission, but uses the open WebSocket connection instead. The fact that the server only sends a response message once it receives a message from the frontend is only an implementation detail in this example.

And that's really it for using the WebSocket HTMX extension with a raw WebSocket connection. The simplicity of HTMX is evident, as well as the simplicity of using Suave as a web server. There's not much ceremony at all, letting the developer concentrate on developing the actual application.

SignalR

To be able to accomplish the same thing with SignalR we need one more extra library. We need to include hx-signalr, as well as depend on the regular signalr javascript dependency. However, we don't need the WebSocket extension library from HTMX for this. So our script references look like this:

<script src="https://unpkg.com/[email protected]"></script>
<script src="https://unpkg.com/@microsoft/signalr@next/dist/browser/signalr.js"></script>
<script src="/js/hx-signalr.js"></script>

The big difference between SignalR and plain WebSockets is that SignalR provides a full RPC style API for communicating with your backend. In addition, it also handles a lot of connection and reconnection resolution strategies. As an example, previously, we would connect to a WebSocket endpoint via some URL: ws-connect="/chatroom". But with SignalR, we configure the framework to connect to a "Hub", and then we call functions on that Hub instead. So we would have something along the lines of a Chat Hub that defines a SendMessage method, and then we would call that function from the frontend via the SignalR JavaScript library. This is the main reason that a separate library is needed. As far as the frontend goes, we first need to register the extension and connect to the ChatHub endpoint. This can be done on the body tag: <body hx-ext="signalr" signalr-connect="/chathub">.

In the backend, we now need to setup our SignalR hubs. We're going to mimic what we have in the Suave example, and have a simple chat hub, as well as a notification hub that sends a message every few seconds.

Chat Hub

The ChatHub.fs is a very straightforward hub that you'll find in any SignalR tutorial.

type ChatHub() =
    inherit Hub()

    member private x.BaseSendMessage(message: string) =
        base.Clients.All.SendAsync("chatMessage", $"""<li>Message received: {message}</li>""")

    member x.SendMessage(request: {| ChatMessage: string |}) =
        task {
            printfn "Received send message from frontend"
            do! x.BaseSendMessage(request.ChatMessage)
        }

Here, SendMessage is our hub method that we expose via SignalR to the frontend. It's the function that will send the chat message to all connected clients. The reason for the BaseSendMessage has to do with the fact that we cannot call protected members on base from inside the task CE. But the interesting part is that the response from this function is a HTML snippet. In this particular case, we're sending a <li> element with the message contents as the response. This is what HTMX will swap in in the frontend.

The relevant section in the HTML for using the chat hub SendMessage is

<div
    signalr-subscribe="chatMessage"
    hx-target="#chatRoom"
    hx-swap="beforeend"
>
    <ul id="chatRoom"></ul>
    <form id="form" signalr-send="SendMessage">
        <input name="ChatMessage" />
    </form>
</div>

It's a simple HTML form, that upon submit will send a form request via SignalR to the hub method. The important bit is this signalr-send="SendMessage". That SendMessage has to match exactly to the method on the ChatHub in the backend. The form element names also have to match one-to-one with the request type that the backend expects. In this case the backend is expecting a property with key ChatMessage. The HTMX SignalR package serializes everything as a string for simplicity's sake, and also doesn't convet camelCase to PascalCase or vice versa.

The other important bit is that we need to subscribe to the response from the server. signalr-subscribe="chatMessage" handles this subscription, and once again, the chatMessage has to match exactly with the name given to the message response on the server. This is the first parameter in the base.Clients.All.SendAsync call in the backend. Then we're utilizing standard HTMX function to target a specific div, and setting the swap strategy so that we can append the li element that we recieved from the backend into an every growing list.

Notification service

We can also register a hosted service that will run continuously in the background in our backend. This can serve as our notification endpoint similar to the Suave version above.

type NotificationService(hubContext: IHubContext<ChatHub>) =
    inherit BackgroundService()

    override _.ExecuteAsync(cancellationToken: System.Threading.CancellationToken) =
        task {
            while not cancellationToken.IsCancellationRequested do
                let randomStringMessage = ....

                do!
                    hubContext.Clients.All.SendAsync(
                        "notifications",
                        $"""
                        <div id="notifications">
                            Notification received at {DateTime.UtcNow.ToString("hh:mm:ss.F")}: {randomStringMessage}
                        </div>
                    """

                    )

                do! Task.Delay(Random.Shared.Next(1000, 3000), cancellationToken)
        }

Here we don't have a specific method to call on a hub, but we're injecting a hubContext into the service. Meaning that this background service has access to all information about the ChatHub, most importantly, the client connections.

Once again sending a random string message every few seconds to all connected clients. The important bits again are the name of the SignalR message, here notifications, and that the body of the message is actual HTML which will be swapped in. Other than that, not much else to do but register it in the ASP.NET pipeline.

The frontend bit is incredibly simple. Ensure we're connected to the chat hub, and then define an area where the notifications will be displayed

<div signalr-subscribe="notifications">
    <div id="notifications"></div>
</div>

We subscribing to the notifications message, which once again needs to match exactly to the string being passed into the SendAsync call in the backend. This time, we're going to use the default HTMX swap strategy that simply replaces the div with the div coming from the backend. And with that, we're done.

Wrap up

Here we once again see the simplcity of HTMX. We're passing plain ol' HTML from the backend, and rendering snippets of it in the frontend where appropriate. This greatly simplifies the overall workload on shipping individual features, and with some extensions also lets us use either raw WebSockets, or the powerful SignalR framework to add real time communication to our web app.

Once again, code examples available on Codeberg, and on GitHub