Building a distributed chatroom with Raxx.Kit

Raxx.Kit is a project generator to start developing web applications with Raxx and Elixir. Raxx is a toolkit focused on building the web layer for all kinds of Elixir applications.

Just want to see the code? The finished application is available here.

Why not Phoenix?

Raxx is a lightweight alternative when compared to the more comprehensive and opinionated Phoenix framework. Therefore it is a good fit for simple web service. It can also be a good choice when an application does not fit with the conventions chosen by Phoenix.

In conjuction with other excellent tools in the Elixir (and erlang) ecosystem Raxx is still a powerful choice. I will demonstrate this by showing how easy it is to build a multi-node chat application.

This tutorial was made with raxx_kit 0.5.2

Starting a project


      $ mix archive.install hex raxx_kit
      $ mix raxx.kit watercooler
      ...
      ...
      Get started:

          cd watercooler
          iex -S mix

      View on http://localhost:8080
      View on https://localhost:8443 (uses a self signed certificate)
    

Follow the instructions printed by the generator to start the project. Visit the home page at http://localhost:8080 and the following should be visible.

raxx_kit auto generated home page

Commit: starting a project with raxx.kit

Updating the home page

The Watercooler.WWW.HomePage module renders the homepage. Below is this module as generated by Raxx.Kit.


      # lib/watercooler/www/home_page.ex
      defmodule Watercooler.WWW.HomePage do
        use Raxx.Server
        use Watercooler.WWW.HTMLView

        @impl Raxx.Server
        def handle_request(_request, _state) do
          response(:ok)
          |> render(%{})
        end
      end
    

Update the map passed to render, to include the current node name.


      |> render(%{node: Node.self()})
    

Replace the home page template for the watercooler home page.


      <!-- lib/watercooler/www/home_page.html.eex -->
      <main>
        <h1>Watercooler</h1>
        <h2>Node: <%= @node %></h2>
        <iframe name="myframe" width="0" height="0"></iframe>
        <form action="/publish" method="post" target="myframe">
          <input type="text" name="message">
          <button type="submit">Publish</button>
        </form>
        <hr>
        <h1>Messages</h1>
        <ul id="messages"></ul>
      </main>
    

Finally replace the stylesheet with something for the home page.


      /* lib/watercooler/public/main.css */
      main {
        max-width: 720px;
        margin-left: auto;
        margin-right: auto;
      }

      h1 {
        text-align: center;
      }

      iframe {
        border: none;
      }
    

Refresh the home page to see the chatroom interface.

Commit: updating the home page

The chatroom

There is no chatroom module bundled as part of Raxx because it has nothing to do with the web. However there are tools that come with Elixir that do the job very well. We will build the core functionality for this application using the pg2 library.

pg2 is part of the erlang ecosystem and it's documentation is in erlang.

Fortunately, we do not need to know much about pg2 to be able to create a chatroom.


      # lib/watercooler.ex
      defmodule Watercooler do
        @group :watercooler

        def publish(message) do
          :ok = :pg2.create(@group)
          for client <- :pg2.get_members(@group) do
            send(client, {@group, message})
          end

          {:ok, message}
        end

        def listen() do
          :ok = :pg2.create(@group)
          :ok = :pg2.join(@group, self())
          {:ok, @group}
        end
      end
    

It is good practise to separate the business logic from anything dealing with the web. One reason is that publish and listen can be reused.

Commit: Add the chatroom

Publishing a message

Now there is a chatroom, users need to be able to access it over the web.


      # lib/watercooler/www/publish.ex
      defmodule Watercooler.WWW.Publish do
        use Raxx.Server

        @impl Raxx.Server
        def handle_request(request, _state) do
          %{"message" => message} =
            request.body # 1.
            |> URI.decode_www_form()
            |> URI.decode_query()

          {:ok, _} = Watercooler.publish(message) # 2.

          redirect("/") # 3.
        end
      end
    

In response to a request the following action is taken:

  1. Decode the body of the request and extract the message to be sent.
  2. Publish the message using the interface to the business logic defined before.
  3. Response by redirecting the client back to the home page.

