>> 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.