Star date: 2017.218
I recently started building a web api in Elixir that makes use of Phoenix channels where the intent is to access the API from a C++ websocket client. There do exist some C++ client libraries but I like to avoid dependencies when possible and this didn't really feel like something that should require a library, I just needed some documentation on what the protocol looks like. Since I couldn't really find the documentation I was looking for I decided to just read the implementation of the javascript client and reverse engineer the protocol. The following is is my own observations on how the protocol works.
This document could be helpful to you if you are doing any of the following:
If you want a complete specification of the channels websocket protocol then this isn't it. It seems like the only definitive source is the code in the JS implementation which you can find here.
heartbeat
event to avoid
connection timeout.phx_join
message.topic
.First of all, Phoenix Channels can support multiple transport mechanisms. For
this I only care about websockets. In order for your server to allow websocket
connections you need to have the following line in user_socket.ex
(it should
be there by default):
transport :websocket, Phoenix.Transports.WebSocket
And in endpoint.ex
you should have something like this:
socket "/socket", MyAppWeb.UserSocket
Remember that the word "socket" in UserSocket
refers not to a websocket
specifically, but a more general concept of a socket. Since we have specified
that our socket is available at /socket
it means that the websocket endpoint
will automatically be /socket/websocket
because the format is apparently
/path_to_socket/<transport>
.
Therefore, in this case, the websocket URL for a localhost test server would look like this-ish:
ws://localhost:4000/socket/websocket
Events look like:
{
"topic": "...",
"event": "...",
"payload": {},
"ref": 0
}
topic
: Usually this is the room the event relates to.event
: This defines which handler will get invoked on the server side (or
potentially client-side if going the other direction). There are some built-in
events mostly prefixed with phx_
.payload
: The actual data associated with the event. For some events (like
phx_join
) the payload is ignored.ref
: Just an idenfifier for the message. When you get back a reply it will
have the same ref
value as the event that it is replying to. Since channels
are asynchronous you could quickly send two events before receiving a reply and
you would need to use ref
to know which event it relates to. In my examples I
have hard coded ref
to 0
but in reality you probably want a counter and some
helper function to get the next reference number (or use a uuid).To avoid a connection timeout the client needs to send the server a heartbeat event. I don't actually know how long before the timeout occurs (probably configurable on server?) but the javascript client defaults to sending the heartbeat every 30 seconds. The heartbeat message looks like this:
{
"topic": "phoenix",
"event": "heartbeat",
"payload": {},
"ref": 0
}
Normally the topic
is a room, but in this case it looks like "phoenix"
is a
special topic used for system events.
It's just anothing event, but this time we set the topic
to be the room we
want to join and set the event
to phx_join
:
{
"topic": "room:lobby",
"event": "phx_join",
"payload": {},
"ref": 0
}
If the join was successful then you get a response like this:
{
"topic": "room:lobby",
"ref": 0,
"payload": {
"status": "ok",
"response": {}
},
"join_ref": null,
"event": "phx_reply"
}
If the join was not successful then you get "error"
instead of "ok"
.
In my channel (on the server) I will define two sample handlers:
# Just replies with exact same payload
def handle_in("echo", payload, socket) do
{:reply, payload, socket}
end
# Same as echo but sends the message to all clients
def handle_in("shout", payload, socket) do
broadcast! socket, "shout", payload
{:noreply, socket}
end
Based on that server, here are some client-server examples:
Client sends this:
{
"topic": "room:lobby",
"event": "echo",
"payload": { "hello": "world" },
"ref": 0
}
Server sends this back to just that one client:
{
"topic": "room:lobby",
"ref": 0,
"payload": {
"status": "ok",
"response": {
"hello": "world"
}
},
"join_ref": null,
"event": "phx_reply"
}
Client sends this:
{
"topic": "room:lobby",
"event": "shout",
"payload": { "hello": "world" },
"ref": 0
}
Server sends this to every client in room:lobby:
{
"topic": "room:lobby",
"ref": null,
"payload": {
"hello": "world"
},
"join_ref": null,
"event": "shout"
}
Client sends this:
{
"topic": "room:lobby",
"event": "event-that-does-not-exist-on-server",
"payload": { },
"ref": 0
}
Since the event is not valid the server sends this reply:
{
"topic": "room:lobby",
"ref": 0,
"payload": {},
"event": "phx_error"
}
Client sends this:
{
"topic": "room:room-that-does-not-exist",
"event": "echo",
"payload": { },
"ref": 0
}
Since the room is not the one we joined the server sends this reply:
{
"topic": "room:mainn",
"ref": 0,
"payload": {
"status": "error",
"response": {
"reason": "unmatched topic"
}
},
"join_ref": null,
"event": "phx_reply"
}
You will automatically leave the room if you disconnect your connection, but you
can also explicitly leave the room without closing your connection by using the
built-in event phx_leave
:
{
"topic": "room:lobby",
"event": "phx_leave",
"payload": {},
"ref": 0
}