Writing an Erlang WebSocket Client

I’ve recently authored a simple websocket client for Erlang here. The motivation was that all existing clients either didn’t support ssl, or support RFC 6455, the latest websocket RFC draft specification. The following outlined my goals for the project:

  1. The usage should resemble a standard OTP behaviour
  2. It should be efficient
  3. It should be minimal

Make your libraries resemble OTP behaviours

Following the principle of least astonishment, providing an interface to your code that resembles something familiar is an easy way to make code less error prone and more usable. This also forces the application author to read more into how OTP behaviours are actually implemented with nothing more than receive blocks, tail recursion, and ! sending patterns.

The core of the websocket_client loop looks like

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
%% @doc Main loop
-spec websocket_loop(State :: tuple(), HandlerState :: any(),
                     Buffer :: binary()) ->
    ok.
websocket_loop(State = #state{handler = Handler, remaining = Remaining,
                              socket = Socket, transport = Transport},
               HandlerState, Buffer) ->
    receive
        {cast, Frame} ->
            ok = Transport:send(Socket, encode_frame(Frame)),
            websocket_loop(State, HandlerState, Buffer);
        {_Closed, Socket} ->
            Handler:websocket_terminate({close, 0, <<>>}, HandlerState);
        {_TransportType, Socket, Data} ->
            case Remaining of undefined ->
                retrieve_frame(State, HandlerState,
                               << Buffer/binary, Data/binary >>);
                _ ->
                    retrieve_frame(State, HandlerState,
                                   State#state.opcode,
                                   Remaining, Data, Buffer)
            end;
        Msg ->
            HandlerResponse = Handler:websocket_info(Msg, HandlerState),
            handle_response(State, HandlerResponse, Buffer)
    end,
    ok.

This might seem confusing at first glance but let’s break it down.

1
2
3
-spec websocket_loop(State :: tuple(), HandlerState :: any(),
                     Buffer :: binary()) ->
    ok.

This is the type specification, indicating that the function takes a state tuple, the handler state (which could be anything), and the existing existing data in the buffer. Of course, this loop must be tail recursive, so all data needed to run the loop must be contained somewhere in these arguments.

Next, the code jumps immediately into the receive clause. Let’s go through each case:

1
2
3
    {cast, Frame} ->
        ok = Transport:send(Socket, encode_frame(Frame)),
        websocket_loop(State, HandlerState, Buffer);

Here, if the loop receives a message that looks like {cast, Frame}, it will encode the frame, send it via the transport (which is either ssl or gen_tcp), and then reenter the loop again with no state changes.

1
2
    {_Closed, Socket} ->
        Handler:websocket_terminate({close, 0, <<>>}, HandlerState);

All other 2-arity tuples the loop receives with the socket as the second filed must be a close message emitted by the socket itself. In this case, the handler’s websocket_terminate callback is called to allow the user to do any cleanup necessary. The loop is not reentered afterwards and the process is allowed to finish.

1
2
3
4
5
6
7
8
    {_TransportType, Socket, Data} ->
        case Remaining of undefined ->
            retrieve_frame(State, HandlerState,
                           << Buffer/binary, Data/binary >>);
            _ ->
                retrieve_frame(State, HandlerState, State#state.opcode,
                               Remaining, Data, Buffer)
        end;

This message is received whenever the socket receives data since the sockets are in {active, true} mode (having the sockets in {active, false} mode means that the recv function needs to be called explicitly to receive any data on the socket). The function then checks if we were previously waiting for data to finish an existing frame, or if this is the start of an entirely new frame. The appropriate retrieve_frame is called depending.

Note here that I do not explicitly continue the loop but allow retrieve_frame to decide if it wants to continue the loop or not. This was done to make the loop clean as many errors can occur upon websocket data retrieval that may force the client to shutdown according to the websocket standard.

1
2
3
    Msg ->
        HandlerResponse = Handler:websocket_info(Msg, HandlerState),
        handle_response(State, HandlerResponse, Buffer)

Finally, all other messages are sent to the handler and the response is used to determine if the loop should send any data to the server before restarting again.

The library is now in a usable state, meaning that it can:

  1. Issue a handshake on either tcp or ssl
  2. Receive a handshake response
  3. Verify the response’s correctness
  4. Accept data and send data
  5. Interact as intended with the provided handler on start_link
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
-module(sample_ws_handler).

-behaviour(websocket_client_handler).

-export([
         start_link/0,
         init/1,
         websocket_handle/2,
         websocket_info/2,
         websocket_terminate/2
        ]).

start_link() ->
    crypto:start(),
    ssl:start(),
    websocket_client:start_link(?MODULE, wss, "echo.websocket.org", 443, "/", []).

init([]) ->
    websocket_client:cast(self(), {text, <<"message 1">>}),
    {ok, 2}.

websocket_handle({text, Msg}, 5) ->
    io:format("Received msg ~p~n", [Msg]),
    {close, <<>>, 10};
websocket_handle({text, Msg}, State) ->
    io:format("Received msg ~p~n", [Msg]),
    timer:sleep(1000),
    BinInt = list_to_binary(integer_to_list(State)),
    {reply, {text, <<"hello, this is message #", BinInt/binary >>}, State + 1}.

websocket_terminate({close, Code, Payload}, State) ->
    ok.

The above handler connects to the echo server over wss and prints the following output with a 1 second interval between each message:

1
2
3
4
message 1
hello, this is message #2
hello, this is message #3
hello, this is message #4

Note how it reads and feels like a typical gen_server. Neat huh? There is still a lot of work to be done on the client in the form of testing, error handling, and RFC compliance. Contributions accepted!

Comments