Home Reference Source

src/systems/AssetSystem/index.js

import System from '../System';
import Asset from './Asset';
import Events from '../../utils/Events';
import parser from '../../utils/jsonParser';

export { Asset };

const _pathRegex = /(\w+)(\:\/\/)(.*)/;

/**
 * Assets database and loader.
 *
 * @example
 * const system = new AssetSystem('assets/', { cache: 'no-store' }, AssetSystem.fetchArrayView);
 */
export default class AssetSystem extends System {

  /**
   * Default browser fetch mechanism.
   *
   * @param {*}	args - Fetch engine parameters.
   *
   * @return {Promise} Promise that fetches file.
   */
  static fetch(...args) {
    return fetch(...args);
  }

  /**
   * Custom fetch mechanism that loads data from array view.
   *
   * @param {ArrayBufferView}	view - Data array buffer view.
   * @param {string}	path - Asset path.
   * @param {*}	options - Fetch engine options.
   * @param {Function}	fallbackEngine - Fallback fetch engine.
   *
   * @return {Promise} Promise that fetches file from array view.
   */
  static fetchArrayView(view, path, options = {}, fallbackEngine = null) {
    if (!!view && !ArrayBuffer.isView(view)) {
      throw new Error('`view` is not type of ArrayView!');
    }
    if (typeof path !== 'string') {
      throw new Error('`path` is not type of String!');
    }
    if (!!fallbackEngine && !(fallbackEngine instanceof Function)) {
      throw new Error('`fallbackEngine` is not type of Function!');
    }

    try {
      if (!view) {
        if (!!fallbackEngine) {
          return fallbackEngine(path, options);
        } else {
          return Promise.resolve(new Response(new Blob(), { status: 404 }));
        }
      } else {
        return Promise.resolve(
          new Response(new Blob([ view ]), { status: 200 })
        );
      }
    } catch(error) {
      return Promise.reject(error);
    }
  }

  /**
   * Web fetch mechanism generator that loads data from specified address.
   *
   * @param {string}	address - Assets hosting address.
   * @param {Function}	fallbackEngine - Fallback fetch engine.
   *
   * @return {Function} Function that fetches file from web.
   */
  static makeFetchEngineWeb(address, fallbackEngine = AssetSystem.fetch) {
    if (typeof address !== 'string') {
      throw new Error('`address` is not type of String!');
    }

    return (path, options) => AssetSystem.fetch(
      `${address}/${path}`,
      options,
      fallbackEngine
    );
  }

  /** @type {string} */
  get pathPrefix() {
    return this._pathPrefix;
  }

  /** @type {*} */
  get fetchOptions() {
    return this._fetchOptions;
  }

  /** @type {Function} */
  get fetchEngine() {
    return this._fetchEngine;
  }

  /** @type {Function} */
  set fetchEngine(value) {
    if (!(value instanceof Function)) {
      throw new Error('`value` is not type of Function!');
    }

    this._fetchEngine = value;
  }

  /** @type {Events} */
  get events() {
    return this._events;
  }

  /**
   * Constructor.
   *
   * @param {string|null}	pathPrefix - Path prefix used for every requested asset or null no path prefix.
   * @param {*|null}	fetchOptions - Custom fetch options or null if default will be used.
   * @param {Function|null}	fetchEngine - Custom fetch engine or null if default will be used.
   */
  constructor(pathPrefix, fetchOptions, fetchEngine) {
    super();

    if (!!pathPrefix && typeof pathPrefix !== 'string') {
      throw new Error('`pathPrefix` is not type of String!');
    }

    this._pathPrefix = pathPrefix || '';
    this._fetchOptions = fetchOptions || {};
    this._fetchEngine = fetchEngine || AssetSystem.fetch;
    this._assets = new Map();
    this._loaders = new Map();
    this._events = new Events();
    this._loaded = 0;
    this._toLoad = 0;
  }

  /**
   * Destructor (dispose internal resources and clear assets database).
   *
   * @example
   * system.dispose();
   * system = null;
   */
  dispose() {
    const { _assets, _loaders, _events } = this;

    for (const asset of _assets.values()) {
      asset.dispose();
    }

    _assets.clear();
    _loaders.clear();
    _events.dispose();
  }

  /**
   * Register assets loader under given protocol.
   *
   * @param {string}	protocol - Assets loader protocol name.
   * @param {Function}	assetConstructor - Asset factory.
   *
   * @example
   * system.registerProtocol('json', JSONAsset.factory);
   */
  registerProtocol(protocol, assetConstructor) {
    if (typeof protocol !== 'string') {
      throw new Error('`protocol` is not type of String!');
    }
    if (!(assetConstructor instanceof Function)) {
      throw new Error('`assetConstructor` is not type of Function!');
    }

    const { _loaders } = this;

    if (_loaders.has(protocol)) {
      throw new Error(`There is already registered protocol: ${protocol}`);
    }

    _loaders.set(protocol, assetConstructor);
  }

