Browser based chat application. Server is written in F#.NET with ASP.NET Core and the web framework Giraffe. SignalR is used to simplify pushing and pulling between server and client through websockets. The TypeScript language is used in the frontend with React + Redux to architecture the application in a type safe declarative style. Material UI is used for styling elements for a modern and professional look.
-
Install client dependencies with command:
npm install
-
Run automated development build process of client code with command:
npm run build
Watches the source files for changes and compiles on save.
-
Start server with command:
npm run server
Dependencies should download automatically for dotnet core.
-
Compile optimized build targets of client code with command:
npm run release
Given that the purpose of this project is to store useful knowledge for future reference I will be more detailed in explaining the server than I will with the client code.
Let's start by taking a look at the server code by building it up gradually. Since Giraffe isn't technically used in this project (and probably should be removed), I won't include it here.
Minimal code to get an unconfigured Kestrel server running
open System
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Hosting
let configureApp (_: IApplicationBuilder): unit = ()
[<EntryPoint>]
let main _ =
WebHostBuilder()
.UseKestrel()
.Configure(Action<IApplicationBuilder> configureApp)
.Build()
.Run()
0 // return an integer exit code
This server is useless as far as I know. It can't serve anything.
open System
open System.IO
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Hosting
let configureApp (app: IApplicationBuilder): unit =
app.UseDefaultFiles()
.UseStaticFiles()
|> ignore
[<EntryPoint>]
let main _ =
let publicPath = Path.GetFullPath "./public"
WebHostBuilder()
.UseKestrel()
.UseWebRoot(publicPath)
.UseContentRoot(publicPath)
.Configure(Action<IApplicationBuilder> configureApp)
.Build()
.Run()
0 // return an integer exit code
To tell our server to serve static files we call the methods UseDefaultFiles()
and UseStaticFiles()
in the application configuration function configureApp
.
let configureApp (app: IApplicationBuilder): unit =
app.UseDefaultFiles()
.UseStaticFiles()
|> ignore
The methods UseWebRoot
and UseContentRoot
on the WebHostBuilder
object allow us to specify where our server should look for static files automatically.
let publicPath = Path.GetFullPath "./public"
// ...
.UseWebRoot(publicPath)
.UseContentRoot(publicPath)
open System
open System.IO
open Microsoft.AspNetCore.Http
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Hosting
open Microsoft.AspNetCore.SignalR
open Microsoft.Extensions.DependencyInjection
type MyHub() =
inherit Hub()
let configureApp (app: IApplicationBuilder): unit =
app.UseDefaultFiles()
.UseStaticFiles()
.UseSignalR(fun routes -> routes.MapHub<MyHub>(PathString "/myhub"))
|> ignore
let configureServices (services: IServiceCollection): unit =
services.AddSignalR() |> ignore
[<EntryPoint>]
let main _ =
let publicPath = Path.GetFullPath "./public"
WebHostBuilder()
.UseKestrel()
.UseWebRoot(publicPath)
.UseContentRoot(publicPath)
.Configure(Action<IApplicationBuilder> configureApp)
.ConfigureServices(configureServices)
.Build()
.Run()
0 // return an integer exit code
We need to create a class inheriting from the Hub
-class which defines client-server interactions.
type MyHub() =
inherit Hub()
Even though this is a class, one cannot seem to persist mutable state in it as the class seems to be instantiated and destroyed only as it's needed.
The method UseSignalR
is invoked in the app configuration step and configured with an endpoint /myhub
to access our hub methods.
.UseSignalR(fun routes -> routes.MapHub<MyHub>(PathString "/myhub"))
A service configuration function configureServices
is required for SignalR to work and is passed to the ConfigureServices
method on the WebHostBuilder
object.
.ConfigureServices(configureServices)
open System
open System.IO
open Microsoft.AspNetCore.Http
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Hosting
open Microsoft.AspNetCore.SignalR
open Microsoft.Extensions.DependencyInjection
type Message = { userName: string
content: string }
type MyHub() =
inherit Hub()
member __.PostMessage(message: Message): unit =
__.Clients.All.SendAsync("receiveMessage", message) |> ignore
let configureApp (app: IApplicationBuilder): unit =
app.UseDefaultFiles()
.UseStaticFiles()
.UseSignalR(fun routes -> routes.MapHub<MyHub>(PathString "/myhub"))
|> ignore
let configureServices (services: IServiceCollection): unit =
services.AddSignalR() |> ignore
[<EntryPoint>]
let main _ =
let publicPath = Path.GetFullPath "./public"
WebHostBuilder()
.UseKestrel()
.UseWebRoot(publicPath)
.UseContentRoot(publicPath)
.Configure(Action<IApplicationBuilder> configureApp)
.ConfigureServices(configureServices)
.Build()
.Run()
0 // return an integer exit code
We want our server to receive objects representing messages containing the contents of the message itself as well as the name of the sender. The first step is to define a record type for those objects.
type Message = { userName: string
content: string }
Next we define a method I've decided to call PostMessage
that takes an object of type Message
and performs some side effect.
member __.PostMessage(message: Message): unit =
// ...
This method can be invoked directly by the client to run on the server. In our case we want PostMessage
to send the message back to all clients connected to /myhub
. We do this by calling
__.Clients.All.SendAsync("receiveMessage", message) |> ignore
SendAsync
triggers an event on clients called receiveMessage
with given parameters. In our case the message
object.
That is pretty much how the server works.
I only included Giraffe in the code base so that I have an example of how to integrate it. I'm breaking my own rules because I shouldn't include more than is strictly necessary for the purpose of the demo. I dislike YOLO programmers but... YOLO
I will not go into the client in detail, the App
-component is not strictly necessary to understand in order to understand how client-server communication works. I will only show what I think is important to make this work.
import { HubConnectionBuilder } from '@aspnet/signalr'
const myhub: HubConnection = new HubConnectionBuilder().withUrl('/myhub').build()
We're using the same endpoint as defined in the server code.
.withUrl('/myhub')
.UseSignalR(fun routes -> routes.MapHub<MyHub>(PathString "/myhub"))
This has ofcourse (and unfortunately) caused an implicit coupling with our server code. That means that if this endpoint is not the same as the one on the server, the code will not work. Unfortunately we're going to create a few more implicit couplings between the client code and the server code before this is over.
import { HubConnectionBuilder } from '@aspnet/signalr'
const myhub: HubConnection = new HubConnectionBuilder().withUrl('/myhub').build()
const connectionEstablishment: Promise<void> = myhub.start()
myhub.on('receiveMessage', data => /* ... */))
As we defined in the server code we may now listen to the receiveMessage
event on the client and use whatever data is sent from the server.
member __.PostMessage(message: Message): unit =
__.Clients.All.SendAsync("receiveMessage", message) |> ignore
More implicit coupling.
As we can see the data is defined as a Message
type Message = { userName: string
content: string }
so we will need an equivalent definition in the client code to help us work with the data correctly. In my application this definition is defined in App.tsx
export interface IMessage {
userName: string
content: string
}
Yet even more implicit coupling. Will it ever stop?
myhub.on('receiveMessage', (message: IMessage) =>
store.dispatch({ type: APPEND_MESSAGE, message }))
You can ofcourse do other things with the data
object (renamed "message" and typed as IMessage
in my case). With Redux the whole application will react appropriately on incoming messages.
import { HubConnectionBuilder } from '@aspnet/signalr'
const myhub: HubConnection = new HubConnectionBuilder().withUrl('/myhub').build()
const connectionEstablishment: Promise<void> = myhub.start()
myhub.on('receiveMessage', (message: IMessage) => /* ... */))
const app =
<Provider store={store}>
<App onSendMessage={({ userName, content }) =>
myhub.invoke('PostMessage', { userName, content })} />
</Provider>
We call the invoke
method on the HubConnection
object
myhub.invoke('PostMessage', { userName, content })
Notice that the object passed to the invoke
method has to satisfy the shape of the Message
type. Say it with me: "Boo! Implicit coupling!"
This will execute the method with the same name on the MyHub
class on the server.
type MyHub() =
inherit Hub()
member __.PostMessage(message: Message): unit =
__.Clients.All.SendAsync("receiveMessage", message) |> ignore
which in turn triggers the receiveMessage
event on all connected clients, thereby passing along the incoming message and causing all clients to react to it
myhub.on('receiveMessage', (message: IMessage) => /* ... */))
Unless I've missed anything, that's how to use SignalR for server-client communication.
It's not obvious to me that we want to. I lean, however, towards explicit coupling, even if it means more coupling. For example we could have a .json
file defining name of events, endpoints and functions and have explicit coupling with that .json
file and the client code and server code. It get's a little tricky when you want common types.
Originally I wanted to use Fable to compile F# to JavaScript for the frontend code but I am not entirely convinced this is a good idea. While F# for the frontend means you can have shared code between frontend and backend, I get the feeling that the Fable ecosystem is not mature yet. I may be wrong and maybe I'll take another look at it in the future. But for now I decided to stick to TypeScript for the sake of simplicity. Anyway, using Fable would make most if not all of implicit coupling between client code and server code explicit.