Skip to content

WebSockets in Phoenix

Preparation

We will create comment section that will work in real time using websockets.

In order to do that, first we create comments:

mix ecto.gen.migration add_comments
create table(:comments) do
    add :content, :string
    add :user_id, references(:users)  
    add :topic_id, references(:topics)
end

Make the comment model in web/models/comment.ex:

defmodule Discuss.Comment do
  use Discuss.Web, :model

  schema "comments" do
    field :content, :string
    belongs_to :user, Discuss.User
    belongs_to :topic, Discuss.Topic
  end

  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:content, :user_id, :topic_id])
    |> validate_required([:content, :user_id, :topic_id])
  end

end

And modify the User and Topic models.

has_many :comments, Discuss.Comment

Actual WebSockets

Phoenix has built in WebSockets support to exchange information in real-time. It also supports alternatives like HTTP Long Polling.

WebSockets have implementation both on server-side and client-side.

On the server side, the web socket configuration can be managed in the web/channels directory.

The client side can be managed using the web/static/js/socket.js file.

The WebSockets interface is split into a separate channels which is similar to a controller. It has multiple methods like join, handle_in.

So, we can start with creating a connection on the client side by modifying the web/static/js/socket.js


import {Socket} from "phoenix"
let socket = new Socket("/socket", {params: {token: window.userToken}})
socket.connect()

const createSocket = (topicId) => {

  let channel = socket.channel('comments:' + topicId, {})
  channel.join()
    .receive("ok", resp => { renderComments(resp.comments) })
    .receive("error", resp => { console.log("Unable to join", resp) })

  document.querySelector('button').addEventListener('click', function(e) {
    e.preventDefault()
    const textarea = document.querySelector('textarea')
    const content = textarea.value
    channel.push('comments:add', {content: content})
    textarea.value = "";
  })

  channel.on(`comments:${topicId}:new`, renderComment)

}

function renderComments(comments) {
  console.log('connected', comments)
  const renderedComments = comments.map(commentTemplate)
  document.querySelector('.collection').innerHTML = renderedComments.join('')
}

function renderComment(event) {
  const renderedComment = commentTemplate(event.comment);
  document.querySelector('.collection').innerHTML += renderedComment;
}

function commentTemplate(comment) {

  const author = (comment.user == null) ? "Anonymous" : comment.user.email;

  return `
      <li class="collection-item">
        ${comment.content}
        <div class="right">
          ${author}
        </div>
      </li>
    `;
}

window.createSocket = createSocket

Then we import this file in web/static/js/app.js

import "./socket"

After that, we modify the web/templates/topic/show.html.eex template:

<h5><%= @topic.title %></h5>

<ul class="collection">
</ul>

<div class="input-field">
    <textarea class="materialize-textarea"></textarea>
    <button class="btn">Add comment</button>
</div>

<script>
document.addEventListener("DOMContentLoaded", function() {
    window.createSocket(<%= @topic.id %>)
})
</script>

Add the comments channel to the web/channels/user_socket.ex which is a routes.ex-like file for WebSockets.

defmodule Discuss.UserSocket do
  use Phoenix.Socket

  channel "comments:*", Discuss.CommentsChannel

  transport :websocket, Phoenix.Transports.WebSocket

  def connect(%{"token" => token}, socket) do
    case Phoenix.Token.verify(socket, "key", token) do
      {:ok, user_id} ->
        {:ok, assign(socket, :user_id, user_id)}
      {:error, _reason} ->
        {:ok, assign(socket, :user_id, nil)}
    end
  end

  def id(_socket), do: nil

end

Notice, that it handles authentication too, it is handled by using a token that is set in web/templates/layout/app.html.eex

<%= if @conn.assigns.user do %>
<script>
    window.userToken = "<%= Phoenix.Token.sign(Discuss.Endpoint, "key", @conn.assigns.user.id) %>";
</script>
<% end %>

Then finally, we add the web/channels/comments_channel.ex

defmodule Discuss.CommentsChannel do

  use Discuss.Web, :channel
  alias Discuss.Topic
  alias Discuss.Comment
  alias Discuss.User

  def join("comments:" <> topic_id, _params, socket) do

    topic_id = String.to_integer(topic_id)

    topic = Topic
    |> Repo.get(topic_id)
    |> Repo.preload(comments: [:user])

    {:ok, %{comments: topic.comments}, assign(socket, :topic, topic)}

  end

  def handle_in("comments:add", %{"content" => content}, socket) do

      topic = socket.assigns.topic
      user_id = socket.assigns.user_id

      changeset = topic
      |> build_assoc(:comments, user_id: user_id)
      |> Repo.preload(:user)
      |> Comment.changeset(%{content: content, user_id: user_id})

      case Repo.insert(changeset) do
        {:ok, comment} ->
          broadcast!(socket, "comments:#{socket.assigns.topic.id}:new", %{comment: comment})
          {:reply, :ok, socket}
        {:error, _reason} ->
          {:reply, {:error, %{errors: changeset}}, socket}
      end

  end

end

The last things to do, since the the websockets uses JSON encode, we need to modify the Comment and User model in order to tell them on how to encode them:

defmodule Discuss.Comment do
  use Discuss.Web, :model

  @derive {Poison.Encoder, only: [:content, :user]}

  schema "comments" do
    field :content, :string
    belongs_to :user, Discuss.User
    belongs_to :topic, Discuss.Topic
  end

  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:content])
    |> validate_required([:content])
  end

end
defmodule Discuss.User do
  use Discuss.Web, :model

  @derive {Poison.Encoder, only: [:email]}

  schema "users" do
    field :email, :string
    field :provider, :string
    field :token, :string

    has_many :topics, Discuss.Topic
    has_many :comments, Discuss.Comment

    timestamps()
  end

  @doc """
  Builds a changeset based on the `struct` and `params`.
  """
  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:email, :provider, :token])
    |> validate_required([:email, :provider, :token])
  end
end