Part I: Bare websockets with Elixir Phoenix (wo/ Channels)

Part I: Bare websockets with Elixir Phoenix (wo/ Channels)

Websockets are great for when you want bidirectional communication between the client and the server over an extended session.

The de facto approach to building websockets with Elixir Phoenix involves using Channels. This pattern is great and it comes with a very well designed protocol and library of clients that make it really easy to get started. But for my use case while building for this customer, I needed a bare websocket since I didn’t have any supported Phoenix Channels client I could use.

This guide will share how I did that and it presumes you have the Phoenix application and server set up already. We will be using an example social media newsfeed backend, which would establish a long-lived connection with a user’s browser and constantly refresh as soon as new posts appear. It is far easier to implement this using Channels but for my demonstration we won’t be doing this.

This is the reference codebase for this article. It contains a pre-generated Phoenix app called my_app.

1. Setup a scaffold for the socket transport

i. We start by creating a custom socket transport by copying the scaffold from the docs here. Its behaviour is to echo back whatever input is sent.

# lib/myapp_web/sockets/newsfeed_socket.ex
defmodule Myapp.NewsfeedSocket do
  @behaviour Phoenix.Socket.Transport
  def child_spec(_opts) do
    # We won't spawn any process, so let's ignore the child spec
    :ignore
  end
  def connect(state) do
    # Callback to retrieve relevant data from the connection.
    # The map contains options, params, transport and endpoint keys.
    {:ok, state}
  end

  def init(state) do
    # Now we are effectively inside the process that maintains the socket.
    {:ok, state}
  end

  # messages sent to us by the client 
  def handle_in({text, _opts}, state) do
    {:reply, :ok, {:text, text}, state}
  end

  # messages sent to this connection process by other processes on the server
  def handle_info(_, state) do
    {:ok, state}
  end

  def terminate(_reason, _state) do
    :ok
  end
end

ii. Add the websocket route in endpoint.ex

# lib/myapp_web/endpoint.ex
socket "/feed", Myapp.NewsfeedSocket, websocket: true

iii. Postman offers a really convenient UI for testing websockets and it’s what we’ll be using in this guide.

Connecting to the route we created via ws://127.0.0.1:4000/feed/websocket and sending some text, we can verify that we have setup the code correctly since it echoes it back:

2. Implement auth validation

Roughly described, the protocol for websockets is:

a. a client initiates the connection by making a HTTP GET request with a specification to upgrade the connection. This corresponds connect/1 in the module above.

b. The server initiates the session by switching to the websocket protocol or declines the HTTP request. This corresponds to init/1.

Our auth validation will have to be implemented in connect/1.

i. First we’ll need to pass the headers onto the state through the route in endpoint.ex. We’ll also add an error handler whose implementation we’ll define later:

# lib/myapp_web/endpoint.ex
socket "/feed", Myapp.NewsfeedSocket,
    websocket: [
      connect_info: [:x_headers],
      error_handler: {Myapp.NewsfeedSocket, :handle_error, []}
    ]
💡
The reference for all websocket configurations available can be found here.

ii. Next we’ll add the validate_auth function and call it in our module:

defmodule Myapp.NewsfeedSocket do
...
  def connect(state) do
    validate_auth(state)
  end
...
  defp validate_auth(state) do
    headers = Enum.into(state[:connect_info][:x_headers] || [], %{})
    auth_token = headers["x-authorization"]
    case auth_token do
      "secret" ->
        {:ok, state}
      _ ->
        {:error, :unauthorized}
    end
  end
...

This simple validate method checks the x-authorization header to confirm the value included matches the secret string we expect, otherwise returning an error.

iii. Last we’ll add the handle_error function. This allows us to set the status code on the HTTP response

defmodule Myapp.NewsfeedSocket do
 import Plug.Conn
...
 def handle_error(conn, error) do
    case error do
      :unauthorized ->
        conn
        |> send_resp(401, "Unauthorized")

      _ ->
        conn
        |> send_resp(500, "Internal Server Error")
    end
  end
...

iv. Validating this on Postman we should see an error if we make a request with an invalid or missing auth header

We’ll disable this validation by commenting out so we can build out the rest of our application:

defp validate_auth(state) do
...
    _ ->
       # {:error, :unauthorized}
       {:ok, state}
end

3. Create Newsfeed business logic

We’ll add some simple business logic to return a list of posts which represents our newsfeed.

# lib/myapp/newsfeed.ex
defmodule Myapp.Newsfeed do
  # make struct JSON encodable
  @derive Jason.Encoder
  defstruct author: nil, body: nil, time: nil

  def get_feed(),
    do: [
      %__MODULE__{author: "user1", body: "It do be like that", time: ~U[2025-01-01 12:00:00Z]},
      %__MODULE__{author: "user2", body: "Covfefe again", time: ~U[2025-01-01 12:01:00Z]},
      %__MODULE__{author: "user3", body: "It's giving", time: ~U[2025-01-01 12:02:00Z]}
    ]
end

4. Periodic fetch and refresh of feed

We want to send the posts to the user immediately they connect then refresh this by sending them the latest posts every 10 seconds.

defmodule Myapp.NewsfeedSocket do
 alias Myapp.Newsfeed
 ... 
 def init(state) do
    send(self(), :refresh_and_push_feed)
    {:ok, state}
 end

 def handle_info(:refresh_and_push_feed, state) do
    feed = Jason.encode!(Newsfeed.get_feed())
    # schedule the refresh for the next interval
    schedule_feed_refresh()
    {:push, {:text, feed}, state}
  end

 @doc """
  Schedule pushing of newsfeed every 10 seconds
  """
  def schedule_feed_refresh() do
    Process.send_after(self(), :refresh_and_push_feed, 10_000)
  end
...
end

i. handle_info/2 handles messages sent to the process managing the websocket connection. We add a handle_info/2 that will fetch the latest posts and push them to the client

ii. We then trigger its call immediately the websocket is initialized in init/1.

iii. Finally, we implement the scheduled refresh to happen every 10 seconds.

iv. We verify that this works on Postman, by seeing a message come in from our server every 10 seconds

This is part 1 of 3 of this series. In the next articles, I will show you how to implement ping/pong opcodes and how to write tests. Let me know if you have any questions.