lib/a2s/client.ex

defmodule A2S.Client do
  @moduledoc """
  An easy to use client that performs the packet assembly necessary to execute A2S queries.

  This client handles requests concurrently and should be suitable for most general uses.

  Note: must supply a `:name`.
  """

  use Supervisor

  @default_name A2S_Singleton

  defmodule Config do
    @moduledoc false
    defstruct [:name, :port, :idle_timeout, :recv_timeout]
  end

  ## Initialization

  def child_spec(opts) do
    %{
      id: a2s_name(opts),
      start: {__MODULE__, :start_link, [opts]}
    }
  end

  @doc """
  The following configuration options are available:

  * `:name` - Alias of the top-level supervisor. Can be provided if you intend to run multiple client instances. Defaults to `#{inspect(@default_name)}`.

  * `:port` - UDP socket port used for querying game servers. Defaults to `20850`.

  * `:idle_timeout` - `A2S.Client` internally allocates a `:gen_statem` for each address queried, that should exit after idling to keep from leaking resources. Defaults to `120_000` or 2 minutes.

  * `:recv_timeout` - Timeout the `:gen_statem`'s will wait between receiving individual packets before returning `{:error, :recv_timeout}`. Defaults to `3000` or 3 seconds.
  """
  @spec start_link(keyword()) :: :ignore | {:error, any()} | {:ok, pid()}
  def start_link(opts \\ []) do
    name = a2s_name(opts)

    config = %Config{
      name: name,
      port: Keyword.get(opts, :port, 20850),
      idle_timeout: Keyword.get(opts, :idle_timeout, 120_000),
      recv_timeout: Keyword.get(opts, :recv_timeout, 3000)
    }

    Supervisor.start_link(__MODULE__, config, name: supervisor_name(name))
  end

  defp a2s_name(opts), do: Keyword.get(opts, :name, @default_name)

  @impl true
  def init(%Config{} = config) do
    %Config{
      name: name,
      port: port,
      idle_timeout: idle_timeout,
      recv_timeout: recv_timeout
    } = config

    :persistent_term.put({A2S.Statem, :recv_timeout}, recv_timeout)
    :persistent_term.put({A2S.Statem, :idle_timeout}, idle_timeout)

    children = [
      {Registry, [keys: :unique, name: registry_name(name)]},
      {A2S.DynamicSupervisor, [name: dynamic_supervisor_name(name)]},
      {A2S.Socket, [name: socket_name(name), port: port, a2s_registry: registry_name(name)]}
    ]

    Supervisor.init(children, strategy: :one_for_all)
  end

  ## API

  @doc """
  Query a game server running at `address` for the data specified by `query`.

  Additional options are available as a keyword list:
  * `:name` - Alias of the top-level supervisor. Defaults to `A2S.Client`.

  * `:timeout` - Absolute timeout for the request to complete. Defaults to `5000` or 5 seconds.
  """
  @spec query(
          :info | :players | :rules,
          {:inet.ip_address(), :inet.port_number()},
          list({atom(), any()})
        ) ::
          {:info, A2S.Info.t()}
          | {:players | A2S.Players.t()}
          | {:rules | A2S.Rules.t()}
          | {:error, any}

  def query(query, address, opts \\ []) do
    client_name = Keyword.get(opts, :name, @default_name)
    timeout = Keyword.get(opts, :timeout, 5000)

    :gen_statem.call(find_or_start(address, client_name), query, timeout)
  end

  defp find_or_start(address, client) do
    registry = registry_name(client)

    case Registry.lookup(registry, address) do
      [{pid, _value}] ->
        pid

      [] ->
        case A2S.DynamicSupervisor.start_child(address, client) do
          {:ok, pid} -> pid
          {:ok, pid, _info} -> pid
          {:error, {:already_started, pid}} -> pid
        end
    end
  end

  ## Helpers

  @doc false
  def registry_name(name), do: :"#{name}.Registry"

  @doc false
  def dynamic_supervisor_name(name), do: :"#{name}.DynamicSupervisor"

  @doc false
  def socket_name(name), do: :"#{name}.Socket"

  @doc false
  def supervisor_name(name), do: :"#{name}.Supervisor"
end