  /**
   * Unregister given assets loader protocol.
   *
   * @param {string}	protocol - Assets loader protocol name.
   *
   * @example
   * system.unregisterProtocol('json');
   */
  unregisterProtocol(protocol) {
    if (typeof protocol !== 'string') {
      throw new Error('`protocol` is not type of String!');
    }
    if (!this._loaders.delete(protocol)) {
      throw new Error(`There is no registered protocol: ${protocol}`);
    }
  }

  /**
   * Get asset by it's full path.
   *
   * @param {string}	path - Asset path (with protocol).
   *
   * @return {Asset|null} Asset instance if found or null otherwise.
   *
   * @example
   * const config = system.get('json://config.json');
   */
  get(path) {
    if (typeof path !== 'string') {
      throw new Error('`path` is not type of String!');
    }

    return this._assets.get(path) || null;
  }

  /**
   * Load asset from given path.
   *
   * @param {string}	path - Asset path (with protocol).
   *
   * @return {Promise} Promise of fetch engine loader.
   *
   * @example
   * system.load('json://config.json').then(asset => console.log(asset.data));
   */
  load(path) {
    this._events.trigger('progress', this._loaded, this._toLoad);
    ++this._toLoad;
    return this._load(path)
      .then(asset => {
        ++this._loaded;
        this._events.trigger('progress', this._loaded, this._toLoad);
        --this._loaded;
        --this._toLoad;
        this._events.trigger('progress', this._loaded, this._toLoad);
        return asset;
      })
      .catch(error => {
        ++this._loaded;
        this._events.trigger('progress', this._loaded, this._toLoad);
        --this._loaded;
        --this._toLoad;
        this._events.trigger('progress', this._loaded, this._toLoad);
        throw error;
      });
  }

  /**
   * Load list of assets in sequence (one by one).
   *
   * @param {Array.<string>}	paths - Array of assets paths.
   *
   * @return {Promise} Promise of fetch engine loader.
   *
   * @example
   * const list = [ 'json://config.json', 'text://hello.txt' ];
   * system.loadSequence(list).then(assets => console.log(assets.map(a => a.data)));
   */
  async loadSequence(paths) {
    if (!(paths instanceof Array)) {
      throw new Error('`paths` is not type of Array!');
    }

    const result = [];
    this._events.trigger('progress', this._loaded, this._toLoad);
    this._toLoad += paths.length;
    for (let i = 0, c = paths.length; i < c; ++i) {
      result.push(
        await this._load(paths[i])
          .then(asset => {
            ++this._loaded;
            this._events.trigger('progress', this._loaded, this._toLoad);
            return asset;
          })
          .catch(error => {
            ++this._loaded;
            this._events.trigger('progress', this._loaded, this._toLoad);
            throw error;
          })
      );
    }

    this._loaded -= paths.length;
    this._toLoad -= paths.length;
    this._events.trigger('progress', this._loaded, this._toLoad);
    return result;
  }

  /**
   * Load list of assets possibly all at the same time (asynchronously).
   *
   * @param {Array.<string>}	paths - Array of assets paths.
   *
   * @return {Promise} Promise of fetch engine loader.
   *
   * @example
   * const list = [ 'json://config.json', 'text://hello.txt' ];
   * system.loadAll(list).then(assets => console.log(assets.map(a => a.data)));
   */
  async loadAll(paths) {
    if (!(paths instanceof Array)) {
      throw new Error('`paths` is not type of Array!');
    }

    this._events.trigger('progress', this._loaded, this._toLoad);
    this._toLoad += paths.length;
    return Promise.all(paths.map(
      path => this._load(path)
        .then(asset => {
          ++this._loaded;
          this._events.trigger('progress', this._loaded, this._toLoad);
          return asset;
        })
        .catch(error => {
          ++this._loaded;
          this._events.trigger('progress', this._loaded, this._toLoad);
          throw error;
        })
    ))
    .then(assets => {
      this._loaded -= paths.length;
      this._toLoad -= paths.length;
      this._events.trigger('progress', this._loaded, this._toLoad);
      return assets;
    })
    .catch(error => {
      this._loaded -= paths.length;
      this._toLoad -= paths.length;
      this._events.trigger('progress', this._loaded, this._toLoad);
      throw error;
    });
  }

