Effective Hapi: Lessons learned leveraging the web-server framework
I keep coming back to Hapi for server-side javascript projects, and every time I learn a little more and I discover how the framework has progressed. I recently built a project called Cellar Door, and I want to share some lessons I learned from that work.
First insight, let the tests start the server #
Up until now I have monkeyed around with running the web server in one terminal session, combined with automatically running tests in another terminal session. It has worked fine for all my projects, but it introduces complexity and bugs in all sorts of subtle ways. So I wondered, “maybe it could be improved?” To which, I had an un-ironic epiphany.
Read on and I’ll explain below.
server.js
'use strict'
require('dotenv').config()
const Hapi = require('hapi')
const init = async () => {
const server = Hapi.server({
port: process.env.PORT || 3000,
host: 'localhost'
})
// ... configuration of plugins, logging and view templates ...
server.route({
method: 'GET',
path: '/',
handler: (request, h) => 'Hello world'
})
await server.start()
console.log(`Server running at: ${server.info.uri}`)
return server
}
// exit immediately on unhandled promises
process.on('unhandledRejection', err => {
console.log(err)
process.exit(1)
})
// only start server if file is called directly
if (require.main === module) {
init()
}
// export init function so that it can be reached by tests.
module.exports = init
server.test.js
import test from 'ava'
import initServer from './server'
import rp from 'request-promise-native'
let server = null
let testUrl = null
test.before(async t => {
process.env.PORT = 0
server = await initServer()
testUrl = `http://localhost:${server.info.port}`
})
test.after(async t => {
await server.stop()
})
test('visiting index unauthenticated redirects to /login', async t => {
const response = await rp({
method: 'GET',
uri: `${testUrl}/`,
simple: false,
resolveWithFullResponse: true
})
t.is(
response.toJSON().request.uri.pathname,
'/login',
'Expected to be redirected to /login'
)
})
I changed the server.js
file to only start the server if run directly like node server.js
. This made it possible for me to require the server initialisation function into several test files.
Notice that the server.js
file begins with a call to dotenv, this picks up server configuration from a configuration file. But! The server has not been started yet.
Before the tests are run I set process.env.PORT=0
, which makes Hapi use any random port. Right after starting the server we ask Hapi for the port it got and set the testUrl
. This works because the tests and the server is run within the same process.
At the time of writing I have multiple test files that start and stop their own server instances before running integration tests. All tests finishes under a second total.
Leverage framework functionality, before pulling in packages #
If you’ve worked with Node.js for a while, you probably have a lot of NPM packages that you like to pull into any project. But hold your horses a little bit and take a look at Hapi’s API and the tutorial pages. Chances are the framework authors have built battle-tested functionality to meet the needs of most projects.
Don’t aim for zero-dependencies though, pull in as many packages as you see fit.
Let’s look at some ways to leverage the framework.
/**
* Hapi has great facilities for setting up multiple data storages
* backed by in-memory implementations or more full-fledged databases.
*/
server.app.tokenStore = server.cache({
segment: 'tokens',
expiresIn: 1000 * 60 * 60 * 3 // three hours
})
/**
* Encrypted cookies? No problem.
*/
const server = Hapi.server({
port: process.env.PORT || 3000,
host: 'localhost',
state: {
ttl: 1000 * 60 * 60 * 12, // twelve hours
strictHeader: true,
ignoreErrors: true,
// cookies should only be set on https connections, except in testing.
isSecure: process.env.NODE_ENV !== 'test',
isHttpOnly: true,
isSameSite: 'Strict',
encoding: 'iron',
password: process.env.IRON_SECRET
}
})
// Hapi has many events that you can use to great advantage.
// Here we decorate all responses with additional security headers.
// This hardens usage of javascript and iFrames.
server.ext('onPreResponse', (request, h) => {
// exception responses returned by Boom can't be manipulated, exit early.
if (!request.response.header) {
return h.continue
}
// Disallow site being showed in an iFrame.
request.response.header('X-FRAME-OPTIONS', 'deny')
if (process.env.NODE_ENV !== 'test') {
// CSP breaks browser-sync, so we ignore it for development
// Disallow inline javascript, and JS served from other sites.
request.response.header('Content-Security-Policy', "default-src 'self'")
}
return h.continue
})
Push logic down into route handlers #
When you go about adding routes it can be hard to strike the right balance between what to keep in your main server.js
file and what you “push down” into separate route handler files. Gathering code too much or splitting code too much can both hurt readability and maintainability.
Simple routes can live in server.js
until they grow more complex, upon which you extract them into separate files. Don’t take it too far though and create one file per handler function, it’s okay to group handlers within the same file if it reads well.
server.js
// Sample route
server.route({
method: 'POST',
path: '/',
options: { auth: false },
// Here we require a route handler exactly where it's used.
// This is advantageous because we don't have to travel up and
// down the server.js file to understand which import is used
// where.
handler: require('./src/authorization.handler')
.exchangeAuthorizationCodeForToken
})
authorization.handler.js
// It's great to add more specialised packages in route handlers
// to avoid cluttering the growing server.js file.
const Boom = require('boom')
const uuidv4 = require('uuid/v4')
// I try hard to verbalise the purpose of my functions.
// life's too short for misunderstandings.
const authorizeCreationOfAuthorizationCode = async (request, h) => {
if (!request.auth.isAuthenticated) {
return Boom.unauthorized()
}
const { state, redirect_uri, client_id, me, scope } = request.payload
const code = uuidv4()
// Notice that we can reach the server instance with request.server
// Furthermore, you can attach objects to server.app to make them
// available in all your routes.
//
// If you remember the earlier example we called server.cache() to
// create a in-memory store before attaching it to server.app.
// Shazam, we have it available in a route without requiring a module.
await request.server.app.tokenStore.set(code, {
redirect_uri,
client_id,
me,
scope
})
return h.redirect(
// In addition to server.app you can also use server.methods to
// make commonly used functions available throughout your app. As
// an added bonus it comes with caching built-in.
request.server.methods.createStatefulUrl({
url: redirect_uri,
state: { code, state }
})
)
}
module.exports = {
authorizeCreationOfAuthorizationCode
// ... other related handlers ...
}
So, in conclusion #
Have a look at Cellar Door and judge for yourself if my advice made my code readable. :)