Discover Elixir & Phoenix

Back to All Courses

Lesson 18

Channels & websockets

Although Phoenix is great at many things, handling real-time events is where the framework really shines! Dealing with websockets has traditionally been quite cumbersome for web developers, but Erlang's runtime environment combined with Elixir's ease of use and Phoenix's abstractions make it a breeze. Without further ado, let's dive into it!

In order to be able to test this functionality, we will open a new browser application (so if you're already logged-in on Chrome, use Firefox for example), and create a brand new user. Personally I'm going to use Safari and call the new user "alice". You should then log in and create a new room where both of your users can communicate with each other.

Here's my setup with Chrome on the left side and Safari on the right side.

We now want to be able to send a message from our user to "alice", and have that message instantly appear in Alice's window, without her having to manually fetch it!

Creating and connecting to a channel

Phoenix introduces the concept of channels -- a websocket endpoint that clients can connect to, in order to send and receive messages. The best way to understand it is through an example. Let's generate a new channel for our rooms!

mix phx.gen.channel Room

You should now have a file at lib/messengyr_web/channels/room_channel.ex. Let's open it and take a look. As you can see, there's a default room called lobby that anyone can join:

# lib/messengyr/web/channels/room_channel.ex

  # ...

  def join("room:lobby", payload, socket) do
    if authorized?(payload) do
      {:ok, socket}
    else
      {:error, %{reason: "unauthorized"}}
    end
  end

  # ...

If you scroll down the file, you'll see that authorized?/1 is a private function that always returns true, so joining this room will currently always work without any problems.

We also list all of our "channel routes" in the user_socket.ex-file. Here we can specify what the endpoint that connects to the RoomChannel should be. In this case, we'll just un-comment the line that Phoenix already has for us near the top of the file, which says that all routes that start with the prefix room: should go to the room channel:

# lib/messengyr_web/channels/user_socket.ex

defmodule Messengyr.UserSocket do
  use Phoenix.Socket

  ## Channels
  channel "room:*", Messengyr.Web.RoomChannel # Un-comment this line!

  # ...

Let's now try to connect to our "lobby" room from React. Phoenix has actually already generated a JavaScript file that helps us connect to our rooms, at assets/js/socket.js. If you open this file, you'll find plenty of comments explaining how to use it. Delete those comments so that we can get a better overview of what the file is doing. We'll also delete the part that connects to the channel "topic:subtopic", since that endpoint doesn't exist. You final file should look like this:

// assets/js/socket.js

import {Socket} from "phoenix";

let socket = new Socket("/socket", {params: {token: window.userToken}});

socket.connect();

export default socket;

Next, we'll go into menu-container.js and import this socket file at the top. Using the socket object, we can create a channel variable, which specifies which Phoenix channel we want to connect to (in this case, "room.lobby"), and finally we'll join the channel:

// assets/js/components/menu-container.js

// ...

let channel = socket.channel('room:lobby');

channel.join()
.receive('ok', resp => {
  console.info("Joined the lobby!");
});

class MenuContainer extends React.Component {
  // ...

And that's all you need! If you refresh the browser, you should now see this message in your JavaScript log...

...and this in your Phoenix log:

Sending a socket message

Now that we're connected to the socket, we can try to send a message to it! If we open room_channel.ex again, you'll see that it already has some default handle_in/3-functions. One of them pattern matches against the string "shout" -- that function takes whatever message it receives and broadcasts it to all the users that are connected to the room.

If invoke this "shout"-function from JavaScript, we simply use the channel variable that we created earlier and push the string "shout". We also want to listen to the channel so that we can log something when we receive the "shout":

// assets/js/components/menu-container.js

// ...

let channel = socket.channel('room:lobby');

channel.join()
.receive('ok', resp => {
  console.info("Joined the lobby!");

  // Push the "shout":
  channel.push('shout');

  // For every "shout" we receive, log a message:
  channel.on('shout', () => {
    console.info("A user just shouted the lobby!");
  });
});

Again, refresh the page and you should see the message in your JavaScript console:

As you probably expected.

This might not seem very advanced, but the cool part is that the messages aren't necessarily tied to your own user. Any user connected to the "room:lobby" channel can broadcast the "shout" message, and it will still be intercepted by you!

We can try this out by opening the other browser where Alice is logged-in (Safari in my case), and hit refresh multiple times. That way, Alice will send out multiple shouts, and you'll notice that your own user will log every single one!

Authenticating sockets

Before we start sending and receiving more messages, we want to make sure that our websocket endpoint requires authentication, so that only logged-in users can use it. For this, we'll use Guardian and our JWT-token again.

If you re-open the autogenerated socket.js-file, you'll see that Phoenix is trying to guide us in the right direction by sending a window.userToken variable under the key "token" when it connects to the socket endpoint. This userToken is undefined however, but we do have the global variable jwtToken which we used in our Fetch requests earlier. Let's alter the file so that it uses that instead:

// assets/js/socket.js

// ...

let socket = new Socket("/socket", {
  params: {
    guardianToken: window.jwtToken,
  }
});

// ...

Now, back in user_socket.ex, we're going to use the resource_from_claims/1-function from our Messengyr.Auth.Guardian module to decode the token and retrieve the user that belongs to it. We'll do this in the user socket's connect/2-function, which is called as soon as the user attempts to connect to the socket. After we've retrieved the user, we'll assign it to the socket variable so that we can easily read it later. If the user isn't logged in, or we cannot retrieve the user for whatever reason, we'll return an :error.

# lib/messengyr_web/channels/user_socket.ex

defmodule MessengyrWeb.UserSocket do
  
  # ...

  # Extract the "guardianToken" value into a "jwt" variable:
  def connect(%{"guardianToken" => jwt}, socket) do

    # Decode the jtw and get the user associated with it:
    with {:ok, claims} <- Guardian.decode_and_verify(jwt),
         {:ok, user} <- Messengyr.Auth.Guardian.resource_from_claims(claims)
    do
      # Assign the user to the socket!
      {:ok, assign(socket, :current_user, user)}
    else
      _ -> :error
    end
  end

  # ...
  
end

Here you can also clearly see the benefit of using Elixir's with -- it allows us to easily pattern match multiple conditionals one after the other. If we wanted to write the same logic using case statements, things would get deeply nested:

# With "case":
case Guardian.decode_and_verify(jwt) do
  {:ok, claims} ->
  	case Messengyr.Auth.Guardian.resource_from_claims(claims) do
      {:ok, user} -> {:ok, assign(socket, :current_user, user)}
      _ -> :error
    end
  {:error, _reason} -> :error
end

# VS

# With "with":
with {:ok, claims} <- Guardian.decode_and_verify(jwt),
     {:ok, user} <- Messengyr.Auth.Guardian.resource_from_claims(claims)
do
  {:ok, assign(socket, :current_user, user)}
else
  _ -> :error
end

We'll also define a second connect/2-function that doesn't pattern match against guardianToken. If the user tries to connect without that parameter, it should return an :error instantly:

# lib/messengyr_web/channels/user_socket.ex

defmodule MessengyrWeb.UserSocket do
  
  # ...

  def connect(%{"guardianToken" => jwt}, socket) do
	# ...
  end

  # Add this function:
  def connect(_params, _socket) do
    :error
  end

  def id(_socket), do: nil
end

If you refresh the page, you should be able to use the socket just like before. However, you can try experimenting with the socket.js-file and comment out the guardianToken line. You'll notice that, if you don't send the token, or if you change it to something that's not valid, you won't be able to connect to the socket (which is the expected behaviour)!

If we send a valid "guardianToken" everything is fine...

...but if we don't, we get an error!

Connecting to a specific room

Alright, enough with the "shouting" in the "lobby"! What we really want to do is to connect to each room where the user is a member. Instead of connecting to "room:lobby", a user could then connect to the channels "room:1" and "room:2", provided that they are a member of the rooms with ID 1 and 2.

Let's start with the room_channel.ex-file. We'll remove all the functions that are currently in it, and instead have a single join/3-function that connects to a room with a specific ID:

# lib/messengyr_web/channels/room_channel.ex

defmodule MessengyrWeb.RoomChannel do
  use MessengyrWeb, :channel

  def join("room:" <> room_id, _payload, socket) do
    {:ok, socket}
  end
  
end

Next, we'll go to menu-container.js and delete the code that's related to joining the "lobby" channel. Instead, we'll create a getRoomChannel-function that takes a roomId, connects to the channel of that room, and returns the channel:

# assets/js/components/menu-container.js

// ...

import socket from "../socket";

let getRoomChannel = (roomId) => {
  let channel = socket.channel(`room:${roomId}`);

  channel.join()
  .receive("ok", resp => {
    console.info(`Joined room ${roomId} successfully`, resp);
  })
  .receive("error", resp => {
    console.error(`Unable to join ${roomId}`, resp);
  });

  return channel;
};

Finally, we'll loop through all of our rooms (right after fetching them with AJAX in componentDidMount) and add a channel to each room object before putting them in our Redux store:

// assets/js/components/menu-container.js

// ...

class MenuContainer extends React.Component {

  componentDidMount() {
    fetch('/api/rooms', {
      // ...
    })
    .then((response) => {
      let rooms = response.rooms;

      // Get the room channel for each room...
      rooms.forEach((room) => {
        room.channel = getRoomChannel(room.id);
      });

      // ... then add the rooms to the Redux store:
      this.props.setRooms(rooms);

      // ...
      
    })
    .catch((err) => {
      // ...
    });
  }
  
  // ...
}

Refresh the page, and you should now see your user join only the rooms that you're a member of in the console:

Handling errors

What we have is pretty good, however, we should handle cases where the user tries to connect to a room that doesn't exist. Also, what happens if another logged-in user manually connects to the channel "room:10" to spy on the conversation I'm having with Alice? We obviously can't let that happen, so we need the join/3-function in room_channel.ex to make sure that the room existsand only let in the members of that specific room.

To do that, we'll first need two new functions in our chat context: get_room/1 and room_has_user?/2:

# lib/messengyr/chat/chat.ex

defmodule Messengyr.Chat do

  # ...

  def get_room(id) do
    Repo.get(Room, id)
  end

  # ...

  def room_has_user?(room, user) do
    # Find a row in the "room_users" table that contains
    # the given room and user
    query = from ru in RoomUser,
      where: ru.room_id == ^room.id and ru.user_id == ^user.id

    # If a room was found, return true!
    case Repo.one(query) do
      %RoomUser{} -> true
      _ -> false
    end
  end

  # ...

end

Using these new functions, we can now update the room channel's join/3-function:

# lib/messengyr_web/channels/room_channel.ex

defmodule MessengyrWeb.RoomChannel do
  
  # ...

  # Don't forget to alias:
  alias Messengyr.Chat
  alias Messengyr.Chat.Room

  def join("room:" <> room_id, _payload, socket) do
    me = socket.assigns.current_user

    case Chat.get_room(room_id) do
      # Make sure that we get a room struct (and not nil)
      %Room{} = room ->
        if Chat.room_has_user?(room, me) do
          {:ok, socket}
        else
          {:error, %{reason: "You're not a member of this room!"}}
        end
      _ -> {:error, %{reason: "This room doesn't exist!"}}
    end
  end
  
end

There we go! Now it should be safe from eavesdroppers. To test that we cannot connect to just any random room, you can try to call the getRoomChannel-function from React with a room ID that doesn't exist (like 999):

// assets/js/components/menu-container.js

// ...

getRoomChannel(999);

class MenuContainer extends React.Component {

  // ...

You should then see an error message for that room in your log:

Now you know how to connect to a websocket in Phoenix and perform some basic authentication in your channels! In the next chapter, we'll make our two users communicate with each other through this channel.