Current section
Files
Jump to
Current section
Files
lib/wobserver/web/client_socket.ex
defmodule Wobserver.Web.ClientSocket do
@moduledoc ~S"""
Low level WebSocket handler
Connects to the Javascript websocket and parses all requests.
Example:
```elixir
defmodule Wobserver.Web.Client do
use Wobserver.Web.ClientSocket
alias Wobserver.System
def client_init do
{:ok, %{}}
end
def client_handle(:hello, state) do
{:reply, :ehlo, state}
end
def client_info(:update, state) do
{:noreply, state}
end
end
```
"""
require Logger
alias Wobserver.Util.Node.Discovery
alias Wobserver.Util.Node.Remote
alias Wobserver.Web.ClientSocket
@typedoc "Response to browser."
@type response ::
{:reply, atom | list(atom), any, any}
| {:reply, atom | list(atom), any}
| {:noreply, any}
@doc ~S"""
Initalizes the WebSocket.
Return {`:ok`, initial state} or {`:ok`, initial state, socket timeout}.
"""
@callback client_init :: {:ok, any} | {:ok, any, non_neg_integer}
@doc ~S"""
Handles messages coming from the WS client.
Return browser response.
"""
@callback client_handle(atom | {atom, any}, any) :: ClientSocket.response()
@doc ~S"""
Handles messages coming from other processes.
Return browser response.
"""
@callback client_info(any, any) :: ClientSocket.response()
defmacro __using__(_) do
quote do
import Wobserver.Web.ClientSocket, only: :functions
@security Application.compile_env(:wobserver, :security, Wobserver.Security)
@behaviour :cowboy_websocket
@behaviour Wobserver.Web.ClientSocket
@timeout 60_000
# Callbacks
## Init / Shutdown
@doc ~S"""
Initialize the websocket connection.
"""
def init(req, state) do
case @security.authenticated?(req) do
true ->
{:ok, opts} = client_opts()
{:cowboy_websocket, req, state, opts}
false ->
{:ok, state}
end
end
@doc ~S"""
Initialize the websocket connection by calling the implementing client.
The `req` cowboy request is passed along to the client.
The `type` and `options` are passed, but unused in the setup.
"""
@spec websocket_init(any) :: {:ok, any}
def websocket_init(_) do
{:ok, state} = client_init()
{:ok, %{state: state, proxy: nil}}
end
@doc ~S"""
Gracefully handles websocked terminate.
The `reason`, `req` and `state` are ignored.
"""
def terminate(_reason, _partial_req, _state) do
:ok
end
## Incoming from client / browser
@doc ~S"""
Handles incoming messages from the websocket client.
The `message` is parsed and passed on to the client, which responds with an update `state` and possible reply.
"""
@spec websocket_handle({:text, String.t()}, state :: any) ::
{:reply, {:text, String.t()}, any}
| {:ok, any}
def websocket_handle(message, state)
def websocket_handle({:text, command}, state = %{proxy: nil}) do
case parse_command(command) do
{:setup_proxy, name} ->
setup_proxy(name, state)
:nodes ->
{:reply, :nodes, Discovery.discover(), state.state}
|> send_response(state)
parsed_command ->
parsed_command
|> client_handle(state.state)
|> send_response(state)
end
end
def websocket_handle(payload = {:text, command}, state) do
case parse_command(command) do
{:setup_proxy, name} ->
setup_proxy(name, state)
:nodes ->
{:reply, :nodes, Discovery.discover(), state.state}
|> send_response(state)
parsed_command ->
send(state.proxy, {:proxy, command})
{:ok, state}
end
end
## Outgoing
@doc ~S"""
Handles incoming messages from processes.
The `message` is passed on to the client, which responds with an update `state` and possible reply.
"""
@spec websocket_info(
message :: any,
state :: any
) ::
{:reply, {:text, String.t()}, any}
| {:ok, any}
def websocket_info(message, state)
def websocket_info({:proxy, data}, state) do
{:reply, {:text, data}, state}
end
def websocket_info(:proxy_disconnect, state) do
{:reply, :proxy_disconnect, state.state}
|> send_response(%{state | proxy: nil})
end
def websocket_info(message, state) do
message
|> client_info(state.state)
|> send_response(state)
end
end
end
# Helpers
## Command
@doc ~S"""
Parses the JSON `payload` to an atom command and map data.
"""
@spec parse_command(payload :: String.t()) :: atom | {atom, any}
def parse_command(payload) do
command_data = Poison.decode!(payload)
command =
case String.split(command_data["command"], "/") do
[one_command] -> one_command |> String.to_atom()
list_of_commands -> list_of_commands |> Enum.map(&String.to_atom/1)
end
case command_data["data"] do
"" -> command
nil -> command
data -> {command, data}
end
end
@doc ~S"""
Send a JSON encoded to the websocket client.
The given `message` is JSON encoded (exception: `:noreply`).
The `socket_state` is used updated to reflect changes made by the client.
The cowboy `req` is returned untouched.
"""
@spec send_response(
message ::
{:noreply, any}
| {:reply, atom | list(atom), any}
| {:reply, atom | list(atom), map | list | String.t() | nil, any},
socket_state :: map
) ::
{:reply, {:text, String.t()}, map}
| {:ok, map}
def send_response(message, socket_state)
def send_response({:noreply, state}, socket_state) do
{:ok, %{socket_state | state: state}}
end
def send_response({:reply, type, message, state}, socket_state) do
data = %{
type: uniform_type(type),
timestamp: :os.system_time(:seconds),
data: message
}
case Poison.encode(data) do
{:ok, payload} ->
{:reply, {:text, payload}, %{socket_state | state: state}}
{:error, error} ->
Logger.warn(
"Wobserver.Web.ClientSocket: Can't send message, reason: #{inspect(error)}, message: #{inspect(message)}"
)
{:ok, %{socket_state | state: state}}
end
end
def send_response({:reply, type, state}, socket_state) do
send_response({:reply, type, nil, state}, socket_state)
end
@doc """
Sets up a websocket proxy to a given `proxy`.
The `state` is modified to add in the new proxy.
"""
@spec setup_proxy(proxy :: String.t(), state :: map) ::
{:reply, {:text, String.t()}, map}
| {:ok, map}
def setup_proxy(proxy, state) do
connected =
proxy
|> Discovery.find()
|> Remote.socket_proxy()
case connected do
{:error, message} ->
{:reply, :setup_proxy, %{error: message}, state.state}
|> send_response(state)
{pid, "local"} ->
if state.proxy != nil, do: send(state.proxy, :disconnect)
name = Discovery.local().name
{
:reply,
:setup_proxy,
%{success: "Connected to: #{name}", node: name},
state.state
}
|> send_response(%{state | proxy: pid})
{pid, name} ->
{
:reply,
:setup_proxy,
%{success: "Connected to: #{name}", node: name},
state.state
}
|> send_response(%{state | proxy: pid})
end
end
@spec uniform_type(type :: atom | list(atom)) :: String.t()
defp uniform_type(type)
defp uniform_type(type) when is_atom(type), do: type |> Atom.to_string()
defp uniform_type(type) when is_list(type) do
type
|> Enum.map(&Atom.to_string/1)
|> Enum.join("/")
end
end