In this post I'll demonstrate some real-time communication in a simple application using ReasonML. If you're brand new to Reason, some assumed basic comfort in JavaScript should be most of what you need, and there's a handy cheatsheet to get you started.
I'm using the bs-socket bindings for socket.io, a widely used Node.js real-time engine, and their example as a base.
The finished application will present each client with a set of named buttons and a dialog box to add a new button, as well as a running total of connected clients. Clicking a button will remove it from the set, and this set will stay in sync across all connected clients.
Requirements
This is a Node project. I'll be using yarn if you'd like to follow along exactly. All other dependencies will be handled by node.
Setup
First install the BuckleScript platform if you do not already have it have it:
$ yarn global add bs-platform
Now we can use the bsb
build tool to create a basic project:
$ bsb -init reason-buttons -theme basic-reason
$ cd reason-buttons/
$ yarn start
This will start the compiler in watch mode - any changes you make to a file will immediately trigger a recompile of the resulting JavaScript, right next to the source. Verify you see both Demo.re
and Demo.bs.js
. under reason-buttons/src
. Rename your Reason file to ButtonServer.re
and see it immediately recompile to reflect the difference - Demo.bs.js
is removed and the same contents now fill ButtonServer.bs.js
.
Add a script to your newly generated package.json
to execute this file:
// ..
"scripts": {
"build": "bsb -make-world",
"serve": "node src/ButtonServer.bs.js", // <- here
"start:re": "bsb -make-world -w",
"clean": "bsb -clean-world"
},
// ..
I also renamed start
to start:re
- feel free to manage your scripts however is most comfortable.
One change I always immediately make in a Node.js app is pulling the port number out so it can be specified via environment variable. Luckily, interop is dirt simple! We can just use Node to grab it from an environment variable. Create a file at src/Extern.re
with the following contents:
[@bs.val] external portEnv: option(string) = "process.env.PORT";
[@bs.val] external parseInt: (string, int) => int = "parseInt";
The [@bs.val]
syntax is a BuckleScript compiler directive. There's an overview of the various syntaxes here and the rest of that guide goes in depth about when to use each. I won't get too far into the nuts and bolts of JS interop in this post, the docs are thorough and for the most part in find the resulting code legible. The basic idea is that the keyword external
is kind of like let
except the body is a string name pointing to the external function. This way we can incrementally strongly type the JavaScript we need and have Reason typecheck everything smoothly.
This code will also leverage the option
data type utilities for nullable values like getWithDefault
from Belt
, the standard library that ships with Reason. Replace the contents of src/ButtonServer.js
with the following:
open Belt.Option;
open Extern;
let port = getWithDefault(portEnv, "3000");
print_endline("Listening at *:" ++ port);
I like to use 3000
for my default, you're of course welcome to use whatever you like.
Over in ButtonServer.bs.js
the compiled output is quite readable:
// Generated by BUCKLESCRIPT VERSION 4.0.18, PLEASE EDIT WITH CARE
'use strict';
var Belt_Option = require("bs-platform/lib/js/belt_Option.js");
var Caml_option = require("bs-platform/lib/js/caml_option.js");
var port = Belt_Option.getWithDefault((process.env.PORT == null) ? undefined : Caml_option.some(process.env.PORT), "3000");
console.log("Listening at *:" + port);
exports.port = port;
/* port Not a pure module */
Lets verify it works. Open up a separate terminal and type yarn serve
. You should see the following:
$ yarn serve
yarn run v1.13.0
$ node src/ButtonServer.bs.js
Listening at *:3000
Done in 0.09s
$
Dependencies
For an example of how to use node's Http
module manually see this post by Maciej Smolinski. For simplicity's sake I'll just use the community bindings for bs-express
. We'll also pull in bs-socket
:
$ yarn add -D bs-express https://github.com/reasonml-community/bs-socket.io.git
Then add it to bs-config.json
:
// ..
"bs-dependencies": [
"bs-express",
"bs-socket"
],
// ..
Bucklescript will take care of the rest as long as the package in question has a bsconfig.json
.
Messages
Before we actually implement our server, though, we need to define some message types. This will help us plan the scope of the application. Create a new file at src/Messages.re
with the following contents:
/* Messages */
type labelName = string;
type buttonList = list(labelName);
type numClients = int;
type msg =
| AddButton(labelName)
| RemoveButton(labelName);
type clientToServer =
| Msg(msg)
| Howdy;
type serverToClient =
| Msg(msg)
| ClientDelta(int)
| Success((numClients, buttonList));
These are the various messages we'll be sending back and forth. This is the biggest difference from using socket.io
in JavaScript, where custom events are named with strings. Here we always just emit a generic message but use ReasonML pattern matching to destructure the payload itself. The library currently doesn't cover stringly typed events, though the one issue open is asking about it. The readme on that GitHub repo puts it succinctly: "The API differs a bit from socket.io's API to be more idiomatic in Reason. Generally, e.g. JavaScript's socket.emit("bla", 10)
becomes Server.emit(socket, Bla(10))
in Reason".
Take a look at Messages.bs.js
:
// Generated by BUCKLESCRIPT VERSION 4.0.18, PLEASE EDIT WITH CARE
/* This output is empty. Its source's type definitions, externals and/or unused code got optimized away. */
They don't end up represented at all in our bundle - it's just a compile-time benefit. Neat!
The Server
Express
Alright - one last step before we can write our server. Back in src/Extern.re
, add the following typings for Http
at the bottom of the file:
module Http = {
type http;
[@bs.module "http"] external create: Express.App.t => http = "Server";
[@bs.send] external listen: (http, int, unit => unit) => unit = "";
};
Now we're ready! Get back into src/ButtonServer.re
and make it look like this:
open Belt.Option;
open Express;
open Extern;
let port = getWithDefault(portEnv, "3000");
let app = express();
let http = Http.create(app);
Http.listen(http, port |> int_of_string, () =>
print_endline("Listening at *:" ++ port)
);
|>
is the pipe operator. In brief, a |> b
is the same as b(a)
. It can be much more readable when chaining multiple functions.
Just to verify it works, add a placeholder /
endpoint, above the Http.listen()
line. We'll come back to the client.
App.get(app, ~path="/") @@
Middleware.from((_, _) => Response.sendString("<h1>HELLO, REASON</h1>"));
Alright, I lied - there's one more bit o' syntax there. Per the docs (@@)
is the application operator - "g @@ f @@ x is exactly equivalent to g (f (x))." If you're familiar with Haskell, it's ($)
, or if you're familiar with...math, I guess, it's g o f(x)
.
Let's make sure we're good to go:
$ yarn serve
$ node src/ButtonServer.bs.js
Listening at *:3000
If you point your browser, you should see HELLO REASON.
Socketry
Now for the real-time bits! Add the following two lines below your /
endpoint, but above your call to Http.listen()
:
module Server = BsSocket.Server.Make(Messages);
let io = Server.createWithHttp(http);
Now socket.io
is configured to use the newly defined Message types. To keep track of the current set of buttons and connected clients, we'll need some state:
type appState = {
buttons: list(string),
clients: list(BsSocket.Server.socketT),
};
let state = ref({buttons: ["Click me"], clients: []});
The state is held inside a mutable ref
. We can access the current contents via state^
, and assign to it with the assignment operator :=
. When the server starts up it has no clients and one default button.
Also handy is this helper function to emit a message to every client stored except the client passed:
let sendToRest = (socket, msg) =>
state^.clients
|> List.filter(c => c != socket)
|> List.iter(c => Server.Socket.emit(c, msg));
Now everything is set up to define the real meat of the application. Start with the following outline:
Server.onConnect(
io,
socket => {
// our code here....
},
);
The first part is how to handle a client connecting. Replace the placeholder comment with the following:
open Server;
print_endline("Client connected");
state := {...state^, clients: List.append(state^.clients, [socket])};
sendToRest(socket, ClientDelta(1));
Socket.emit(
socket,
Success((List.length(state^.clients), state^.buttons)),
);
For convenience we'll open our Server
module into the local scope, and then adjust our state to include the new client. We use the sendToRest
function to emit the ClientDelta
message to everyone else who may already be stored in state.clients
, and finally send back the Success
message, telling the newly connected client about the current state.
The next order of business is handling the disconnect. Right below the last Socket.emit()
call add:
Socket.onDisconnect(
socket,
_ => {
print_endline("Client disconnected");
sendToRest(socket, ClientDelta(-1));
state :=
{...state^, clients: List.filter(c => c == socket, state^.clients)};
},
);
The client gets dropped from the app state and everyone else still connected is updated on the change. The only part left is to handle the clientToServer
messages we defined in Messages.re
:
Socket.on(
socket,
fun
| Msg(msg) => {
switch (msg) {
| AddButton(name) =>
print_endline("Add " ++ name);
state :=
{...state^, buttons: state^.buttons |> List.append([name])};
sendToRest(socket, Msg(AddButton(name)));
| RemoveButton(name) =>
print_endline("Remove " ++ name);
state :=
{
...state^,
buttons: state^.buttons |> List.filter(a => a == name),
};
sendToRest(socket, Msg(RemoveButton(name)));
};
}
| Howdy => {
print_endline("Howdy back, client");
},
);
Whenever a button is added or removed, we adjust our state accordingly and let everyone else know about the change. That's it for the server!
The Client
Nuts 'n' Bolts
I'd feel remiss if I didn't use the ReasonReact library for this demo. It's excellent. First, add the dependencies:
$ yarn add react react-dom
$ yarn add -D reason-react
Also add reason-react
to bsconfig.json
:
"bs-dependencies": [
"bs-express",
"bs-socket",
"reason-react"
],
While we're in here, let's activate JSX. Add the following entry to the top level:
"reason": {
"react-jsx": 2
},
To handle bundling, I'm going to use Parcel. This is not necessary - you're welcome to use anything you're comfortable with. To follow along, add the dependency:
$ yarn add -D parcel-bundler
Also add a script to package.json
to run it:
"scripts": {
//..
"start:bundle": "parcel watch index.html",
//..
},
We also need to create that index.html
. Put it at your project root:
<!-- https://github.com/sveltejs/template/issues/12 -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Reason Buttons</title>
<script id="s"></script>
<script>
document.getElementById('s').src = "socket.io/socket.io.js"
</script>
</head>
<body>
<div id="app"></div>
<script defer src="./src/Index.re"></script>
</body>
</html>
This stub includes a workaround in the head for using Parcel with socket.io on the client side. Also note that Parcel understands ReasonML - we can pass in Index.re
for the entry point directly. Once this file is here, open a new terminal and enter yarn start:bundle
- this can be left running and will recompile your bundle when needed.
We now need to tell our server to serve this file instead of our placeholder string. We'll use a little more interop from this - add the following to Extern.re
, helpfully lifted from the bs-socket example:
module Path = {
type pathT;
[@bs.module "path"] [@bs.splice]
external join : array(string) => string = "";
};
[@bs.val] external __dirname : string = "";
Now replace the endpoint in ButtonServer.re
with:
App.use(
app,
{
let options = Static.defaultOptions();
Static.make(Path.join([|__dirname, "../dist"|]), options)
|> Static.asMiddleware;
},
);
App.get(app, ~path="/") @@
Middleware.from((_, _, res) =>
res |> Response.sendFile("index.html", {"root": __dirname})
);
This sets up our static file serving and serves dist/index.html
, which is generated by Parcel, at /
instead of the placeholder string.
Code
We've pointed Parcel towards src/Index.re
- might be a good idea to put a file there! Create it with the following contents:
ReactDOMRe.renderToElementWithId(<ButtonClient />, "app");
This is how ReasonReact mounts to the DOM. We're finally ready to build the component.
In a real app, this would ideally be split into several components - one for the buttons, one for the input, maybe a separate one for the counter. For demonstration purposes I'm just throwing it all in one component, but if this app were to get much larger splitting it apart would likely be step number one.
Create a file at src/ButtonClient.re
. First, we'll set up our socket client at the top of the file:
module Client = BsSocket.Client.Make(Messages);
let socket = Client.create();
Below that, we need to define the state
for our component as well as the action
s we can take to transform that state in order to create a reducerComponent
:
type state = {
numClients: int,
buttons: list(string),
newButtonTitle: string,
};
type action =
| AddButton(string)
| ClientDelta(int)
| RemoveButton(string)
| Success((int, list(string)))
| UpdateTitle(string);
let component = ReasonReact.reducerComponent("ButtonClient");
This is pretty similar to the socket.io
messages, with the addition of a newButtonTitle
to allow the client to name the buttons they add.
The rest of the component will live in this skeleton:
let make = _children => {
...component,
initialState: _state => {numClients: 1, buttons: [], newButtonTitle: ""},
didMount: self => {
// socket.io message handling
},
reducer: (action, state) =>
switch (action) {
// actions
},
render: self =>
<div>
<h1> {ReasonReact.string("Reason Buttons")} </h1>
<div>
// Buttons
</div>
<div>
// Add A Button
</div>
<span>
// Current Count
</span>
</div>,
};
We'll look at each section separately. The initialState
given here will just be used to render the component right off the bat - as soon as our client connects, it's going to receive a Success
message which will overwrite this value.
We need to translate incoming socket.io
messages. I've put this in the didMount
method to make sure our client has successfully loaded. Replace the placeholder with:
Client.on(socket, m =>
switch (m) {
| Msg(msg) =>
switch (msg) {
| AddButton(name) => self.send(AddButton(name))
| RemoveButton(name) => self.send(RemoveButton(name))
}
| ClientDelta(amt) => self.send(ClientDelta(amt))
| Success((numClients, buttons)) =>
self.send(Success((numClients, buttons)))
}
);
Client.emit(socket, Howdy);
The Client.on()
portion is pattern matching on the incoming serverToClient
messages and mapping it to the proper ReasonReact action
. We also send back a Howdy
message to the server once successfully loaded.
The next order of business is our reducer. We need to define how exactly each action
should manipulate our state
:
switch (action) {
| AddButton(name) =>
ReasonReact.Update({
...state,
buttons: List.append(state.buttons, [name]),
})
| ClientDelta(amt) =>
ReasonReact.Update({...state, numClients: state.numClients + amt})
| RemoveButton(name) =>
ReasonReact.Update({
...state,
buttons: List.filter(b => b != name, state.buttons),
})
| Success((numClients, buttons)) =>
ReasonReact.Update({...state, numClients, buttons})
| UpdateTitle(newButtonTitle) =>
ReasonReact.Update({...state, newButtonTitle})
},
The ...
spread operator is a huge help! This code also takes advantage of a feature called "punning" - for instance, in UpdateTitle(newButtonTitle)
, newButtonTitle
is both being used as a temporary name for the message payload and the name of the field in the app state
. If they're named the same thing, we can use the shorthand {...state, newButtonTitle}
instead of {...state, newButtonTitle: newButtonTitle}
.
All that's left to define is the UI! The list of buttons will render each button name in our state
as a button which when clicked will signal the removal of that button:
{ReasonReact.array(
self.state.buttons
|> List.map(button =>
<button
key=button
onClick={_ => {
self.send(RemoveButton(button));
Client.emit(socket, Msg(RemoveButton(button)));
}}>
{ReasonReact.string(button)}
</button>
)
|> Array.of_list,
)}
We both send the action
to our component's reducer as well as emit the clientToServer
message to the server to make sure it gets removed everywhere.
Next up is the box to set the name of any new button created:
<input
type_="text"
value={self.state.newButtonTitle}
onChange={evt =>
self.send(UpdateTitle(ReactEvent.Form.target(evt)##value))
}
/>
<button
onClick={_ => {
let name = self.state.newButtonTitle;
self.send(UpdateTitle(""));
self.send(AddButton(name));
Client.emit(socket, Msg(AddButton(name)));
}}>
{ReasonReact.string("Add button " ++ self.state.newButtonTitle)}
</button>
Upon submitting, the component will reset the field to an empty string.
The last bit is the count of total connected clients:
{ReasonReact.string(
(self.state.numClients |> string_of_int) ++ " connected",
)}
And that's a wrap! Let's fire it up. Assuming you've had yarn start:re
and yarn start:bundle
running, open a new terminal and finally invoke yarn serve
. Now open up a couple of browser windows, point them all to localhost:3000
and you should see them remain in sync with each other as you add and remove buttons. Hooray!
Completed code can be found here.
Cover image was found here.