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:
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.
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.
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.
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 apackage.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.
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.
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.
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-
andpresence-
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.
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>
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.
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.
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.