# ChannelClient

Channel client for connecting to Phoenix Channels from Elixir.

Rework from [phoenix_client](https://hex.pm/packages/phoenix_client) library.

## Installation

Add `channel_client` and a json library as dependencies in your `mix.exs` file.
`jason` is specified as the default json library.

```elixir
def deps do
  [
    {:channel_client, "~> 0.12"},
    {:jason, "~> 1.4"}
  ]
end
```

If you choose to use a different json library, you can set it through the
socket options with the `:json_library` key (or its alias, `:serializer`).

## Usage

There are two things required to connect to a phoenix server using channels, a
`ChannelClient.Socket` and a `ChannelClient.Channel`. The socket establishes
the connection to the remote socket. The channel takes a topic and is used
to join a remote channel. In the following example we will assume that we are
attempting to communicate with a locally running phoenix server with a `RoomChannel`
with the topic `room:lobby` configured to route to the `RoomChannel`
in the `UserSocket`.

First, Lets create a client socket:

```elixir
socket_opts = [
  url: "ws://localhost:4000/socket/websocket"
]

{:ok, socket} = ChannelClient.Socket.start_link(socket_opts)
```

The socket will automatically attempt to connect when it starts. If the socket
becomes disconnected, it will attempt to reconnect automatically.
Please note that `start_link` is not synchronous so you must wait for the
socket to become connected before attempting to join a channel.

You can control how frequently the socket will attempt to reconnect by setting
`reconnect_interval` in the socket_opts.

Next, we will create a client channel and join the remote.

```elixir
{:ok, _response, channel} = ChannelClient.Channel.join(socket, "rooms:lobby")
```

Now that we have successfully joined the channel, we are ready to push and receive
new messages. Pushing a message can be done synchronously or asynchronously. If
you require a reply, or want to institute a time out, you can call `push`. If
you do not require a response, you can call `push_async`.

In this example, we will assume the server channel has the following `handle_in`
callbacks:

```elixir
  def handle_in("new:msg", message, socket) do
    {:reply, {:ok, message}, socket}
  end

  def handle_in("new:msg_async", _message, socket) do
    {:noreply, socket}
  end
```

```elixir
message = %{hello: :world}
{:ok, ^message} = ChannelClient.Channel.push(channel, "new:msg", message)
:ok = ChannelClient.Channel.push_async(channel, "new:msg_async", message)
```

Payloads may be any value the configured JSON library can encode. Payloads
that cannot be encoded return `{:error, reason}` from `push/4`, and are logged
and dropped by `push_async/3` — the socket connection stays healthy either way.

Messages that are pushed or broadcasted to the client channel will be sent to the
pid that called `join`. Messages will be of the of the struct `%ChannelClient.Message{}`.

In this example we will assume the server channel has the following `handle_in`
callback

```elixir
  def handle_in("new:msg", message, socket) do
    push(socket, "incoming:msg", message)
    {:reply, :ok, socket}
  end
```

```elixir
message = %{hello: :world}
{:ok, ^message} = ChannelClient.Channel.push(channel, "new:msg", message)
flush

%ChannelClient.Message{
  channel_pid: #PID<0.186.0>,
  event: "incoming:msg",
  payload: %{"hello" => "world"},
  ref: nil,
  topic: "room:lobby"
}
```

Replies that arrive without a matching synchronous push (for example replies
to `push_async`) are forwarded to the joining process as
`%ChannelClient.Message{}` structs with the event `"phx_reply"`.

## Wire formats

JSON is the default, but the format layer is pluggable. ETF (Erlang terms),
TOON and BTOON ship built in:

```elixir
{:ok, socket} =
  ChannelClient.Socket.start_link(url: "ws://localhost:4000/socket/websocket", format: :etf)

# TOON/BTOON delegate to {:toon_ex, "~> 1.5"} (optional dependency):
{:ok, socket} = ChannelClient.Socket.start_link(url: "...", format: :toon)
{:ok, socket} = ChannelClient.Socket.start_link(url: "...", format: :btoon)
```

Custom formats (MessagePack, protobufs, ...) implement the
`ChannelClient.Format` behaviour and plug in with `format: MyApp.MsgPack`.
Legacy options (`:vsn`, `:json_library`, `:serializer`) keep working.
See the [Formats guide](guides/formats.md).

## Pluggable architecture

Sockets support Plug-style middleware for messages, in two pipelines:

```elixir
{:ok, socket} =
  ChannelClient.Socket.start_link(
    url: "ws://localhost:4000/socket/websocket",
    inbound_plugs: [
      {ChannelClient.Plugs.FilterEvents, events: ["presence_diff"]}
    ],
    outbound_plugs: [
      fn msg, _opts -> {:cont, %{msg | payload: Map.put(msg.payload || %{}, "sent_at", DateTime.utc_now())}} end
    ]
  )
```

A plug is a module implementing the `ChannelClient.Plug` behaviour
(`init/1` + `call/2`) or a plain `fun/2`. It returns `{:cont, message}`
to pass the message along (optionally transformed) or `{:halt, reason}` to
block it — halted outbound frames surface as errors to sync callers,
halted inbound frames never reach your processes. Faulty plugs are logged
and treated as halts; they cannot crash the socket.

Built-ins ship under `ChannelClient.Plugs` (`Logger`, `FilterEvents`).
See the [Plugs guide](guides/plugs.md) for the full walkthrough.

## Telemetry & tracing

Every connection, message and channel operation emits standard
[`:telemetry`](https://hexdocs.pm/telemetry) events, including
`start`/`stop`/`exception` span triples you can feed straight into
dashboards or tracing backends:

```elixir
:telemetry.attach(
  "my-handler",
  [:channel_client, :push, :stop],
  fn _name, %{duration: d}, meta, _ ->
    Logger.debug("push #{meta.event} -> #{meta.result}")
  end,
  nil
)
```

Payload contents are never included in event metadata. See the
[Telemetry guide](guides/telemetry.md) for the full event catalog.

### Reconnections

When the underlying connection drops, all channels are unregistered and each
joining process receives a `%ChannelClient.Message{}` with the event
`"phx_close"` (clean close) or `"phx_error"`. Callers are expected to react by
joining again once the socket reports connected through
`ChannelClient.Socket.connected?/1`.

Both `:text` and `:binary` WebSocket frames are supported for inbound
messages.

### Common configuration

You can configure the socket to be started in your main application supervisor.
You will need to name the socket so it can be referenced from your channel.

```elixir
  socket_opts =
    Application.get_env(:channel_client, :socket)

  children = [
    {ChannelClient.Socket, {socket_opts, name: ChannelClient.Socket}}
  ]
```

You will need a socket for each server you are connecting to. Here is an example
for connecting to multiple remote servers.

```elixir
  socket_1_opts =
    Application.get_env(:channel_client, :socket_1)
  socket_2_opts =
    Application.get_env(:channel_client, :socket_2)

  children = [
    {ChannelClient.Socket, {socket_1_opts, name: :socket_1, id: :socket_id_1}},
    {ChannelClient.Socket, {socket_2_opts, name: :socket_2, id: :socket_id_2}}
  ]
```

Channels are usually constructed in a process such as a `GenServer`. Here is
an example of how this is typically used.

```elixir
defmodule MyApp.Worker do
  use GenServer

  alias ChannelClient.{Socket, Channel, Message}

  # start_link ...

  def init(_opts) do
    {:ok, _response, channel} = Channel.join(Socket, "room:lobby")
    {:ok, %{
      channel: channel
    }}
  end

  # do some work, call `Channel.push` ...

  def handle_info(%Message{event: "incoming:msg", payload: payload}, state) do
    IO.puts "Incoming Message: #{inspect payload}"
    {:noreply, state}
  end
end
```

## License

Apache-2.0. Based on the original `phoenix_client` work.
