Skip to main content

lib/http/web_socket.ex

defmodule HTTP.WebSocket do
  @moduledoc """
  Browser-like WebSocket client API for Elixir.

  Events are delivered as messages to the owner process:

      {HTTP.WebSocket, socket, %HTTP.WebSocket.Event.Open{}}
      {HTTP.WebSocket, socket, %HTTP.WebSocket.Event.Message{}}
      {HTTP.WebSocket, socket, %HTTP.WebSocket.Event.Error{}}
      {HTTP.WebSocket, socket, %HTTP.WebSocket.Event.Close{}}

  Plain Elixir binaries are sent as text frames. Use `array_buffer/1` or
  `HTTP.Blob` for binary frames.
  """

  alias HTTP.WebSocket.ArrayBuffer
  alias HTTP.WebSocket.Connection
  alias HTTP.WebSocket.Frame
  alias HTTP.WebSocket.Options

  defstruct pid: nil, ref: nil, url: nil

  @connecting 0
  @open 1
  @closing 2
  @closed 3
  @call_timeout 5_000

  @type t :: %__MODULE__{pid: pid() | nil, ref: reference() | nil, url: String.t() | nil}

  @spec connecting() :: 0
  def connecting, do: @connecting

  @spec open() :: 1
  def open, do: @open

  @spec closing() :: 2
  def closing, do: @closing

  @spec closed() :: 3
  def closed, do: @closed

  @spec new(String.t() | URI.t(), String.t() | [String.t()], keyword() | map()) ::
          t() | {:error, term()}
  def new(url, protocols \\ [], init \\ []) do
    ref = make_ref()

    with {:ok, options} <- Options.new(url, protocols, put_ref(init, ref)),
         {:ok, pid} <-
           DynamicSupervisor.start_child(
             HTTP.WebSocket.ConnectionSupervisor,
             {Connection, options}
           ) do
      %__MODULE__{pid: pid, ref: ref, url: options.url}
    end
  end

  @spec array_buffer(binary()) :: ArrayBuffer.t() | {:error, :invalid_array_buffer}
  def array_buffer(data) when is_binary(data), do: ArrayBuffer.new(data)
  def array_buffer(_data), do: {:error, :invalid_array_buffer}

  @spec url(t()) :: String.t() | nil
  def url(%__MODULE__{url: url}), do: url

  @spec ready_state(t()) :: 0 | 1 | 2 | 3
  def ready_state(socket), do: connection_call(socket, :ready_state, @closed)

  @spec buffered_amount(t()) :: non_neg_integer()
  def buffered_amount(socket), do: connection_call(socket, :buffered_amount, 0)

  @spec extensions(t()) :: String.t()
  def extensions(socket), do: connection_call(socket, :extensions, "")

  @spec protocol(t()) :: String.t()
  def protocol(socket), do: connection_call(socket, :protocol, "")

  @spec binary_type(t()) :: :blob | :array_buffer
  def binary_type(socket), do: connection_call(socket, :binary_type, :blob)

  @spec set_binary_type(t(), :blob | :array_buffer) :: :ok | {:error, term()}
  def set_binary_type(socket, binary_type) when binary_type in [:blob, :array_buffer] do
    connection_call(socket, {:set_binary_type, binary_type}, {:error, :closed})
  end

  def set_binary_type(_socket, _binary_type), do: {:error, :invalid_binary_type}

  @spec send(t(), String.t() | HTTP.Blob.t() | ArrayBuffer.t()) :: :ok | {:error, term()}
  def send(socket, data), do: connection_call(socket, {:send, data}, {:error, :closed})

  @spec close(t()) :: :ok | {:error, term()}
  def close(socket), do: close(socket, nil, "")

  @spec close(t(), non_neg_integer()) :: :ok | {:error, term()}
  def close(socket, code), do: close(socket, code, "")

  @spec close(t(), non_neg_integer() | nil, String.t()) :: :ok | {:error, term()}
  def close(socket, code, reason) when is_binary(reason) do
    with {:ok, payload} <- Frame.close_payload(code, reason) do
      connection_call(socket, {:close, code, reason, payload}, {:error, :closed})
    end
  end

  def close(_socket, _code, _reason), do: {:error, :invalid_close_reason}

  defp connection_call(%__MODULE__{pid: pid}, request, default) when is_pid(pid) do
    GenServer.call(pid, request, @call_timeout)
  catch
    :exit, _reason -> default
  end

  defp connection_call(_socket, _request, default), do: default

  defp put_ref(init, ref) when is_map(init), do: Map.put(init, :ref, ref)
  defp put_ref(init, ref) when is_list(init), do: Keyword.put(init, :ref, ref)
end