index.js

/**
 * @external Class
 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes
 */

/**
 * @callback EnhancementCreator
 * @param {Class} Target  The class to enhance.
 * @returns {Proxy<Class>} A proxied version of the `Target`.
 */

/**
 * @callback GetDependencies
 * @param {Array} list  All the dependencies Aurelia returned, for both the target class
 *                      and the enhancement class.
 * @returns {Array} A list of dependencies for the requested case, be the target class or
 *                  the enhancement class.
 * @ignore
 */

/**
 * @typedef {Object} InjectData
 * @property {Array}           list               The unique list of all the dependencies
 *                                                both classes need.
 * @property {GetDependencies} getForTarget       Given the list of all the obtained
 *                                                dependencies,
 *                                                it filters the ones needed for the
 *                                                target class.
 * @property {GetDependencies} getForEnhancement  Given the list of all the obtained
 *                                                dependencies,
 *                                                it filters the ones needed for the
 *                                                enhancement class.
 * @ignore
 */

/**
 * These are necessary resources for the `isNativeFn` function.
 *
 * @ignore
 */
const { toString } = Object.prototype;
/**
 * @ignore
 */
const { toString: fnToString } = Function.prototype;
/**
 * @ignore
 */
const reBase = String(toString)
  .replace(/[.*+?^${}()|[\]\/\\]/g, '\\$&')
  .replace(/toString|(function).*?(?=\\\()| for .+?(?=\\\])/g, '$1.*?');
/**
 * @ignore
 */
const reNative = RegExp(`^${reBase}$`);
/**
 * Checks whether a function is native or not.
 *
 * @param {Function} fn  The function to validate.
 * @returns {boolean}
 * @see https://davidwalsh.name/detect-native-function
 * @ignore
 */
const isNativeFn = (fn) => fnToString.call(fn).match(reNative);
/**
 * This utility function takes care of generating a unique list of dependencies for both,
 * the target and the class that enhances it. It then provides methods to extract the
 * dependencies of each one when Aurelia is done instantiating them.
 *
 * @param {Array} [target=[]]       The list of dependencies for the target class.
 * @param {Array} [enhancement=[]]  The list of dependencies for the enhance class.
 * @returns {InjectData}
 * @ignore
 */
const getInjectData = (target = [], enhancement = []) => {
  const list = target.slice();
  const enhancementPositions = {};
  enhancement.forEach((dep) => {
    const targetIndex = list.indexOf(dep);
    if (targetIndex > -1) {
      enhancementPositions[dep] = targetIndex;
    } else {
      list.push(dep);
      enhancementPositions[dep] = list.length - 1;
    }
  });

  return {
    list,
    /**
     * Given the list of all the obtained dependencies, it filters the ones needed for the
     * target class.
     *
     * @type {GetDependencies}
     * @ignore
     */
    getForTarget: (values) => values.slice(0, target.length),
    /**
     * Given the list of all the obtained dependencies, it filters the ones needed for the
     * enhancement class.
     *
     * @type {GetDependencies}
     * @ignore
     */
    getForEnhancement: (values) =>
      enhancement.map((dep) => values[enhancementPositions[dep]]),
  };
};
/**
 * This is called from the proxy created on {@link enhanceInstance} when the enhancement
 * implements a method of the target that is being requested.
 * The function will first call the enhanced method, then evaluate whether it should
 * resolved as a promise (becuase the method returned a `Promise`) or sync, check if the
 * target implements the lifecycle method to recive what the enhancement returned and
 * finally, call the original method.
 *
 * @param {Object}  target       The target class instance.
 * @param {Object}  enhancement  The instance with the enhanced methods.
 * @param {string}  name         The name of the method being requested.
 * @param {boolean} callTarget   Whether or not the target method should be called.
 * @returns {Function} A version of the method that calls both, the enhancement and the
 *                     original.
 * @ignore
 */
const composeMethod = (target, enhancement, name, callTarget) => (...args) => {
  const enhancedMethod = enhancement[name];
  const enhancedValue = enhancedMethod.bind(enhancement)(...args);
  const normalizedName = name.replace(/^[a-z]/, (match) => match.toUpperCase());
  const lcMethodName = `enhanced${normalizedName}Return`;
  const hasLCMethod = typeof target[lcMethodName] === 'function';
  let result;
  const isPromise = enhancedValue && typeof enhancedValue.then === 'function';
  if (isPromise) {
    result = enhancedValue.then((value) => {
      if (hasLCMethod) {
        target[lcMethodName].bind(target)(value, enhancement);
      }

      return callTarget ? target[name](...args) : value;
    });
  } else {
    if (hasLCMethod) {
      target[lcMethodName].bind(target)(enhancedValue, enhancement);
    }

    result = callTarget ? target[name].bind(target)(...args) : enhancedValue;
  }

  return result;
};
/**
 * Creates a proxy for a target class instance so when a method is called, it will check
 * if the enhancement class implements its in order to trigger that one before the
 * original.
 *
 * @param {Class}  ProxyClass   The definition of the proxy class. This is needed in order
 *                              to return it when Aurelia asks for the instance
 *                              constructor.
 * @param {Object} target       The target class instance to proxy.
 * @param {Object} enhancement  The instance that will add methods to the target class.
 * @returns {Object} A proxied version of the `target`.
 * @ignore
 */
