Packages

A WebSocket client built on gen_statem and Mint.

Current section

Files

Jump to
parley lib parley.ex
Raw

lib/parley.ex

defmodule Parley do
@moduledoc """
A WebSocket client built on `gen_statem` and [Mint WebSocket](https://hexdocs.pm/mint_web_socket).
`Parley` provides a callback-based API similar to `GenServer`. You define a
module with `use Parley`, implement the callbacks you need, and interact with
the connection through the functions in this module.
## Usage
defmodule MyClient do
use Parley
@impl true
def handle_connect(state) do
IO.puts("Connected!")
{:ok, state}
end
@impl true
def handle_frame({:text, msg}, state) do
IO.puts("Received: \#{msg}")
{:ok, state}
end
@impl true
def handle_disconnect(_reason, state) do
IO.puts("Disconnected")
{:ok, state}
end
end
### Starting with a pid
{:ok, pid} = MyClient.start_link(%{}, url: "wss://example.com/ws")
Parley.send_frame(pid, {:text, "hello"})
Parley.disconnect(pid)
### Starting with a registered name
{:ok, _pid} = MyClient.start_link(%{}, url: "wss://example.com/ws", name: MyClient)
Parley.send_frame(MyClient, {:text, "hello"})
Parley.disconnect(MyClient)
### Starting under a supervisor
children = [
{MyClient, {%{}, url: "wss://example.com/ws", name: MyClient}}
]
Supervisor.start_link(children, strategy: :one_for_one)
## Options
* `:url` (required) — the WebSocket URL to connect to (e.g. `"wss://example.com/ws"`)
* `:name` — used for name registration, see the "Name registration" section below
* `:headers` — custom headers sent with the WebSocket upgrade request
(e.g. `[{"authorization", "Bearer token"}]`). Default: `[]`
* `:connect_timeout` — timeout in milliseconds for the WebSocket upgrade
handshake (default: `10_000`)
* `:transport_opts` — options passed to the transport layer (`:gen_tcp` for
`ws://`, `:ssl` for `wss://`). Use this for TLS configuration such as
certificate pinning, custom CAs, or TCP-level timeouts
(e.g. `[timeout: 5_000, cacertfile: "path/to/ca.pem"]`)
* `:protocols` — Mint HTTP protocols to use for the connection
(default: `[:http1]`)
## Name registration
The `:name` option supports the same values as `GenServer`:
* an atom — registered locally with `{:local, atom}`
* `{:global, term}` — registered with `:global`
* `{:via, module, term}` — registered with a custom registry
## Connection lifecycle
The connection is managed as a state machine with three states:
```mermaid
stateDiagram-v2
[*] --> disconnected: start_link/3
disconnected --> connecting: TCP connect + WebSocket upgrade
connecting --> connected: upgrade success
connecting --> disconnected: error / timeout
connected --> disconnected: error / close / disconnect/1
state connected {
[*] --> handle_connect
handle_connect --> waiting
waiting --> handle_frame: frame received
handle_frame --> waiting
}
state disconnected {
[*] --> handle_disconnect
}
```
- **`disconnected`** — initial state. On process start, immediately attempts to connect.
Calls `c:handle_disconnect/2` when entering from another state.
- **`connecting`** — TCP connection established, waiting for the WebSocket upgrade
handshake to complete. Frames sent via `send_frame/2` during this state are
automatically queued and delivered once connected.
- **`connected`** — WebSocket upgrade complete. Calls `c:handle_connect/1` on entry,
then `c:handle_frame/2` for each frame received from the server.
## Callbacks
All callbacks are optional and have default implementations. Override only
the ones you need.
* `c:init/1` — called when the process starts, before connecting.
Transforms the `init_arg` into user state (default: passes it through)
* `c:handle_connect/1` — called when the WebSocket handshake completes
* `c:handle_frame/2` — called when a frame is received from the server
* `c:handle_ping/2` — called when a ping frame is received (pong is sent automatically)
* `c:handle_info/2` — called when the process receives a non-WebSocket message
* `c:handle_disconnect/2` — called when the connection is lost or closed
`c:handle_connect/1`, `c:handle_frame/2`, `c:handle_ping/2`, and `c:handle_info/2` also support
`{:push, frame, state}` to send a frame from within the callback, and
`{:stop, reason, state}` to stop the process. See the callback docs for details.
"""
@typedoc "The user-managed state passed through all callbacks."
@type state :: term()
@typedoc "A WebSocket frame."
@type frame :: {:text, String.t()} | {:binary, binary()} | {:ping, binary()} | {:pong, binary()}
@doc """
Called when the process starts, before connecting to the server.
Receives the `init_arg` passed to `start_link/3` and returns the initial
user state. Use this to validate arguments, build structs, create ETS tables,
or start linked processes.
## Return values
* `{:ok, state}` — proceed with the transformed state
* `{:stop, reason}` — stop the process before connecting
"""
@callback init(init_arg :: term()) :: {:ok, state} | {:stop, reason :: term()}
@doc """
Called when the WebSocket handshake completes.
## Return values
* `{:ok, state}` — update state, remain connected
* `{:push, frame, state}` — send a frame immediately after connecting
(useful for auth or subscribe messages)
* `{:stop, reason, state}` — reject the connection, stop the process
"""
@callback handle_connect(state) ::
{:ok, state} | {:push, frame, state} | {:stop, reason :: term(), state}
@doc """
Called when a frame is received from the server.
## Return values
* `{:ok, state}` — update state
* `{:push, frame, state}` — send a frame back to the server
* `{:stop, reason, state}` — close the connection and stop the process
"""
@callback handle_frame(frame, state) ::
{:ok, state} | {:push, frame, state} | {:stop, reason :: term(), state}
@doc """
Called when a ping frame is received.
The pong response is always sent automatically before this callback is
invoked, so the WebSocket protocol is never violated. Use this callback
to observe pings for heartbeat monitoring, latency tracking, or logging.
Supported return values:
* `{:ok, state}` — continue with updated state
* `{:push, frame, state}` — send a frame and continue
* `{:stop, reason, state}` — gracefully stop the connection
"""
@callback handle_ping(payload :: binary(), state) ::
{:ok, state}
| {:push, frame, state}
| {:stop, reason :: term(), state}
@doc """
Called when the process receives a message that is not a WebSocket frame.
This is the equivalent of GenServer's `handle_info/2`. Use it to handle
timer messages (`Process.send_after/3`), inter-process messages, and
any other messages sent directly to the Parley process.
This callback is invoked in all states (connected, connecting, and
disconnected). However, `{:push, frame, state}` is only effective
while connected — in other states the push is ignored and a warning
is logged.
## Return values
* `{:ok, state}` — update state
* `{:push, frame, state}` — send a frame to the server (connected only)
* `{:stop, reason, state}` — stop the process
"""
@callback handle_info(message :: term(), state) ::
{:ok, state} | {:push, frame, state} | {:stop, reason :: term(), state}
@doc """
Called when the connection is lost or closed.
The `reason` indicates why the connection ended:
* `:closed` — graceful disconnect via `disconnect/1`
* `{:remote_close, code, reason}` — server-initiated close frame
* `{:error, reason}` — stream or decode error
* `:connect_timeout` — WebSocket upgrade handshake timed out
## Return values
* `{:ok, state}` — acknowledge the disconnect
"""
@callback handle_disconnect(reason :: term(), state) :: {:ok, state}
defmacro __using__(_opts) do
quote do
@behaviour Parley
@impl true
def init(init_arg), do: {:ok, init_arg}
@impl true
def handle_connect(state), do: {:ok, state}
@impl true
def handle_frame(_frame, state), do: {:ok, state}
@impl true
def handle_ping(_payload, state), do: {:ok, state}
@impl true
def handle_info(_message, state), do: {:ok, state}
@impl true
def handle_disconnect(_reason, state), do: {:ok, state}
defoverridable init: 1,
handle_connect: 1,
handle_frame: 2,
handle_ping: 2,
handle_info: 2,
handle_disconnect: 2
@doc """
Returns a child specification for starting this module under a supervisor.
Accepts a tuple `{init_arg, opts}` where `opts` are passed to `start_link/2`.
"""
def child_spec({init_arg, opts}) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [init_arg, opts]}
}
end
defoverridable child_spec: 1
@doc """
Starts this WebSocket client linked to the current process.
Delegates to `Parley.start_link/3`. See `Parley.start_link/3` for options.
"""
def start_link(init_arg, opts \\ []) do
Parley.start_link(__MODULE__, init_arg, opts)
end
end
end
@doc """
Starts a `Parley` process linked to the current process.
This is often used to start the process as part of a supervision tree.
`module` is the module that implements the `Parley` callbacks.
`init_arg` is passed as the initial user state accessible in callbacks.
## Options
* `:url` (required) — the WebSocket URL to connect to (e.g. `"wss://example.com/ws"`)
* `:name` — used for name registration, see the "Name registration" section
in the module documentation
* `:headers` — custom headers sent with the WebSocket upgrade request
(e.g. `[{"authorization", "Bearer token"}]`). Default: `[]`
* `:connect_timeout` — timeout in milliseconds for the WebSocket upgrade
handshake (default: `10_000`)
* `:transport_opts` — options passed to the transport layer (`:gen_tcp` for
`ws://`, `:ssl` for `wss://`). Use this for TLS configuration such as
certificate pinning, custom CAs, or TCP-level timeouts
(e.g. `[timeout: 5_000, cacertfile: "path/to/ca.pem"]`)
* `:protocols` — Mint HTTP protocols to use for the connection
(default: `[:http1]`)
## Return values
See `:gen_statem.start_link/3` for return values.
"""
@spec start_link(module(), state(), keyword()) :: :gen_statem.start_ret()
def start_link(module, init_arg, opts \\ []) when is_atom(module) and is_list(opts) do
{url, opts} = Keyword.pop!(opts, :url)
{connect_timeout, opts} = Keyword.pop(opts, :connect_timeout)
{headers, opts} = Keyword.pop(opts, :headers)
{transport_opts, opts} = Keyword.pop(opts, :transport_opts)
{protocols, opts} = Keyword.pop(opts, :protocols)
connection_opts =
Enum.reject(
[
connect_timeout: connect_timeout,
headers: headers,
transport_opts: transport_opts,
protocols: protocols
],
fn {_k, v} -> is_nil(v) end
)
do_start(:start_link, module, {url, init_arg, connection_opts}, opts)
end
@doc """
Starts a `Parley` process without a link (outside of a supervision tree).
Accepts the same arguments and options as `start_link/3`. Useful for
interactive or scripted use where you don't want the calling process
to be linked.
"""
@spec start(module(), state(), keyword()) :: :gen_statem.start_ret()
def start(module, init_arg, opts \\ []) when is_atom(module) and is_list(opts) do
{url, opts} = Keyword.pop!(opts, :url)
{connect_timeout, opts} = Keyword.pop(opts, :connect_timeout)
{headers, opts} = Keyword.pop(opts, :headers)
{transport_opts, opts} = Keyword.pop(opts, :transport_opts)
{protocols, opts} = Keyword.pop(opts, :protocols)
connection_opts =
Enum.reject(
[
connect_timeout: connect_timeout,
headers: headers,
transport_opts: transport_opts,
protocols: protocols
],
fn {_k, v} -> is_nil(v) end
)
do_start(:start, module, {url, init_arg, connection_opts}, opts)
end
defp do_start(link, module, init_arg, opts) do
case Keyword.pop(opts, :name) do
{nil, opts} ->
apply(:gen_statem, link, [Parley.Connection, {module, init_arg}, opts])
{atom, opts} when is_atom(atom) ->
apply(:gen_statem, link, [{:local, atom}, Parley.Connection, {module, init_arg}, opts])
{{:global, _term} = tuple, opts} ->
apply(:gen_statem, link, [tuple, Parley.Connection, {module, init_arg}, opts])
{{:via, via_module, _term} = tuple, opts} when is_atom(via_module) ->
apply(:gen_statem, link, [tuple, Parley.Connection, {module, init_arg}, opts])
{other, _opts} ->
raise ArgumentError, """
expected :name option to be one of the following:
* nil
* atom
* {:global, term}
* {:via, module, term}
Got: #{inspect(other)}
"""
end
end
@doc """
Sends a WebSocket frame to the server.
Returns `:ok` if the frame was sent successfully, or `{:error, reason}` if
the send failed (e.g. the process is in the `:disconnected` state).
## Examples
:ok = Parley.send_frame(pid, {:text, "hello"})
:ok = Parley.send_frame(pid, {:binary, <<1, 2, 3>>})
"""
@spec send_frame(:gen_statem.server_ref(), frame()) :: :ok | {:error, term()}
def send_frame(server, frame) do
:gen_statem.call(server, {:send, frame})
end
@doc """
Gracefully disconnects from the WebSocket server.
Sends a WebSocket close frame and transitions the process to the
`:disconnected` state. The process remains alive after disconnecting.
## Examples
:ok = Parley.disconnect(pid)
"""
@spec disconnect(:gen_statem.server_ref()) :: :ok
def disconnect(server) do
:gen_statem.call(server, :disconnect)
end
end