Home Reference Source

packages/remote-instance/src/parser.js

/**
 * Copyright 2017 Moshe Simantov
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import bl from 'bl';
import deepEqual from 'deep-equal';
import msgPack5 from 'msgpack5';
import duplexify from 'duplexify';

const TYPE_CODE_MIN = 0x01;
const TYPE_CODE_MAX = 0xff;
const kParser = Symbol('parser');

/**
 * @example
 * import Parser from 'remote-instance';
 *
 * const parser = new Parser();
 */
export default class Parser {
  /**
   * Check if the given value is a remote-instance constructor.
   * An remote-instance constructor is a constructor with a method `toArgumentsList()`
   * that enable to get the construct arguments of this class to create it remotely.
   *
   * @param {function} constructor the given value to check
   * @return {boolean} True if it's a remote-instance constructor
   */
  static isConstructor(constructor) {
    return (
      typeof constructor === 'function' &&
      constructor.prototype != null &&
      typeof constructor.prototype.toArgumentsList === 'function'
    );
  }

  /**
   * Construct a new instance of the given constructor with the given arguments list.
   * If the constructor has a static method `fromArgumentsList(argumentsList)` this
   * function will use that function to construct the instance.
   *
   * @param {function} constructor The given constructor
   * @param {Array} [argumentsList] The given arguments list that will apply an the new operator.
   * @return {*} A new instance of the given constructor
   */
  static construct(constructor, argumentsList = []) {
    if (typeof constructor.fromArgumentsList === 'function') {
      return constructor.fromArgumentsList(argumentsList);
    }

    return new constructor(...argumentsList);
  }

  /**
   * Trim a given argument list to a shorter list by removing undefined or default values.
   * Default arguments comparing via strict deep-equal comparison.
   * For example, for the given argument list: `[1, 'foo', undefined, {}]` and the given
   * default: `[undefined, 'foo', 'bar', {}]`. The trimmed arguments list will be: `[1]`.
   *
   * @param {Array} argumentsList The given argument list
   * @param {Array} [defaults] The default arguments for this argument list.
   * @return {Array}
   */
  static trimArgumentsList(argumentsList, defaults = []) {
    let trimIndex = argumentsList.length;
    const opts = { strict: true };

    while (trimIndex > 0) {
      const index = trimIndex - 1;
      const arg = argumentsList[index];

      if (arg !== undefined && !deepEqual(arg, defaults[index], opts)) {
        break;
      }

      trimIndex = index;
    }

    return Array.prototype.slice.call(argumentsList, 0, trimIndex);
  }

  /**
   * Create an instance of remote-instance Parser.
   *
   * @param {object} [opts] The given options for the parser
   * @param {object} [opts.parser] An alternative `msgpack5` parser instance
   */
  constructor(opts = {}) {
    /**
     * @type {Object}
     * @private
     */
    this[kParser] = opts.parser || msgPack5();
  }

  /**
   * Register an instance on the parser with given type-code and constructor.
   * The type-code value must be consistent in all the remote devices for the given constructor.
   * The first argument could be also an object-map of type-codes and constructors instead.
   *
   * @param {number|object} typeCode The numeric type-code for this instance between `0x01` to
   * `0xff`.
   * @param {function} [constructor] A remote-instance constructor
   * @return {Parser}
   */
  register(typeCode, constructor) {
    if (
      typeof typeCode === 'object' &&
      Object.getPrototypeOf(typeCode) === Object.prototype
    ) {
      Object.keys(typeCode).forEach(key => {
        this.register(Number(key), typeCode[key]);
      });
      return this;
    }

    if (
      !Number.isInteger(typeCode) ||
      typeCode < TYPE_CODE_MIN ||
      typeCode > TYPE_CODE_MAX
    ) {
      throw new TypeError(
        `Expect typeCode to be integer between ${TYPE_CODE_MIN}-${
          TYPE_CODE_MAX
        }: ${typeCode}`
      );
    }

    if (!this.constructor.isConstructor(constructor)) {
      throw new TypeError(
        `Expect constructor to have "toArgumentsList" method: ${constructor}`
      );
    }

    this[kParser].register(
      typeCode,
      constructor,
      expr => this.encodeArgumentsList(expr.toArgumentsList()),
      buffer =>
        this.constructor.construct(
          constructor,
          this.decodeArgumentsList(buffer)
        )
    );

    return this;
  }

  /**
   * Decode a given arguments list buffer.
   *
   * @param {Buffer|BufferList} buffer an encoded arguments list buffer
   * @return {Array} The decoded arguments list
   */
  decodeArgumentsList(buffer = bl()) {
    const bufferList = buffer instanceof bl ? buffer : bl().append(buffer);
    const argumentsList = [];

    while (bufferList.length) {
      argumentsList.push(this.decode(bufferList));
    }

    return argumentsList;
  }

  /**
   * Encode a given arguments list to a buffer.
   *
   * @param {Array} argumentsList The given arguments list
   * @return {BufferList} The encoded arguments list
   */
  encodeArgumentsList(argumentsList = []) {
    const bufferList = bl();

    // Trim undefined arguments
    let maxIndex = argumentsList.length - 1;
    while (maxIndex >= 0 && argumentsList[maxIndex] === undefined) {
      maxIndex -= 1;
    }

    // Encode only the necessary values
    for (let i = 0; i <= maxIndex; i += 1) {
      const buffer = this.encode(argumentsList[i]);
      bufferList.append(buffer);
    }

    return bufferList;
  }

  /**
   * Encode an value or an instance to a buffer.
   *
   * @param {*} value Any value or registered instance
   * @return {BufferList} The encoded buffer
   */
  encode(value) {
    return this[kParser].encode(value);
  }

  /**
   * Decode an value or an instance from an encoded buffer.
   *
   * @param {Buffer|BufferList} buffer The encoded buffer
   * @return {*} Any value or registered instance
   */
  decode(buffer) {
    return this[kParser].decode(buffer);
  }

  /**
   * Get an transform stream that convert objects to buffer.
   *
   * @return {Stream}
   */
  encoder() {
    return this[kParser].encoder();
  }

  /**
   * Get an transform stream that convert buffer to objects.
   *
   * @return {Stream}
   */
  decoder() {
    return this[kParser].decoder();
  }

  /**
   * Create a duplex stream that transform duplex buffer stream to object stream.
   *
   * @param {Stream} stream A duplex buffer stream
   * @param {object} [opts] An options object to construct the duplex stream with.
   *    The option `objectMode` is forced to be true.
   * @return {Stream} A duplex object stream
   */
  transform(stream, opts = {}) {
    const duplexReadable = this.decoder();
    const duplexWritable = this.encoder();

    stream.pipe(duplexReadable);
    duplexWritable.pipe(stream);

    const objectStream = duplexify(
      duplexWritable,
      duplexReadable,
      Object.assign(opts, {
        objectMode: true,
      })
    );

    stream.on('error', err => objectStream.destroy(err));
    objectStream.on('close', () => stream.destroy());
    stream.on('close', () => objectStream.destroy());

    return objectStream;
  }
}

/**
 * @type {Parser.isConstructor}
 */
export const isConstructor = Parser.isConstructor;

/**
 * @type {Parser.construct}
 */
export const construct = Parser.construct;

/**
 * @type {Parser.trimArgumentsList}
 */
export const trimArgumentsList = Parser.trimArgumentsList;