okanjo-app-server

Server framework using HAPI and friends

Usage no npm install needed!

<script type="module">
  import okanjoAppServer from 'https://cdn.skypack.dev/okanjo-app-server';
</script>

README

Okanjo App Server

Build Status Coverage Status

Configurable web and API server powered by HAPI for the Okanjo App ecosystem.

This package bundles all the common things needed to build a web or API server, such as:

  • Run a HTTP/API server (via hapi)
  • Provides a consistent way for apps to define routes
  • Serve static assets (via inert)
  • Render template views (via vision and nunjucks)
  • Handle JSONP requests and error responses consistently
  • Report bad request responses for dev/production debugging
  • Run a WebSocket server (via socket.io)
  • Being totally configurable.

Setup is done mostly through configuration. Using all of these modules together requires a fair amount of boilerplate. This module attempts to eliminate most of the boilerplate setup with a reusable, configurable module, so your app can development time can focus on building the app, not boilerplate.

You should have a basic understanding of how HAPI works, otherwise this module won't make a ton of sense to you.

Installing

Add to your project like so:

npm install okanjo-app-server

Note: requires the okanjo-app module.

Note: v2 and on uses Hapi v18+. Use v1 for Hapi 16

Example Usage

Here's a super basic implementation.

Your directory structure might look like this:

  • example-app/
    • routes/ – place to put your route files
      • example-routes.js – example route file, seen below
    • static/ – place to put your static assets like css, images, js, etc
    • view-extensions/ – place to stick nunjucks extensions
      • example-ext.js – example extension file, seen below
    • views/ – place to put your view templates
      • example.j2 – example template, seen below
    • config.js – okanjo-app config
    • index.js – app entrypoint

You can find these example files here: docs/example-app

config.js:

"use strict";
const Path = require('path');

module.exports = {
    webServer: {

        // Hapi server / global settings
        hapiServerOptions: {
            // Listening port
            port: 3000, // Port to listen on, default: null (os assigned)
        }, // HAPI server settings, see: // https://hapijs.com/api#server()
                
        // Graceful shutdown handling
        drainTime: 5000, // how long to wait to drain connections before killing the socket, in milliseconds, default: 5000
        
        // Route configuration
        routePath: Path.join(__dirname, 'routes'), // where to find route files, default: undefined

        // Socket.io configuration
        webSocketEnabled: true, // Whether to enable socket.io server, default: false
        webSocketConfig: undefined, // socket.io server options, see: https://socket.io/docs/server-api/#new-server-httpserver-options (default: undefined)

        // View handler configuration
        viewHandlerEnabled: true, // Whether to enable template rendering, default: false
        viewPath: Path.join(__dirname, 'views'), // The directory where view files are based from, required if viewHandlerEnabled is enabled. 
        cacheTemplates: false, // Whether to let hapi-vision cache templates for better performance, default: false
        nunjucksEnvOptions: undefined, // http://mozilla.github.io/nunjucks/api.html#configure  - e.g. { noCache: true }
        nunjucksExtensionsPath: Path.join(__dirname, 'view-extensions'), // The directory where extension modules live, sig: function(env) { /* this = webServer */ }

        // Static file handler configuration
        staticHandlerEnabled: true, // Whether to enable static asset serving, default: false
        staticPaths: [ // Array of path to route definitions for arbitrary paths, default: []
            { path: Path.join(__dirname, 'static'), routePrefix: '/' },  // exports the static/ directory under /
            { path: Path.join(__dirname, 'dist'), routePrefix: '/dist' } // exports the dist/ directory under /dist
        ],
        staticListingEnabled: false, // Whether to allow directory listings, default: false
        staticNpmModules: [ // Array of module names and paths to expose as static paths, useful for exposing dependencies on the frontend w/o build tools, default: []
            { moduleName: 'async', path: 'dist' } // e.g. node_modules/async/dist/async.min.js -> /vendor/async/async.min.js
        ]
    }
};

This config.js includes all available options. You may exclude or comment-out the ones that do not apply to your application.

index.js:

"use strict";
const OkanjoApp = require('okanjo-app');
const OkanjoServer = require('okanjo-app-server');

// Configure the app
const config = require('./config.js');
const app = new OkanjoApp(config);

// Configure the server
const server = new OkanjoServer(app, app.config.webServer);

// Start it up
(async () => {
    await server.init(); // optional, if you wish to do your own setup before starting HAPI
    await server.start();
})()
    .then(() => {
        console.log('Server started at:', server.hapi.info.uri);
        console.log('Use Control-C to quit')
    })
    .catch((err) => {
        console.error('Something went horribly wrong', err);
        process.exit(1);
    })
;

You can make this much more elaborate by starting the server in a worker using okanjo-app-broker so you can hot-reload the entire server on changes, etc.

routes/example-routes.js

A route file needs to export a function. The context of the function (this) will be the OkanjoServer instance.

Route files are loaded synchronously, so no async operations should be performed.

"use strict";

/**
 * @this OkanjoServer
 */
module.exports = function() {

    // This route replies with a rendered view using the example.j2 template and given context
    this.hapi.route({
        method: 'GET',
        path: '/',
        handler: (request, h) => {
            return h.view('example.j2', {
                boom: "roasted"
            });
        },
        config: {
            // ... validation, authentication. tagging, etc
        }
    });

    // This route replies with an api response
    this.hapi.route({
        method: 'GET',
        path: '/api/sometimes/works',
        handler: async (request, h) => {
            const res = await pretendServiceFunction();     // Fire off a pretend service function
            return this.app.response.ok(res);                    // Return the response
        },
        config: {
            // ... validation, authentication. tagging, etc
        }
    });

    /**
     * Pretend service function that returns a payload or throws an error
     */
    const pretendServiceFunction = async () => {
        if (Math.random() >= 0.50) { // half the time, return an error
            throw this.app.response.badRequest('Nope, not ready yet.');
        } else {
            return { all: 'good' };
        }
    };

};