All that remains is for this action to be added to the router.


      # lib/watercooler/www.ex
      defmodule Watercooler.WWW do
        use Ace.HTTP.Service

        use Raxx.Router, [
          { %{method: :GET, path: []}, Watercooler.WWW.HomePage },
          { %{method: :POST, path: ["publish"]}, Watercooler.WWW.Publish },
          { _, Watercooler.WWW.NotFoundPage }
        ]

        use Raxx.Static, "./public"
        use Raxx.Logger
      end
    

Commit: publishing a message

Sending messages to the browser

We will use server sent events to notify clients when someone publishes a message to the chatroom. These are a standard that allow notifications to be sent to a client from the server. We use this standard as it is supported by most browsers and has a nice JavaScript API.


      # mix.exs
      defp deps do
        [
          {:server_sent_event, "~> 0.3.1"},
          ...
    

Remember to run mix deps.get

Clients are now able to post messages but there is no way for them see what anyone else publishes.


      # lib/watercooler/www/listen.ex
      defmodule Watercooler.WWW.Listen do
        use Raxx.Server
        alias ServerSentEvent, as: SSE

        @impl Raxx.Server
        def handle_request(_request, state) do
          {:ok, _} = Watercooler.listen() # 1.

          response = response(:ok)
          |> set_header("content-type", SSE.mime_type())
          |> set_body(true) # 2.

          {[response], state} # 3.
        end

        @impl Raxx.Server
        def handle_info({:watercooler, message}, state) do
          event = SSE.serialize(message)
          {[Raxx.data(event)], state} # 4.
        end
      end
    
  1. Join the chatroom, this process will now receive messages for every publish event.
  2. Create a response with a body of unknown length
  3. Return the request so far and updated server state (in this example unchanged).
  4. For every message from the chatroom serialize it and send to the client. Again we return the same server state

Remember to add the action to the router, the JavaScript coming up assumes the endpoint is /listen.

Commit: sending messages to the browser

Listening in the browser

All the code which is required in the browser are these few lines.


      // lib/watercooler/assets/main.js
      var $message = document.getElementById('messages') // 1.

      displayUpdate = function (update) { // 2.
        var line = "<li>" + update.data +"</li>"
        $message.innerHTML = line + $message.innerHTML
      }
      var source = new EventSource('/listen'); // 3.

      source.onmessage = displayUpdate // 4.
    
  1. Get a reference to the element in the browser where we will show messages.
  2. Define a function to display posts on the page
  3. Start a new connection to the endpoint which sends messages.
  4. For every message from the server call the function to show update on the page.

Now we can open several tabs and send messages between them. However at this point only one node is running and the title says multi-node.

Commit: listening in the browser

Starting multiple nodes

When starting multiple nodes each one needs a name and a way to find other nodes. To tell each node what others to look for, create a sys.config with the following content.


      %% sys.config
      [{kernel,
        [
          {sync_nodes_optional,
            [
              'node1@127.0.0.1',
              'node2@127.0.0.1'
            ]},
          {sync_nodes_timeout, 5000}
        ]}
      ].
    

This is an erlang file because the contents are passed to the erlang VM at startup.

Now start two nodes, telling each one to use sys.config and it's own name.


      # terminal 1
      $ PORT=8081 SECURE_PORT=8441 iex \
        --name node1@127.0.0.1 \
        --erl "-config sys.config" \
        -S mix

      # terminal 2
      $ PORT=8082 SECURE_PORT=8442 iex \
        --name node2@127.0.0.1 \
        --erl "-config sys.config" \
        -S mix
    

Opening browser windows to localhost:8081 and localhost:8082 and publish a message. The message will appear in both windows.

Commit: starting multiple nodes

HTTP/2 and HTTPS

HTTP/2 is only available in the browser over secure (https) connections. You can try repeating the experiment above using https://localhost:8081 and https://localhost:8082. This will work exactly the same but now all communication, both to and from the server, is over a single connection.

Next

Interested in using Raxx? The best place to get started is the README. Any questions, ask on the Elixir Forum or chat in the #raxx slack channel.