As a server-side JavaScript platform, Node.js has become an indispensable part of web development today. The main reasons for this success are that the entry hurdle is very low, that there are many developers who know JavaScript as a programming language, and last but not least that Node.js is very lightweight. But this is exactly where one of the biggest problems for developers arises. Node itself only provides the basic interfaces to the system and the protocol level. Application developers have to take care of everything above the HTTP protocol themselves. This strategy has led to the formation of one of the most active communities in web development around Node.js. Using a package manager like npm or Yarn, you can install and use libraries and frameworks for your application. When it comes to web application development, without a doubt, the bedrock is Express. With over 13 million weekly downloads on npmjs.com, Express is the undisputed leader in the category of web application frameworks. Despite its relatively long development time and wide distribution, Express has some problems that make more and more developers look for alternatives. First and foremost, the handling of updates or the lack of them. Express has been stuck in the major version 4 for years now. For a framework, this indicates a high degree of stability, but also means that it does not receive any profound changes and modernizations. Some of the design patterns that Express relies on have become a bit outdated in the meantime, so it is not surprising that many smaller frameworks pass Express by. An interesting alternative to Express is Hapi. This framework cannot really be called a newcomer. The first commit of this project is from 2011, so it’s more of an old-timer. What is special about Hapi is the philosophy of the framework.
Hapi – the background
The idea behind Hapi is to make Node.js usable for business-critical processes and applications. Hapi originates from Walmart and was developed there to handle the peak loads of Black Friday Sales. According to the development team, the most important features of Hapi are:
- Security: Hapi aims to be an Enterprise Framework. The development team checks every line of the source code for security. The creator of Hapi, Eran Hammer, is the author of the OAuth specification. So in a way, he stands for security. Another potential security problem in JavaScript projects is the use of package managers like npm. This is not because of npm itself, but because there is no quality control for the packages. If you take a look at the dependency list of larger JavaScript projects, it usually shows a lot of entries. The idea behind this is that standard problems do not always have to be solved by yourself. However, this also leads to packages like is-odd or is-even, which theoretically can be replaced with one line of program code. And each of these packages represents a potential security risk due to its open source character. In the past, problems have occurred exactly at this point. For example, the left-pad package was removed from the npm registry by the maintainer, which caused massive problems for all packages where left-pad was entered as a dependency. Such problems can now be ruled out since the removal of packages has been limited. Another problem with dependencies in a project became obvious by is-promise. A faulty update in the implementation of the ES-Module support led to significant difficulties here. This dependency on external factors made the Hapi developers decide to completely abandon external dependencies. If you take a look into the package-lock.json file of your application after the installation of the framework, you will find that all packages, with one exception, are from the @hapi namespace. The exception is the package mime-db. This package consists basically of a json file with mime information and is therefore rather uncritical.
- Quality: Hapi’s development team sets very high-quality standards for itself, so the various quality metrics are quite impressive. Above all a very high coverage with unit tests and a very strict coding standard stand. Both measures support the goal of reducing errors in the framework to an absolute minimum. Should an error nevertheless occur, the team will do its best to fix it as soon as possible. This strategy is reflected in the open issues on GitHub. There are currently only a handful of open issues, but almost 3000 issues have been resolved.
- Developers first: Hapi is relatively easy to learn. However, this does not mean that the framework has a smaller range of functions compared to the competition, but that the developers have done a good job in designing the features and interfaces. The structures and functions you come into contact with as a developer of an application based on Hapi are clear and consistent. The processes in the framework are comprehensible and do not require special conventions or hidden mechanisms in the background.
- Predictability: Hapi was developed with the requirement to work for large projects and distributed teams. In such an environment it is important that the execution sequence of the individual functions that lie between the incoming request and the outgoing response is clearly traceable and reliable. The middleware concept, as known from Express, has the decisive disadvantage that the functions are executed in the order in which they are registered. In a small application, where all middleware functions can be registered at a central location, this is not yet a problem. However, as the application grows and several teams work on the same code base, it is more common for such extensions to have to be registered in different places. In such a case, troubleshooting can become quite time-consuming. Therefore Hapi offers the possibility to let plug-ins build on each other, so you can define a fixed sequence. You can also define priorities so that the order is still guaranteed and adhered to when making later adjustments.
- Extensibility: For the development of a server-side Web-API Hapi already provides a lot of features. However, you often have to solve special problems that cannot be solved with the standard tools of a framework. For this purpose, Hapi offers numerous points where you can intervene in the processes of the framework and thus extend your application. Probably the most important and most frequently used possibility are the plug-ins from Hapi. These are functions with which you can break down the logic of your application into smaller units. They also allow you to intervene in the different phases of the lifecycle of a request.
- Support: Unlike most other JavaScript libraries and frameworks, Hapi also offers a commercial license. The core of Hapi is, and remains, open source without restrictions. However, with the license, you secure additional support. For example, you can get guaranteed response times in case of a problem or buy support for older versions of the framework. You can find further information on the support page of Hapi [1].
Getting started with Hapi
In contrast to many other projects, Hapi does not provide a command line tool to initialize an application. Both when creating the application and when extending it, you have to take action and create the directories, files and code structures yourself. However, this process can be done in four simple steps:
- Create a directory for the application.
- Create base files: The root directory of an application usually contains only a number of configuration files. The minimum requirement at this point is the package.json file. It contains general information and a list of installed dependencies. Optionally, you can also store the entry file into the application in the root directory. Following the convention, the file is named index.js.
- Install Hapi: The most important dependency of a Hapi application is the framework itself, which you install with the command npm install @hapi/hapi. All packages that come from the Hapi development team are collected in the scope @hapi. The prefix @hapi stands for the quality and security standards of the framework.
- Getting started with the application: The last step into the Hapi application consists of the implementation of the index.js file. In Listing 1 you can see the content of this file.
Listing 1: Introduction to the Hapi application
import Hapi from '@hapi/hapi'; const server = Hapi.server({ port: 3000, host: 'localhost', }); await server.start(); console.log('Server running on %s', server.info.uri);
With the current versions of Node.js you can use some new features of the platform. With the ES modules, you no longer load the dependencies with the require function but with the import keyword. For exporting modules, use the export keyword instead of the module.exports construct. The requirement to use this feature is that you give the application files the extension .mjs instead of the usual .js. Another very convenient feature is Top-Level await. Without this feature, you can only use the await keyword in conjunction with async functions. Currently, this is still an experimental feature that you have to activate manually via the feature flag –harmony-top-level-await. In the near future, however, it will be dropped so that you can use the top-level await feature without any further modifications. Since the start method of the Hapi-Server works with Promises, you can wait for the initialization of the server with await and in case of success, you can display a corresponding message on the console.
The server method of the Hapi package accepts a configuration object that determines the basic behavior of the application. The most important properties you can define at this point are Port and Host. Further options here are for example compression, which allows you to influence the compression, or tls, which allows you to use an encrypted connection.
RESTful interfaces with Hapi
In a modern Node.js application, the focus is less on rendering templates and delivering them and more on providing interfaces. For this purpose, you can implement handler functions for the different HTTP methods and arbitrary URL paths, which generate a corresponding response. As with initialization, Hapi also works with configuration objects when defining routes. For demonstration purposes, we implement a simple user administration. The interface should offer the possibility to read out information about all or single users, create new users, modify existing users, and delete them.
The first interface is the readout of all records. This is a GET request to the path /users. If successful, the server responds with the status code 200, the content type Application/JSON, and an array of user objects. Listing 2 contains the source code for this first route.
Listing 2: Reading all records
import { userModel } from './model.mjs'; export const userController = (server) => { server.route({ method: 'GET', path: '/users', handler: (request, h) => { return userModel.getAll(); }, }); };
Hapi makes hardly any specifications for the structuring of an application. On the one hand, this is a great advantage, as it gives the developers a lot of freedom. On the other hand, it is also a disadvantage for beginners and larger teams, as it requires conventions and discipline. The goal when developing an application is that the source code looks the same across the application so that a developer can find his way around as quickly as possible.
The more extensive an application is, the more you should be careful to structure the source code cleanly and divide it into different modules. The structure in the example is based on MVC architecture. The definition of the routes is done in a file named user-controller.mjs. This file exports a function to which you pass the preconfigured server object. On this server object you call the route method. The most important properties of the configuration object passed to this method are method, which you use to specify the HTTP method to which this route should respond, path, which you use to specify the URL path, and handler, which contains a function that generates the response.
The core of the route is the handler function. It receives as arguments the request object and an instance of the response toolkit, a collection of properties and tools. Following the convention of Hapi, the response toolkit is abbreviated with h, which stands for Hapi. The handling of the data is outsourced to a separate model. In the example, the model ensures that the data can be read asynchronously and abstracts the access to a database. To read the data, the model provides the getAll method, which returns a promise object, which is then resolved into an array of user objects. Hapi takes care of setting the status code and the content type. Hapi also supports asynchronous operations, so you can use the promise object of the model directly as a return value of the handler function. You can test the modifications to the source code directly in the browser. For the remaining routes, however, it is worth using a tool like Postman, which allows you to conveniently formulate requests with other HTTP methods, such as PUT or DELETE.
Access to parameters and body
In the next step we will integrate the possibility to read out a single record and to delete one. In both cases you must be able to specify the ID of the desired record. Usually this is done as a dynamic part of the URL path. For the record with the ID 42 the path is then /users/42. Listing 3 contains the source code for the route that takes care of deleting a record.
Listing 3: Deleting a record.
server.route({ method: 'DELETE', path: '/users/{id}', handler: async (request, h) => { const id = parseInt(request.params.id); await userModel.remove(id); // h.response().status(204); return ''; }, });
Hapi relieves you of as many standard tasks as possible at this point so that you can concentrate on implementing the actual business logic. Mark the id variable in the URL path with curly brackets so that the path is /users/{id}. You can access this variable via the request.params.id property. Within the handler function, you then only need to ensure that the information is extracted from the request and passed to the model, which takes over further processing. When generating the response to the client, the support of async/await is again applied. By using the await keyword Hapi sends the response to the client only after the delete operation has been performed. Since you only have to signal success or failure to the client when deleting a record, it is sufficient in this case to return an empty string. Hapi will then take care of setting the correct status code, in this case 204. Hapi will also deal with the error handling for you. For example, if an error occurs while deleting the record, you can throw it as an exception. If you do not explicitly catch the error with a try-catch block, Hapi generates a response with status code 500 and the message “An internal server error occurred”. The advantage of such a general response is that a potential attacker gets very little information.
The last two operations for the user interface are creating and editing records. In both cases, you not only have to define the combination of URL path and HTTP method to control functionality, but also access the body of the request. Both when creating and editing, the actual payload is transmitted in the body. The problem at this point is that in Node.js the body of a request is implemented as a data stream because a request can consist of several chunks, i.e. several parts, which have to be consumed and put together. Without the support of a framework you have to implement the handling of this data stream yourself. For this purpose, Express has a middleware component called BodyParser, which has to be installed separately. At this point, Hapi goes its own way, one that is very comfortable for developers: You can access the body of the request directly via the payload property of the request object. Listing 4 shows the source code which is responsible for creating a new record.
Listing 4: Creating a new record
server.route({ method: 'POST', path: '/users', handler: async (request, h) => { const body = request.payload; const newUser = await userModel.create(body); return h.response(newUser).type('application/json').code(201); }, });
In this example the userModel also takes care of data management and writes the data into the database. This operation is asynchronous like the delete operation and relies on the await keyword. The object that the model returns contains something very important for the client: information about the newly created record. This information is usually generated by the database and returned when a new record is created. The userModel enriches the record submitted by the Client with the ID and returns it. The example also shows how you can use the response toolkit to influence the response from the server to the client. You pass the body of the response to the response method. The code method allows you to set a status code and with the type method you can set the response type. If necessary, the header method allows you to set individual fields in the response header.
Delivering static contents
In addition to the implementation of RESTful interfaces, Hapi is also able to slip into the role of a normal web server and deliver static content such as HTML, CSS, client-side JavaScript or media files such as images or videos. Even if there are solutions, for example with nginx or Apache, which solve this task significantly better and with better performance, the requirement to deliver static content can always arise. However, this happens most often in small installations with few parallel requests and a small amount of static content.
The functionality to deliver static content is not a direct part of Hapi itself, but has to be installed as an inert plugin. You can achieve this with the command npm install @hapi/inert. Afterwards you have to specify the path where the content can be found when configuring your server and register the inert plugin. The corresponding source code can be found in Listing 5.
Listing 5: Configuration of the inert plugin
... const server = Hapi.server({ port: 3000, host: 'localhost', routes: { files: { relativeTo: join(resolve(), 'public'), }, }, }); await server.register(inert); ...
With this configuration you can now decide whether you want to deliver single files from the public directory or make the whole directory available. The easiest solution in this case is to define a handler object instead of a handler function. Here you can use the file property to select a single file or the directory property to deliver an entire directory. Listing 6 summarizes both possibilities.
Listing 6: Delivering static content
server.route({ method: 'GET', path: '/{param*}', handler: { directory: { path: '.', }, }, }); server.route({ method: 'GET', path: '/logo.jpg', handler: { file: 'logo.jpg', }, });
Logging
As we have already seen when deleting records, Hapi includes a mechanism to handle errors and send a corresponding response to the client. However, this response hardly contains any useful hints to deduce the error. Looking on the console for a corresponding output will also be in vain. In an application, however, there are numerous situations in which you want to log information. This concerns errors as well as various other status messages. Hapi offers two different log interfaces to record such messages. You can use either the log method of the server object or the log method of the request object. The difference between both is that the log method of the request object contains additional information about the current request. Neither variant produces direct output on the console, but both trigger events to which you can subscribe. The events property of the server object is an EventEmitter. This means that you can register event handlers using the on method. The event types that matter here are named log for server.log and request for request.log.
The lighter type of logging is done via the server object. Here you have access to the logged event and the assigned tags. The event object contains the timestamp of the event, the tags as a string array, the data passed during the log call and the channel of the event. For the tag object, the tags, in this case the log levels, are the keys. The active log level has the value true.
With a request log, this looks a little different. Here the complete request is logged, so that although a lot of information is available, the log entries can also become confusing very quickly. The second argument of the handler function of the request log event consists of an object, which is similar to the event object of the server log. Additionally, you can access a unique identifier for the request here, so that you can assign the log entries to specific requests. In connection with request logs Hapi also offers the possibility to collect all log entries belonging to a specific request. You can activate this feature via the collect property in the server’s log configuration. In this case the logs are collected in the form of an array per request.
As mentioned before, the request logs can quickly become confusing. On the other hand, the objects offer valuable information that you do not want to do without when troubleshooting. For this reason you do not usually log directly to the console. The advantage is that there are numerous plugins for Hapi that take care of this exact problem. One solution is the hapi-pino plugin, which uses the Pino Logger to handle Hapi’s log events. A list of other plugins for handling log events can be found in [2]. The advantage of such a plugin is that you can configure your logger with very little effort and make sure that the information gets to a central logging server, which allows you to analyze the logs in a detailed and comfortable way. This means you can find errors and performance problems much faster than if you have to parse a confusing log file or console output.
Conclusion
Numerous web application frameworks exist for Node.js. By far the most popular is Express. However, as a typical open source solution in the Node.js ecosystem, this framework also has some disadvantages. With Hapi, Walmart has created a competitor for Express that has already proven itself for several years and has a clear orientation towards large and distributed web applications based on Node.js. One of the most remarkable differences between Hapi and almost all Node.js libraries and frameworks is that Hapi has no external dependencies. Thus, the development team guarantees a consistently high quality standard and reduces the uncertainties that arise from the use of open source software.
Hapi works similarly to Express in many areas, but modifies the concepts so that the source code of an application is as simple, readable and stable as possible.
The architecture of Hapi consists of several layers. In essence, the framework provides everything you need to implement a simple server-side web application. Based on this, there is a plugin interface that allows you to integrate ready-made extensions. These are provided by the Hapi team as well as the community. In general, Hapi’s own extensions can be recognized by the @hapi prefix. After all, you yourself can intervene in the processes of an application at numerous points. The most obvious places are the handler functions of the routes. However, Hapi also provides hooks for the request lifecycle, and last but not least, you can write reusable plugins for your application yourself.
The strategy of the development team and the features the framework offers make Hapi a reliable and scalable solution for Node.js backends.