Skip to content
This repository has been archived by the owner on Feb 25, 2019. It is now read-only.

Developer Guide

Christian Smith edited this page Feb 11, 2016 · 3 revisions

Create an Extensible Application

A Sunstone app is a npm package that consists of a main module and a plugins directory containing one or more plugins. The name of the main file can be anything you want, but it has to match the main entry in package.json.

.
├── app.js				// main file
├── package.json			// the app is a npm package
└── plugins				// everything other than the main file is in plugins
    ├── P1
    ├── P2    
    ├── ...
    └── Pn

To get started, initialize a new npm package and install sunstone as a dependency.

$ mkdir cloudthing
$ cd cloudthing
$ npm init
$ npm install --save sunstone

Main Module

The main module requires sunstone, configures the directories to search for plugins, and exports the bootstrapped application. In your new package, create a file called app.js with the following contents.

var path = require('path')

// Create a new host application and configure the plugin directories
module.exports = require('sunstone').bootstrap({
  directories: [
    path.join(__dirname, 'plugins'),		// built-in plugins
    path.join(process.cwd(), 'plugins')		// user extensions
  ]
})

In your package.json, be sure to set main to app.js, or whatever you named the file.

{
  "name": "cloudthing",
  "version": "0.0.0",
  "description": "Example of a Sunstone host application",
  "main": "app.js",
  "dependencies": {
    "sunstone": "^0.0.0",
    // ...
  }
}

Extending your Application

Once you've created an extensible application and made it available as a npm package (or linked it into your project), it can be run with one line of code. All extensions are plugins and live in the configured plugins directory. Plugins work the same way in both the extensible package and the extending app.

require('cloudthing').run()

Creating Plugins

All additional application logic lives in plugins. Each plugin is a directory inside one of the configured plugin directories for the host or extending applications. Plugin directories can have an arbitrary number of files in any kind of organization, with only one rule. Each plugin must include an index.js file at its root level that is used to register the plugin.

plugins
├── core
│   └── index.js
├── settings
│   ├── index.js
│   ├── ...
└── extra
    └── index.js

The index.js file must export a function that takes the plugin registry as an argument. The argument can be named anything you want. This might be helpful to developers extending your app with their own plugins. For example, we might want to call our app "cloudthing".

// export a function that takes the plugin registry as an argument
module.exports = function (cloudthing) {
  // ...
}

For the remaining examples here, we'll stick to "app". Plugins are defined inside this exported function by calling app.plugin(<NAME>, <METADATA>). This returns a plugin object providing the API that's used to build up your plugin components and define lifecycle behavior. The most important method provided by plugin is initializer. This method takes a callback that acts as a wrapper around the rest of your plugin definition. The 'initializer' callback allows plugins to be loaded and have their dependencies resoved before initializing the plugin.

'use strict'

module.exports = function (app) {

  // define a new plugin by providing a name and metadata
  app.plugin('feature', {
    version: '0.0.0',
    dependencies: {}
  })

  // this wrapper allows plugins to be loaded before initializing them, 
  // so that dependencies can be verified
  .initializer(function (plugin) {
    // use the plugin development API here
  })
}

NOTE: The initializer wrapper is necessary so that plugins are able to extend the plugin API itself. See Extending the Plugin API

Plugin Components & Dependency Injection

Sunstone plugins use dependency injection to abstract components from the file system. This makes it possible to use an application's internal API to create and share plugin components without knowing how the app's source code is organized or how to construct a given dependency.

The plugin object (returned from app.plugin and passed to .initializer) has several methods useful for building a plugin, many of which register "dependencies" on the injector for use by other components. Most of these methods are chainable.

plugin.factory(name, fn) → {this}

The factory method registers a new dependency on the injector, validates it, and determines which other dependencies it requires. The first argument is the name of the new dependency and the second argument is a function that returns the value of the dependency. However, this function is not invoked at the time the dependency is registered. Invocation is lazy. Getting a dependency from the Injector invokes the function and stores the return value.

In this example, the third component depends on the first two.

plugin
  .factory('one', function () {
    return 1
  })
  .factory('two', function () {
    return 2
  })
  .factory('oneplustwo', function (one, two) {
    return one + two
  })

plugin.require(modules) → {this}

Components can also be loaded from node modules with the require. This method accepts a string, array or object. By passing an object you can alias the package name.

plugin.require('express')

plugin.require([
  'crypto',
  'ioredis'
])

plugin.require({
  '_': 'lodash',
  'fs': 'fs-extra',
  'myLibrary': './myLibrary'
})

plugin.extension(name, fn) → {this}

In some cases, a plugin developer may want to access some part of an app's API without actually registering a new component on the injector. The 'extension' method provides for this case.

plugin.extension('UserExtension', function (User) {
  User.extendSchema({
    domainSpecificAttribute: { type: 'whatever', ... }
  })
})

plugin.alias(alias, name) → {this}

The alias method provides a way to access an item from the injector by more than one name.

plugin
  .factory('myDependency', function () {
    // ...
  })
  .alias('myAlias', 'myDependency')

plugin.adapter(name, fn) → {this}

An application developer may want to defer selecting a specific implementation of a dependency to runtime. This can be accomplished using only calls to the factory method by injecting the injector itself. The plugin API includes a method called adapter to help distinguish between this case and other uses of factory.

