Phoenix.SocketClient

Copy Markdown View Source

release Hex.pm Documentation

Elixir client for Phoenix Channels WebSocket connections.

Both WebSocket transports use HTTP.WebSocket from http_web_socket 0.20 or 0.21, part of the gsmlg-dev/http_fetch package family.

Installation

Add phoenix_socket_client to your list of dependencies in mix.exs. The package is available on Hex.pm.

def deps do
  [
    {:phoenix_socket_client, "~> 0.8.6"}
  ]
end

Usage

Basic Connection

The socket can be started as a supervised process by adding it to your supervision tree. You can also start it as a standalone process.

# As a supervised process
def start(_type, _args) do
  children = [
    {Phoenix.SocketClient, url: "ws://localhost:4000/socket/websocket", name: MyApp.Socket}
  ]

  Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end

# As a standalone process
{:ok, socket} = Phoenix.SocketClient.start_link(
  url: "ws://localhost:4000/socket/websocket",
  params: %{"token" => "your-token"},
  headers: [{"Authorization", "Bearer your-token"}]
)

Joining Channels

{:ok, response, channel} = Phoenix.SocketClient.Channel.join(socket, "rooms:lobby", %{user_id: 123})

# Handle incoming messages via message passing
# Messages are received as Phoenix.SocketClient.Message structs
receive do
  %Phoenix.SocketClient.Message{event: "new_msg", payload: payload} ->
    IO.inspect(payload)
  %Phoenix.SocketClient.Message{event: "user:joined", payload: payload} ->
    IO.puts("User joined: #{inspect(payload)}")
end

# Or in tests/explicit handling:
assert_receive %Phoenix.SocketClient.Message{event: "new_msg", payload: %{"body" => body}}

# Push messages to the channel
Phoenix.SocketClient.Channel.push(channel, "new_msg", %{"body" => "Hello"})

Custom Channels

You can create your own channel modules to handle channel logic in a more structured way. The easiest way to create a custom channel is to use Phoenix.SocketClient.Channel:

defmodule MyChannel do
  use Phoenix.SocketClient.Channel

  @impl true
  def init(args) do
    # initialize your channel state
    # args: {sup_pid, socket_pid, topic, params}
    {:ok, args}
  end

  # optional callbacks
  # @impl true
  # def handle_in(event, payload, state) do
  #   {:noreply, state}
  # end
end

Then, you can use the topic_channel_map option when starting the socket to map a topic to your custom channel:

{:ok, socket} = Phoenix.SocketClient.start_link(
  url: "ws://localhost:4000/socket/websocket",
  topic_channel_map: %{
    "rooms:lobby" => MyChannel
  }
)

Handling Incoming Messages with Hooks

You can also handle incoming messages using hooks. This is useful when you want to handle messages in a more direct way, without using message passing.

{:ok, response, channel} = Phoenix.SocketClient.Channel.join(socket, "rooms:lobby")

# Register a hook for the "new_msg" event
Phoenix.SocketClient.Channel.on(channel, "new_msg", fn payload ->
  IO.inspect(payload)
end)

# Unregister the hook
Phoenix.SocketClient.Channel.off(channel, "new_msg")

You can also use a module as a hook. The module must implement a handle_in/2 function, which will be called with the event and the payload.

defmodule MyHook do
  def handle_in(event, payload) do
    IO.puts("Got event \#{event} with payload \#{inspect payload}")
  end
end

{:ok, response, channel} = Phoenix.SocketClient.Channel.join(socket, "rooms:lobby")

# Register a module hook for the "new_msg" event
Phoenix.SocketClient.Channel.on(channel, "new_msg", MyHook)

Reconfiguring the Socket

You can reconfigure the socket client at runtime using the reconfigure/2 function. If any connection-related options are changed, the socket will be restarted.

# Change the url
Phoenix.SocketClient.reconfigure(socket, url: "ws://new.example.com/socket")

# Change the connection params
Phoenix.SocketClient.reconfigure(socket, params: %{"token" => "new-token"})

Configuration Options

OptionTypeDefaultDescription
:nameatom()nilThe name to register the socket supervisor.
:urlString.t()RequiredWebSocket URL (e.g., "ws://localhost:4000/socket/websocket")
:paramsmap() | keyword()%{}Query parameters for connection
:headers[{String.t(), String.t()}][]HTTP headers for connection
:transportmodule()Phoenix.SocketClient.Transports.WebsocketTransport module
:heartbeat_intervalinteger()30_000Keep-alive interval in milliseconds
:reconnect_intervalinteger()60_000Reconnection delay in milliseconds
:reconnect?boolean()trueEnable automatic reconnection
:auto_connectboolean()trueConnect automatically on startup
:json_librarymodule()JSONJSON encoder/decoder module
:vsnString.t()"2.0.0"Phoenix Channels protocol version
:topic_channel_mapmap()%_A map from a topic string to a channel module.
:join_channelslist()[]A list of channels to automatically join on connect.
:default_channel_modulemodule()Phoenix.SocketClient.Channel.EchoRoomThe default channel module to use.
:default_channel_paramsmap()%_The default parameters to use for channels.