view-extensions/example-ext.js

A Nunjucks extension file needs to export a function. The context of the function (this) will be the OkanjoServer instance.

Nunjucks extension files are loaded synchronously, so no async operations should be performed.

"use strict";

/**
 * @this OkanjoServer
 * @param env – Nunjucks environment
 */
module.exports = function(env) {
    
    // Remember, this.app is available here :)

    // You could add globals to Nunjucks
    env.addGlobal('env', this.app.currentEnvironment); 
    env.addGlobal('pid', process.pid);
    
    // You could add custom filters to Nunjucks
    env.addFilter('doSomething', (str, count) => {
        // return some string
        return "yay fun " + str + " " + count;
    });
    
};

views/example.j2

Views are standard Nunjucks templates. For example:

<html>
<head>
    <link rel="stylesheet" href="/css/example.css" />
</head>
<body>
<ul>
    <li>Boom: {{boom}}</li><!-- Set by routes/example-routes.js's GET / route -->
    <li>ENV: {{env}}</li><!-- Set by view-extensions/example-ext.js -->
    <li>PID: {{pid}}</li><!-- Set by view-extensions/example-ext.js -->
    <li>doSomething: {{ boom|doSomething(1) }}</li><!-- Custom filter defined by view-extensions/example-ext.js -->
</ul>
</body>
</html>

The template, when rendered via http://localhost:3000/ shows:

<html>
<head>
    <link rel="stylesheet" href="/css/example.css" />
</head>
<body>
<ul>
    <li>Boom: roasted</li><!-- Set by routes/example-routes.js's GET / route -->
    <li>ENV: default</li><!-- Set by view-extensions/example-ext.js -->
    <li>PID: 2875</li><!-- Set by view-extensions/example-ext.js -->
    <li>doSomething: yay fun roasted 1</li><!-- Custom filter defined by view-extensions/example-ext.js -->
</ul>
</body>
</html>

You can create sub-directories and organize your views however you'd like. Utilize Nunjucks' extends and include operators as you wish. Remember, paths are relative to the configured by viewPath.

OkanjoServer

Server class. Must be instantiated to be used.

Statics

  • OkanjoServer.extensions.jsonpResponseCodeFix – Extension that replaces non 200-level responses with 200 so non-ok level responses can execute on the browser
  • OkanjoServer.extensions.responseErrorReporter – Extension that reports 500-level responses via app.report, useful for production monitoring

Properties

  • server.app – (read-only) The OkanjoApp instance provided when constructed
  • server.config – (read-only) The configuration provided when constructed
  • server.options – (read-only) The options provided when constructed
  • server.hapi – (read-only) The HAPI instance created when initialized.
  • server.io – (read-only) The socket.io instance created when initialized.

Methods

new OkanjoServer(app, [config, [options]], [callback])

Creates a new server instance.

  • app – The OkanjoApp instance to bind to
  • config – (optional, object) The OkanjoServer configuration, see config.js
  • options – (optional, object) Server options object
    • options.extensions – Array of functions to call when initializing. Useful for initializing async hapi plugins or custom configurations.

For example:

new OkanjoServer(app, config, {
    extensions: [
        
        // Use the built-in extensions
        OkanjoServer.extensions.jsonpResponseCodeFix,   // replaces non 200-level responses with 200 so non-ok level responses can execute on the browser
        OkanjoServer.extensions.responseErrorReporter,  // reports 
        
        // Register a hapi extension, for example, query string parsing (like the old days)
        async function giveMeQueryStringsBack() {
            await this.hapi.register({
                plugin: require('hapi-qs'),
                options: {}
            });
        },
        
        // Register authentication strategies, etc
        async function registerAuthenticationStrategies() {

            // plugin to use HTTP basic auth username as an api key
            await this.hapi.register({
                plugin: require('hapi-auth-basic-key'),
                options: {}
            });
                
            // Register the strategy
            this.hapi.auth.strategy('key-only', 'basic', {
                validateFunc: (req, key, secret, authCallback) => {
                    // FIXME - put your real key authentication here (e.g. db or redis lookup)
                    let valid = key === 'my-secret-key';
                    let err = null;
                    
                    // Pass back validity and credentials if valid
                    authCallback(err, valid, { key });
                }
            });
        }
        
    ]
}, (err) => {
    // server is configured, ready to start
});

await server.init()

Configures the underlying services, such as HAPI, Socket.io, etc. Called automatically by server.start, if not done manually. Before v2, this was done in the constructor.

  • callback(err) – Function to fire once the server has started. If err is present, something went wrong.

await server.start()

Starts the server instance.

  • callback(err) – Function to fire once the server has started. If err is present, something went wrong.

await server.stop()

Attempts to gracefully shutdown the server instance. If config.drainTime elapses, the socket will be forcibly killed.

  • callback(err) – Function to fire once the server has stopped. If err is present, something went wrong.

Events

This class fires no events.

Extending and Contributing

Our goal is quality-driven development. Please ensure that 100% of the code is covered with testing.

Before contributing pull requests, please ensure that changes are covered with unit tests, and that all are passing.

Testing

To run unit tests and code coverage:

npm run report

This will perform:

  • Unit tests
  • Code coverage report
  • Code linting

Sometimes, that's overkill to quickly test a quick change. To run just the unit tests:

npm test

or if you have mocha installed globally, you may run mocha test instead.