Dependency-Free, Compliant Websocket Server written in Elixir.
Kitchen sink not included. Every mandatory Autobahn Testsuite case is passing. (Three fragmented UTF-8 are flagged as non-strict and as compression is not implemented, these are all flagged as "Unimplemented")
If you're looking for a channel/room implementation, check out ExWsChannels.
defmodule YourApp.YourWSHandler do
# This is the only function you HAVE to define.
# Note that WebSocket messages are just bytes that could represent
# anything. ExWs exposes these bytes as-is (as an iodata). In most
# cases, you'll probably want to decode that using JSON and process
# the resulting payload, but exposing the raw bytes allows for
# a lot more possibilities.
def message(data, state) do
# be careful, data is an iodata
case Jason.decode(data) do
{:ok, data} -> process(data)
_ -> close(3000, "invalid payload")
end
end
defp process(%{"join" => channel}) do
#...
end
end
Include the dependency in your project:
{:exms, "~> 0.0.1"}
Define your handler:
defmodule YourApp.YourWSHandler do
use ExWs.Handler
def message(_data, state) do
# do something with data
state
end
end
And start the server in your supervisor tree:
children = [
# ...
{ExWs.Supervisor, [port: 4545, handler: YourApp.YourWSHandler]}
]
From within your handler, you can use the write/1
function to
send a message to the user:
def message(data, state) do
# data is an iolist
write(data) # echo the message back to the user
state
end
The handshake/3
callback lets you handle the initial handshake:
# this is the default implementation
def handshake(_path, _headers, state) do
{:ok, state}
end
Where path
is the requested URL path as a string, and headers
is a map with lowercase string keys.
If you want to reject the handshake, say because the path/headers does not contain the correct authentication, return a {:close, ExWs.invalid_handshake/1}
:
def handshake(_path, headers, state) do
case lookup_one_time_token(headers["token"]) do
{:ok, user_id} -> {:ok, %{user_id: user_id}} # set a new state
_ -> {:close, ExWs.invalid_handshake("invalid_token")}
end
end
Note that the value given to ExWs.invalid_handshale/1
(in the above case, we're talking about "invalid_token") is placed in the Error
header of the handshake response (for troubleshooting purpose)
You can set the initial state by providing an init/0
callback:
# this is the default implementation
def init(), do: nil
The closed/2
callback is called whenever the socket is closed:
# default closed/2 implementation
defp closed(_reason, state) do
shutdown()
state
end
If you overwrite closed/2
, you almost certainly want to call shutdown/0
(it both closes the socket and shuts down the underlying GenServer).
Within your handler, the following functions are available:
ping/0
send a ping message to the clientwrite/1
writes the message to the clientclose/0
close the connectionclose2/
close the connection specifying acode
andmessage
. As per the specs, yourcode
should be 3000-4999. Your message must be < 123 bytes.get_socket/0
gets the underlying socket
Note that get_socket/0
will return the socket during init/0
and closed/2
(but the socket can be closed by the other side at any point).
Note that if you call close/0
directly, the closed/2
callback will be executed.
All WebSocket messages are framed and there's some overhead in creating this framing. When you call write/1
with a binary value, the handler will frame your payload and write the framed message to the socket.
For static messages, you can opt to pre-frame the message using the ExWs.bin/1
and ExWs.txt/1
functions. write/1
will detect these pre-framed messages and send them directly as-is.
defmodule YourApp.YourWSHandler do
use ExWs.Handler
@message_over_9000 ExWs.txt(" 9000!!")
def message("it's over", state) do
write(@message_over_9000)
state
end
end
WebSocket has a separate message type for binary data and text data. Implementations must reject any message declared as txt which is not valid UTF8.
This library does not do this validation. The message/2
callback receives both text and binary messages.
If you want to differentiate between the two, implement message/3
instead of message/2
:
def message(op, data, state) do
# op will be :txt or :bin
end
The default write/1
function uses the text type. You can override write/1
to change this behavior:
defp write(data) do
ExWs.write(get_socket(), ExWs.bin(data))
end
Or you can do it on a case-by-case basis:
write(ExWs.bin(some_data))
write(data)
# as as
write(ExBin.txt(data))
For performance reason, you may want to write directly to the socket, without going through the handler. For example, you might implement room/channel logic by storing the socket directly into the ETS table (writing to sockets from concurrent elixir processes is fine).
As we already saw, the get_socket/0
helper will return the socket. But you cannot write to the socket directly using gen_tcp.send/2
since weboscket messages must be framed.
You have two options, either use the ExWs.bin/1
and ExWs.txt/1
helpers to frame data:
:gen_tcp.send(socket, ExWs.txt("leto atreides"))
Or use the ExWs.write/2
helper:
ExWs.write(socket, "leto atreides")
Note that ExWs
also exposes ping/1
and close/1
.