Tuning in to my preferences in a hapi.js server


I've used hapi.js at work as well as for personal projects, but I've always either consumed it from someone elses design or stood it up from a template, again from someone elses design. I wanted to force myself to dig in and better understand the design of Hapi itself. I find that this can be beneficial for a lot of reasons, but the big one for me is high-level question-answering. "Why would I choose Hapi over another server?". Until I get my hands dirty with it, it's sorta hard to not just take someone else's opinion and run with it.

I'm going to use it as the application server for my minimal-javascript project to kick the tires a bit here. So I guess that's worth pointing out. I'm using it as an application server, not just a data server. So this may be different investigation style than you otherwise might see. I won't currently be hooking up to a DB, etc.

Getting Started

So.. not a good first experience. I have a pretty hard time navigating the docs. First example I ran across -- I want to generate an html response for a hello-world route. In the docs I found that each route can be given a handler that takes the following shape (by looking at an example):

function (request, h) {}

The docs links to a route.options.handler that is mentioned as another way to add the handler, which was confusing to start as well. Neither the primary route.handler docs or the route.options.handler method actually say what the shape means or what the expected payloads are, so that took a little digging. Instead, and by chance, I came across the description of lifecycle methods, which describes that each of the lifecycle methods take this same shape (i.e., request, and h args) where it then says what each of these args mean. Once I had the route setup, I attempted to create a custom response and set the content-type and again ran into issues understanding the docs and how to create this custom response. Do I create a response and modify that, or can I use method chainging and return that? Anyway, enough complaining. In reality I really like Hapi and the community around it, but these things are still worth mentioning. Long story short, I had a rough first go at navigating the docs.

Live Server and First Route

import Hapi from '@hapi/hapi';

const init = async () => {
  const server = Hapi.server({
    port: 8080,
    host: 'localhost'
  });

  server.route({
    method: 'GET',
    path: '/',
    handler: (request, h) => {
      return h.response('<html><body>Hello world!</body></html>')
        .header('Content-Type', 'text/html')
    }
  });

  await server.start();
  console.log('Server running on %s', server.info.uri);
};

process.on('unhandledRejection', (err) => {
  console.log(err);
  process.exit(1);
});

init();

I used port 8080 because I deploy this with app-engine and 8080 is the default exposed port. I'm not sure I fully understand host at this point. The docs say it is the public hostname or ip address, but also say localhost is the safest option unless running in Docker where networking to the host could be problematic. So I'm rolling with that until I run into issues when I deploy.

Serving up an html file instead of a string

The first thing I tried was to read an html file off disk and just return the contents. When I reloaded the page I got a 500 response. Usually when I get this, I can hop over to the running server logs and know immediately what is likely wrong. In this case, there were zero logs output from the server. The rabbit whole goes further. Time to dig into why I'm not seeing any logs.

Adding some logs

At first I tried using:

server.events.on('log', (event, tags) => {
  console.log(event);
});

but that didn't result in any logs being shown. What ended up doing the trick was adding server.option.debug to the server initialization.

const server = Hapi.server({
  port: 8080,
  host: 'localhost',
  debug: {log: "*", request: "*"}
});

There are better ways to handle server logs, but this should at least get it up and printing catastrophic failures with stacktraces.

Now that I have logs, I was able to display a basic html file by loading it directly from the file system with Node.

server.route({
  method: 'GET',
  path: '/',
  handler: (request, h) => {
    return h.response(readFileSync('website/index.html'))
      .header('Content-Type', 'text/html')
  }
});

Having to restart the server for each change

As soon as I had the server running and was starting to make regular route handler changes, I wanted something to bounce the server for me instead of having to kill it with Ctrl+c then npm start again. nodemon is good for this, but I've never set it up myself so down another rabbit hole I go. Or, actually no this is really straightforward. It appears the defaults will look for .js files recursively in the directory so all I modified with my current setup was the npm start script to run nodemon instead of node. This certainly won't work for everything, but works for me right now so I'm moving on.

Routing iteration 1

It sure seems like I'm going to have some pain in trying to keep routes up to date and matched with html files since I have them hard-coded. The fact that I had html files directly linked to also created some awkwardness since I'd have to create routes that hard-code the .html with the path. I believe there are plugins like vision that will do some of this for you, but I want to force myself to think about it myself before going and taking a look at how standard plugins pull it off.

It's not perfect, but here's a pretty brute-force way to handle routing for more html pages:

server.route({
  method: 'GET',
  path: '/',
  handler: (request, h) => {
    return h.response(readFileSync(`website/index.html`))
      .header('Content-Type', 'text/html')
  }
});

server.route({
  method: 'GET',
  path: '/{page}',
  handler: (request, h) => {
    return h.response(readFileSync(`website/${request.params.page}.html`))
      .header('Content-Type', 'text/html')
  }
});

In thinking through how I would handle subdirectories I created a test file inside the file system in a directory. Of course given path: '/{page}' as above, adding a path won't match. In doing some digging, however, I learned something kinda cool about hapi routing. You can do something like: path: '/{page*}' to match all params following or path: '/{page*2}' to match only two segments following, so something like /page/blah/hello. By defining path: '/{page*}' arbitrarily we should be able to access any level of depth.

Securing and limiting at least a bit. (wip)

  • remove non-html requests
  • don't allow pathing for relative dirs to climb, so something like ../../