node package manager
Easy collaboration. Discover, share, and reuse code in your team. Create a free org »

knifecycle

knifecycle

Manage your NodeJS processes's lifecycle.

NPM version Build status Dependency Status devDependency Status Coverage Status Code Climate Dependency Status

Browser Support Matrix

Most (maybe all) applications rely on two kinds of dependencies.

The code dependencies are fully covered by require/system modules in a testable manner (with mockery or System directly). There is no need for another dependency management system if those libraries are pure functions (involve no global states at all).

Unfortunately, applications often rely on global states where the JavaScript module system shows its limits. This is where knifecycle enters the game.

It is largely inspired by the Angular service system except it should not provide code but access to global states (time, filesystem, db). It also have an important additional feature to shutdown processes which is really useful for back-end servers and doesn't exists in Angular.

You may want to look at the architecture notes to better handle the reasonning behind knifecycle and its implementation.

At this point you may think that a DI system is useless. My advice is that it depends. But at least, you should not make a definitive choice and allow both approaches. See this Stack Overflow anser for more context about this statement.

Features

  • services management: start services taking their dependencies in count and shut them down the same way for graceful exits (namely dependency injection with inverted control);
  • singleton: maintain singleton services across several running execution silos.
  • easy end to end testing: just replace your services per your own mocks and stubs while ensuring your application integrity between testing and production;
  • isolation: isolate processing in a clean manner, per concerns;
  • functional programming ready: encapsulate global states allowing the rest of your application to be purely functional;
  • no circular dependencies for services: while circular dependencies are not a problem within purely functional libraries (require allows it), it may be harmful for your services, knifecycle impeach that while providing an $injector service à la Angular to allow accessing existing services references if you really need to;
  • generate Mermaid graphs of the dependency tree;
  • build raw initialization modules to avoid embedding Knifecycle in your builds.

Usage

Using knifecycle is all about declaring the services our application needs and running your application over it.

Let's say we are building a web service. First, we need to handle a configuration file so we are creating an initializer to instanciate our CONFIG service:

// services/config.js
import fs from 'fs';
import { initializer } from 'knifecycle';
 
