Basic Networking in Elm

CMSC 22300, Spring 2020

By Justin Lubin.

The goal of this tutorial will be to create an application that registers the number of times a webpage is clicked. The fun part is that this information will be propogated to all viewers of the website! Think of it as a really simple and collaborative "cookie clicker"-style app. It should provide a solid start to anyone looking to include basic networking functionality in their final project.

We will use Google's Firebase technology to accomplish this without writing any backend code. Specifically, we will be using the Firebase Realtime Database. Let's get started!

Setup

Creating a Project Skeleton

In a new directory on your computer, run elm init and configure the elm.json file to your liking. For simplicity, I will assume that you have set "source-directories" to ".".

Also, run elm install elm/browser and elm install elm/json; we'll need those packages later.

Now, create two files: Main.elm (for our Elm app) and firebase.js (for our JavaScript ports). We'll fill these in later, so just leave them blank for now.

Now is also a good time to create a simple Makefile, too. Here's mine:

all:
    elm make Main.elm --output=main.js

Note that Makefiles MUST be indented with tabs, not spaces.

Lastly, create a file index.html. For now, fill it with the following content:

<!DOCTYPE html>

<html>
  <head>
    <meta charset="UTF-8">
    <title>Firebase Example</title>
  </head>
  <body>
    <div id="app"></div>

    <!-- JavaScript generated by Elm -->
    <script src="main.js"></script>

    <!-- Elm setup -->
    <script>
      var app = Elm.Main.init({
        node: document.getElementById("app")
      });
    </script>

    <!-- Our Firebase scripts -->
    <script src="firebase.js"></script>
  </body>
</html>

Creating a Firebase Project

To use Firebase, we will need to create a Firebase project. To do so, you will need a Google account (essentially a Gmail address). Feel free to create a new one just for this purpose or simply reuse any personal one that you may have.

Next, make sure you're signed into the Google account you wish to use and navigate to the Firebase console.

Click "Add Project" and choose whatever name you want for it. Follow the on-screen instructions.

Once you have created your new Firebase project, click on "Project Overview" in the left-hand navigation column. In the center of the screen, there should be a button to set up the project for the web. The button looks like this: </>.

Enter a project nickname and click "Register app". (You don't need Firebase Hosting for this project.)

Copy the code that Firebase presents you into your index.html file after the <div id="app"></div> line but before the <script src="main.js"></script> line. Give or take a few HTML comments, your index.html file should look something like this now (with the long chains of Xs replaced with your actual information):

<!DOCTYPE html>

<html>
  <head>
    <meta charset="UTF-8">
    <title>Firebase Example</title>
  </head>
  <body>
    <div id="app"></div>

    <!-- Firebase API -->
    <script src="https://www.gstatic.com/firebasejs/7.14.2/firebase-app.js"></script>
    <script src="https://www.gstatic.com/firebasejs/7.14.2/firebase-analytics.js"></script>

    <!-- Firebase config -->
    <script>
      var firebaseConfig = {
        apiKey: "XXXXXXXXXXXXXXXXXXXX",
        authDomain: "XXXXXXXXXXXXXXXXXXXX",
        databaseURL: "XXXXXXXXXXXXXXXXXXXX",
        projectId: "XXXXXXXXXXXXXXXXXXXX",
        storageBucket: "XXXXXXXXXXXXXXXXXXXX",
        messagingSenderId: "XXXXXXXXXXXXXXXXXXXX",
        appId: "XXXXXXXXXXXXXXXXXXXX",
        measurementId: "XXXXXXXXXXXXXXXXXXXX"
      };
      firebase.initializeApp(firebaseConfig);
      firebase.analytics();
    </script>

    <!-- JavaScript generated by Elm -->
    <script src="main.js"></script>

    <!-- Elm setup -->
    <script>
      var app = Elm.Main.init({
        node: document.getElementById("app")
      });
    </script>

    <!-- Our Firebase scripts -->
    <script src="firebase.js"></script>
  </body>
</html>

We're almost done with the setup and can soon write some real code!

Enabling and Configuring Firebase Realtime Database

The last thing we need to do before writing some code is---as the title of this section suggests---enable and configure the Firebase Realtime Database, which is the technology that actually allows us to send and receive realtime messages/data between clients from anywhere in the world.

To enable the database, return to your Firebase console. In the left-hand navigation column, click "Database".

Create a new Realtime Database by following the database creation prompts. Select "Locked" mode for the permissions, which we'll manually change ourselves momentarily.

Once the Realtime Database has been created, go back to the Databases view in the Firebase console and select the newly created database. Make sure you click "Realtime Database", not "Cloud Firebase"; you may need to switch to the correct view by clicking on the dropdown menu next to the big, bold "Database" header).

Under the "Database" header, click the "Rules" tab. Change the falses to trues; this will allow anyone to read and write to your database. In a real-world setting, you would want to enable user authentication for security, but, for the purposes of this class, we can assume everyone is a benign actor here :-)

The "Rules" file should now look something like this:

{
  /* Visit https://firebase.google.com/docs/database/security to learn more about security rules. */
  "rules": {
    ".read": true,
    ".write": true
  }
}

Important: Next, click the "Data" tab under the "Database" header and change the value null to the string "0"; this is the initial data that we'll need later.

Lastly, we need to add the Firebase Realtime Database API to our index.html file. Right after the Firebase <script> tags but before the Firebase configuration, add in the following line of code:

<script src="https://www.gstatic.com/firebasejs/7.14.2/firebase-database.js"></script>

Our index.html file is now complete, and we shouldn't need to modify it any further. Here's what it should look like in its entirety:

<!DOCTYPE html>

<html>
  <head>
    <meta charset="UTF-8">
    <title>Firebase Example</title>
  </head>
  <body>
    <div id="app"></div>

    <!-- Firebase API -->
    <script src="https://www.gstatic.com/firebasejs/7.14.2/firebase-app.js"></script>
    <script src="https://www.gstatic.com/firebasejs/7.14.2/firebase-analytics.js"></script>

    <!-- Important! Firebase Realtime Database -->
    <script src="https://www.gstatic.com/firebasejs/7.14.2/firebase-database.js"></script>

    <!-- Firebase config -->
    <script>
      var firebaseConfig = {
        apiKey: "XXXXXXXXXXXXXXXXXXXX",
        authDomain: "XXXXXXXXXXXXXXXXXXXX",
        databaseURL: "XXXXXXXXXXXXXXXXXXXX",
        projectId: "XXXXXXXXXXXXXXXXXXXX",
        storageBucket: "XXXXXXXXXXXXXXXXXXXX",
        messagingSenderId: "XXXXXXXXXXXXXXXXXXXX",
        appId: "XXXXXXXXXXXXXXXXXXXX",
        measurementId: "XXXXXXXXXXXXXXXXXXXX"
      };
      firebase.initializeApp(firebaseConfig);
      firebase.analytics();
    </script>

    <!-- JavaScript generated by Elm -->
    <script src="main.js"></script>

    <!-- Elm setup -->
    <script>
      var app = Elm.Main.init({
        node: document.getElementById("app")
      });
    </script>

    <!-- Our Firebase scripts -->
    <script src="firebase.js"></script>
  </body>
</html>

Code

We'll now focus our attention on the Main.elm and firebase.js files we created earlier. Let's start with what we're more familiar with: the Elm file.

The Elm Side

Begin Main.elm with a module definition and some imports:

port module Main exposing (main)

import Browser
import Browser.Events
import Json.Decode
import Html exposing (Html)

Note that we use the port prefix on the module definition because we will be using ports to interface with Firebase.

Next, add in some types:

-- Types

type alias Model =
  { count : Maybe Int
  }

type Msg
  = Click
  | ReceiveValue String

Our Model will consist of a single Maybe Int. This value will be Just n once we've loaded the worldwide click count (n) from the Firebase Realtime Database, and Nothing while we're still waiting to receive the data at startup.

The Msgs we will handle are simple: Clicks for when the user clicks the screen, and ReceiveValues for when we receive data from the Firebase Realtime Database.

We'll have two ports for this module. They are:

-- Ports

port firebaseWrite : String -> Cmd msg
port firebaseRead : (String -> msg) -> Sub msg

The former of these ports will be used to write to the database, and the latter will be used to listen to updates from the database.

Whenever one client writes to the database by using the first command, all the clients (including the client that requested the write) will automatically recieve that update via the subscription from the second command.

The init for our model is straightforward:

-- Init

init : () -> (Model, Cmd Msg)
init _ =
  ( { count = Nothing }
  , Cmd.none
  )

The update is slightly more complex:

-- Update

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    Click ->
      case model.count of
        Just n ->
          ( model
          , firebaseWrite (String.fromInt (n + 1))
          )

        Nothing ->
          ( model
          , Cmd.none
          )

    ReceiveValue value ->
      case String.toInt value of
        Just n ->
          ( { model | count = Just n }
          , Cmd.none
          )

        Nothing ->
          ( model
          , Cmd.none
          )

There are four cases to this function. The first case occurs when a Click message occurs and the model has already loaded the click count from the Firebase Realtime Database. In this case, it is safe to push the updated click count to the database. (There is no need to update our model, because pushing to the database will trigger a database read on every client---in particular, the client that sent the update---so the ReceiveValue case will handle updating the local model). Note that we haven't implemented firebaseWrite in JavaScript yet, so we can just assume that we will eventually get it to work properly for now (and, in fact, we'll do so in the next section).

If we have not yet received the click count from the database, though (the second case), then Click messages should do nothing. Otherwise, we would be pushing a value that is out of sync with the actual count!

A note of caution: there is a race condition in this code. If two clients click at the same time, then they will both request that the database's value be one greater than the value that is locally stored in their models. The database will handle both of these requests, and the click count will be set to one greater than the local values. But two clicks have occurred, not one! Fortunately, this is a really low-stakes application, so it doesn't matter too much. If you really need to ensure that there are no race conditions in your code, then you will need to design your database and client code more carefully. But, for most cases, a race condition here or there won't be the end of the world, especially for something like the final project for this class. (Sorry systems, databases, and networks folks!)

In any case, the final two cases are simpler. Whenever we receive a value from the database (which will be a String), we try to parse it as an integer, and, if we succeed (which we always should, assuming we set things up correctly), we then update the local model.

Now let's add in the subscriptions!

-- Subscriptions

subscriptions : Model -> Sub Msg
subscriptions model =
  Sub.batch
    [ Browser.Events.onClick (Json.Decode.succeed Click)
    , firebaseRead ReceiveValue
    ]

Sub.batch allows us to listen to multiple subscriptions at once. The first of the two subscriptions that we listen to fires Click events whenever the user clicks the application. The second fires a ReceiveValue event whenever we receive data from the Firebase Realtime Database. As with firebaseWrite, we haven't actually implemented firebaseRead in JavaScript yet, so, for now, we can just assume that we'll eventually get it to work!

Let's wrap things up with a simple view (minimalism is back in style, or so they say):

-- View

view : Model -> Html Msg
view model =
  case model.count of
    Just n ->
      Html.text <|
        "The worldwide count is " ++ String.fromInt n ++ "."

    Nothing ->
      Html.text "Loading worldwide count..."

And tie everything together with our definition of main:

-- Main

main =
  Browser.element
    { init = init
    , update = update
    , subscriptions = subscriptions
    , view = view
    }

And that'll do it on the Elm side! Here's the entirety of Main.elm:

port module Main exposing (main)

import Browser
import Browser.Events
import Json.Decode
import Html exposing (Html)

-- Types

type alias Model =
  { count : Maybe Int
  }

type Msg
  = Click
  | ReceiveValue String

-- Ports

port firebaseWrite : String -> Cmd msg
port firebaseRead : (String -> msg) -> Sub msg

-- Init

init : () -> (Model, Cmd Msg)
init _ =
  ( { count = Nothing }
  , Cmd.none
  )

-- Update

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    Click ->
      case model.count of
        Just n ->
          ( model
          , firebaseWrite (String.fromInt (n + 1))
          )

        Nothing ->
          ( model
          , Cmd.none
          )

    ReceiveValue value ->
      case String.toInt value of
        Just n ->
          ( { model | count = Just n }
          , Cmd.none
          )

        Nothing ->
          ( model
          , Cmd.none
          )

-- Subscriptions

subscriptions : Model -> Sub Msg
subscriptions model =
  Sub.batch
    [ Browser.Events.onClick (Json.Decode.succeed Click)
    , firebaseRead ReceiveValue
    ]

-- View

view : Model -> Html Msg
view model =
  case model.count of
    Just n ->
      Html.text <|
        "The worldwide count is " ++ String.fromInt n ++ "."

    Nothing ->
      Html.text "Loading worldwide count..."

-- Main

main =
  Browser.element
    { init = init
    , update = update
    , subscriptions = subscriptions
    , view = view
    }

The JavaScript Side

Now it's time to use JavaScript to implement the firebaseWrite and firebaseRead ports that we relied on in the Elm code.

Open up firebase.js and add in the following lines of code:

app.ports.firebaseWrite.subscribe(function(data) {
  firebase.database().ref("/").set(data);
});

firebase.database().ref("/").on("value", function(snapshot) {
  app.ports.firebaseRead.send(snapshot.val());
});

That's actually all we need to get the app running! You can check out the application at this point by running make in the directory containing your Makefile and opening the index.html file in your browser. Try clicking and seeing the counter go up from multiple browser tabs. You can also see the data update in real time in the Firebase Realtime Database console under the "Data" tab. Pretty nifty!

These lines of code that I just presented are pretty dense, though, and consitute the core of interacting with the Firebase Realtime Database.

The first line of code listens for when our Elm code requests a firebaseWrite command. Whenever that occurs, we set the root path (i.e. /) in our database to whatever data Elm requested to be written.

We can access all the Firebase Realtime Database functions via the firebase.database() object. The ref method on this object represents a location in the database where we can read and write data. Its argument is a path separated by slashes: in a social networking app, for example, we might access "/users/justin/likes" to see all the content that I like. You can think of the database as acting like files on your computer, but it's in The Cloud™! You can read more about the structure of the Firebase Realtime Database here.

On the object returned by the ref method, we can call set to write whatever JSON data we want to that part of the database. This is exactly what we do on the second line of code. But be careful: if there's any data at that part of the database already, it will be overwritten!

The last three lines of code listen for changes to the root path of our Firebase Realtime Database. The on method on the object returned by ref is an event handler that gets triggered whenever the event specified by its first parmeter is triggered. In this case, we pass in the string "value" for the event type parameter, which means our code will get called whenever the value at / changes (writes, updates, deletes, etc.).

For the callback to the on method, we specify a function that takes in a snapshot parameter, which represents a "snapshot" of the database at the time of the event. The easiest way to use this snapshot is to call the val method on it, which doesn't even require any arguments! In this case, the val method will return the new value at the root of our database. Our callback just takes this value and threads it along to Elm.

And that's it! You now have a collaborative clicker application written (mostly) in Elm with Firebase!

If you want to learn more, I highly recommend checking out the Firebase Realtime Database tutorials "Read and Write Data" and "Work with Lists of Data", which go into just the right level of detail to serve as next steps to this tutorial. You can learn about things like updating data and pushing data to a list in the database rather than just overwriting any data present at a location (perfect for a chat application). In general, the official documentation that those tutorials are part of is quite nice.

Thanks for reading, and good luck with your project!