Source: loader/Loader.js

const Signal = require('engine/MiniSignals');
const parseUri = require('./parse-uri');
const async = require('./async');
const Resource = require('./Resource');

// some constants
const MAX_PROGRESS = 100;
const rgxExtractUrlHash = /(#[\w\-]+)?$/;

/**
 * Manages the state and loading of multiple resources to load.
 *
 * @class
 */
class Loader {
  /**
   * @constructor
   * @param {string} [baseUrl=''] - The base url for all resources loaded by this loader.
   * @param {number} [concurrency=10] - The number of resources to load concurrently.
   */
  constructor(baseUrl = '', concurrency = 10) {
    /**
     * The base url for all resources loaded by this loader.
     *
     * @member {string}
     */
    this.baseUrl = baseUrl;

    /**
     * The progress percent of the loader going through the queue.
     *
     * @member {number}
     */
    this.progress = 0;

    /**
     * Loading state of the loader, true if it is currently loading resources.
     *
     * @member {boolean}
     */
    this.loading = false;

    /**
     * A querystring to append to every URL added to the loader.
     *
     * This should be a valid query string *without* the question-mark (`?`). The loader will
     * also *not* escape values for you. Make sure to escape your parameters with
     * [`encodeURIComponent`](https://mdn.io/encodeURIComponent) before assigning this property.
     *
     * @example
     *
     * ```js
     * const loader = new Loader();
     *
     * loader.defaultQueryString = 'user=me&password=secret';
     *
     * // This will request 'image.png?user=me&password=secret'
     * loader.add('image.png').load();
     *
     * loader.reset();
     *
     * // This will request 'image.png?v=1&user=me&password=secret'
     * loader.add('iamge.png?v=1').load();
     * ```
     */
    this.defaultQueryString = '';

    /**
     * The middleware to run before loading each resource.
     *
     * @member {function[]}
     */
    this._beforeMiddleware = [];

    /**
     * The middleware to run after loading each resource.
     *
     * @member {function[]}
     */
    this._afterMiddleware = [];

    /**
     * The `_loadResource` function bound with this object context.
     *
     * @private
     * @member {function}
     * @param {Resource} r - The resource to load
     * @param {Function} d - The dequeue function
     * @return {undefined}
     */
    this._boundLoadResource = (r, d) => this._loadResource(r, d);

    /**
     * The resources waiting to be loaded.
     *
     * @private
     * @member {Resource[]}
     */
    this._queue = async.queue(this._boundLoadResource, concurrency);

    this._queue.pause();

    /**
     * All the resources for this loader keyed by name.
     *
     * @member {object<string, Resource>}
     */
    this.resources = {};

    /**
     * Dispatched once per loaded or errored resource.
     *
     * The callback looks like {@link Loader.OnProgressSignal}.
     *
     * @member {Signal}
     */
    this.onProgress = new Signal();

    /**
     * Dispatched once per errored resource.
     *
     * The callback looks like {@link Loader.OnErrorSignal}.
     *
     * @member {Signal}
     */
    this.onError = new Signal();

    /**
     * Dispatched once per loaded resource.
     *
     * The callback looks like {@link Loader.OnLoadSignal}.
     *
     * @member {Signal}
     */
    this.onLoad = new Signal();

    /**
     * Dispatched when the loader begins to process the queue.
     *
     * The callback looks like {@link Loader.OnStartSignal}.
     *
     * @member {Signal}
     */
    this.onStart = new Signal();

    /**
     * Dispatched when the queued resources all load.
     *
     * The callback looks like {@link Loader.OnCompleteSignal}.
     *
     * @member {Signal}
     */
    this.onComplete = new Signal();

    /**
     * When the progress changes the loader and resource are disaptched.
     *
     * @memberof Loader
     * @callback OnProgressSignal
     * @param {Loader} loader - The loader the progress is advancing on.
     * @param {Resource} resource - The resource that has completed or failed to cause the progress to advance.
     */

    /**
     * When an error occurrs the loader and resource are disaptched.
     *
     * @memberof Loader
     * @callback OnErrorSignal
     * @param {Loader} loader - The loader the error happened in.
     * @param {Resource} resource - The resource that caused the error.
     */

    /**
     * When a load completes the loader and resource are disaptched.
     *
     * @memberof Loader
     * @callback OnLoadSignal
     * @param {Loader} loader - The loader that laoded the resource.
     * @param {Resource} resource - The resource that has completed loading.
     */

    /**
     * When the loader starts loading resources it dispatches this callback.
     *
     * @memberof Loader
     * @callback OnStartSignal
     * @param {Loader} loader - The loader that has started loading resources.
     */

    /**
     * When the loader completes loading resources it dispatches this callback.
     *
     * @memberof Loader
     * @callback OnCompleteSignal
     * @param {Loader} loader - The loader that has finished loading resources.
     */
  }

  /**
   * Adds a resource (or multiple resources) to the loader queue.
   *
   * This function can take a wide variety of different parameters. The only thing that is always
   * required the url to load. All the following will work:
   *
   * ```js
   * loader
   *     // normal param syntax
   *     .add('key', 'http://...', function () {})
   *     .add('http://...', function () {})
   *     .add('http://...')
   *
   *     // object syntax
   *     .add({
   *         name: 'key2',
   *         url: 'http://...'
   *     }, function () {})
   *     .add({
   *         url: 'http://...'
   *     }, function () {})
   *     .add({
   *         name: 'key3',
   *         url: 'http://...'
   *         onComplete: function () {}
   *     })
   *     .add({
   *         url: 'https://...',
   *         onComplete: function () {},
   *         crossOrigin: true
   *     })
   *
   *     // you can also pass an array of objects or urls or both
   *     .add([
   *         { name: 'key4', url: 'http://...', onComplete: function () {} },
   *         { url: 'http://...', onComplete: function () {} },
   *         'http://...'
   *     ])
   *
   *     // and you can use both params and options
   *     .add('key', 'http://...', { crossOrigin: true }, function () {})
   *     .add('http://...', { crossOrigin: true }, function () {});
   * ```
   *
   * @memberof Loader#
   *
   * @param {string} [name] - The name of the resource to load, if not passed the url is used.
   * @param {string} [url] - The url for this resource, relative to the baseUrl of this loader.
   * @param {object} [options] - The options for the load.
   * @param {boolean} [options.crossOrigin] - Is this request cross-origin? Default is to determine automatically.
   * @param {Resource.LOAD_TYPE} [options.loadType=Resource.LOAD_TYPE.XHR] - How should this resource be loaded?
   * @param {Resource.XHR_RESPONSE_TYPE} [options.xhrType=Resource.XHR_RESPONSE_TYPE.DEFAULT] - How should
   *      the data being loaded be interpreted when using XHR?
   * @param {object} [options.metadata] - Extra configuration for middleware and the Resource object.
   * @param {HTMLImageElement|HTMLAudioElement|HTMLVideoElement} [options.metadata.loadElement=null] - The
   *      element to use for loading, instead of creating one.
   * @param {boolean} [options.metadata.skipSource=false] - Skips adding source(s) to the load element. This
   *      is useful if you want to pass in a `loadElement` that you already added load sources to.
   * @param {function} [cb] - Function to call when this specific resource completes loading.
   * @return {Loader} Returns itself.
   */
  add(name, url, options, cb) {
        // special case of an array of objects or urls
    if (Array.isArray(name)) {
      for (let i = 0; i < name.length; ++i) {
        this.add(name[i]);
      }

      return this;
    }

        // if an object is passed instead of params
    if (typeof name === 'object') {
      cb = url || name.callback || name.onComplete;
      options = name;
      url = name.url;
      name = name.name || name.key || name.url;
    }

        // case where no name is passed shift all args over by one.
    if (typeof url !== 'string') {
      cb = options;
      options = url;
      url = name;
    }

        // now that we shifted make sure we have a proper url.
    if (typeof url !== 'string') {
      throw new Error('No url passed to add resource to loader.');
    }

        // options are optional so people might pass a function and no options
    if (typeof options === 'function') {
      cb = options;
      options = null;
    }

        // if loading already you can only add resources that have a parent.
    if (this.loading && (!options || !options.parentResource)) {
      throw new Error('Cannot add resources while the loader is running.');
    }

        // check if resource already exists.
    if (this.resources[name]) {
      throw new Error(`Resource named "${name}" already exists.`);
    }

        // add base url if this isn't an absolute url
    url = this._prepareUrl(url);

        // create the store the resource
    this.resources[name] = new Resource(name, url, options);

    if (typeof cb === 'function') {
      this.resources[name].onAfterMiddleware.once(cb);
    }

        // if loading make sure to adjust progress chunks for that parent and its children
    if (this.loading) {
      const parent = options.parentResource;
      const fullChunk = parent.progressChunk * (parent.children.length + 1); // +1 for parent
      const eachChunk = fullChunk / (parent.children.length + 2); // +2 for parent & new child

      parent.children.push(this.resources[name]);
      parent.progressChunk = eachChunk;

      for (let i = 0; i < parent.children.length; ++i) {
        parent.children[i].progressChunk = eachChunk;
      }
    }

        // add the resource to the queue
    this._queue.push(this.resources[name]);

    return this;
  }

  /**
   * Sets up a middleware function that will run *before* the
   * resource is loaded.
   *
   * @method before
   * @memberof Loader#
   * @param {function} fn - The middleware function to register.
   * @return {Loader} Returns itself.
   */
  pre(fn) {
    this._beforeMiddleware.push(fn);

    return this;
  }

  /**
   * Sets up a middleware function that will run *after* the
   * resource is loaded.
   *
   * @alias use
   * @method after
   * @memberof Loader#
   * @param {function} fn - The middleware function to register.
   * @return {Loader} Returns itself.
   */
  use(fn) {
    this._afterMiddleware.push(fn);

    return this;
  }

  /**
   * Resets the queue of the loader to prepare for a new load.
   * @memberof Loader#
   * @return {Loader} Returns itself.
   */
  reset() {
    this.progress = 0;
    this.loading = false;

    this._queue.kill();
    this._queue.pause();

    // abort all resource loads
    let k, res;
    for (k in this.resources) {
      res = this.resources[k];

      if (res._onLoadBinding) {
        res._onLoadBinding.detach();
      }

      if (res.isLoading) {
        res.abort();
      }
    }

    this.resources = {};

    return this;
  }

  /**
   * Starts loading the queued resources.
   * @memberof Loader#
   * @param {function} [cb] - Optional callback that will be bound to the `complete` event.
   * @return {Loader} Returns itself.
   */
  load(cb) {
    // register complete callback if they pass one
    if (typeof cb === 'function') {
      this.onComplete.once(cb);
    }

    // if the queue has already started we are done here
    if (this.loading) {
      return this;
    }

    // distribute progress chunks
    const chunk = 100 / this._queue._tasks.length;

    for (let i = 0; i < this._queue._tasks.length; ++i) {
      this._queue._tasks[i].data.progressChunk = chunk;
    }

    // update loading state
    this.loading = true;

    // notify of start
    this.onStart.dispatch(this);

    // start loading
    this._queue.resume();

    // complete if no tasks exist
    if (this._queue.idle()) {
      this.progress = MAX_PROGRESS;
      this._onComplete();
    }

    return this;
  }

  /**
   * Prepares a url for usage based on the configuration of this object
   *
   * @private
   * @memberof Loader#
   * @param {string} url - The url to prepare.
   * @return {string} The prepared url.
   */
  _prepareUrl(url) {
    const parsedUrl = parseUri(url, { strictMode: true });
    let result, hash;

        // absolute url, just use it as is.
    if (parsedUrl.protocol || !parsedUrl.path || url.indexOf('//') === 0) {
      result = url;
    }
        // if baseUrl doesn't end in slash and url doesn't start with slash, then add a slash inbetween
    else if (this.baseUrl.length
            && this.baseUrl.lastIndexOf('/') !== this.baseUrl.length - 1
            && url.charAt(0) !== '/'
        ) {
      result = `${this.baseUrl}/${url}`;
    }
    else {
      result = this.baseUrl + url;
    }

        // if we need to add a default querystring, there is a bit more work
    if (this.defaultQueryString) {
      hash = rgxExtractUrlHash.exec(result)[0];

      result = result.substr(0, result.length - hash.length);

      if (result.indexOf('?') !== -1) {
        result += `&${this.defaultQueryString}`;
      }
      else {
        result += `?${this.defaultQueryString}`;
      }

      result += hash;
    }

    return result;
  }

  /**
   * Loads a single resource.
   *
   * @private
   * @memberof Loader#
   * @param {Resource} resource - The resource to load.
   * @param {function} dequeue - The function to call when we need to dequeue this item.
   */
  _loadResource(resource, dequeue) {
    resource._dequeue = dequeue;

        // run before middleware
    async.eachSeries(
            this._beforeMiddleware,
            (fn, next) => {
              fn.call(this, resource, () => {
                    // if the before middleware marks the resource as complete,
                    // break and don't process any more before middleware
                next(resource.isComplete ? {} : null);
              });
            },
            () => {
              if (resource.isComplete) {
                this._onLoad(resource);
              }
              else {
                resource._onLoadBinding = resource.onComplete.once(this._onLoad, this);
                resource.load();
              }
            }
        );
  }

  /**
   * Called once each resource has loaded.
   *
   * @private
   * @memberof Loader#
   */
  _onComplete() {
    this.loading = false;

    this.onComplete.dispatch(this, this.resources);
  }

  /**
   * Called each time a resources is loaded.
   *
   * @private
   * @memberof Loader#
   * @param {Resource} resource - The resource that was loaded
   */
  _onLoad(resource) {
    resource._onLoadBinding = null;

        // run middleware, this *must* happen before dequeue so sub-assets get added properly
    async.eachSeries(
      this._afterMiddleware,
      (fn, next) => {
        fn.call(this, resource, next);
      },
      () => {
        resource.onAfterMiddleware.dispatch(resource);

        this.progress += resource.progressChunk;
        this.onProgress.dispatch(this, resource);

        if (resource.error) {
          this.onError.dispatch(resource.error, this, resource);
        }
        else {
          this.onLoad.dispatch(this, resource);
        }

        // remove this resource from the async queue
        resource._dequeue();

        // do completion check
        if (this._queue.idle()) {
          this.progress = MAX_PROGRESS;
          this._onComplete();
        }
      }
    );
  }
}

module.exports = Loader;