Improving Real-time communication using Fable.SignalR

This is a continuation of my previous blog post about real-time communication with Giraffe and Fabulous. Reading the other article is not required for this one, as it aims to accomplish the same thing, but using a nice friendly F# wrapper around SignalR. This post is also my entry for the F# advent calendar organized by Sergey Tihon. Once again a huge thanks to Sergey for organizing these every year. Awkwardly, Mark Allibone has also written an article for the advent calendar, which is very similar to my first post. Well, here's hoping we can learn something from each other.

SignalR continues to be a fantastic library when it comes to real-time communication communication between servers and clients using WebSockets. This isn't only the case for web apps, but any client-server applications; such as in my case, communicating between a Giraffe server, and several Fabulous clients. However, like I mentioned in my previous article, my main problem with SignalR is that everything is "stringly" typed. Meaning that registering event listeners and firing events are based on a simple text name. Of course one way to mitigate this is to use a shared Constants project, but that seems like an awful lot of extra work for such a simple step. Additionally, since SignalR is primarily designed to be consumed from C#, its API leaves a lot to be desired for F# users. It requries the creation of classes that inherit from Hub, as well as registering event listeners that take in that stringly typed event name as a parameter.

Another problem that plagues us, but isn't directly related to SignalR itself, is that in the Fable/Fabulous world, people like to use the Elmish model. This is another point where using the standard SignalR package doesn't directly fit into the desired MVU architecture, however SignalR isn't alone in this problem. Any 3rd party library that isn't designed to be used in the MVU style will suffer this problem. We got around this problem in my previous post by doing some set up work ahead of time, and passing the dispatch function around.

Enter Fable.SignalR

If you've been paying attention to the F# community for awhile now, you'll immediately recognize the Fable name, and what this library aims to accomplish. For those that don't know what Fable is, it is a compiler that transpiles F# to JavaScript allowing you to develop web application purely using F#. Fable.SignalR is a library created by the amazing Shmew to wrap SignalR and create nice bindings for use with Fable, as well as the Elmish MVU model. It also has nice bindings for use in F# servers such as ones written using Giraffe or Saturn. Most recently, he also added the same set of bindings for use in any .Net client, such as Xamarin apps, or more specifically, Fabulous apps. Perfect. Exactly what we need.

The Shared project

The major addition to using Fable.SignalR is that we now require a shared project that both the server and client will reference. This project holds the URL where the web socket connection will be listening, but also the specific messages that are passed back and forth. The big advantage to having this shared project is that we can now defined a discriminated union each for the Actions that the client asks the server to execute, as well as the Response that the server will send to any client that should handle the specific response. Although the URL could always be in a shared project, even when using regular SignalR, the messages would still be stringly typed.

./src/Shared/Hub.fs

namespace Shared

module SignalRHub =
    [<RequireQualifiedAccess>]
    type Action =
    | SendMessageToAll of string
    | SendMessageToUser of string * string
    | ClientConnected of string

    [<RequireQualifiedAccess>]
    type Response =
    | ReceiveMessage of string
    | ReceiveDirectMessage of string * string
    | ParticipantConnected of string list

[<RequireQualifiedAccess>]
module Endpoints =
    let [<Literal>] Root = "/chathub"

The Action is something that the client will send to the server, optionally with extra parameters. e.g. When the user sends a message to all connected clients, we can send a message with the type SignalRHub.Action.SendMessageToAll "Hello everyone". Once the server processes this request, it can send a Response message to any clients that should receive it. e.g. SignalR.Response.ReceiveMessage "Hello everyone". Check the diagran below to see exactly who handles what message and how they are sent.

Refactoring the server

The simple server that I wrote for the other post is implemented using Giraffe. The main idea there was that you would create a class that inherits from Hub, and then register it in the ASP.Net pipeline as a middleware. When using Fable.SignalR, things look relatively similar. Instead of a class, we new have a module to hold our individual functions. These functions are then passed to a configuration record that is injected in the middleware pipeline. There are two functions that are required to be implemented. One is an invoke function, which acts upon a received action and then sends a response to the calling client. Additional messages can be sent to other clients. The other function is a send function. This function doesn't return a response to the caller directly, but may send any websocket events. To the client, this looks like a fire and forget function. Additionally, the URL must be configured, and there's an optional config object where you can configure things such as logging, lifecycle events, authentication middleware etc.

Here we have the ChatHub module, and the config object that we then pass into the Giraffe middleware pipeline. The participants dictionary is this demo's version of a persistent user store that stores any and all clients. In a real application this can be anything you want.

open Fable.SignalR
open FSharp.Control.Tasks.V2
open Shared.SignalRHub

