Fork me on GitHub
Show:

File: ../src/promise.js

define([
  'aeris/util',
  'aeris/errors/invalidargumenterror'
], function(_, InvalidArgumentError) {


  /**
   * Create a lightweight Promise for async related work.
   *
   * @constructor
   * @publicApi
   * @class aeris.Promise
   */
  var Promise = function() {


    /**
     * The current state of the promise (e.g. pending, resolved, or rejected).
     *
     * @type {string}
     * @property state
     */
    this.state = 'pending';


    /**
     * An array of arguments to send to the callbacks.
     *
     * @type {Array}
     * @private
     * @property arguments_
     */
    this.arguments_ = null;


    /**
     * @typedef {Array.<Array.<Function, Object>> callbackStore
     */
    /**
     * An object containing deferred callbacks for
     * resolved and rejected states.
     *
     * @type {{done: {callbackStore}, fail: {callbackStore}, always: {callbackStore}}}
     * @private
     * @property deferred_
     */
    this.deferred_ = {
      resolved: [],
      rejected: []
    };

    // Bind resolve/reject to the promise instance
    this.resolve = this.resolve.bind(this);
    this.reject = this.reject.bind(this);
  };


  /**
   * Ensure a callback is called when the promise is resolved.
   *
   * @param {Function} callback
   * @param {Object} opt_ctx Callback context.
   * @method done
   */
  Promise.prototype.done = function(callback, opt_ctx) {
    this.bindCallbackToState_('resolved', callback, opt_ctx);

    return this;
  };


  /**
   * Ensure a callback is called when the promise is rejected.
   *
   * @param {Function} callback
   * @param {Object} opt_ctx Callback context.
   * @method fail
   */
  Promise.prototype.fail = function(callback, opt_ctx) {
    this.bindCallbackToState_('rejected', callback, opt_ctx);

    return this;
  };


  /**
   * Ensure a callback is called when the promise is either resolved or rejected.
   *
   * @param {Function} callback
   * @param {Object} opt_ctx Callback context.
   * @method always
   */
  Promise.prototype.always = function(callback, opt_ctx) {
    this.done(callback, opt_ctx);
    this.fail(callback, opt_ctx);

    return this;
  };


  /**
   * Ensure a callback is called when the promise adopts the specified state.
   *
   * @throws {aeris.errors.InvalidArgumentError} If no callback defined.
   *
   * @param {'resolved'|'rejected'} state
   * @param {Function} callback
   * @param {Object} opt_ctx Callback context.
   * @method bindCallbackToState_
   * @private
   */
  Promise.prototype.bindCallbackToState_ = function(state, callback, opt_ctx) {
    if (!_.isFunction(callback)) {
      throw new InvalidArgumentError('Invalid \'' + state + '\' state callback.');
    }

    // If state is already bound, immediately invoke callback
    if (this.state === state) {
      callback.apply(opt_ctx, this.arguments_);
    }
    else {
      this.deferred_[state].push([callback, opt_ctx]);
    }
  };


  /**
   * Mark a promise is resolved, passing in a variable number of arguments.
   *
   * @param {...*} var_args A variable number of arguments to pass to callbacks.
   * @method resolve
   */
  Promise.prototype.resolve = function(var_args) {
    this.adoptState_('resolved', arguments);
  };


  /**
   * Mark a promise is rejected, passing in a variable number of arguments.
   *
   * @param {...*} var_args
   * @method reject
   */
  Promise.prototype.reject = function(var_args) {
    this.adoptState_('rejected', arguments);
  };


  /**
   * Mark a promise with the specified state
   * passing in an array of arguments
   *
   * @param {'resolved'|'rejected'} state The state with which to mark the promise.
   * @param {Array} opt_args An array of responses to send to deferred callbacks.
   * @private
   * @method adoptState_
   */
  Promise.prototype.adoptState_ = function(state, opt_args) {
    var length;
    var callbacks;

    // Enforce state is 'rejected' or 'resolved'
    if (state !== 'rejected' && state !== 'resolved') {
      throw new Error('Invalid promise state: \'' + state + '\'. +' +
        'Valid states are \'resolved\' and \'rejected\'');
    }

    if (this.state === 'pending') {
      this.state = state;
      this.arguments_ = opt_args;

      // Run all callbacks
      callbacks = this.deferred_[this.state];
      length = callbacks.length;
      for (var i = 0; i < length; i++) {
        var fn = callbacks[i][0];
        var ctx = callbacks[i][1];
        fn.apply(ctx, this.arguments_);
      }

      // Cleanup callbacks
      for (var cbState in this.deferred_) {
        if (this.deferred_.hasOwnProperty(cbState)) {
          this.deferred_[cbState] = [];
        }
      }
    }
    // Do nothing if promise is already resolved/rejected
  };


  /**
   *
   * @return {string} The current state of the promise.
   *  'pending', 'resolved', or 'rejected'.
   * @method getState
   */
  Promise.prototype.getState = function() {
    return this.state;
  };


  /**
   * Resolve/reject the promise
   * when the proxy promise is resolved/rejected.
   *
   * @method {aeris.Promise} proxy
   */
  Promise.prototype.proxy = function(proxyPromise) {
    proxyPromise.
      done(this.resolve).
      fail(this.reject);

    return this;
  };


  /**
   * Create a master promise from a combination of promises.
   * Master promise is resolved when all component promises are resolved,
   * or rejected when any single component promise is rejected.
   *
   * @param {...*} var_args A variable number of promises to wait for or an.
   *                        array of promises.
   * @return {aeris.Promise} Master promise.
   * @method when
   */
  Promise.when = function(var_args) {
    var promises = Array.prototype.slice.call(arguments);
    var masterPromise = new Promise();
    var masterResponse = [];
    var resolvedCount = 0;

    // Allow first argument to be array of promises
    if (promises.length === 1 && promises[0] instanceof Array) {
      promises = promises[0];
    }

    promises.forEach(function(promise, i) {
      if (!(promise instanceof Promise)) {
        throw new InvalidArgumentError('Unable to create master promise: ' +
          promise.toString() + ' is not a valid Promise object');
      }

      promise.fail(function() {
        masterPromise.reject.apply(masterPromise, arguments);
      });

      promise.done(function() {
        var childResponse = _.argsToArray(arguments);
        masterResponse[i] = childResponse;
        resolvedCount++;

        if (resolvedCount === promises.length) {
          masterPromise.resolve.apply(masterPromise, masterResponse);
        }
      });
    }, this);

    // Resolve immediately if called with no promises
    if (promises.length === 0) {
      masterPromise.resolve();
    }

    return masterPromise;
  };


  /**
   * Calls the promiseFn with each member in `objects`.
   * Each call to the promiseFn will be postponed until the promise
   * returned by the previous call is resolved.
   *
   * @param {Array<*>} objects
   * @param {function():aeris.Promise} promiseFn
   * @return {aeris.Promise}
   *         Resolves with an array containing the resolution value of each
   *         call to the promiseFn.
   */
  Promise.sequence = function(objects, promiseFn) {
    var promiseToResolveAll = new Promise();
    var resolvedArgs = [];
    var rejectSequence = promiseToResolveAll.reject.
      bind(promiseToResolveAll);
    var resolveSequence = promiseToResolveAll.resolve.
      bind(promiseToResolveAll, resolvedArgs);

    var nextAt = function(i) {
      var next = _.partial(nextAt, i + 1);
      var obj = objects[i];

      if (obj) {
        Promise.callPromiseFn_(promiseFn, obj).
          done(function(arg) {
            // When the promiseFn resolves,
            // Save the resolution data
            // and run again with the next object.
            resolvedArgs.push(arg);
            next();
          }).
          fail(rejectSequence);
      }
      else {
        // No more objects exist,
        // --> we're done.
        resolveSequence();
      }
    };
    nextAt(0);

    return promiseToResolveAll;
  };


  /**
   *
   * @param {function():Promise} promiseFn
   * @param {*...} var_args
   * @private
   */
  Promise.callPromiseFn_ = function(promiseFn, var_args) {
    var args = Array.prototype.slice.call(arguments, 1);
    var promise = promiseFn.apply(null, args);

    if (!(promise instanceof Promise)) {
      throw new InvalidArgumentError('Promise.sequence expects the promiseFn ' +
        'argument to return an aeris.Promise object.');
    }

    return promise;
  };


  /**
   * Similar to Promise#when, but accepts a map function
   * which transforms array members into promises.
   *
   * eg.
   *
   *  var apiEndpoints = [ '/endpointA', '/endpointB' ];
   *
   *  function request(endpoint) {
   *  // .. returns a promise
   *  }
   *
   *  // Resolves when requests have completed for all endpoints.
   *  Promise.map(apiEndpoints, request);
   *
   * @param {Array} arr
   * @param {function(*):aeris.Promise} mapFn
   * @return {aeris.Promise}
   */
  Promise.map = function(arr, mapFn, opt_ctx) {
    return Promise.when(arr.map(mapFn, opt_ctx));
  };

  Promise.resolve = function(val) {
    var promise = new Promise();
    promise.resolve(val);
    return promise;
  };


  return _.expose(Promise, 'aeris.Promise');

});