Current section

Files

Jump to
pushest lib pushest.ex
Raw

lib/pushest.ex

defmodule Pushest do
@moduledoc ~S"""
Pushest handles communication with Pusher server via wesockets. Abstracts
un/subscription, client-side triggers, private/presence channel authorizations.
Keeps track of subscribed channels and users presence when subscribed to presence channel.
Pushest is meant to be used in your module where you can define callbacks for
events you're interested in.
A simple implementation would be:
```
defmodule SimpleClient do
use Pushest
def start_link() do
options = %{
cluster: "eu",
encrypted: true,
secret: "SECRET"
}
Pushest.start_link("APP_KEY", options, __MODULE__, name: __MODULE__)
end
def handle_event({:ok, "public-channel", "some-event"}, frame) do
# do something with public frame
end
def handle_event({:ok, "private-channel", "some-other-event"}, frame) do
# do something with private frame
end
end
```
"""
@typedoc ~S"""
Options for Pushest to properly communicate with Pusher server.
- `:cluster` - Cluster where your Pusher app is configured.
- `:encrypted` - When set to true communication with Pusher is fully encrypted.
- `:secret` - Necessary to subscribe to private/presence channels and trigger events.
"""
@type pusher_opts :: %{cluster: String.t(), encrypted: boolean, secret: String.t()}
use GenServer
require Logger
alias Pushest.Data.{State, Frame, SocketInfo, Options, Url, Presence}
alias Pushest.Utils
@client Application.get_env(:pushest, :conn_client)
@doc ~S"""
Invoked when the Pusher event occurs (e.g. other client sends a message).
"""
@callback handle_event({atom, String.t(), String.t()}, term) :: term
defmacro __using__(_opts) do
quote do
@behaviour Pushest
def subscribe(pid, channel, user_data) do
GenServer.cast(pid, {:subscribe, channel, user_data})
end
def subscribe(pid, channel) when is_pid(pid) do
GenServer.cast(pid, {:subscribe, channel, %{}})
end
def subscribe(channel, user_data) do
GenServer.cast(__MODULE__, {:subscribe, channel, user_data})
end
def subscribe(channel) do
GenServer.cast(__MODULE__, {:subscribe, channel, %{}})
end
def trigger(pid, channel, event, data) do
GenServer.cast(pid, {:trigger, channel, event, data})
end
def trigger(channel, event, data) do
GenServer.cast(__MODULE__, {:trigger, channel, event, data})
end
def channels(pid) do
GenServer.call(pid, :channels)
end
def channels do
GenServer.call(__MODULE__, :channels)
end
def presence(pid) do
GenServer.call(pid, :presence)
end
def presence do
GenServer.call(__MODULE__, :presence)
end
def unsubscribe(pid, channel) do
GenServer.cast(pid, {:unsubscribe, channel})
end
def unsubscribe(channel) do
GenServer.cast(__MODULE__, {:unsubscribe, channel})
end
def handle_event({status, channel, event}, frame) do
require Logger
Logger.error(
"No #{inspect(status)} handle_event/2 clause in #{__MODULE__} provided for #{
inspect(event)
}"
)
end
defoverridable handle_event: 2
end
end
@doc ~S"""
Starts a Pushest process linked to current process.
Please note, you need to provide a module as a third element, Pushest will try
to invoke `handle_event` callbacks in that module when Pusher event occurs.
For available pusher_opts values see `t:pusher_opts/0`.
"""
@spec start_link(String.t(), pusher_opts, module, list) :: {:ok, pid} | {:error, term}
def start_link(app_key, pusher_opts, module, opts \\ []) do
state = init_state(app_key, pusher_opts, module)
GenServer.start_link(__MODULE__, state, opts)
end
@doc ~S"""
Starts the @client connection to the Pusher URL and upgrades it to WS/S communication.
Stores @client.conn PID in the state.
"""
@spec init(%State{}) :: {:ok, %State{}}
def init(state = %State{url: %Url{domain: domain, path: path, port: port}}) do
{:ok, conn_pid} = @client.open(domain, port)
case @client.await_up(conn_pid) do
{:ok, :http} ->
@client.ws_upgrade(conn_pid, path)
{:ok, %{state | conn_pid: conn_pid}}
{:error, msg} ->
{:stop, "Connection init error #{inspect(msg)}"}
end
end
@doc ~S"""
Async server-side callback handling subscription to a Pusher channel.
Sends WS frame as a sideeffect.
"""
@spec handle_cast({atom, String.t(), map}, %State{}) :: {:noreply, %State{}}
def handle_cast({:subscribe, channel = "presence-" <> _rest, user_data}, state) do
case Utils.validate_user_data(user_data) do
{:ok, user_data} ->
do_subscribe(channel, user_data, state)
{:error, _} ->
Logger.error(
"#{channel} is a presence channel and subscription must include channel_data"
)
end
{:noreply, %{state | presence: %{state.presence | me: user_data}}}
end
def handle_cast({:subscribe, channel, user_data}, state) do
do_subscribe(channel, user_data, state)
{:noreply, state}
end
@doc ~S"""
Async server-side callback handling unsubscription from a Pusher channel.
Sends WS frame as a sideeffect.
"""
def handle_cast({:unsubscribe, channel}, state = %State{conn_pid: conn_pid, channels: channels}) do
frame = channel |> Frame.unsubscribe() |> Frame.encode!()
@client.ws_send(conn_pid, {:text, frame})
{:noreply, %{state | channels: List.delete(channels, channel)}}
end
@doc ~S"""
Async server-side callback handling event triggers with a data payload.
Sends WS frame as a sideeffect.
"""
def handle_cast({:trigger, channel, event, data}, state = %State{conn_pid: conn_pid}) do
frame =
channel
|> Frame.event(event, data)
|> Frame.encode!()
@client.ws_send(conn_pid, {:text, frame})
{:noreply, state}
end
@doc ~S"""
Sync server-side callback returning list of subscribed channels.
"""
@spec handle_call(:channels | :presence, {pid, term}, %State{}) ::
{:reply, list | %Presence{}, %State{}}
def handle_call(:channels, _from, state = %State{channels: channels}) do
{:reply, channels, state}
end
@doc ~S"""
Sync server-side callback returning current presence information.
Contains IDs of all the subscribed users and optional informations about them.
"""
def handle_call(:presence, _from, state = %State{presence: presence}) do
{:reply, presence, state}
end
@spec handle_info(term, %State{}) :: {:noreply, %State{}}
def handle_info({:gun_ws_upgrade, _conn_pid, :ok, _headers}, state) do
{:noreply, state}
end
@doc ~S"""
Handles varios Pusher events, updates state and tries to call user-defined callbacks.
"""
def handle_info(
{:gun_ws, _conn_pid, {:text, raw_frame}},
state = %State{module: module, channels: channels, presence: presence}
) do
frame = Frame.decode!(raw_frame)
case frame.event do
"pusher:connection_established" ->
Logger.debug("pusher:connection_established")
{:noreply, %{state | socket_info: SocketInfo.decode(frame.data)}}
"pusher_internal:subscription_succeeded" ->
Logger.debug("pusher_internal:subscription_succeeded")
presence = Presence.merge(presence, frame.data["presence"])
{:noreply, %{state | channels: [frame.channel | channels], presence: presence}}
"pusher_internal:member_added" ->
Logger.debug("pusher_internal:member_added")
{:noreply, %{state | presence: Presence.add_member(presence, frame.data)}}
"pusher_internal:member_removed" ->
Logger.debug("pusher_internal:member_removed")
{:noreply, %{state | presence: Presence.remove_member(presence, frame.data)}}
"pusher:error" ->
message = Map.get(frame.data, "message")
Logger.debug(fn -> "pusher:error #{inspect(message)}" end)
try_callback(module, :handle_event, [{:error, message}, frame])
{:noreply, state}
_ ->
try_callback(module, :handle_event, [{:ok, frame.channel, frame.event}, frame])
{:noreply, state}
end
end
def handle_info(params, state) do
Logger.debug(fn -> "pusher:event #{inspect(params)}" end)
{:noreply, state}
end
@spec init_state(String.t, map, module) :: %State{}
defp init_state(app_key, options, module) do
%State{
app_key: app_key,
url: Utils.url(app_key, options),
options: %Options{} |> Map.merge(options),
module: module
}
end
@spec do_subscribe(String.t, map, %State{}) :: term
defp do_subscribe(channel, user_data, state = %State{conn_pid: conn_pid}) do
auth = Utils.auth(state, channel, user_data)
frame = Frame.subscribe(channel, auth, user_data)
@client.ws_send(conn_pid, {:text, Frame.encode!(frame)})
end
@spec try_callback(module, atom, list) :: term
defp try_callback(module, function, args) do
apply(module, function, args)
catch
:error, payload ->
stacktrace = System.stacktrace()
reason = Exception.normalize(:error, payload, stacktrace)
{:"$EXIT", {reason, stacktrace}}
:exit, payload ->
{:"$EXIT", payload}
end
end