extensible

Create highly extensible software components.

Usage no npm install needed!

<script type="module">
  import extensible from 'https://cdn.skypack.dev/extensible';
</script>

README

extensible

Create highly extensible software components.

Build Status
browser support

Installation

npm install --save extensible

Introduction

This library simplifies modularization of cross-cutting concerns in libraries/applications. It's a simple framework for doing aspect-oriented programming in javascript software.

Objects created by the exported function can be extended with methods(empty prototypes) and middlewares that implement aspects of the object's methods.

Usage

For maximum reuse, middlewares should be very small and keep their knowledge of other installed middlewares to a minimum. In practice, they will know something about middlewares installed in deeper layers.

The best way to understand is through an example that shows its features. We will build a tiny database library based on leveldb(leveldown) which can be extended via plugins. The following code defines the core API and the innermost middleware/layer:

// levelup.js
var leveldown = require('leveldown');
var extensible = require('extensible');

var levelup = extensible();

// Add basic methods:
levelup.$defineMethod('open', 'location, cb');
levelup.$defineMethod('get', 'key, cb');
levelup.$defineMethod('put', 'key, value, cb');
levelup.$defineMethod('del', 'key, cb');

// Now add the innermost layer, which implements the core database methods:
levelup.$use({
  open: function(location, cb) {
    this.db = leveldown(location);
    this.db.open(cb);
  },

  get: function(key, cb) {
    this.db.get(key, cb);
  },

  put: function(key, value, cb) {
    this.db.put(key, value, cb);
  },

  del: function(key, cb) {
    this.db.del(key, cb);
  }
});

// Export a constructor
module.exports = function(options) {
  return levelup.$fork();
};

This will result in a very simple but working database API:

var levelup = require('./levelup');
var db = levelup();
var k = new Buffer([1, 2, 3]);
var v = new Buffer([1, 2, 3, 4]);

db.open('./db-example', function(err) {
  db.put(k, v, function(err) {
    db.get(k, function(err, val) {
      console.error(val); // <SlowBuffer 01 02 03 04>
    });
  });
});

The created module only supports buffers/strings as keys/values. Lets build a plugin which adds support to using arbitrary objects. We will use 'msgpack-js' for serializing values and 'bytewise' for serializing keys:

// levelup-pack.js
var bytewise = require('bytewise');
var msgpack = require('msgpack-js');

// Since this is not the innermost layer, we will use the last argument, 'next'
// to invoke the next layer.
//
// All methods receive keys so they must be serialized with bytewise for a
// couchdb-like ordering of records.
//
// Methods that return values(get) must also override the callback to convert
// the buffer back to javascript objects.
module.exports = {
  get: function(key, cb, next) {
    next(bytewise.encode(key), function(err, value) {
      if (err) return cb(err);
      cb(null, msgpack.decode(value));
    });
  },

  put: function(key, value, cb, next) {
    next(bytewise.encode(key), msgpack.encode(value), cb);
  },

  del: function(key, cb, next) {
    next(bytewise.encode(key), cb);
  }
};

Note that we havent altered the 'open' method. When a middleware doesn't implement a method, it will automatically invoke next layer.

To use the new feature, install the middleware into the db object, which will wrap it into another layer:

var levelup = require('./levelup');
var levelupPack = require('./levelup-pack');

var db = levelup();
// wrap into the serialization layer
db.$use(levelupPack);

var k = [1, 2, 3];
var v = {name: 'john doe'};

db.open('./db-example', function(err) {
  db.put(k, v, function(err) {
    db.get(k, function(err, val) {
      console.error(val); // { name: 'john doe' }
    });
  });
});

Middlewares can also be functions, which are called with the context set to the object being extended. To illustrate lets build a plugin which converts our API to return objects implementing the Promises/A+ spec through the 'rsvp' promise library.

This example will also show how to perform instrospection and modify a method signature while maintaining compatibility with previous layers:

// levelup-promise.js
var rsvp = require('rsvp');

module.exports = function() {
  var _this = this; // reference to the object
  var rv = {};

  // this assumes all methods follow node convention of callback as last arg
  this.$eachMethodDescriptor(function(method) {
    // redefine the method signature by removing the last 'cb' parameter
    var newArgs = method.args.slice();
    var lastArg = newArgs.pop();
    // Only wrap if the last argument is named 'cb'
    if (lastArg !== 'cb')
      return;
    _this.$defineMethod(method.name, newArgs.join(','));
    rv[method.name] = function() {
      var next = arguments[arguments.length - 4];
      // get all args up to and excluding 'next'
      var args = Array.prototype.slice.call(arguments, 0, arguments.length - 4);

      return new rsvp.Promise(function(resolve, reject) {
        // push the callback for the next layer, which still has the old
        // method signature
        args.push(function(err, result) {
          if (err) return reject(err);
          // resolve passing all values returned
          resolve(result);
        });

        next.apply(this, args);
      });
    };
  });

  return rv;
};