  /**
   * Load asset from given path with specified fetch engine.
   *
   * @param {string}	path - Asset path (with protocol).
   * @param {Function} fetchEngine - Fetch engine used to fetch asset.
   *
   * @return {Promise} Promise of fetch engine loader.
   *
   * @example
   * system.loadWithFetchEngine('json://config.json', AssetSystem.fetch).then(asset => console.log(asset.data));
   */
  async loadWithFetchEngine(path, fetchEngine) {
    const fe = this.fetchEngine;
    this.fetchEngine = fetchEngine;
    const result = await this.load(path);
    this.fetchEngine = fe;
    return result;
  }

  /**
   * Load list of assets in sequence (one by one) with specified fetch engine.
   *
   * @param {Array.<string>}	paths - Array of assets paths.
   * @param {Function} fetchEngine - Fetch engine used to fetch assets.
   *
   * @return {Promise} Promise of fetch engine loader.
   *
   * @example
   * const list = [ 'json://config.json', 'text://hello.txt' ];
   * system.loadSequenceWithFetchEngine(list, AssetSystem.fetch).then(assets => console.log(assets.map(a => a.data)));
   */
  async loadSequenceWithFetchEngine(paths, fetchEngine) {
    const fe = this.fetchEngine;
    this.fetchEngine = fetchEngine;
    const result = await this.loadSequence(paths);
    this.fetchEngine = fe;
    return result;
  }

  /**
   * Load list of assets possibly all at the same time (asynchronously) with specified fetch engine.
   *
   * @param {Array.<string>}	paths - Array of assets paths.
   * @param {Function} fetchEngine - Fetch engine used to fetch assets.
   *
   * @return {Promise} Promise of fetch engine loader.
   *
   * @example
   * const list = [ 'json://config.json', 'text://hello.txt' ];
   * system.loadAllWithFetchEngine(list, AssetSystem.fetch).then(assets => console.log(assets.map(a => a.data)));
   */
  async loadAllWithFetchEngine(paths, fetchEngine) {
    const fe = this.fetchEngine;
    this.fetchEngine = fetchEngine;
    const result = await this.loadAll(paths);
    this.fetchEngine = fe;
    return result;
  }

  /**
   * Unload asset by path and remove from database.
   *
   * @param {string}	path - Asset path (with protocol).
   *
   * @example
   * system.unload('json://config.json');
   */
  unload(path) {
    const { _assets } = this;
    const asset = _assets.get(path);

    if (!asset) {
      throw new Error(`Trying to unload non-existing asset: ${path}`);
    }

    this._events.trigger('unload', asset);
    asset.dispose();
    _assets.delete(path);
  }

  /**
   * Unload all assets from paths list.
   *
   * @param {Array.<string>}	paths - Array of assets paths.
   */
  unloadAll(paths) {
    if (!(paths instanceof Array)) {
      throw new Error('`paths` is not type of Array!');
    }

    for (const path of paths) {
      this.unload(path);
    }
  }

  /**
   * @override
   */
  onUnregister() {
    dispose();
  }

  _load(path) {
    if (typeof path !== 'string') {
      throw new Error('`path` is not type of String!');
    }

    const found = path.lastIndexOf('|');
    if (found < 0) {
      return this._loadPart(path);
    } else {
      const prefix = path.substr(0, found).trim();
      const container = this._assets.get(prefix);
      if (!container) {
        throw new Error(`There is no loaded subassets container: ${prefix}`);
      }

      const oldEngine = this.fetchEngine;
      const postfix = path.substr(found + 1).trim();
      this.fetchEngine = container.makeFetchEngine(oldEngine);
      return this._loadPart(postfix, path).finally(() => {
        this.fetchEngine = oldEngine;
      });
    }
  }

  _loadPart(path, key = null) {
    if (typeof path !== 'string') {
      throw new Error('`path` is not type of String!');
    }
    let options = null;
    const found = path.indexOf('?');
    if (found >= 0) {
      options = parser.parse(path.substr(found + 1));
      path = path.substr(0, found);
    }
    if (!key) {
      key = path;
    }

    const result = _pathRegex.exec(path);
    if (!result) {
      throw new Error(`\`path\` does not conform asset path name rules: ${path}`);
    }

    const [ , protocol,, filename ] = result;
    const loader = this._loaders.get(protocol);
    if (!loader) {
      throw new Error(`There is no registered protocol: ${protocol}`);
    }

    const { _assets } = this;
    if (_assets.has(path)) {
      return Promise.resolve(_assets.get(path));
    }

    const asset = loader(this, protocol, filename, options);
    if (!(asset instanceof Asset)) {
      throw new Error(
        `Cannot create asset for file: ${filename} of protocol: ${protocol}`
      );
    }

    return asset.load().then(data => {
      this._assets.set(key, asset);
      this._events.trigger('load', asset);
      return data;
    });
  }

}