plugin
  .factory('RedisResource', function (redis) {
    // ...
  })
  .factory('MongoResource', function (mongodb) {
    // ...
  })
  .adapter('Resource', function (injector, settings) {
     // where settings.property is 'RedisResource' or 'MongoResource'
     return injector.get(settings.property)
  })
  .factory('User', function (Resource) {
    // ...
  })

Organizing Large Plugins

As plugins grow in the number and size of their components, it's useful to break down the plugin into several separate files.

plugin.include(filepath) → {this}

The include method provides a way to build up a plugin from several files.

// index.js
'use strict'

module.exports = function (sunstone) {
  sunstone.plugin('MyResource', {
    version: '0.0.1',
    dependencies: {
      'Server': '0.0.1'
    }
  })
  .initializer(function (plugin) {
    plugin
      .include('./other')
      .include('./yetanother')
  })
}

Include files must export a function that takes the plugin instance as an argument. Inside that function, any plugin methods can be called.

// other.js
'use strict'

module.exports = function (plugin) {
  plugin.factory('MyModel', function (a, b) {
    // ...
  })
}

Working Directly with the Injector

Dependencies are registered and maintained in memory by the Injector. As we saw in the adapter example, it's possible to inject the injector itself. For the most part there is no reason to do this, but in a few cases there is no other way to accomplish a task.

injector.register(descriptor)

Registering a dependency on the Injector validates it and determines which other dependencies it requires. This method takes a descriptive object as an argument that must include a name, type, plugin, and fn or value. It does not invoke the fn property, if one is provided.

injector.register({
  name: 'server',
  type: 'factory',
  plugin: '<plugin.name>',
  fn: function (express, logger) {
    let server = express()
    server.use(logger)
    return server
  }
})

injector.get(name) → {*}

Getting a dependency from the Injector invokes the dependency's fn property as a function and caches the return value on the value property for future calls to get. This recursively satisfies the requirements of fn.

injector.get('server')
=> server
// this caches the return value of `fn` as `value`
=> Dependency {
     name: 'server',
     type: 'factory',
     plugin: '<plugin.name>',
     fn: function (express, logger) { ... },
     value: server
   }

injector.filter(predicate) → {DependencyCollection}

The injector can be queried for plugins matching a given predicate using the filter method. This is most useful for managing plugin lifecycle.

Suppose we have the following dependencies registered with the injector:

injector.register({ name: 'a', plugin: 'MyPlugin', type: 'factory', fn: () => 'a' })
injector.register({ name: 'b', plugin: 'OtherPlugin', type: 'factory', fn: () => 'b' })
injector.register({ name: 'c', plugin: 'MyPlugin', type: 'other', fn: () => 'c' })

We can then query the injector by calling the filter method and passing an object describing the desired results.

// filter by type
injector.filter({ type: 'factory' })
// => DependencyCollection [
//      Dependency { name: 'a', plugin: 'MyPlugin', type: 'factory', fn: () => 'a' },
//      Dependency { name: 'b', plugin: 'OtherPlugin', type: 'factory', fn: () => 'b' }
//    ]

// filter by plugin
injector.filter({ plugin: 'MyPlugin' })
// => DependencyCollection [
//      Dependency { name: 'a', plugin: 'MyPlugin', type: 'factory', fn: () => 'a' },
//      Dependency { name: 'c', plugin: 'MyPlugin', type: 'other', fn: () => 'c' }
//    ]

// filter by type and plugin
injector.filter({ type: 'factory', plugin: 'MyPlugin' })
// => DependencyCollection [
//      Dependency { name: 'a', plugin: 'MyPlugin', type: 'factory', fn: () => 'a' }
//    ]

Filter can also take a function argument that returns a boolean value.

injector.filter(dependency => dependency.name.match(/pattern/))

Note that filter returns dependency describing objects and not the dependency values themselves. The DependencyCollection instance returned by filter has a values method that can be invoked to get an array of actual dependency values.

injector.filter({ type: 'custom' }).values()

Extending the Plugin API

In some cases it may be useful to create new kinds of components that have their own type on the injector and their own method name for registering components.

plugin.assembler(name, fn) → {this}

For example, knowledge of Express routers is beyond the scope of Sunstone, but if you wanted to define a specialized factory method for registering routers, you could do it like so:

app.plugin('server', {
  version: '0.0.0'
})
.initializer(function (plugin) {
  plugin.assembler('router', function (injector) {
    let plugin = this
    return function (name, factory) {
      injector.register({
        name,
        type: 'router',
        plugin: plugin.name,
        factory
      })
    })
  })
})

This makes a new plugin method called router that can be used as follows:

app.plugin('other', {
  version: '0.0.0'
})
.initializer(function (plugin) {
  plugin.router('SomeRouter', function (Router, SomeResource) {
    let router = new Router()

    router.get('endpoint', function () {
      SomeResource
        .list(req.query)
        .then(function (results) {
          res.json(results)
        })
        .catch(error => next(error))
    })

    return router
  })
})

The dependency injector can then be queried by this new "type" value.

injector.filter({ type: 'router' })

Managing Plugin Lifecycle

plugin.starter(callback) → {this}

plugin.starter(function (injector, server) {
  injector
    .filter({ plugin: this.name, type: 'router' })
    .values()
    .forEach(router => {
      router.mount(server)
    })
})