Writing ReasonML bindings for JavaScript APIs
If you haven't heard about Reason, you should probably look into what it is first. However, if you have a JavaScript background and have just recently heard about Reason, the rest of the article should make sense for you.
If you're looking to write ReasonML bindings for an existing JavaScript library, you'll find some documentation on how the process works (both in the Reason and Bucklescript docs). There's also plenty of bindings that you can use for some popular libraries (moment.js, React, etc.).
For this blog post, I wanted to write a set of bindings for a JavaScript library for the sole purpose of documenting the process so other people can learn more about it.
I picked Web Workers as they have a pretty simple API and so it shouldn't take us very long to be able to use it with ReasonML. I also couldn't find any existing bindings in either Reason or Bucklescript, so I thought it'd make sense to take the opportunity to contribute something new.
For the purpose of this blog post, we'll only be covering the basic Web Workers API. This includes the Worker
constructor, the postMessage
method and the onMessage
event handlers.
You can find the finished repository with the bindings and an example here.
Before you read any further
These bindings were a Reason learning experiment and so a couple of things in this article might not be written in "the Reason way".
Our final goal (except in Reason)
index.js
var myWorker = new Worker("worker.js");
myWorker.onmessage = function(e) {
console.log("Message received from worker");
}
myWorker.postMessage({"text": "Hello world"});
worker.js
onMessage = function(e) {
console.log(e.data);
postMessage("Message received from main thread");
}
In order to write the above example in Reason, we'll have to create a set of Reason bindings for the JavaScript API. The first thing that you should absolutely read before writing any such bindings is the Bucklescript documentation on Interop.
On the Main Thread
Let's start with the constructor — new Worker(string)
. This is a method that takes in a string
and returns a Worker
. So, we should start by creating a type for Web Workers and declaring the binding for that constructor using Bucklescript's interop syntax.
type webWorker;
[@bs.new] external create_webworker : string => webWorker = "Worker";
There's already a few key things to note:
- The Web Worker type has no definition yet (it just exists). This is because we should write the bindings such that they work first, and worry about the details later.
- We called our constructor
create_webworker
as this name adheres to the Reason naming standard. - We bind
create_webworker
tonew Worker()
using@bs.new
.
So, how would we use this file from our main file? (we'll call it demo.re
)
let myWorker = WebWorkers.create_webworker("worker.js");
Now, we need to create an external binding for the postMessage
method. This is a method on the Worker
type that takes in "something" and returns nothing.
[@bs.send] external postMessage : (webWorker, 'a) => unit = "postMessage";
- It's a
@bs.send
which means that Reason will apply the method on the first argument, of typewebWorker
. - We use 'a as a "for all types".
- It returns nothing, so
unit
.
We can use this method like this (in demo.re
):
let msg = {"text": "Hello world"};
WebWorkers.postMessage(myWorker, msg);
Now, we need to define the onmessage
handler for when the worker uses its postMessage
function. This time, we need to use @bs.set
to define a method on the webWorker
type. This method takes in a function that receives a MessageEvent and returns nothing. The method itself returns nothing as well.
module MessageEvent = {
type t;
[@bs.get] external data : t => 'a = "";
};
[@bs.set] external setOnMessage : (webWorker, MessageEvent.t => unit) => unit = "onmessage";
- The
MessageEvent
is a submodule with a type that represents the actual MessageEvent type. - This submodule then needs a
.data()
method (that maps to the read-only property.data
) which returns the actual data stored (which can be of any type, in true JavaScript spirit). - We use empty
""
because the JavaScript property name isdata
so Reason can infer that mapping for us. - Finally, we create a
setOnMessage
function that sets (using@bs.set
for mapping) theonMessage
property of awebWorker
. The function that is passed in has to be of typeMessageEvent.t => unit
.
Let's take a look at our final demo.re
file which uses the API we have built so far.
let worker = WebWorkers.create_webworker("worker.js");
let msg = {"text": "Hello world"};
WebWorkers.postMessage(worker, msg);
let msgBackHandler = (e: WebWorkers.MessageEvent.t) => {
Js.log("I am the main thread and I have received a message back from the worker:");
Js.log(WebWorkers.MessageEvent.data(e))
};
WebWorkers.setOnMessage(worker, msgBackHandler);
The Worker Script
The worker script was fairly more complicated. Here's a refresher of what our goal for this file is:
worker.js
onMessage = function(e) {
console.log(e.data);
postMessage("Message received from main thread");
}
Here's what I came up with for setting the onMessage
property of self
.
type window;
[@bs.val] external self : window = "";
[@bs.set] external setWorkerOnMessage : (window, MessageEvent.t => unit) => unit = "onmessage";
[@bs.val] external postMessageFromWorker : ('a) => unit = "postMessage";
- We declare the bland type
window
(which could be also beJS.Dom.window
). - We map
WebWorkers.self
towindow.self
using@bs.val
. - We declare
WebWorkers.onmessage
which takes in awindow
and anonmessage
handler and maps toonmessage
. - We created
WebWorkers.postMessageFromWorker
which takes in anything and returns nothing.
The Final Result
Let's take a look at the final Reason result.
demo.re
let worker = WebWorkers.create_webworker("worker.js");
let msg = {"text": "Hello world"};
WebWorkers.postMessage(worker, msg);
let msgBackHandler = (e: WebWorkers.MessageEvent.t) => {
Js.log("I am the main thread and I have received a message back from the worker:");
Js.log(WebWorkers.MessageEvent.data(e))
};
WebWorkers.onMessage(worker, msgBackHandler);
worker.re
WebWorkers.onmessage(
WebWorkers.window,
(e: WebWorkers.MessageEvent.t) => {
Js.log("I am the Web Worker and I have received a message:");
Js.log(WebWorkers.MessageEvent.data(e));
WebWorkers.postMessageFromWorker("my result");
}
);
Note how on the worker's side, we can also make use of the WebWorkers.MessageEvent.data
getter util to parse the MessageEvent
passed to the onmessage
handler.
Next steps
This API could be polished to be more strongly typed and to cover more of the Web Workers API.
Additionally, I will try to submit my bindings to the bs-webapi-incubator project, which holds Reason bindings for diverse Web APIs. For now, the work I did for this blog post lives here.
If you want to look at the JavaScript output (it's very interesting, I promise), feel free to clone it and run it.
Conclusion
The Bucklescript/Reason interop (FFI) system is very powerful and lets us add strong typing to previously existing JavaScript APIs. This is just a breath of fresh air after working with a weakly typed language (JavaScript) for so long.
However, the FFI also lets us "add types as we go". You can start by mapping just the bare basics of your API to Reason without strong typing and then, as you go along, you can incrementally polish your FFI.