Now install on the db object to wrap it into another layer:

var levelup = require('./levelup');
var levelupPack = require('./levelup-pack');
var levelupPromise = require('./levelup-promise');

var db = levelup();
// serialization
db.$use(levelupPack);
// promises
db.$use(levelupPromise);

var k1 = [1, 2, 3], k2 = [4, 5, 6];
var v1 = {name: 'foo'}, v2 = {name: 'bar'};

db.open('./db-example').then(function(err) {
  return db.put(k1, v1);
}).then(function() {
  return db.put(k2, v2);
}).then(function() {
  return db.get(k2);
}).then(function(val) {
  console.log(val); // {name: 'bar'}
  return db.get(k1);
}).then(function(val) {
  console.log(val); // {name: 'foo'}
}).catch(function(err) {
  console.error(err);
});

API


extensible()

The extensible constructor function returns a new, empty object which can be the base of a new extensible component.


extensible#$defineMethod(name[, args[, descriptor]])

Defines a new empty method on the object. The object has no behavior until a middleware implementing some aspect is installed with use(). Its possible to redefine an existing method with a different number of arguments/signature, in which case the middleware must take care of adapting the arguments for the next layer(which still uses the old signature).

name is the method name and may be any valid javascript property name.

args is a string with comma-separated parameter names which are used to generate the middleware wrapper functions, so it must match the middleware's method signature.

descriptor is an object containing metadata that can be discovered and introspected later, possibly by other middlewares/plugins. The object passed to descriptor is merged with an object with the {name(string), args(array)} schema.


extensible#$use(middleware[, opts])

Extend object with middleware, which will become the new top layer. middleware may implement any of the methods already defined with defineMethod(). Other methods are simply ignored, even if they are added later.

The implemented methods must have the same number of arguments passed to the last defineMethod() call. It may can optionally use the next, layer, state and self arguments described as follows:

  • next: Helper function to call the next middleware layer. This function has the same parameters declared with defineMethod() and may also accept an extra state described below. This must not be called if the middleware was the first added to the object(it is the bottom layer). If the method was upgraded, next will have the signature of the method defined in the next middleware layer.

  • layer: Object that wraps the current middleware and has a reference to the next middleware through a 'next' property. This can be used to call another method in a lower layer without passing through the whole middleware pipeline.

  • state: Argument which is passed implicitly through the middleware pipeline and may be modified by any of the invoked middlewares. One use case for this is to pass options to a non-adjacent lower middleware separated by middlewares with 'incompatible signatures'.

  • self: Reference to the extensible object. This is only used by the special $call method(See the '$fork' method below) for when the callable object is called like a method(this no longer points to the extensible object).

If middleware is a function it will be treated as a factory and called with the object being extended as context(this) and opts as argument.


extensible#$getMethodDescriptor(name)

Returns the descriptor object for a previously defined method.


extensible#$eachMethodDescriptor(cb)

Invokes cb for each method descriptor defined in the object. The iteration order is not predictable.


extensible#$eachLayer(cb)

Invokes cb for each layer(object wrapping a middleware) in the object. The iteration order is bottom->top (middlewares installed first are visited first).


extensible#$fork([asCallable[, inheritProperties]])

Forks by creating a new object with all methods and layers from the current object. It may be called with two optional arguments:

  • asCallable: The forked object is callable, meaning that a function is returned. To implement the function behavior, implement the $call method (Read below). For most purposes, it may be used as a normal extensible() that can be called like a function.

  • inheritProperties: If true, the parent properties will be inherited through the prototype chain instead of simply copied. If a falsy value is passed(the default) the forked object will contain separate properties for the layers and descriptors, so it may be extended independently of the original object.


extensible#$instance()

For normal objects, this creates a child object linked through the prototype chain(Unlike $fork, the child object cannot be extended independently). If the object is callable, the properties/methods are simply copied(normal javascript inheritance becomes broken for this object and its children). This will call a $constructor method if defined, forwarding any passed arguments.


extensible#$instanceOf()

Replacement for instanceof that works with callable objects.

Special methods

Extensible objects can implement the following special methods(in the same middleware-based architecture of normal methods):

  • $call: This implements the behavior of calling callable objects.
  • $constructor: Initializer called with arguments passed to $instance