const enhanceInstance = (ProxyClass, target, enhancement) =>
  new Proxy(target, {
    /**
     * This a proxy trap for when the implementation tries to access a property of the
     * proxy.
     * It validates if its the constructor, in order to return the `ProxyClass`, then
     * validates if it's a native method, then if it's present on the enhancement, and
     * finally on the target class.
     *
     * @param {Object} targetCls  The original class.
     * @param {string} name       The name of the property.
     * @returns {*}
     * @ignore
     */
    get: (targetCls, name) => {
      let result;
      if (name === 'constructor') {
        result = ProxyClass;
      } else {
        const targetValue = targetCls[name];
        const targetIsFn = typeof targetValue === 'function';
        const enhancementValue = enhancement[name];
        const enhancementIsFn = typeof enhancementValue === 'function';
        if (targetIsFn && isNativeFn(targetValue)) {
          result = targetValue;
        } else if (enhancementIsFn) {
          result = composeMethod(targetCls, enhancement, name, targetIsFn);
        } else {
          result = targetValue;
        }
      }

      return result;
    },
    /**
     * This is a proxy trap for when `in` is called, it validates first on the original
     * class and then on the enhancement.
     *
     * @param {Object} targetCls  The original class.
     * @param {string} name       The name of the property.
     * @returns {boolean}
     * @ignore
     */
    has: (targetCls, name) => name in targetCls || name in enhancement,
    /**
     * This is a proxy trap for `getOwnPropertyDescriptor`, it first validates against the
     * enhancement and then does a fallback to the original class.
     *
     * @param {Object} targetCls  The original class.
     * @param {string} name       The name of the property.
     * @returns {Object}
     * @ignore
     */
    getOwnPropertyDescriptor: (targetCls, name) => {
      let result = Object.getOwnPropertyDescriptor(enhancement, name);
      if (typeof result === 'undefined') {
        result = Object.getOwnPropertyDescriptor(targetCls, name);
      }

      return result;
    },
    /**
     * This is a proxy trap for the `Object.keys` function. It gets the keys from the
     * original class and then from the enhancement.
     *
     * @param {Object} targetCls  The original class.
     * @returns {string[]}
     * @ignore
     */
    ownKeys: (targetCls) => [
      ...new Set([...Reflect.ownKeys(targetCls), ...Reflect.ownKeys(enhancement)]),
    ],
  });
/**
 * Creates a proxy from a target class declaration in order to:
 * 1. Concatenate the list of dependencies both classes need.
 * 2. Instance both the target and the enhancement classes, sending the right
 * dependencies.
 *
 * @param {Class} Target       The target class to proxy.
 * @param {Class} Enhancement  The class that will add methods to the target.
 * @returns {Class} A proxied version of the `Target`.
 * @ignore
 */
const proxyClass = (Target, Enhancement) => {
  const injectData = getInjectData(Target.inject, Enhancement.inject);
  const ProxyClass = new Proxy(Target, {
    /**
     * This is a proxy trap for the constructor; it instantiates the original class, then
     * the enhacement, creates a proxy with both together, and returns the proxy.
     *
     * @param {T}     TargetCls  The original class.
     * @param {Array} args       The arguments sent to the constructor.
     * @returns {Proxy<T>}
     * @template T  The type of the class to proxy.
     * @ignore
     */
    construct: (TargetCls, args) => {
      const targetInstance = new TargetCls(...injectData.getForTarget(args));
      const enhancementInstance = new Enhancement(
        targetInstance,
        ...injectData.getForEnhancement(args),
      );

      return enhanceInstance(ProxyClass, targetInstance, enhancementInstance);
    },
    /**
     * This a proxy trap for when the implementation tries to access a property of the
     * proxy.
     * It checks if the property is `inject` in order to return the list of dependencies
     * for both classes (original and enhancement).
     *
     * @param {Object} target  The original class.
     * @param {string} name    The name of the property.
     * @returns {*}
     * @ignore
     */
    get: (target, name) => (name === 'inject' ? injectData.list : target[name]),
    /**
     * This is a proxy trap for `getOwnPropertyDescriptor`, it's used to validate if the
     * property to access is the list of dependencies.
     *
     * @param {Object} target  The original class.
     * @param {string} name    The name of the property.
     * @returns {Object}
     * @ignore
     */
    getOwnPropertyDescriptor: (target, name) =>
      name === 'inject'
        ? {
            configurable: true,
            enumerable: true,
            value: injectData.list,
          }
        : Object.getOwnPropertyDescriptor(target, name),
  });

  return ProxyClass;
};
/**
 * Creates a function to enhance an Aurelia's class with other class(es).
 * This method has this sintax because is intended to be used as a decorator.
 *
 * @param {...Class} enhancements  The class or list of classes to enhance the target.
 * @returns {EnhancementCreator}
 * @example
 *
 * <caption>As decorator</caption>
 *
 * \@enhance(MyEnhancement)
 * class MyViewModel { ... }
 *
 * @example
 *
 * <caption>As function:</caption>
 *
 *   enhance(MyEnhancement)(MyViewModel);
 *
 */
const enhance = (...enhancements) => (Target) =>
  enhancements.reduce((Current, Enhancement) => proxyClass(Current, Enhancement), Target);

module.exports = enhance;