Packages

A Nostr client library for Elixir. Uses `nostr_lib` and `mint` to connect to relays, send events, and subscribe to relays.

Current section

Files

Jump to
nostr_ex lib socket.ex
Raw

lib/socket.ex

defmodule NostrEx.Socket do
@moduledoc """
A GenServer implementing a websocket connection to a relay.
Once it is in `ready?: true` state, messages can be sent via `send_message/2`,
with arguments either the `pid` or the `name` registered on `init`, and the encoded Nostr message to send.
There are two other API function for this GenServer: `connect/1` to connect to a relay via the GenServer process started at `pid`,
and `get_status/1`, which returns a subset of the state, essentially:
```
%{
url: URI.to_string(state.uri),
name: state.name,
closing?: state.closing?,
ready?: state.ready?
}
```
The `send_message/2` is a `call`, and will return `:ok` if the socket was in a ready state and successfully sent the message, and an `{:error, reason}` tuple otherwise.
Before terminating, the process will update the `RelayAgent` to delete subscription info associated with this relay.
"""
use GenServer, restart: :transient
require Logger
require Mint.HTTP
alias NostrEx.{RelayAgent, RelayRegistry}
alias Nostr.Message
@default_connect_timeout 3_000
@default_call_timeout 5_000
defstruct [
:uri,
:conn,
:websocket,
:request_ref,
:caller,
:status,
:resp_headers,
:name,
closing?: false,
ready?: false
]
## Public API
@spec start_link(%{uri: URI.t(), name: String.t()}) :: GenServer.on_start()
def start_link(%{uri: uri, name: name}) do
GenServer.start_link(__MODULE__, {uri, name}, name: via_tuple(name))
end
@doc """
Connect to a relay.
The GenServer process is first started with the relay name on `init/1`, and connected to separately via this function,
since it may take an arbitrary amount of time.
By default, the timeout is 3 seconds to connect and upgrade the connection to a websocket.
The connection still needs to complete the handshake to be ready to receive messages,
therefore it is recommended to check the socket's `ready?` status via `get_status/1` before sending messages.
"""
@spec connect(pid(), timeout()) :: {:ok, :connected} | {:error, String.t()}
def connect(pid, timeout \\ @default_connect_timeout) do
try do
GenServer.call(pid, :connect, timeout)
catch
:exit, {:timeout, _} ->
{:error, "connection timed out after #{timeout}ms. Is your URL correct?"}
:exit, {{:shutdown, reason}, _msg} ->
{:error, reason}
:exit, msg ->
Logger.error(msg)
{:error, "Exited"}
end
end
@doc """
Send a serialized message to the relay via the connection at this relay name or `pid`.
"""
@spec send_message(pid() | String.t(), binary()) :: :ok | {:error, atom() | String.t()}
def send_message(relay_name, text) when is_binary(relay_name) and is_binary(text) do
via_tuple(relay_name) |> GenServer.call({:send_text, text}, @default_call_timeout)
end
def send_message(pid, text) when is_pid(pid) and is_binary(text) do
GenServer.call(pid, {:send_text, text}, @default_call_timeout)
end
def send_message(relay_name, _text) do
{:error,
"invalid relay_name format, expected a registered string or a pid, got: #{inspect(relay_name)}"}
end
@doc """
Get the status of the current connection.
Returns the `url`, `name`, `ready?` and `closing?` args from the state.
"""
@spec get_status(pid()) :: %{
url: String.t(),
name: String.t(),
ready?: boolean(),
closing?: boolean()
}
def get_status(pid) do
GenServer.call(pid, :status, @default_call_timeout)
end
## GenServer Callbacks
@impl GenServer
@spec init({URI.t(), String.t()}) :: {:ok, %__MODULE__{}}
def init({uri, name}) do
Process.flag(:trap_exit, true)
{:ok, %__MODULE__{uri: uri, name: name}}
end
@impl GenServer
@spec handle_call(:connect, GenServer.from(), %__MODULE__{}) ::
{:noreply, %__MODULE__{}} | {:stop, {:shutdown, String.t()}, %__MODULE__{}}
def handle_call(:connect, from, %{uri: uri} = state) do
case establish_connection(uri) do
{:ok, conn, request_ref} ->
new_state = %{state | conn: conn, request_ref: request_ref, caller: from}
{:noreply, new_state}
{:error, reason} ->
{:stop, {:shutdown, reason}, state}
end
end
@impl GenServer
@spec handle_call({:send_text, binary()}, GenServer.from(), %__MODULE__{}) ::
{:reply, :ok | {:error, atom()}, %__MODULE__{}}
def handle_call({:send_text, text}, _from, %{ready?: true} = state) do
case send_text_frame(state, text) do
{:ok, new_state} ->
{:reply, :ok, new_state}
{:error, reason, new_state} ->
{:reply, {:error, reason}, new_state}
end
end
@impl GenServer
def handle_call({:send_text, _text}, _from, %{ready?: false} = state) do
{:reply, {:error, :not_ready}, state}
end
@impl GenServer
@spec handle_call(:status, GenServer.from(), %__MODULE__{}) ::
{:reply, %{url: String.t(), name: String.t(), ready?: boolean(), closing?: boolean()},
%__MODULE__{}}
def handle_call(:status, _from, state) do
status_data = build_status(state)
{:reply, status_data, state}
end
@impl GenServer
@spec handle_info(term(), %__MODULE__{}) ::
{:noreply, %__MODULE__{}} | {:stop, :normal, %__MODULE__{}}
def handle_info({:EXIT, _pid, reason}, state) do
Logger.debug("Relay process exited: #{inspect(reason)}")
{:stop, :normal, state}
end
def handle_info({:tcp_closed, _port}, state) do
Logger.debug("Connection closed by remote #{state.uri.host}.")
new_state = %{state | closing?: true, ready?: false, websocket: nil}
{:stop, :normal, new_state}
end
def handle_info(message, state) do
case Mint.WebSocket.stream(state.conn, message) do
{:ok, conn, responses} ->
new_state =
%{state | conn: conn}
|> handle_responses(responses)
if new_state.closing? do
do_close(new_state)
else
{:noreply, new_state}
end
{:error, _conn, %Mint.TransportError{reason: :closed}, _responses} ->
new_state = %{state | closing?: true, ready?: false, websocket: nil}
{:stop, :normal, new_state}
{:error, conn, reason, _responses} ->
Logger.error("WebSocket stream error: #{inspect(reason)}")
new_state = %{state | conn: conn} |> reply_to_caller({:error, reason})
{:noreply, new_state}
:unknown ->
{:noreply, state}
end
end
@impl GenServer
@spec terminate(term(), %__MODULE__{}) :: :ok
def terminate(_reason, state) do
case RelayAgent.get(state.name) do
nil ->
:ok
subscriptions when is_list(subscriptions) ->
Enum.each(subscriptions, fn sub_id ->
close_message =
sub_id
|> Message.close()
|> Message.serialize()
_ = send_close_frame(state, close_message)
end)
end
RelayAgent.delete_relay(state.name)
:ok
end
## Private Functions
@spec via_tuple(String.t()) :: {:via, Registry, {module(), String.t()}}
defp via_tuple(name), do: {:via, Registry, {RelayRegistry, name}}
@spec establish_connection(URI.t()) :: {:ok, Mint.HTTP.t(), reference()} | {:error, String.t()}
defp establish_connection(uri) do
http_scheme = if uri.scheme == "wss", do: :https, else: :http
ws_scheme = String.to_atom(uri.scheme)
with {:ok, conn} <- Mint.HTTP.connect(http_scheme, uri.host, uri.port, protocols: [:http1]),
{:ok, conn, ref} <- Mint.WebSocket.upgrade(ws_scheme, conn, uri.path, []) do
{:ok, conn, ref}
else
{:error, %Mint.TransportError{} = error} ->
msg = Exception.message(error)
{:error, "Connection error: #{msg}"}
{:error, %Mint.HTTPError{} = error} ->
msg = Exception.message(error)
{:error, "HTTP error: #{msg}"}
{:error, _conn, reason} ->
{:error, "WebSocket upgrade failed: #{inspect(reason)}"}
end
end
@spec send_text_frame(%__MODULE__{}, binary()) ::
{:ok, %__MODULE__{}} | {:error, atom(), %__MODULE__{}}
defp send_text_frame(state, text) do
case send_frame(state, {:text, text}) do
{:ok, new_state} ->
{:ok, new_state}
{:error, :closed} ->
{:error, :connection_closed, %{state | closing?: true, ready?: false}}
{:error, %Mint.TransportError{reason: :closed}} ->
{:error, :connection_closed, %{state | closing?: true, ready?: false}}
{:error, reason} ->
{:error, reason, state}
end
end
@spec build_status(%__MODULE__{}) :: %{
url: String.t(),
name: String.t(),
ready?: boolean(),
closing?: boolean()
}
defp build_status(state) do
%{
url: URI.to_string(state.uri),
name: state.name,
closing?: state.closing?,
ready?: state.ready?
}
end
@spec handle_responses(%__MODULE__{}, list()) :: %__MODULE__{}
defp handle_responses(state, responses) do
Enum.reduce(responses, state, &handle_response/2)
end
@spec handle_response(tuple(), %__MODULE__{}) :: %__MODULE__{}
defp handle_response({:status, ref, status}, %{request_ref: ref} = state) do
%{state | status: status}
end
defp handle_response({:headers, ref, resp_headers}, %{request_ref: ref} = state) do
%{state | resp_headers: resp_headers}
end
defp handle_response({:done, ref}, %{request_ref: ref} = state) do
case Mint.WebSocket.new(state.conn, ref, state.status, state.resp_headers) do
{:ok, conn, websocket} ->
%{state | conn: conn, websocket: websocket, status: nil, resp_headers: nil, ready?: true}
|> reply_to_caller({:ok, :connected})
{:error, conn, reason} ->
%{state | conn: conn}
|> reply_to_caller({:error, reason})
end
end
defp handle_response({:data, ref, data}, %{request_ref: ref, websocket: websocket} = state)
when not is_nil(websocket) do
case Mint.WebSocket.decode(websocket, data) do
{:ok, websocket, frames} ->
%{state | websocket: websocket}
|> handle_frames(frames)
{:error, websocket, reason} ->
%{state | websocket: websocket}
|> reply_to_caller({:error, reason})
end
end
defp handle_response(_response, state), do: state
@spec send_frame(%__MODULE__{}, tuple() | atom()) ::
{:ok, %__MODULE__{}} | {:error, atom() | term()}
defp send_frame(state, frame) do
with {:ok, websocket, data} <- Mint.WebSocket.encode(state.websocket, frame),
{:ok, conn} <- Mint.WebSocket.stream_request_body(state.conn, state.request_ref, data) do
{:ok, %{state | websocket: websocket, conn: conn}}
else
{:error, %Mint.WebSocket{}, %{reason: :closed}} ->
{:error, :closed}
{:error, %Mint.WebSocket{}, reason} ->
{:error, reason}
{:error, _conn, reason} ->
{:error, reason}
end
end
@spec handle_frames(%__MODULE__{}, list()) :: %__MODULE__{}
defp handle_frames(state, frames) do
Enum.reduce(frames, state, &handle_frame/2)
end
@spec handle_frame(tuple(), %__MODULE__{}) :: %__MODULE__{}
defp handle_frame({:ping, data}, state) do
case send_frame(state, {:pong, data}) do
{:ok, new_state} -> new_state
{:error, _reason} -> state
end
end
defp handle_frame({:close, _code, _reason}, state) do
%{state | closing?: true}
end
defp handle_frame({:text, text}, state) do
text
|> Message.parse()
|> handle_nostr_message(state)
end
defp handle_frame(frame, state) do
Logger.debug("Unexpected frame received: #{inspect(frame)}")
state
end
@spec handle_nostr_message(tuple() | atom(), %__MODULE__{}) :: :ok
defp handle_nostr_message({:event, subscription_id, _} = event, state) do
registry_dispatch(subscription_id, event)
state
end
defp handle_nostr_message({:notice, message}, state) do
Logger.notice("NOTICE from #{state.uri.host}: #{message}")
state
end
defp handle_nostr_message({:eose, subscription_id}, state) do
registry_dispatch(subscription_id, {:eose, subscription_id, state.uri.host})
state
end
defp handle_nostr_message({:close, sub_id}, state) do
registry_dispatch(sub_id, {:close, sub_id, state.uri.host})
RelayAgent.delete_subscription(state.name, sub_id)
state
end
defp handle_nostr_message({:ok, event_id, success, message}, state) do
registry_dispatch(:ok, %{
event_id: event_id,
success: success,
message: message,
relay: state.uri.host
})
state
end
defp handle_nostr_message(:error, state) do
Logger.error("Parse error for message from #{state.uri.host}")
state
end
defp handle_nostr_message(unknown, state) do
Logger.warning("Unknown message from #{state.uri.host}: #{inspect(unknown)}")
state
end
@spec do_close(%__MODULE__{}) :: {:stop, :normal, %__MODULE__{}}
defp do_close(state) do
# Streaming a close frame may fail if the server has already closed for writing
_ = send_frame(state, :close)
case state.conn do
nil ->
{:stop, :normal, state}
conn ->
Mint.HTTP.close(conn)
{:stop, :normal, state}
end
end
@spec reply_to_caller(%__MODULE__{}, term()) :: %__MODULE__{}
defp reply_to_caller(state, response) do
case state.caller do
nil ->
state
caller ->
GenServer.reply(caller, response)
%{state | caller: nil}
end
end
@doc """
Send a message to a given pubsub topic
"""
@spec registry_dispatch(atom() | binary(), term()) :: :ok
def registry_dispatch(sub_id, message) do
Registry.dispatch(NostrEx.PubSub, sub_id, fn entries ->
for {pid, _} <- entries, do: send(pid, message)
end)
end
@spec send_close_frame(%__MODULE__{}, binary()) :: :ok
defp send_close_frame(%{websocket: nil}, _message), do: :ok
defp send_close_frame(%{conn: nil}, _message), do: :ok
defp send_close_frame(%{websocket: websocket} = state, message) do
case Mint.WebSocket.encode(websocket, {:text, message}) do
{:ok, _websocket, data} ->
_ = Mint.WebSocket.stream_request_body(state.conn, state.request_ref, data)
:ok
{:error, _reason} ->
:ok
end
end
end