>> Introspection

I need an Elixir client for VccExchange, a cryptocurrency trading platform, to place orders and track trades. The REST client uses cURL so porting it via tesla is straightforward; however, the websocket uses socket.io which requires a specific protocol or library. Although socket.io clients are implemented in several languages, Elixir sadly does not have one. While it might be worth making a client as a project, I only needed to subscribe to specific events specifically balance changes and trades executed.

Source: socket.ex

>> websockex

When I was working on a similar case with Bitfinex, websockex is a good websocket library that feels like a normal GenServer that the following snippet shows:

defmodule App.Socket do
  require Logger

  use WebSockex

  # Taken from inspecting the dashboard with the browser's native debugger
  @websocket_url "wss://socket.vcc.exchange:6001/socket.io/?EIO=3&transport=websocket"

  def start_link(_opts) do
    state = %{}
    {:ok, conn} = WebSockex.start_link(@websocket_url, __MODULE__, state)

    # TODO: Subscribe to events

    {:ok, conn}
  end

  def handle_connect(_conn, state) do
    Logger.debug("Socket Connected")

    {:ok, state}
  end

  def handle_disconnect(disconnection, state) do
    Logger.debug("Socket Disconnected")

    {:ok, state}
  end

  def handle_frame({_type, msg}, state) do
    Logger.debug("Message Received: #{inspect(msg)}")

    {:ok, state}
  end

  def handle_cast({:send, {_type, msg} = frame}, state) do
    Logger.debug("Message Sent: #{inspect(msg)}")

    {:reply, frame, state}
  end
end

defmodule App.Application do
  use Supervisor

  def start_link(opts) do
    Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @impl true
  def init(_init_arg) do
    children = [
      {App.Socket, []}
    ]

    Supervisor.init(children, strategy: :one_for_one)
  end
end

Running this via App.Socket.start_link(nil) or iex -S mix connects properly to the server. Now how to subscribe to the channels/events? In particular, this snippet from the official documentation needs to be emulated:

const io = require('socket.io-client');
const socket = io('wss://socket.vcc.exchange:6001');

const API_KEY = '';
const CHANNEL = 'private-App.User.1';
const EVENT = 'App\\Events\\BalanceUpdated';

socket.on('disconnect', function () {
    console.log('disconnected');
});
socket.on('reconnect', function () {
    console.log('reconnect');
});
socket.on('reconnect_error', function () {
    console.log('reconnect error');
});
socket.on('connect', function () {
    console.log('connected');
    socket.emit('subscribe', {
        channel: CHANNEL,
        auth: {
            headers: {'Authorization': `Bearer ${API_KEY}`}
        }
    }).on(EVENT, function(channel, data) {
        console.log(data);
    });
});

Since websocketex handles majority of the event handlers, only socket.emit needs to be handled. Looking at the socket.io protocol, the next snippet shows how it encodes the parameters:

@header "42"

@spec emit(charlist, any) :: charlist
def emit(event_name, data) do
  body = [event_name, data]

  "#{@header}#{Jason.encode!(body)}"
end

emit("hey", "jude")
"42[\"hey\",\"jude\"]"

emit("subscribe", %{channel: "MYCHANNEL"})
"42[\"subscribe\",{\"channel\":\"MYCHANNEL\"}]"

While this is no the full spec, it is enough to send over the wire. So after the socket connects, I send the subscription event for each channel:

def start_link(_opts) do
  {:ok, conn} = WebSockex.start_link(@websocket_url, __MODULE__, state)

  auth = %{headers: %{"Authorization" => "Bearer #{api_key()}"}}

  [
    "App.Trades.#{pair()}",
    "private-App.User.#{user_id()}"
  ]
  |> Enum.each(fn channel ->
    frame = emit("subscribe", %{channel: channel, auth: auth})

    WebSockex.send_frame(conn, {:text, frame})
  end)

  {:ok, conn}
end

defp pair(),
  do: "ETHBTC"

defp user_id(),
  do: System.get_env("VCCE_USER_ID", "123456")

defp api_key(),
  do: System.get_env("VCCE_API_KEY", "CREATE_YOUR_OWN_KEY")

The socket now receives changes in balance and new trades but they still need to be handled.

>> Event Handling

Received frames are handled in Websockex.handle_frame/2. Let us examine the balance update event.

42[
  "App\\Events\\BalanceUpdated",
  "private-App.User.123456",
  {
    "data": {
      "eth": {
        "balance": "10.000",
        "available_balance": "9.000"
      }
    },
    "socket": null
  }
]

Almost JSON, the frame is wrapped by the socket.io protocol which we can pattern match over after decoding it for ease:

def handle_frame({:text, "42" <> msg}, state) do
  case Jason.decode!(msg) do
    [
      "App\\Events\\BalanceUpdated",
      "private-App.User." <> _user_id,
      %{
        "data" => _data,
        "socket" => nil
      }
    ] ->
      Logger.info("BalanceUpdated")

    data ->
      Logger.warn("Unhandled Message: #{inspect(data)}")
  end

  {:ok, state}
end

So in handling more events, the pattern can be augmented and a side effect can be triggered. However, the socket.io events still need to be handled. When the socket connects to server, the socket receives the following frame:

0{
  "sid": "SESSION_ID",
  "upgrades": [],
  "pingInterval": 25000,
  "pingTimeout": 5000
}

The first frame sent by socket.io is the session information. The important fields are the pingInterval and pingTimeout which indicate a heartbeat in milliseconds. If you leave the socket open around that time, it will disconnect and attempt to reconnect. The quick solution is to use :timer.send_interval/3 and send a ping frame which is "2" in socket.io:

@heartbeat_interval 5_000

def start_link(_opts) do
  # Rest of the code

  :timer.send_interval(@heartbeat_interval ,self(), :ping)

  {:ok, conn}
end

def handle_info(:ping, state),
  do: {:reply, {:text, "2"}, state}

def handle_frame({:text, "0" <> _session_frame}, state),
  do: {:ok, state}

def handle_frame({:text, "3"}, state),
  do: {:ok, state}

Aside from handling the pong response ("3"), the last one to handle is joining a channel which is easily:

def handle_frame({:text, "40"}, state),
  do: {:ok, state}

The socket is now stable and working more or less.

>> Conclusion

It would be nice if an official socket.io client existed to make this all easier and comprehensive. In particular, error handling and testing is not ergonomic or apparent. Perhaps exposing a framework agnostic websocket might alleviate library concerns but support ultimately matters. Also, I realized that I could just introspect the websocket messages in the browser and reference that instead of guessing exact message which would have saved me hours.