Current section

Files

Jump to
phoenix_socket_client lib phoenix socket_client channel.ex
Raw

lib/phoenix/socket_client/channel.ex

defmodule Phoenix.SocketClient.Channel do
@moduledoc """
A process for interacting with a Phoenix Channel.
"""
alias Phoenix.SocketClient.Channel.State
@typedoc """
The state of the channel.
"""
@type state :: %State{}
@doc """
Callback for handling incoming messages.
"""
@callback handle_message(event :: String.t(), payload :: map(), state :: state()) ::
{:noreply, new_state :: state()}
defmacro __using__(_opts) do
quote do
@behaviour Phoenix.SocketClient.Channel
use GenServer
alias Phoenix.SocketClient.Channel.State
alias Phoenix.SocketClient.{Message, Telemetry}
@doc false
def start_link(args) do
GenServer.start_link(__MODULE__, args)
end
# Callbacks
@impl true
def init({sup_pid, socket_pid, topic, params, registry_name}) do
Registry.register(registry_name, topic, self())
{:ok,
%State{
sup_pid: sup_pid,
socket_pid: socket_pid,
topic: topic,
params: params,
registry_name: registry_name
}}
end
@impl true
def handle_call(
:join,
{_pid, _ref} = from,
%{sup_pid: sup_pid, topic: topic, params: params} = state
) do
message = Message.join(topic, params)
push = Phoenix.SocketClient.push(sup_pid, message)
{:noreply,
%State{
state
| join_ref: push.ref,
caller: elem(from, 0),
pushes: [{from, push} | state.pushes],
join_start_time: System.monotonic_time()
}}
end
@impl true
def handle_call(
:leave,
_from,
%{sup_pid: sup_pid, socket_pid: _socket_pid, topic: topic} = state
) do
Phoenix.SocketClient.update_channel_status(sup_pid, self(), topic, :leaving)
message = Message.leave(topic)
_push = Phoenix.SocketClient.push(sup_pid, message)
{:stop, :normal, :ok, %{state | leave_start_time: System.monotonic_time()}}
end
@impl true
def handle_call({:push, event, payload}, from, %{sup_pid: sup_pid, topic: topic} = state) do
message = %Message{
topic: topic,
event: event,
payload: payload,
ref: Message.generate_ref(),
join_ref: state.join_ref
}
push = Phoenix.SocketClient.push(sup_pid, message)
Telemetry.message_sent(self(), topic, event, payload)
{:noreply, %State{state | pushes: [{from, push} | state.pushes]}}
end
@impl true
def handle_call(:get_topic, _from, state) do
{:reply, state.topic, state}
end
@impl true
def handle_cast({:push, event, payload}, %{sup_pid: sup_pid, topic: topic} = state) do
message = %Message{
topic: topic,
event: event,
payload: payload,
channel_pid: self(),
join_ref: state.join_ref
}
Phoenix.SocketClient.push(sup_pid, message)
Telemetry.message_sent(self(), topic, event, payload)
{:noreply, state}
end
@impl true
def handle_info(
%Message{event: "phx_reply", ref: ref} = msg,
%{pushes: pushes, topic: topic, join_ref: join_ref, params: params} = s
) do
pushes =
case Enum.split_with(pushes, &(elem(&1, 1).ref == ref)) do
{[{from_ref, _push}], pushes} ->
%{"status" => status, "response" => response} = msg.payload
case status do
"ok" ->
if ref == join_ref do
if s.join_start_time do
duration = System.monotonic_time() - s.join_start_time
Telemetry.channel_join_duration(s.socket_pid, s.topic, duration)
end
Phoenix.SocketClient.update_channel_status(
s.sup_pid,
self(),
s.topic,
:joined,
params
)
Telemetry.channel_joined(s.sup_pid, s.topic, self(), msg.payload, %{})
end
Telemetry.message_received(self(), topic, "phx_reply", msg.payload)
"error" ->
if ref == join_ref do
Phoenix.SocketClient.update_channel_status(
s.sup_pid,
self(),
s.topic,
:errored,
params
)
Telemetry.channel_join_error(s.sup_pid, s.topic, msg.payload, %{})
end
Telemetry.message_received(self(), topic, "phx_reply", msg.payload)
_ ->
:noop
end
GenServer.reply(from_ref, {String.to_atom(status), response})
pushes
{[], pushes} ->
send(s.caller, %{msg | channel_pid: s.caller, topic: s.topic})
pushes
end
{:noreply, %State{s | pushes: pushes}}
end
@impl true
def handle_info(%Message{} = message, state) do
Telemetry.message_received(self(), state.topic, message.event, message.payload)
handle_message(message.event, message.payload, state)
end
@impl true
def terminate(
reason,
%{
sup_pid: sup_pid,
socket_pid: socket_pid,
topic: topic,
params: params,
registry_name: registry_name,
leave_start_time: start_time
} = _state
) do
if start_time do
duration = System.monotonic_time() - start_time
Telemetry.channel_leave_duration(socket_pid, topic, duration)
end
Registry.unregister(registry_name, topic)
if sup_pid && topic do
joined_channels = Phoenix.SocketClient.get_state(sup_pid, :joined_channels)
channel_data = Map.get(joined_channels, topic)
if reason == :normal and (channel_data && channel_data.status != :errored) do
Phoenix.SocketClient.remove_channel(sup_pid, topic)
else
Phoenix.SocketClient.update_channel_status(sup_pid, self(), topic, :errored, params)
end
Telemetry.channel_left(self(), topic, reason)
end
:ok
end
defoverridable init: 1,
handle_call: 3,
handle_cast: 2,
handle_info: 2,
terminate: 2
end
end
@timeout 5_000
@doc """
Stops the channel process.
"""
@spec stop(pid) :: :ok
def stop(pid) do
leave(pid)
end
@doc """
Join a channel topic through a socket with optional params
A socket can only join a topic once. If the socket you pass already has a
channel connection for the supplied topic, you will receive an error
`{:error, {:already_joined, pid}}` with the channel pid of the process joined
to that topic through that socket. If you require to join the same topic with
multiple processes, you will need to start a new socket process for each channel.
Calling join will link the caller to the channel process.
"""
@spec join(pid | atom, binary, map, non_neg_integer) ::
{:ok, any, pid}
| {:error, :socket_not_connected}
| {:error, :timeout}
| {:error, {:already_joined, pid}}
| {:error, any}
def join(sup_pid, topic, params \\ %{}, timeout \\ @timeout)
def join(nil, _topic, _params, _timeout), do: {:error, :socket_not_started}
def join(sup_pid, topic, params, timeout) do
if Phoenix.SocketClient.connected?(sup_pid) do
case Phoenix.SocketClient.channel_join(sup_pid, topic, params) do
{:ok, pid} -> do_join(pid, sup_pid, topic, params, timeout)
{:error, {:already_started, _}} = error -> error
error -> error
end
else
{:error, :socket_not_connected}
end
end
@doc """
Leave the channel topic and stop the channel
"""
@spec leave(pid) :: :ok
def leave(pid) do
GenServer.call(pid, :leave)
end
@doc """
Push a message to the server and wait for a a response or timeout
The server must be configured to return `{:reply, _, socket}`
otherwise, the call will timeout.
"""
@spec push(pid, binary, map, non_neg_integer) ::
{:ok, any} | {:error, any | :timeout}
def push(pid, event, payload, timeout \\ @timeout) do
GenServer.call(pid, {:push, event, payload}, timeout)
end
@doc """
Push a message to the server and do not wait for a response
"""
@spec push_async(pid, binary, map) :: :ok
def push_async(pid, event, payload) do
GenServer.cast(pid, {:push, event, payload})
end
defp do_join(pid, sup_pid, topic, params, timeout) do
try do
case GenServer.call(pid, :join, timeout) do
{:ok, reply} ->
{:ok, reply, pid}
{:error, _} = error ->
Phoenix.SocketClient.update_channel_status(sup_pid, pid, topic, :errored, params)
GenServer.stop(pid)
error
end
catch
:exit, {:timeout, _} ->
Phoenix.SocketClient.update_channel_status(sup_pid, pid, topic, :errored, params)
GenServer.stop(pid, :normal)
{:error, :timeout}
:exit, {:noproc, _} ->
Phoenix.SocketClient.update_channel_status(sup_pid, pid, topic, :errored, params)
GenServer.stop(pid, :normal)
{:error, :noproc}
:exit, reason ->
Phoenix.SocketClient.update_channel_status(sup_pid, pid, topic, :errored, params)
GenServer.stop(pid, :normal)
{:error, exit_reason(reason)}
end
end
defp exit_reason({:timeout, _}), do: :timeout
defp exit_reason(reason), do: reason
end