Real-Time Communication in ReasonML with bs-socket

Ben Lovy - Mar 3 '19 - - Dev Community

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
},
// ..
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 */
Enter fullscreen mode Exit fullscreen mode

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
$
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then add it to bs-config.json:

// ..
"bs-dependencies": [
  "bs-express",
  "bs-socket"
],
// ..
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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. */
Enter fullscreen mode Exit fullscreen mode

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 = "";
};
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

|> 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>"));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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: []});
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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....
  },
);
Enter fullscreen mode Exit fullscreen mode

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)),
    );
Enter fullscreen mode Exit fullscreen mode

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)};
      },
    );
Enter fullscreen mode Exit fullscreen mode

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");
        },
    );
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Also add reason-react to bsconfig.json:

  "bs-dependencies": [
    "bs-express",
    "bs-socket",
    "reason-react"
  ],
Enter fullscreen mode Exit fullscreen mode

While we're in here, let's activate JSX. Add the following entry to the top level:

  "reason": {
    "react-jsx": 2
  },
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Also add a script to package.json to run it:

"scripts": {
  //..
  "start:bundle": "parcel watch index.html",
  //..
},
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 = "";
Enter fullscreen mode Exit fullscreen mode

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})
);
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Below that, we need to define the state for our component as well as the actions 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");
Enter fullscreen mode Exit fullscreen mode

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>,
};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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})
},
Enter fullscreen mode Exit fullscreen mode

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,
)}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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",
 )}
Enter fullscreen mode Exit fullscreen mode

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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .