Build a simple realtime app with hapi.js and Pusher Channels

Introduction

Hapi.js is a modern Node.js framework that has been gaining popularity in the JavaScript world. In this tutorial, we will introduce hapi.js. We will use it to build a realtime application with the help of the powerful Pusher Channels API.

Our application will store contacts and make them available to all its users in real time. You can see it action in the image below:

realtime-hapijs-demo

Requirements

To follow along with this tutorial, you would need the following installed:

You also need to have a working knowledge of JavaScript and ES6 syntax.

Introduction to hapi.js

Hapi, short for **HTTP API Server was developed by the team at Walmart Labs, led by Eran Hammer. It embraces the philosophy that configuration is better than code. It also provides a lot more features out of the box than other popular JavaScript frameworks like Express.js and Koa.

Introduction to Pusher Channels

Pusher Channels is a service that makes it easy to add realtime functionality to various applications. We will use it in our application. Sign up for a free account here, create a Channels app, and copy out the app credentials (App ID, Key, and Secret) from the "App Keys” section.

Setup and configuration

Let us get started by setting up our project. Create a folder with the project name hapi-contacts and change directory to that folder in your terminal:

    mkdir hapi-contacts && cd hapi-contacts

To initialize the application, run the following command:

    npm init -y

Tip: The -y or --yes flag helps to create a package.json file with default values.

Next, we will install hapi.js and other needed packages to our application. The inert package helps serve static files and directories in a hapi.js application, while the pusher package helps us interact with the Pusher API. Run this command to install the needed packages:

    npm i hapi inert pusher

Now, we can create the files needed for our application. We will maintain a very simple file structure:

1├── hapi-contacts
2        ├── app.js
3        └── public
4            └── index.html

The app.js file will contain all the server-side logic for our application, while the index.html file will contain the view for our application and the client-side logic.

Building our backend

Starting a server

We will start off building out the backend for our app by starting a hapi.js server. We can do this by adding the following content to our app.js file:

1// ./app.js
2
3    // Require needed modules
4    const Hapi = require('hapi');
5
6    // Initialise Hapi.js server
7    const server = Hapi.server({
8      port: process.env.port || 4000,
9      host: 'localhost'
10    });
11
12    const init = async () => {
13      // start server
14      await server.start();
15      console.log(`Server running at: ${server.info.uri}`);
16    };
17
18    // handle all unhandled promise rejections
19    process.on('unhandledRejection', err => {
20      console.log(err);
21      process.exit(1);
22    });
23
24    // Start application
25    init();

In the code above, we first require the hapi package, then initialize it to the server variable. We use this variable in the init() function to start our server with the server.start() method. Finally, we add a process.on(``'``unhandledRejection``'``) event listener to handle all unhandled or “un-caught” promise rejections and log the errors to console.

To run the app:

    node app.js

Note: visiting localhost:4000 at this point will return a 404 as we have not defined any routes yet.

Defining Routes

Next, we will define routes for adding and removing contacts from our application. We will use Pusher to trigger events and broadcast the details of new and deleted contacts to all the application’s users.

Note: in reality, the contact details should be persisted to some form of database or store. We did not do this in this tutorial as it is beyond its scope. You can implement a data store in your own version of the app!

First we initialize Pusher before the init() function:

1// ./app.js
2    // ...
3    const Pusher = require('pusher');
4
5    // Initialize Pusher
6    const pusher = new Pusher({
7      appId: 'YOUR_APP_ID',
8      key: 'YOUR_APP_KEY',
9      secret: 'YOUR_APP_SECRET',
10      cluster: 'YOUR_APP_CLUSTER',
11      encrypted: true
12    });

In the code above, we require the pusher package and initialize Pusher with the credentials we got from the Pusher dashboard. Remember to replace YOUR_APP_ID and similar values with the actual credentials.

Then we define our routes in the init() function:

1// ./app.js
2    // ...
3    const init = async () => {
4      // store contact
5      server.route({
6        method: 'POST',
7        path: '/contact',
8        handler(request, h) {
9          const { contact } = JSON.parse(request.payload);
10          const randomNumber = Math.floor(Math.random() * 100);
11          const genders = ['men', 'women'];
12          const randomGender = genders[Math.floor(Math.random() * genders.length)];
13          Object.assign(contact, {
14            id: `contact-${Date.now()}`,
15            image: `https://randomuser.me/api/portraits/${randomGender}/${randomNumber}.jpg`
16          });
17          pusher.trigger('contact', 'contact-added', { contact });
18          return contact;
19        }
20      });
21
22      // delete contact
23      server.route({
24        method: 'DELETE',
25        path: '/contact/{id}',
26        handler(request, h) {
27          const { id } = request.params;
28          pusher.trigger('contact', 'contact-deleted', { id });
29          return id;
30        }
31      });
32
33      // start server
34      await server.start();
35      console.log(`Server running at: ${server.info.uri}`);
36    };
37    // ...

We define two routes in the init() function. In hapi.js, we can define routes with the server.route() method. The main parameters needed to make use of this method are the path, the method, and a handler. You can read more about routing in hapi.js in their docs.

Triggering events

When a request is made to the '``POST /contact``' route, we first retrieve the contact details in the payload sent to the API via the request.payload object and assign the details to the contact object. Next, we generate an ID and a random avatar, then assign these details to the same contact object. Finally, using Pusher, we trigger a contact-added event on the contact channel, sending the contact object as data to be broadcasted.

The trigger() method has the following syntax:

    pusher.trigger( channels, event, data, socketId, callback );

You can read more about it here.

Note: we are broadcasting data on a public Pusher channel as we want the data to be accessible to everyone. Pusher also allows broadcasting on private and presence channels, which provide functionalities that require authentication. Their channel names are prefixed by private- and presence- respectively.

Similarly, when a request is made to the '``DELETE /contact``' route, we trigger a contact-deleted event on the contact channel and broadcast the ID of the deleted contact.

Building our frontend

Serving static files

To serve the index.html file, we make use of inert, a package that helps us serve static files in a hapi.js application. According to their documentation:

Inert provides handler methods for serving static files and directories, as well as adding an h.file() method to the toolkit, which can respond with file-based resources.

To start serving the index.html page on the '``GET /``' route, let us update the app.js file:

1// ./app.js
2    // ...
3    const Path = require('path');
4    const Inert = require('inert');
5
6    const server = Hapi.server({
7      port: process.env.port || 4000,
8      host: 'localhost',
9      routes: {
10        files: {
11          relativeTo: Path.join(__dirname, 'public')
12        }
13      }
14    });
15    const init = async () => {
16      // register static content plugin
17      await server.register(Inert);
18
19      // index route / homepage
20      server.route({
21        method: 'GET',
22        path: '/',
23        handler: {
24          file: 'index.html'
25        }
26      });
27      // ...
28
29    };
30    //...

In the code block above, we add the routes.files option when creating the server. This specifies that we will be serving static files from the public directory.

The GET / route definition simply uses the file handler to serve the index.html file.

Now, we can add some markup to our view. We will import a CSS framework called Bulma to take advantage of some premade styles. We will also create a simple form and an area for displaying our contacts. Add the following code to the index.html file:

1<!-- ./public/index.html -->
2    <html>
3    <head>
4        <meta name="viewport" content="width=device-width, initial-scale=1">
5        <title>Hapi.js Realtime Application!</title>
6        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.6.2/css/bulma.min.css">
7    </head>
8    <body>
9        <section class="section">
10            <div class="container">
11                <div class="intro">
12                    <h1 class="title">Hello</h1>
13                    <p class="subtitle">
14                        Welcome to <strong class="has-text-primary">HapiContacts</strong>!
15                    </p>
16                </div>
17                <hr>
18                <section class="columns">
19                    <div class="column is-two-fifths">
20                        <h4 class="title is-3">
21                            Add Contact
22                        </h4>
23                        <form id="addContactForm">
24                            <div class="field">
25                                <label class="label">Name</label>
26                                <div class="control">
27                                    <input name="name" required class="input" type="text" placeholder="e.g Alex Smith">
28                                </div>
29                            </div>
30                            <div class="field">
31                                <label class="label">Phone Number</label>
32                                <div class="control">
33                                    <input name="phone" required class="input" type="text" placeholder="e.g. 234-80-988-7676">
34                                </div>
35                            </div>
36                            <div class="field">
37                                <label class="label">Address</label>
38                                <div class="control">
39                                    <textarea name="address" required class="textarea" placeholder="Glover road"></textarea>
40                                </div>
41                            </div>
42                            <div class="field">
43                                <div class="control">
44                                    <button class="button is-primary">Save</button>
45                                </div>
46                            </div>
47                        </form>
48                    </div>
49                    <div class="column">
50                        <h4 class="title is-3">
51                            Contacts
52                        </h4>
53                        <div id="contacts-list" class="columns is-multiline"></div>
54                    </div>
55                </section>
56            </div>
57        </section>
58    </body>
59    </html>

Making API calls

Next, let us define functions for adding and deleting contacts. To make requests to our API endpoints, we will use the JavaScript Fetch API. Let us update the index.html file:

1<!-- ./public/index.html -->
2        <script>
3            const form = document.querySelector('#addContactForm');
4            form.onsubmit = e => {
5                e.preventDefault();
6                const contact = {
7                    name: form.elements['name'].value,
8                    phone: form.elements['phone'].value,
9                    address: form.elements['address'].value
10                }
11                fetch('/contact', {
12                    method: 'POST',
13                    body: JSON.stringify({ contact })
14                })
15                    .then(response => response.json())
16                    .then(response => form.reset())
17                    .catch(error => console.error('Error:', error));
18            }
19            const deleteContact = id => {
20                fetch(`/contact/${id}`, { method: 'DELETE' })
21                    .catch(error => console.error('Error:', error));
22            }
23        </script>
24    </body>
25    </html>

In the code block above, we define an event listener for the onsubmit event, which will be fired once our form for adding contacts is submitted. In the listener function, we make an API call to the '``POST /contact``' endpoint using the intuitive JavaScript Fetch API.

We also define the deleteContact() function to help make API calls to delete contacts from our app.

Note: we make use of the JavaScript Fetch API for making AJAX requests. It is promise-based and more powerful than the regular XMLHttpRequest. A polyfill might be needed for older browsers. A great alternative to the Fetch API is axios.

Listening for events

Our last step in creating our app is to define listeners for the various events we are triggering via Pusher.

Before we define listeners, we need to include the Pusher JavaScript library. This will help us communicate with the Pusher API from the client-side. We will also initialise Pusher with the credentials we have previously gotten from the Pusher dashboard. Updating index.html:

1<!-- ./public/index.html -->
2        <script src="https://js.pusher.com/4.0/pusher.min.js"></script>
3        <script>
4            // ...
5            const pusher = new Pusher('APP_KEY', {
6                cluster: 'APP_CLUSTER',
7                encrypted: true
8            });
9        </script>
10    </body>
11    </html>

Note: don't forget to replace APP_KEY with its actual value.

Next, we will define listener functions for the various events we are triggering via Pusher. Updating index.html:

1<!-- ./public/index.html -->
2        <script>
3            // ...
4            const channel = pusher.subscribe('contact');
5
6            channel.bind('contact-added', ({ contact }) => {
7                appendToList(contact)
8            });
9
10            channel.bind('contact-deleted', ({ id }) => {
11              const contact = document.querySelector(`#${id}`);
12              contact.parentNode.removeChild(contact);
13            });
14
15            // helper function that appends new posts
16            // to the list of blog posts on the page
17            const appendToList = data => {
18                const html = `
19                    <div class="column is-half" id="${data.id}">
20                        <div class="card">
21                            <div class="card-content">
22                                <div class="media">
23                                    <div class="media-left">
24                                        <figure class="image is-48x48"><img src="${data.image}"></figure>
25                                    </div>
26                                    <div class="media-content">
27                                        <p class="title is-4">${data.name}</p>
28                                        <p class="subtitle is-6">${data.phone}</p>
29                                    </div>
30                                </div>
31                                <div class="content"><p>${data.address}</p></div>
32                            </div>
33                            <footer class="card-footer">
34                                <a onclick="deleteContact('${data.id}')" href="#" class="card-footer-item has-text-danger">
35                                    Delete
36                                </a>
37                            </footer>
38                        </div>
39                    </div>`;
40                const list = document.querySelector("#contacts-list");
41                list.innerHTML += html;
42            };
43        </script>
44    </body>
45    </html>

Tip: you can also use Pusher.logToConsole = true; to debug locally

In the code block above, first, we subscribe to the contact public channel on which we trigger all our events with the pusher.subscribe() method. We then assign that subscription to the channel variable.

Next, we define listeners for the contact-added and contact-deleted events using the channel.bind() method. The method has the following syntax:

    channel.bind(eventName, callback);

You can read more about client events in Pusher here.

Lastly, we define a helper function called appendToList() to help us generate and append HTML for each new contact added.

Now, we have a functional realtime hapi.js application! You can run the app with the following command:

    node app.js

The entire code for this tutorial is hosted on Github.

Conclusion

In this tutorial, we have learned how to create a realtime application from scratch using the intuitive hapi.js framework and Pusher Channels. Although hapi.js is a little different from what many JavaScript developers are used to (the Express way), it introduces its own advantages and may just be a good pick for your next project.