PureScript-to-PureScript websockets with simple-json and low-level cowboy bindings
As per the last post, justin’s simple-json is now in the purerl package set, which seemed to be the perfect opportunity to write a demo app with PureScript backend (purerl and cowboy) and PureScript frontend chatting over a websocket.
This will also be an opportunity to discuss some of the changes which make writing low-level bindings to OTP behaviours possible. Expect to see some cool stuff soon exposing some higher level (and likely more opinionated) bindings.
Server
Our demo app will be streaming load numbers, so we add the application os_mon
and write some basic FFI to wrap cpu_sup:avg1
avg1_(Nothing, Just) -> fun () ->
case cpu_sup:avg1() of
{error, _} -> Nothing;
Load when is_integer(Load) -> Just(Load)
end
end.
foreign import avg1_ :: Maybe Int -> (Int -> Maybe Int) -> Effect (Maybe Int)
avg1 :: Effect (Maybe Int)
avg1 = avg1_ Nothing Just
Now we can write a websocket handler. A cowboy handler is a callback module with various mandatory and optional callbacks. To write a callback module in PureScript, we need to ensure that we write a module with top-level bindings with the correct names and types; the purescript-erl-cowboy
library gives a few utilities to help get these types right, but ultimately this is rather fiddly and low-level, and we will ultimately want a higher-level API which allows us to treat handlers as composable values - the disconnect being that modules are not run-time value-level constructs.
A cowboy_websocket
handler has 2 init
functions: firstly a plain init
that initialises state from initial config and switches to the websocket behavior (in common with other cowboy handlers), and secondly a websocket_init
that performs initialisation that should be done in the context of the websocket process itself.
We’re not going to save any state or initial config, so we can define an init
as follows:
type State = Unit
init :: forall c. InitHandler c State
init = mkEffectFn2 \req _ -> do
log "init was called"
pure $ initResult unit req
The pointless log
call is included to illustrate the fact that these handler functions can be effectful things. Now, what ensures “the types line up”? Firstly, InitHandler
is defined as
type InitHandler c s = EffectFn2 Req c (InitResult s)
and secondly the compiler guarantees that a top level binding which is an EffectFn*N*
, Fn*N*
or is a curried function of arity N is represented as an erlang function of arity N. The type EffectFnN
is an arity-N erlang function (as in the JS backend though with a stronger notion of arity) and has no thunking, and this carries over to the top level definition. (I told you it was fiddly.)
Ro cap it off, erl-cowboy
has defined InitHandler c s
to match the cowboy type init(cowboy_req:req(), Config) -> {cowboy_websocket, cowboy_req:req(), State}
.
We now write a websocket_init
function, which we’ll use to set off a timer to send updates. Define a quick
foreign import startInterval :: forall a. Int -> a -> Effect Unit
and we can simply have
data InfoMessage = Timer
websocket_init :: WSInitHandler State
websocket_init = mkEffectFn1 \s -> do
startInterval 100 Timer
pure $ okResult s
Now to the interesting bits. When a frame is recieved, websocket_handle
is called, actually for this app we don’t care as we will only get some ping messages
websocket_handle :: forall f. FrameHandler f
websocket_handle = mkEffectFn2 \frame state -> do
log "got frame"
pure $ okResult state
Now the interesting bit is the websocket_info
handler, which will handle messages sent to our websocket handler process. The startInterval
call above sends a message Timer
of type InfoMessage
, so we will define
websocket_info :: InfoHandler InfoMessage State
We are basically asserting here that nobody else will send a message of any other form to the process, this is morally in the “FFI has no guarantees” camp, here we only send the right messages with our FFI but if that invariant is not maintained, the process will crash.
So without further ado here’s the info handler:
websocket_info = mkEffectFn2 info1
where
info1 :: InfoMessage -> State -> Effect (CallResult Unit)
info1 msg state = do
avg1' <- CPU.avg1
avg5' <- CPU.avg5
avg15' <- CPU.avg15
case avg1', avg5', avg15' of
Just avg1, Just avg5, Just avg15 -> do
let msg :: Message
msg = { load: { avg1, avg5, avg15 }}
let outFrames = singleton $ outFrame $ TextFrame $ SimpleJSON.writeJSON msg
pure $ replyResult state outFrames
_, _, _ -> do
log "Some issue fetching load info"
pure $ okResult state
So the interesting bit is the line defining outFrames
. We wrap a string in a TextFrame
to send, and encoding our message as JSON is sas simple as SimpleJSON.writeJSON
. The Message
type is defined as follows, and crucially this type is shared between server and client:
type Load = {
avg1 :: Int,
avg5 :: Int,
avg15 :: Int
}
type Message = {
load :: Load
}
simple-json
, it’s just that simple.
We’ll omit the code to stand up the cowboy server with routing etc., but you can find it in the repo.
Client
For a client we’ll use spork
to render a few bits of HTML to the page (because really, who wants to use the DOM). Firstly, a little utility to create and subscribe to the socket, here using the purescript-web-socket
low level WebSocket bindings.
createSocket :: String -> (String -> Effect Unit) -> Effect Unit
createSocket url cb = do
socket <- WS.create url []
listener <- EET.eventListener \ev ->
for_ (ME.fromEvent ev) \msgEvent ->
for_ (runExcept $ readString $ ME.data_ msgEvent) cb
EET.addEventListener WSET.onMessage listener false (WS.toEventTarget socket)
void $ setInterval 10000 $ WS.sendString socket "pingy"
Now the client - firstly the scaffolding to render our type to the page:
type Model = Load
data Action = UpdateMessage Message
update ∷ Model → Action → Model
update _ = case _ of
UpdateMessage { load } → load
render ∷ Model → Html Action
render { avg1, avg5, avg15 } =
H.ul []
[ H.li [] [ H.text $ show avg1 ]
, H.li [] [ H.text $ show avg5 ]
, H.li [] [ H.text $ show avg15 ]
]
app ∷ PureApp Model Action
app = { update, render, init: { avg1: 0, avg5: 0, avg15: 0 } }
Nothing to see here, folks, spork makes it simple. We then subscribe to the websocket and push in messages:
main ∷ Effect Unit
main = do
appInstance <- PureApp.makeWithSelector app "#app"
createSocket "ws://localhost:8082/ws" \json ->
for_ (SimpleJSON.readJSON json) \msg -> do
appInstance.push $ UpdateMessage msg
appInstance.run
Again the connection is the use of the shared Message
type and the use of SimpleJSON.readJSON
. We decode JSON with simple-json
on the JavaScript backend, while it was encoded with simple-json
on the Erlang backend - the underlying JSON encoder/decoder was different, but we need not care.
Last words
I think this is pretty cool, but there’s some way to go from this to a more natural, type-safe version of the websocket handler shown here. This is something actively being worked on now, and there are definitely some better options for individual APIs (with different tradeoffs), but I think it’s still useful to see we can implement a callback module directly.
The code for this example is available in the repo.