At Binary Noggin, we frequently use Phoenix Channels to build applications. Although Phoenix Channels are cool, they aren't always needed when integrating with clients. Sometimes a raw websocket connection is all you need to get real-time communication up and running. This is useful for integrating with systems, frameworks and/or languages that:

  • Don't have a well-supported Phoenix Channels Client (this ironically includes Elixir at the time of writing)
  • Don't want the added (small) overhead of the Channels abstraction
  • Simply don't want to use Channels

Whatever the use case, I was surprised to find that adding a handler for raw websockets in Phoenix wasn't well documented, which seemed odd because Phoenix uses Cowboy under the hood. In this quick post, I’ll give an example of how to add a raw websocket handler to a Phoenix application.

Setup

I'm going to generate a new project to help keep track of names. All the steps below this section also apply to existing applications.

First, generate a new project. I'm explicitly disabling the things that aren't needed here, but obviously tune this accordingly.

mix phx.new socks --no-ecto --no-webpack

 

Configuring the Application

Whether or not you’ve just generated a new project or are integrating an existing one, we need to configure the Cowboy Endpoint Adapter's dispatch routes.

First, open your config.exs and create or modify the Endpoint entry:

config :socks, SocksWeb.Endpoint,
  http: [
    dispatch: [
      {:_,
       [
         {"/websocket", SocksWeb.EchoSocket, []},
         {:_, Phoenix.Endpoint.Cowboy2Handler, {SocksWeb.Endpoint, []}}
       ]}
    ]
  ]

Note the dispatch key is nested inside the http key. This means we’ll also need to enable it for https.

dispatch = [
  _: [
    {"/websocket", SocksWeb.EchoSocket, []},
    {:_, Phoenix.Endpoint.Cowboy2Handler, {SocksWeb.Endpoint, []}}
  ]
]

config :socks, SocksWeb.Endpoint,
  http:  [dispatch: dispatch],
  https: [dispatch: dispatch]

 

Next, we'll look at the implementation of the SocksWeb.EchoSocket module. For now, all we care about is the dispatch values. The first key, :_ is a wildcard host. You can read the official Cowboy documentation for more information on this structure. For now, we only want a single host, so we put a wildcard here. The list inside that first :_ host are paths. This works similar to any other HTTP router, so we don't need to dwell on this section.

The left-hand side is the path. For example, if we started our webserver locally, the newly added path would look like: ws://localhost:4000/websocket. The second element of the tuple is the handler implementation we’ll look at next, as well as cowboy options to be passed in.

Our First Handler: Echo

Now that the server is configured correctly, we can actually implement our shiny new handler.

Create a new file in the project in the socks_web folder. I like to maintain the naming convention of Phoenix Channels, but you can name this file anything you want. I'll use lib/socks_web/echo_socket.ex:


defmodule SocksWeb.EchoSocket do
  @moduledoc """
  Simple Websocket handler that echos back any data it receives
  """

  # Tells the compiler we implement the `cowboy_websocket`
  # behaviour. This will give warnings if our
  # return types are notably incorrect or if we forget to implement a function.
  # FUN FACT: when you `use MyAppWeb, :channel` in your normal Phoenix channel
  #           implementations, this is done under the hood for you.
  @behaviour :cowboy_websocket

  # entry point of the websocket socket. 
  # WARNING: this is where you would need to do any authentication
  #          and authorization. Since this handler is invoked BEFORE
  #          our Phoenix router, it will NOT follow your pipelines defined there.
  # 
  # WARNING: this function is NOT called in the same process context as the rest of the functions
  #          defined in this module. This is notably dissimilar to other gen_* behaviours.          
  @impl :cowboy_websocket
  def init(req, opts), do: {:cowboy_websocket, req, opts}

  # as long as `init/2` returned `{:cowboy_websocket, req, opts}`
  # this function will be called. You can begin sending packets at this point.
  # We'll look at how to do that in the `websocket_handle` function however.
  # This function is where you might want to  implement `Phoenix.Presence`, schedule an `after_join` message etc.
  @impl :cowboy_websocket
  def websocket_init(state), do: {[], state}

  # `websocket_handle` is where data from a client will be received.
  # a `frame` will be delivered in one of a few shapes depending on what the client sent:
  # 
  #     :ping
  #     :pong
  #     {:text, data}
  #     {:binary, data}
  # 
  # Similarly, the return value of this function is similar:
  # 
  #     {[reply_frame1, reply_frame2, ....], state}
  # 
  # where `reply_frame` is the same format as what is delivered.
  @impl :cowboy_websocket
  def websocket_handle(frame, state)

  # :ping is not handled for us like in Phoenix Channels. 
  # We must explicitly send :pong messages back. 
  def websocket_handle(:ping, state), do: {[:pong], state}

  # a message was delivered from a client. Here we handle it by just echoing it back
  # to the client.
  def websocket_handle({:text, message}, state), do: {[{:text, message}], state}

  # This function is where we will process all *other* messages that get delivered to the
  # process mailbox. This function isn't used in this handler.
  @impl :cowboy_websocket
  def websocket_info(info, state)

  def websocket_info(_info, state), do: {[], state}