module ChatHub =
    let invoke (msg: Action) (hubContext: FableHub) =
        // not really needed in our simple demo, but config object requires it
        task {
            return Response.ParticipantConnected [String.Empty]
        }

    let send (msg: Action) (hubContext: FableHub<Action, Response>) =
        let participants = hubContext.Services.GetService<Dictionary<string, string>>()

        match msg with
        | Action.ClientConnected participant -> 
            participants.[participant] <- hubContext.Context.ConnectionId
            Response.ParticipantConnected (participants.Keys |> List.ofSeq)
            |> hubContext.Clients.All.Send
        | Action.SendMessageToAll message -> 
            Response.ReceiveMessage message
            |> hubContext.Clients.All.Send
        | Action.SendMessageToUser (recipient, message) -> 
            let sender = //find sender
            let recipientConnectionId = participants.[recipient]
            Response.ReceiveDirectMessage (sender, message)
            |> hubContext.Clients.Client(recipientConnectionId).Send

    let config =
        { SignalR.Settings.EndpointPattern = Shared.Endpoints.Root
          SignalR.Settings.Send = send
          SignalR.Settings.Invoke = invoke 
          SignalR.Settings.Config = None }

And then we register the SignalR service my adding it to the service collection, as well as the application builder

open Fable.SignalR
open Giraffe

let configureApp (app : IApplicationBuilder) =
    app.UseSignalR(ChatHub.config) |> ignore
    app.UseGiraffe webApp

let configureServices (services : IServiceCollection) =
    // Add other services
        
    services.AddGiraffe() |> ignore
    services.AddSignalR(ChatHub.config) |> ignore

Refactoring the clients

This is where things get interesting. Where before we had to setup all of our listeners with magic strings that map to the expected message when first connecting to the hub, and then passing the dispatch function along, we can now hook into the elmish lifecycle. In our elmish update handler, we can pattern match against a SignalRMessage, which contains the Response discriminated union that we declared in the shared project. This allows for statically typed response messages that are directly referenced by both the client and the server.

Additionally, when sending actions to the server, we can dispatch one of the actions defined in the Action discriminated union in the shared project, passing that to the hub connection. This is also a statically typed message that both the client and server reference.

The key point in the client implementation is when registering the hub into the elmish lifecycle:

./src/Clients/Chat.Fabulous/App.fs

open Microsoft.AspNetCore.SignalR.Client
open Fable.SignalR.Elmish
open Shared.SignalRHub

type Msg = 
| //... Other elmish messages
| RegisterHub of Elmish.Hub<Action,Response> // Action and Response from shared project
| SignalRMessage of Response // This is the Response from the shared project

let update msg model =
    match msg with
    | // ... Other messages to handle
    | RegisterHub hub -> 
        let hub = Some hub
        let cmd = Cmd.SignalR.send hub (Action.ClientConnected model.Username)
        { model with Hub = hub }, cmd
    | EnterChatRoom -> 
        let cmd =
            Cmd.SignalR.connect RegisterHub (fun hub ->
                hub.WithUrl(sprintf "http://192.168.1.103:5000%s" Shared.Endpoints.Root)
                    .WithAutomaticReconnect()
                    .OnMessage SignalRMessage) //SignalRMessage comes from 

        model, cmd

When first entering the chat room, we dispatch an EnterChatRoom message. This message then connects to the SignalR server, which then also dispatched a RegisterHub message to run when the connection is succesful. Within the connection builder function, we can configure the connection in a similar fashion to how a regular SignalR client is configued. Most importantly, we can add an OnMessage SignalRMessage, which tells the SignalR client to dispatch a SignalRMessage anytime the server sends a message. Then, within that same update function, we can pattern match against the responses.

let update msg model = // Same update function as above
    match msg with
    | // ... Other messages to handle
    | SignalRMessage response ->
        match response with
        | Response.ParticipantConnected participants ->
            let userConnectedMessage = 
                sprintf "%s connected" (participants |> List.last)
            { model with 
                Messages = userConnectedMessage :: model.Messages
                Participants = participants }, Cmd.none
    
        | Response.ReceiveDirectMessage (sender, message) ->
            let message = (sprintf "%s whispered: %s" sender message)
            { model with Messages = message :: model.Messages }, Cmd.none
        | Response.ReceiveMessage message ->
            { model with Messages = message :: model.Messages }, Cmd.none

Here we deconstruct the SignalRMessage with the various possible responses. We will even get compiler help, as we are pattern matching on the Shared.SignalRHub.Response discriminated union.

Putting it all together

Now we've seen the changes needed in the client and server implementations, as well as the addition of the shared project. To see the entirety of the sample application, view the repositories either in Codeberg here, or in GitHub here. These links will be for the fable branch which is the desired branch that goes along with this article. You can switch to the master branch to view the original implementation.


Additional Reading