// We are using the `initializer` decorator to
// declare our service initializer specificities
// Note that the initializer` decorator is pure
// so it just adds static informations and do not
// register the initializer to the provider yet.
export const initConfig = initializer({
  // we have to give our final service a name
  // for further use in other services injections
  name: 'CONFIG',
  // we will need an `ENV` variable in the initializer
  // so adding it in the injected dependencies.
  inject: ['ENV'],
  // our initializer is simple so we use the `service`
  // type for the initializer which just indicate that
  // the initializer will return a promise of the actual
  // service
  type: 'service',
  // We don't want to read the config file everytime we
  // inject it so declaring it as a singleton
  options: { singleton: true },
// Here is the actual initializer implementation, you
// can notice that it expect the `ENV` dependency to
// be set as a property of an object in first argument.
}, ({ ENV }) => {
  return new Promise((resolve, reject) {
    fs.readFile(ENV.CONFIG_PATH, function(err, data) {
      if(err) {
        return reject(err);
      }
      try {
        resolve(JSON.parse(data));
      } catch (err) {
        reject(err);
      }
  }, 'utf-8');
});

Our service also uses a database so let's write an initializer for it:

// services/db.js
import { initializer } from 'knifecycle';
 
const initDB = initializer({
 name: 'db',
 // Here we are injecting the previous `CONFIG` service
 // plus an optional one. If it does not exist then it
 // will silently fail and the service will be undefined.
 inject: ['CONFIG', '?log'],
 // The initializer type is slightly different. Indeed,
 // we need to manage the database connection errors
 // and wait for it to flush before shutting down the
 // process.
 // A service provider returns a promise of a provider
 // descriptor exposing:
 // - a mandatory `service` property containing the
 // actual service;
 // - an optional `dispose` function allowing to
 // gracefully close the service;
 // - an optional `fatalErrorPromise` property to
 // handle the service unrecoverable failure.
 type: 'provider',,
 options: { singleton: true },
}, ({ CONFIG, log }) {
  return MongoClient.connect(CONFIG.DB_URI)
  .then(function(db) {
    let fatalErrorPromise = new Promise((resolve, reject) {
      db.once('error', reject);
    });
 
    // Logging only if the `log` service is defined
    log && log('info', 'db service initialized!');
 
    return {
      service: db,
      dispose: db.close.bind(db, true),
      fatalErrorPromise,
    };
  });
}

We need a last initializer for the HTTP server itself:

// services/server.js
import { initializer } from 'knifecycle';
import express from 'express';
 
const initDB = initializer({
  name: 'server',
  inject: ['ENV', 'CONFIG', '?log'],
  options: { singleton: true },
}, ({ ENV, CONFIG, log }) => {
  const app = express();
 
  return new Promise((resolve, reject) => {
    const port = ENV.PORT || CONFIG.PORT;
    const server = app.listen(port, () => {
      log && log('info', `server listening on port ${port}!`);
      resolve(server);
    });
  }).then(function(server) {
    let fatalErrorPromise = new Promise((resolve, reject) {
      app.once('error', reject);
      server.once('error', reject);
    });
 
    function dispose() {
      return new Promise((resolve, reject) => {
        server.close((err) => {
          if(err) {
            reject(err);
            return;
          }
          resolve();
        })
      });
    }
 
    return {
      service: app,
      dispose,
      fatalErrorPromise,
    };
  });
});

Great! We are ready to make it work altogether:

import { getInstance } from 'knifecycle';
import initConfig from 'services/config';
import initDB from 'services/db';
import initServer from 'services/server';
 
// We need only one Knifecycle instance so using
// a the singleton API
getInstance()
// Registering our initializers
.register(initConfig)
.register(initServer)
.register(initDB)
// Let's say we need to have another `db`
// service pointing to another db server.
.register(
  // First we remap the injected dependencies. It will
  // take the `DB2_CONFIG` service and inject it as
  // `CONFIG`
  inject(['DB2_CONFIG>CONFIG', '?log'],
    // Then we override its name
    name('db2', initDB)
  )
)
// Finally, we have to create the `DB2_CONFIG` service
// on which the `db2` service now depends on
.register(name('DB2_CONFIG', inject(['CONFIG'], ({ CONFIG }) => {
  // Let's just pick up the `db2` uri in the `CONFIG`
  // service
  return Promise.resolve({
    DB_URI: CONFIG.DB2_URI,
  });
})))
// Add the process environment as a simple constant
.constant('ENV', process.env)
// Add a function providing the current timestamp
.constant('now', Date.now.bind(Date))
// Add a delay function
.constant('delay', Promise.delay.bind(Promise))
// Add process lifetime utils
.constant('waitSignal', function waitSignal(signal) {
  return new Promise((resolve, reject) => {
    process.once(signal, resolve.bind(null, signal));
  });
})
.constant('exit', process.exit.bind(exit))
// Setting a route to serve the current timestamp.
.register(name('timeRoute',
  inject(
    ['server', 'now', '?log'],
    ({ server: app, now, log }) {
      return Promise.resolve()
      .then(() => {
        app.get('/time', (req, res, next) => {
          const curTime = now();
 
          log && log('info', 'Sending the current time:', curTime);
          res.status(200).send(curTime);
        });
      });
    }
  )
))
 
// At this point, nothing is running. To instanciate
// services, we have to create an execution silo using
// them. Note that we required the `$destroy` service
// implicitly created by `knifecycle`
.run(['server', 'timeRoute', 'waitSignal', 'exit', '$destroy'])
// Note that despite we injected them, we do not take
// back the `server` and `timeRoute` services. We only
// need them to get up and running but do not need to
// operate on them
.then(({ waitSignal, exit, $destroy }) {
  // We want to exit gracefully when a SIG_TERM/INT
  // signal is received
  Promise.any([
    waitSignal('SIGINT'),
    waitSignal('SIGTERM'),
  ])
  // The `$destroy` service will disable all silos
  // progressively and then the services they rely
  // on to finally resolve the returned promise
  // once done
  .then($destroy)
  .then(() => {
    // graceful shutdown was successful let's exit
    // in peace
    exit(0);
  })
  .catch((err) => {
    console.error('Could not exit gracefully:', err);
    exit(1);
  });
 
})
.catch((err) => {
  console.error('Could not launch the app:', err);
  process.exit(1);
});

Debugging

Simply use the DEBUG environment variable by setting it to 'knifecycle':

DEBUG=knifecycle npm t

Plans

The scope of this library won't change. However the plan is:

  • improve performances;
  • evolve with Node: I may not need to transpile this library at some point.
  • track bugs ;).

I'll also share most of my own initializers and their stubs/mocks in order to let you reuse it through your projects easily.

API

Classes

Knifecycle

Functions

buildInitializer(constants, loader, dependencies)Promise.<String>

Create a JavaScript module that initialize a set of dependencies with hardcoded import/awaits.

reuseSpecialProps(from, to, [amend])function

Apply special props to the given function from another one

wrapInitializer(wrapper, baseInitializer)function

Allows to wrap an initializer to add extra

inject(dependenciesDeclarations, initializer, [merge])function

Decorator creating a new initializer with some dependencies declarations appended to it.

extra(extraInformations, initializer, [merge])function

Decorator creating a new initializer with some extra informations appended to it. It is just a way for user to store some additional informations but has no interaction with the Knifecycle internals.

options(options, initializer, [merge])function

Decorator to amend an initializer options.

name(name, initializer)function

Decorator to set an initializer name.

type(type, initializer)function

Decorator to set an initializer type.

initializer(properties, initializer)function

Decorator to set an initializer properties.

handler(handlerFunction, [dependencies])function

Shortcut to create an initializer with a simple handler

parseDependencyDeclaration(dependencyDeclaration)Object

Explode a dependency declaration an returns its parts.

Knifecycle

Kind: global class

new Knifecycle()

Create a new Knifecycle instance

Returns: Knifecycle - The Knifecycle instance
Example

import Knifecycle from 'knifecycle'
 
const $ = new Knifecycle();

knifecycle.constant(constantName, constantValue) ⇒ Knifecycle

Register a constant service

Kind: instance method of Knifecycle
Returns: Knifecycle - The Knifecycle instance (for chaining)

Param Type Description
constantName String The name of the service
constantValue any The constant value

Example

import Knifecycle from 'knifecycle'
 
const $ = new Knifecycle();
 
// Expose the process env
$.constant('ENV', process.env);
// Expose a time() function
$.constant('time', Date.now.bind(Date));

knifecycle.service(serviceName, initializer, options) ⇒ Knifecycle

Register a service initializer

Kind: instance method of Knifecycle
Returns: Knifecycle - The Knifecycle instance (for chaining)

Param Type Description
serviceName String Service name
initializer function An initializer returning the service promise
options Object Options attached to the initializer

Example

import Knifecycle from 'knifecycle'
import fs from 'fs';
 
const $ = new Knifecycle();
 
$.service('config', configServiceInitializer, {
  singleton: true,
});
 
function configServiceInitializer({ CONFIG_PATH }) {
  return new Promise((resolve, reject) {
    fs.readFile(CONFIG_PATH, function(err, data) {
      if(err) {
        return reject(err);
      }
      try {
        resolve(JSON.parse(data));
      } catch (err) {
        reject(err);
      }
  }, 'utf-8');
}

knifecycle.provider(serviceName, initializer, options) ⇒ Knifecycle

Register a provider initializer

Kind: instance method of Knifecycle
Returns: Knifecycle - The Knifecycle instance (for chaining)

Param Type Description
serviceName String Service name resolved by the provider
initializer function An initializer returning the service promise
options Object Options attached to the initializer

Example

import Knifecycle from 'knifecycle'
import fs from 'fs';
 
const $ = new Knifecycle();
 
$.provider('config', function configProvider() {
  return new Promise((resolve, reject) {
    fs.readFile('config.js', function(err, data) {
      let config;
      if(err) {
        return reject(err);
      }
      try {
        config = JSON.parse(data.toString);
      } catch (err) {
        return reject(err);
      }
      resolve({
        service: config,
      });
    });
  });
});

knifecycle.toMermaidGraph(options) ⇒ String

Outputs a Mermaid compatible dependency graph of the declared services. See Mermaid docs

Kind: instance method of Knifecycle
Returns: String - Returns a string containing the Mermaid dependency graph

Param Type Description
options Object Options for generating the graph (destructured)
options.shapes Array.<Object> Various shapes to apply
options.styles Array.<Object> Various styles to apply
options.classes Object A hash of various classes contents

Example

import { Knifecycle, inject } from 'knifecycle';
import appInitializer from './app';
 
const $ = new Knifecycle();
 
$.constant('ENV', process.env);
$.constant('OS', require('os'));
$.service('app', inject(['ENV', 'OS'], appInitializer));
$.toMermaidGraph();
 
// returns
graph TD
  app-->ENV
  app-->OS

knifecycle.run(dependenciesDeclarations) ⇒ Promise

Creates a new execution silo

Kind: instance method of Knifecycle
Returns: Promise - Service descriptor promise

Param Type Description
dependenciesDeclarations Array.<String> Service name.

Example

import Knifecycle from 'knifecycle'
 
const $ = new Knifecycle();
 
$.constant('ENV', process.env);
$.run(['ENV'])
.then(({ ENV }) => {
 // Here goes your code
})

knifecycle._getServiceDescriptor(siloContext, injectOnly, serviceName, serviceProvider) ⇒ Promise

Initialize or return a service descriptor

Kind: instance method of Knifecycle
Returns: Promise - Service dependencies hash promise.

Param Type Description
siloContext Object Current execution silo context
injectOnly Boolean Flag indicating if existing services only should be used
serviceName String Service name.
serviceProvider String Service provider.

knifecycle._initializeServiceDescriptor(siloContext, serviceName, serviceProvider) ⇒ Promise

Initialize a service

Kind: instance method of Knifecycle
Returns: Promise - Service dependencies hash promise.

Param Type Description
siloContext Object Current execution silo context
serviceName String Service name.
serviceProvider String Service provider.

knifecycle._initializeDependencies(siloContext, serviceName, servicesDeclarations, injectOnly) ⇒ Promise

Initialize a service dependencies

Kind: instance method of Knifecycle
Returns: Promise - Service dependencies hash promise.

Param Type Default Description
siloContext Object Current execution silo siloContext
serviceName String Service name.
servicesDeclarations String Dependencies declarations.
injectOnly Boolean false Flag indicating if existing services only should be used

Knifecycle.getInstance() ⇒ Knifecycle

Returns a Knifecycle instance (always the same)

Kind: static method of Knifecycle
Returns: Knifecycle - The created/saved instance
Example

import { getInstance } from 'knifecycle'
 
const $ = getInstance();

buildInitializer(constants, loader, dependencies) ⇒ Promise.<String>

Create a JavaScript module that initialize a set of dependencies with hardcoded import/awaits.

Kind: global function
Returns: Promise.<String> - The JavaScript module content

Param Type Description
constants Object An hash for simple constants
loader function The dependency auto-loader
dependencies Array.<String> The main dependencies

Example

import buildInitializer from 'knifecycle/src/build';
 
buildInitializer(constants, loader, ['entryPoint']);

reuseSpecialProps(from, to, [amend]) ⇒ function

Apply special props to the given function from another one

Kind: global function
Returns: function - The newly built function

Param Type Default Description
from function The initialization function in which to pick the props
to function The initialization function from which to build the new one
[amend] Object {} Some properties to override

wrapInitializer(wrapper, baseInitializer) ⇒ function

Allows to wrap an initializer to add extra

Kind: global function
Returns: function - The new initializer

Param Type Description
wrapper function A function taking dependencies and the base service in arguments
baseInitializer function The initializer to decorate

inject(dependenciesDeclarations, initializer, [merge]) ⇒ function

Decorator creating a new initializer with some dependencies declarations appended to it.

Kind: global function
Returns: function - Returns a new initializer

Param Type Default Description
dependenciesDeclarations Array.<String> List of dependencies declarations to declare which services the initializer needs to resolve its own service.
initializer function The initializer to tweak
[merge] Boolean false Whether dependencies should be merged with existing ones or not

Example

import { inject, getInstance } from 'knifecycle'
import myServiceInitializer from './service';
 
getInstance()
.service('myService',
  inject(['ENV'], myServiceInitializer)
);

extra(extraInformations, initializer, [merge]) ⇒ function

Decorator creating a new initializer with some extra informations appended to it. It is just a way for user to store some additional informations but has no interaction with the Knifecycle internals.

Kind: global function
Returns: function - Returns a new initializer

Param Type Default Description
extraInformations Object An object containing those extra informations.
initializer function The initializer to tweak
[merge] Boolean false Whether the extra object should be merged with the existing one or not

Example

import { extra, getInstance } from 'knifecycle'
import myServiceInitializer from './service';
 
getInstance()
.service('myService',
  extra({ httpHandler: true }, myServiceInitializer)
);

options(options, initializer, [merge]) ⇒ function

Decorator to amend an initializer options.

Kind: global function
Returns: function - Returns a new initializer

Param Type Default Description
options Object Options to set to the initializer
options.singleton Object Define the initializer service as a singleton (one instance for several runs)
initializer function The initializer to tweak
[merge] function true Whether options should be merged or not

Example

import { inject, options, getInstance } from 'knifecycle';
import myServiceInitializer from './service';
 
getInstance()
.service('myService',
  inject(['ENV'],
    options({ singleton: true}, myServiceInitializer)
  )
);

name(name, initializer) ⇒ function

Decorator to set an initializer name.

Kind: global function
Returns: function - Returns a new initializer with that name set

Param Type Description
name String The name of the service the initializer resolves to.
initializer function The initializer to tweak

Example

import { name, getInstance } from 'knifecycle';
import myServiceInitializer from './service';
 
getInstance()
.register(name('myService', myServiceInitializer));

type(type, initializer) ⇒ function

Decorator to set an initializer type.

Kind: global function
Returns: function - Returns a new initializer

Param Type Description
type String The type to set to the initializer.
initializer function The initializer to tweak

Example

import { name, type, getInstance } from 'knifecycle';
import myServiceInitializer from './service';
 
getInstance()
.register(
  type('service',
    name('myService',
      myServiceInitializer
    )
  )
 );

initializer(properties, initializer) ⇒ function

Decorator to set an initializer properties.

Kind: global function
Returns: function - Returns a new initializer

Param Type Description
properties Object Properties to set to the service.
initializer function The initializer to tweak

Example

import { initializer, getInstance } from 'knifecycle';
import myServiceInitializer from './service';
 
getInstance()
.register(initializer({
  name: 'myService',
  type: 'service',
  inject: ['ENV'],
  options: { singleton: true }
}, myServiceInitializer));

handler(handlerFunction, [dependencies]) ⇒ function

Shortcut to create an initializer with a simple handler

Kind: global function
Returns: function - Returns a new initializer

Param Type Default Description
handlerFunction function The handler function
[dependencies] Array [] The dependencies to inject in it

Example

import { initializer, getInstance } from 'knifecycle';
 
getInstance()
.register(handler(getUser, ['db', '?log']));
 
const QUERY = `SELECT * FROM users WHERE id=$1`
async function getUser({ db }, userId) {
  const [row] = await db.query(QUERY, userId);
 
  return row;
}

parseDependencyDeclaration(dependencyDeclaration) ⇒ Object

Explode a dependency declaration an returns its parts.

Kind: global function
Returns: Object - The various parts of it

Param Type Description
dependencyDeclaration String A dependency declaration string

Example

parseDependencyDeclaration('pgsql>db');
// Returns
{
  serviceName: 'pgsql',
  mappedName: 'db',
  optional: false,
}

License

MIT