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.
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.
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18:
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.
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 persistant user store that stores any and all clients. In a real application this can be anything you want.
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33:
And then we register the SignalR service my adding it to the service collection, as well as the application builder
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12:
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:
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24:
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.
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17:
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.
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. I now leave you with a diagram that I hope will be helpful in explaining how the various messages fit together, and where each piece of code is handed:
- Fable.SignalR documentation by Shmew
- Managing SignalR ConnectionIds by Kevin Griffin
- Users and Groups by Microsoft