knifecycle
Manage your NodeJS processes's lifecycle.
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:
import fs from 'fs';
import { initializer } from 'knifecycle';
export const initConfig = initializer({
name: 'CONFIG',
inject: ['ENV'],
type: 'service',
options: { singleton: true },
}, ({ 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:
import { initializer } from 'knifecycle';
const initDB = initializer({
name: 'db',
inject: ['CONFIG', '?log'],
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);
});
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:
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';
getInstance()
.register(initConfig)
.register(initServer)
.register(initDB)
.register(
inject(['DB2_CONFIG>CONFIG', '?log'],
name('db2', initDB)
)
)
.register(name('DB2_CONFIG', inject(['CONFIG'], ({ CONFIG }) => {
return Promise.resolve({
DB_URI: CONFIG.DB2_URI,
});
})))
.constant('ENV', process.env)
.constant('now', Date.now.bind(Date))
.constant('delay', Promise.delay.bind(Promise))
.constant('waitSignal', function waitSignal(signal) {
return new Promise((resolve, reject) => {
process.once(signal, resolve.bind(null, signal));
});
})
.constant('exit', process.exit.bind(exit))
.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);
});
});
}
)
))
.run(['server', 'timeRoute', 'waitSignal', 'exit', '$destroy'])
.then(({ waitSignal, exit, $destroy }) {
Promise.any([
waitSignal('SIGINT'),
waitSignal('SIGTERM'),
])
.then($destroy)
.then(() => {
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
- Knifecycle
- new Knifecycle()
- instance
- .constant(constantName, constantValue) ⇒
Knifecycle
- .service(serviceName, initializer, options) ⇒
Knifecycle
- .provider(serviceName, initializer, options) ⇒
Knifecycle
- .toMermaidGraph(options) ⇒
String
- .run(dependenciesDeclarations) ⇒
Promise
- ._getServiceDescriptor(siloContext, injectOnly, serviceName, serviceProvider) ⇒
Promise
- ._initializeServiceDescriptor(siloContext, serviceName, serviceProvider) ⇒
Promise
- ._initializeDependencies(siloContext, serviceName, servicesDeclarations, injectOnly) ⇒
Promise
- static
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();
$.constant('ENV', process.env);
$.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();
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 }) => {
})
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)
);
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');
{
serviceName: 'pgsql',
mappedName: 'db',
optional: false,
}
License
MIT