Plugins

Overview

Lumbar may be extended through plugins that can inject or modify the behavior at numerous different places in the build process. A chaining pattern is used so each plugin can either return, modify or replace the response from plugins that are later in the chain.

Installation

Plugins can be used in 2 ways. The first allows passing configuration options to the plugin which can be accessed as the first parameter if the plugin exports a function.

module.exports = function(options) {
  return {
    // plugin methods
  };
};

For plugins that do not require instance options, a singleton exports pattern can be used

module.exports = {
  // plugin methods
};

Modes

A mode is an operating context or filter so that only plugins that are registered for a given mode are allowed to operate - otherwise they are ignored.

The plugin can contribute new modes (or bind to existing modes) by exporting a mode value which can either be a string or an array of strings. Lumber operates with 3 defined modes by default:

Lumbar will iterate a build lifecycle for each unique platform, module and mode combination. This allows the plugins to filter build resources and operations to only what is meaningful for their purpose.

module.exports = {
  mode: 'scripts' // operate within the scripts mode along with other core plugins
  mode: ['scripts', 'foo'] // scripts mode and add a new mode called 'foo'
  mode: ['foo', 'all'] // add a new 'foo' mode and also operate under all modes
  // if no mode is defined, the plugin will operate under all modes
}

It is recommended that, unless necessary, a mode be explicitly defined.

Plugin API

Plugins primary method of interaction with Lumbar it through the moduleResources, resourceList, file, module, and resource callbacks.

Each of these callbacks are implemented as a chain, allowing for a plugin to modify the current context object and determine if subsequent plugins are allowed to operate, via the next parameter passed to the callback.

Almost all plugin methods are asynchronous and have the same signature - (context, next, complete)

All parameters are described in more detail after the method documentation.

moduleResources moduleResources(context, next, complete)

Called when generating a list of all resources that a given module will need. This method may be used to add additional content to the module such as the router declaration for router modules.

The expected return value is an array. The contents of the array can be whatever is meaningful to the plugin. Other plugin methods can be used to take action on individual entries in the returned list.

Resource expansion

Each value in the returned array will be expanded if that value represents a directory structure. This is done by either using a simple string or using the src attribute. In this case, every child directory and file will automatically be added as a resource entry.

For example, if the application structure is:

app
  - lumbar.json
  - files
  --- file1.txt
  --- sub-files
  ----- file2.txt

And a resource entry is returned with the value of

{src: "files", foo: "bar"}

or

"files"

The resource entries will be converted to

[
  {dir: "files", foo: "bar"},
  {src: "files/file1.txt", srcDir: "files", foo: "bar"}
  {dir: "files/sub-files", srcDir: "files", foo: "bar"}
  {src: "files/sub-files/file2.txt", srcDir: "files", foo: "bar"}
]

The existence of srcDir to determine if the resource was auto-generated from a resource entry representing a directory.

Any additional attributes that were provided will be added to all created entries as you can see with the foo attribute.

Note: the foo attribute would not be present if the resource entry was just files - just {src: "files", foo: "bar"}.

Current behavior

Without implementing this method, the resources retrieved will be the serialized JSON value referenced by the mode key on the module.

For example, if the plugin has defined a mode called foo and a lumbar.json file of:

{
  "modules": {
    "myModule": {

      "foo": [
        "abc", "def"
      ],

      "bar": [
        "ghi", "jkl"
      ]
    }
  }
}

The resources available to the plugin would be:

["abc", "def"]

Example

If the plugin intends to use the 'bar' value (disregarding the fact that maybe the mode should be 'bar'), a sample moduleResources would be:

module.exports = {
  moduleResources: function(context, next, complete) {
    complete(undefined, context.module.bar);
  }
}

It is also possible to add to the module resources when multiple plugins operate within the same mode. Here is an example of the router plugin:

moduleResources: function(context, next, complete) {
  next(function(err, ret) {
    if (err) {
      return complete(err);
    }

    // Generate the router if we have the info for it
    var module = context.module;
    if (module.routes) {
      ret.unshift({ routes: module.routes });
    }

    complete(undefined, ret);
  });

resourceList resourceList(context, next, complete)

Allows plugins to create multiple resources from a single resource. This is called once for each resource generated from the moduleResources callback.

This is useful for plugins that expand on specific resources.

The expected return value is an array of resource objects. The data associated with these objects may be anything the plugin or other plugins will operate on.

Strings will be treated as file or directory includes as will object that define a src field. Resources that define a platform or platforms fields will be filtered based on the current platform being executed.

For example, the scope plugin wraps the returned resources add a execution scope.

resourceList: function(context, next, complete) {
  next(function(err, resources) {
    if (err) {
      return complete(err);
    }

    if (context.config.attributes.scope === 'resource'
        && !context.resource.global
        && !context.resource.dir) {
      resources.unshift(generator('(function() {\n'));
      resources.push(generator('}).call(this);\n'));
    }
    complete(undefined, resources);
  });
}

file file(context, next, complete)

Allows plugins to apply file-level changes to the resources. Called once for each file generated, just prior to resources being combined. May alter the context.resources field to change the resource list.

This could be used, for example, to append JSONP callbacks to a file.

fileName fileName(context, next, complete)

Allows for plugins to override the default file name used for output file creation.

The return value should be an object with the following attributes:

For example, the script plugin uses the platform path and module name to create the file name:

fileName: function(context, next, complete) {
  var name = context.module ? context.module.name : context.package;
  complete(undefined, {path: name, extension: 'js'});
}

module module(context, next, complete)

Allow plugins to apply module-level changes to the resources. Called once for each module. May alter the resource list associated with the module by altering the context.moduleResources field.

This can be useful for writing resources to the output directory. For example, this is how the static-output plugin adds the static files to the output directory:

module: function(context, next, complete) {
  next(function(err) {
    async.forEach(context.moduleResources, function(resource, callback) {
        var fileContext = context.clone();
        fileContext.resource = resource;
        var fileInfo = fu.loadResource(resource, function(err, data) {
          if (err || !data || !data.content) {
            return callback(err);
          }

          fileContext.outputFile(function(callback) {
            var ret = {
              fileName: fileContext.fileName,
              inputs: fileInfo.inputs || [ fileInfo.name ],
              fileConfig: context.fileConfig,
              platform: context.platform,
              package: context.package,
              mode: context.mode
            };

            fu.writeFile(fileContext.fileName, data.content, function(err) {
              callback(err, ret);
            });
          },
          callback);
        });
      },
      complete);
  });
}

resource resource(context, next, complete)

Allows plugins to include content other than direct file references as well as chain resource modifications.

The current resource can be referenced using context.resource.

In general, the plugin should have one of the following return values:

Return: callback function

This function is used for asynchronous data loading. The callback has the standard (err, data) signature

For example, this is how the async callback function can be used to write "Hello World!"

resource: function(context, next, complete) {
  complete(undefined, function(context, complete) {
    if ( *simple* ) {
      complete(undefined, "Hello World!");
    } else {
      var dependantFiles = [...];
      complete(undefined, {data: "Hello World!", inputs: dependantFiles}
    }
  });
}

Return: An object

This object should have the following attributes: src: file path relative to the lumbar.json file dest: only applicable for static resources - the destination path relative to the platform * sourceFile: file path that, if in watch mode, should be watched to trigger a rebuild. This is not needed if src is defined.

Method parameters

Context

Each plugin method is passed a context parameter which describes the entire state of the build at the point of the call. Plugins are free to modify this structure as they please.

The context is cloned at various times during the lumbar lifecycle so any modifications to the context can not be guaranteed to exist outside of the plugin method that made the modification.

Some utility functions are also available:

Next and Complete

Each plugin is responsible for completing the plugin chain by calling next() or compete(). Next is called to let the other plugins respond while complete is used to stop the plugin chain and directly return a result.

The complete callback can be provided as a parameter to next if desired but not necessary.

see examples below:

module.exports = {
  moduleResources: function(context, next, complete) {
    if ( *continue with chain* ) {
      next();

    } else if ( *modify plugin result* ) {
      // define a new complete function
      function _complete (err, data) {
        if (err) {
          // something bad happened
          complete(err, data);
        } else {
          data.push("something new");
          complete(undefined, data);
        }
      }
      // call next and override the existing complete function
      next(_complete);

    } else if ( *stop the plugin chain and return something* ) {
      var something = [...];
      complete(undefined, something);

    } else {
      // we're asyncronous - *always* make sure to call next or complete!
      next();
    }
  }
}

Lifecycle Pseudocode

For an understanding of how these methods work together, see the following extremely simplified pseudocode:

for each defined platform
  for each mode {added by `plugin.mode`}
    for each module in platform {as determined by package}
      resources = `plugin.moduleResources`
      for each resource in resources
        if resource matches `plugin.fileFilter`
          replace/expand resource if it matches a directory
        else
          remove from the list of resources

      for each resource in resources
        replace/flatten resource with `plugin.resourceList`

      call `plugin.module`
      for each resource in resources
        resource = 'plugin.resource'

Caches

Each context object defines a variety of caches that are reset at specific points through the build process. This allows plugins to cache any relevant data for specific timeframes. Note that these objects are shared across all plugins so proper naming conventions should be followed to prevent conflicts.

Warnings

As most Lumbar projects are dealing with a large number of files it is quite susceptible to EMFILE exceptions under OSX. The current recovery method for this is to utilize async methods and retry methods that fail due to this error. A variety of file methods that are protected from this case have been made available on the lumbar.fileUtil object. It is recommended that these methods are used whenever possible while dealing with files throughout the system.

FileUtils

With respect to the previous warning about EMFILE, all file access should be done using fileUtils (fileUtils.js). This should be accessed from the context using the fileUtil key. This wraps much of the functionality of fs with handling of EMFILE errors.

FileUtils also caches files that are referenced to optimize build time.

resetCache resetCache(path)

Clear all cached file content

resolvePath resolvePath(path)

Return a file path that, if relative, is appropriatly qualitied with the build output path based on the 'lookupPath'

readFileSync readFileSync(path)

Same as fs.readFileSync but uses resolvePath

makeRelative makeRelative(path)

The opposite of resolvePath. This will remove the lookup path if the path has that as a prefix.

stat stat(file, callback)

Same as fs.stat but with EMFILE handling

readFile readFile(file, callback)

Same as fs.readFile cacheing. A buffer is returned.

readdir readdir(dir, callback)

same as fs.readdir with cacheing.

ensureDirs ensureDirs(pathname, callback)

Ensure that the parent directories for the provided file path exist and create otherwise.

writeFile writeFile(file, data, callback)

Same as fs.writefile but will also ensure directories, cache file contents, and handle EMFILE errors gracefully.

loadResource loadResource(resource, callback)

Specifically designed to load a lumbar resource (see the lumbar API resource method).

Fork me on GitHub