Message Handling Examples

# Using in a GenServer or other process
{:ok, response, channel} = Phoenix.SocketClient.Channel.join(socket, "rooms:lobby")

# In your GenServer handle_info or process loop:
def handle_info(%Phoenix.SocketClient.Message{event: "new_msg", payload: payload}, state) do
  IO.puts("New message: #{inspect(payload)}")
  {:noreply, state}
end

def handle_info(%Phoenix.SocketClient.Message{event: "user:joined", payload: %{"user" => user}}, state) do
  IO.puts("User #{user} joined the room")
  {:noreply, state}
end

# For simple usage, use receive blocks:
receive do
  %Phoenix.SocketClient.Message{event: event, payload: payload} ->
    IO.puts("Received event: #{event} with payload: #{inspect(payload)}")
after
  5_000 -> IO.puts("No messages received")
end

Error Handling Examples

# Handle connection errors
{:ok, socket} = Phoenix.SocketClient.start_link(
  url: "ws://localhost:4000/socket/websocket",
  params: %{"token" => "invalid-token"}
)

# Check connection status
if Phoenix.SocketClient.connected?(socket) do
  {:ok, _response, channel} = Phoenix.SocketClient.Channel.join(socket, "rooms:lobby")
else
  IO.puts("Failed to connect to server")
end

# Handle channel join errors
case Phoenix.SocketClient.Channel.join(socket, "rooms:private", %{user_id: 123}) do
  {:ok, response, channel} ->
    # Successfully joined channel
    IO.inspect(response)

  {:error, :timeout} ->
    # Join request timed out
    IO.puts("Channel join timed out")

  {:error, :unauthorized} ->
    # Not authorized to join this channel
    IO.puts("Not authorized to join channel")

  {:error, reason} ->
    # Other join errors
    IO.puts("Failed to join channel: #{inspect(reason)}")
end

# Handle message push errors
case Phoenix.SocketClient.Channel.push(channel, "new_msg", %{body: "Hello"}, 5000) do
  {:ok, response} ->
    # Message sent successfully
    IO.inspect(response)

  {:error, :timeout} ->
    # Push request timed out
    IO.puts("Message push timed out")

  {:error, :channel_closed} ->
    # Channel was closed
    IO.puts("Channel is no longer available")
end

Development

Setup

# Install dependencies
mix deps.get

# Compile
mix compile

# Run tests
mix test

# Format code
mix format

Requirements

  • Elixir 1.18+
  • Erlang/OTP 26+

Architecture

The client uses a supervisor tree with separate processes for:

  • Socket connection management
  • Channel lifecycle
  • Individual channel processes
  • State management

All processes are properly supervised with automatic restart strategies.

Telemetry

The library includes comprehensive telemetry events for monitoring and debugging. Telemetry metadata contains allowlisted operational fields. Message payloads, reply bodies, transport options, arbitrary custom metadata, and exception details are omitted; URLs omit credentials, query parameters, and fragments.

Client status callbacks redact private state, current messages, and the termination fields supplied to those callbacks. Full crash confidentiality remains blocked: OTP can append original exception arguments, and the underlying http_web_socket connection exposes private state. See #108 and http_fetch #136. MIX_ENV=test mix run scripts/privacy_crash_probe.exs reproduces the remaining OTP crash-report exposure with a synthetic sentinel and currently exits with status 1.

The events are structured as follows:

  • [:phoenix_socket_client, :socket] - Events related to the socket lifecycle.
  • [:phoenix_socket_client, :channel] - Events related to channel lifecycle.
  • [:phoenix_socket_client, :message] - Events related to messages.
  • [:phoenix_socket_client, :state] - Events related to state changes.

The action key in the metadata distinguishes the specific event. For example, a socket connection event is emitted as [:phoenix_socket_client, :socket] with metadata %{action: :connected, ...}.

Example Usage

# Attach a telemetry handler
:telemetry.attach(
  "my-handler",
  [:phoenix_socket_client, :socket],
  fn _event_name, _measurements, metadata, _config ->
    if metadata.action == :connected do
      IO.puts("Socket connected!")
    end
  end,
  %{}
)

# Use built-in debug handler
Phoenix.SocketClient.Telemetry.attach_debug_handler()

Dependencies

Add :telemetry to your dependencies if using custom handlers:

def deps do
  [
    {:telemetry, "~> 1.0"}
  ]
end