end

 

 

Client-Side Implementation: Echo

In this section, we'll write a very simple client in JavaScript because it's already available. Open app.js, and add the following snippet to it somewhere:

// Create WebSocket connection.
const socket = new WebSocket('ws://localhost:4000/websocket');

// Connection opened
socket.addEventListener('open', function (event) {
    socket.send('Hello Server!');
});

// Listen for messages
socket.addEventListener('message', function (event) {
    console.log('Message from server ', event.data);
});

 

Bonus Client Implementation: Elixir Echo

If we need a client in Elixir, we can use Gun.

defmodule SockClient do
  @host 'localhost'
  @port 4000
  @path '/websocket'

  use GenServer
  require Logger

  def start_link(args) do
    GenServer.start_link(__MODULE__, args)
  end

  @impl GenServer
  def init(_args) do
    connect_opts = %{
      connect_timeout: :timer.minutes(1),
      retry: 10,
      retry_timeout: 100,
      protocols: [:http]
    }
    with {:ok, gun}      <- :gun.open(@host, @port, connect_opts),
         {:ok, protocol} <- :gun.await_up(gun),
         stream          <- :gun.ws_upgrade(gun, @path, []) 
    do
      :ok = :gun.ws_send(gun, stream, {:text, "Hello Server!"})
      state = %{gun: gun, protocol: protocol, stream: stream}
      {:ok, state}
    end
  end

  def handle_info({:gun_ws, gun, stream, {:text, data}}, %{gun: gun, stream: stream} = state) do
    Logger.info ["Message from server ", data]
    {:noreply, state}
  end
end

 

Bonus Round: Nginx Config

This section isn't meant to be a full tutorial on deploying a Phoenix application behind a Nginx proxy, but there's a snippet required for allowing websocket connections when using proxy_pass.

In the http section of nginx.conf, add:

map $http_upgrade $connection_upgrade {
    default upgrade;
    '' close;
}

Then add the following to the server section:

proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;

 

Conclusion

Setting up raw websocket handlers in a standard Phoenix application is a simple fix for establishing real-time communication. If you want to learn more about websockets, I recommend reading Cowboy Websocket Handlers, Gun Websocket Client and JavaScript Websocket API.

 

Connor Rigby is a software engineer with a decade of experience in software development. He specializes in Elixir and Ruby, and is a core contributor to the Nerves and NervesHub projects.

Founded in 2007, Binary Noggin is a team of software engineers who serve as a trusted extension of your team, helping your company succeed through collaboration. We forge customizable solutions using Agile methodologies and our mastery of Elixir, Ruby and other open-source technologies. Share your ideas with us on Facebook and Twitter.

Ingenious solutions in your inbox

Ingenious solutions in your inbox

Join our monthly newsletter list and be the first to learn about the latest blogs, events, and more!

You have Successfully Subscribed!