Home Reference Source

src/DryadPlayer.js

import _ from 'underscore';
import DryadTree from './DryadTree';
import CommandMiddleware from './CommandMiddleware';
import {Promise} from 'bluebird';
import hyperscript from './hyperscript';

if (process) {
  process.on('unhandledRejection', function(reason) {
    console.error('Unhandled Rejection:', reason, reason && reason.stack);
  });
} else {
  Promise.onPossiblyUnhandledRejection((error) => {
    console.error(error);
    throw Error(error);
  });
}


/**
 * Manages play/stop/update for a Dryad tree.
 *
 * A Dryad has no state or functionality until it is played
 * by a DryadPlayer. A Dryad can be played more than once at
 * the same time by creating more DryadPlayers.
 *
 * The DryadPlayer also holds the layers and command middleware
 * which execute the functionality that the Dryads specify.
 */
export default class DryadPlayer {

  constructor(rootDryad, layers, rootContext = {}) {
    this.middleware = new CommandMiddleware();
    this.classes = {};
    if (layers) {
      layers.forEach((layer) => {
        this.use(layer);
      });
    }

    if (!rootContext.log) {
      rootContext.log = console;
    }

    this.log = rootContext.log;

    this._errorHandler = (error) => this.log.error(error);

    this.setRoot(rootDryad, rootContext);
  }

  /**
   * Set a new tree.
   *
   * Behavior while already playing is not yet defined.
   *
   * @param {Dryad} dryad
   */
  setRoot(dryad, rootContext) {
    if (dryad) {
      let classLookup = _.bind(this.getClass, this);
      this.tree = new DryadTree(this.h(dryad), classLookup, rootContext);
    } else {
      this.tree = null;
    }
  }

  /**
   * Convert hyperscript graph to Dryad objects with registered classes
   *
   * @param {Object} hgraph - JSON style object
   * @returns {Dryad}
   */
  h(hgraph) {
    let classLookup = _.bind(this.getClass, this);
    return hyperscript(hgraph, classLookup);
  }

  /**
   * Add a layer of functionality by registering Dryad classes and command middleware.
   *
   * @param {Object} layer - .classes is a list of Dryad classes, .middleware is a list of middleware functions
   */
  use(layer) {
    this.middleware.use(layer.middleware || []);
    (layer.classes || []).forEach((c) => this.addClass(c));
    return this;
  }

  /**
   * Register a Dryad class so it can be located when used in hyperscript.
   * Also needed if a class uses requireParent()
   *
   * @param {Dryad} dryadClass
   */
  addClass(dryadClass) {
    this.classes[dryadClass.name.toLowerCase()] = dryadClass;
  }

  /**
   * Lookup Dryad class by name.
   *
   * Used by hyperscript and requireParent, this requires
   * that layers and their classes were registered and any custom
   * classes that you right are registered. If you aren't using
   * hyperscript then you don't need to register your class.
   * @param {String} className - case-insensitive
   * @returns {Dryad}
   */
  getClass(className) {
    let dryadClass = this.classes[className.toLowerCase()];
    if (!dryadClass) {
      throw new Error(`Dryad class not found: '${className}' in classes: ${Object.keys(this.classes)}`);
    }
    return dryadClass;
  }

  /**
   * @returns {Promise} - that resolves to `this`
   */
  play(dryad) {
    if (dryad) {
      this.setRoot(dryad);
    }
    let prepTree = this._collectCommands('prepareForAdd');
    let addTree = this._collectCommands('add');
    return this._callPrepare(prepTree)
      .then(() => this._call(addTree, 'add'))
      .then(() => this)
      .catch(this._errorHandler);
  }

  /**
   * @returns {Promise} - that resolves to `this`
   */
  stop() {
    let removeTree = this._collectCommands('remove');
    return this._call(removeTree, 'remove').then(() => this).catch(this._errorHandler);
  }

  _collectCommands(commandName) {
    return this.tree.collectCommands(commandName, this.tree.tree, this);
  }

  /**
   * Execute a prepareForAdd tree of command objects.
   *
   * Values of the command objects are functions may return Promises.
   *
   * @param {Object} prepTree - id, commands, context, children
   * @returns {Promise} - resolves when all Promises in the tree have resolved
   */
  _callPrepare(prepTree) {
    var commands = prepTree.commands || {};
    if (_.isFunction(commands)) {
      commands = commands(prepTree.context);
    }
    return callAndResolveValues(commands, prepTree.context).then((resolved) => {
      // save resolved to that node's context
      // and mark that its $prepared: true for debugging
      this.updateContext(prepTree.context, _.assign({state: {prepare: true}}, resolved));
      let childPromises = prepTree.children.map((childPrep) => this._callPrepare(childPrep));
      return Promise.all(childPromises);
    }, (error) => {
      this.updateContext(prepTree.context, {state: {prepare: false, error: error}});
      return Promise.reject(error);
    });
  }

  /**
   * Execute a command tree using middleware.
   *
   * @returns {Promise}
   */
  _call(commandTree, stateTransitionName) {
    const updateContext = (context, update) => {
      this.tree.updateContext(context.id, update);
    };
    return this.middleware.call(commandTree, stateTransitionName, updateContext);
  }

  /**
   * Execute a single command object for a single node using middleware
   * outside the prepareForAdd/add/remove full tree command execution routine.
   *
   * This can be called out of band from a Dryad's add/remove method
   *
   * Its for commands that need to be executed during runtime
   * in response to events, streams etc.
   * eg. spawning synths from an incoming stream of data.
   */
  callCommand(nodeId, command) {
    return this._call(this.tree.makeCommandTree(nodeId, command), 'callCommand');
  }

  /**
   * Allow a Dryad to update its own context.
   *
   * Contexts are immutable - this returns a new context object.
   */
  updateContext(context, update) {
    return this.tree.updateContext(context.id, update);
  }

  /**
   * Get a representation of current state of the tree.
   * Contains add|remove|prepared and may hold errors.
   */
  getDebugState() {
    return this.tree.getDebugState();
  }
}


/**
 * Returns a new object with each value mapped to the called-and-resolved value.
 *
 * For each key/value in commands object,
 * if value is a function then call it
 * if result is a Promise then resolve it.
 *
 * @private
 * @param {Object} commands
 * @returns {Object}
 */
function callAndResolveValues(commands, context) {
  if (_.isEmpty(commands)) {
    return Promise.resolve({});
  }
  const keys = _.keys(commands);
  return Promise.map(keys, (key) => {
    let value = commands[key];
    return Promise.resolve(_.isFunction(value) ? value(context) : value);
  }).then((values) => {
    let result = {};
    keys.forEach((key, i) => {
      result[key] = values[i];
    });
    return result;
  });
}