Source: AMF3/serializer.js

import DynBuffer from '@seirdotexe/dynbuffer';
import Markers from '../AMF/markers.js';
import { determineArray, isNativeObject } from '../AMF/utils.js';
import Reference from './reference.js';

/**
 * @typedef {import('../AMF/alias.js').default} ClassAlias
 */

/** @module AMF3/Serializer */
export default class Serializer {
  /**
   * The AMF class alias holder
   * @private
   * @type {ClassAlias}
   */
  #classAlias;
  /**
   * The DynBuffer instance containing AMF3 bytes for this instance
   * @private
   * @type {DynBuffer}
   */
  #dynbuf;
  /**
   * The AMF3 reference holder
   * @private
   * @type {Reference}
   */
  #reference;
  /**
   * The Dynamic Property Writer function holder
   * @private
   * @type {Function}
   */
  #dynamicPropertyWriter;

  /**
   * Creates a new AMF3 serializer
   * @param {ClassAlias} classAlias - The class alias internally coming from the AMF entrypoint class
   */
  constructor(classAlias) {
    this.#classAlias = classAlias;
    this.#dynbuf = new DynBuffer();
    this.#reference = new Reference();
  }

  /**
   * Cache the method of a Dynamic Property Writer to the AMF0 serializer class
   * @param {Function} method - The Dynamic Property Writer method
   */
  set dynamicPropertyWriter(method) {
    this.#dynamicPropertyWriter = method;
  }

  /**
   * Returns the AMF3 reference reset method, needed for serializing and deserializing AMF packets in AMF0 base
   * @returns {Function} The 'reset' function coming from the AMF3 Reference class
   */
  get reset() {
    return this.#reference.reset;
  }

  /**
   * Writes a variable length unsigned 29-bit integer
   * @private
   * @param {number} value - The value to encode
   * @throws {RangeError} If the given value is greater than 2^29 - 1
   */
  #writeUint29(value) {
    if (value < 0x80) {
      this.#dynbuf.writeByte(value);
    } else if (value < 0x4000) {
      this.#dynbuf.writeByte(((value >> 7) & 0x7F) | 0x80);
      this.#dynbuf.writeByte(value & 0x7F);
    } else if (value < 0x200000) {
      this.#dynbuf.writeByte(((value >> 14) & 0x7F) | 0x80);
      this.#dynbuf.writeByte(((value >> 7) & 0x7F) | 0x80);
      this.#dynbuf.writeByte(value & 0x7F);
    } else if (value < 0x40000000) {
      this.#dynbuf.writeByte(((value >> 22) & 0x7F) | 0x80);
      this.#dynbuf.writeByte(((value >> 15) & 0x7F) | 0x80);
      this.#dynbuf.writeByte(((value >> 8) & 0x7F) | 0x80);
      this.#dynbuf.writeByte(value & 0xFF);
    } else {
      throw new RangeError(`The uint29 '${value}' is out of range for AMF3 U29.`);
    }
  }

  /**
   * Flushes the DynBuffer instance by caching the current stream, clearing the holding stream, and returning the cached stream containing the AMF bytes
   * @returns {Buffer} The buffer containing AMF bytes
   */
  flush() {
    const { stream } = this.#dynbuf;

    this.#dynbuf.clear();

    return stream;
  }

  /**
   * Serializes an object into AMF binary data
   * @param {any} value - The value to serialize
   * @returns {Serializer} Returns the AMF serializer to perform a swift flush in AMF entrypoint class
   */
  serialize(value) {
    if (value === null) {
      this.#serializeNull();
    } else if (value === undefined) {
      this.#serializeUndefined();
    } else {
      const type = value?.constructor?.name;

      switch (type) {
        case 'Number': this.#serializeInteger(value); break;
        case 'Boolean': this.#serializeBoolean(value); break;
        case 'String': this.#serializeString(value); break;
        case 'Object': this.#serializeObject(value); break;
        case 'Array': this.#serializeArray(value); break;
        case 'Date': this.#serializeDate(value); break;
        case 'DynBuffer': case 'Buffer': this.#serializeByteArray(value); break;
        case 'Int32Array': case 'Uint32Array': case 'Float64Array': this.#serializeVector(value, type); break;
        case 'Map': this.#serializeDictionary(value); break;
        default: this.#serializeUnidentifiedObject(value);
      }
    }

    return this;
  }

  /**
   * Serializes a null
   * @private
   */
  #serializeNull() {
    this.#dynbuf.writeByte(Markers.AMF3.NULL);
  }

  /**
   * Serializes an undefined
   * @private
   */
  #serializeUndefined() {
    this.#dynbuf.writeByte(Markers.AMF3.UNDEFINED);
  }

  /**
   * Serializes an integer
   * @private
   * @param {number} value - The integer to serialize
   */
  #serializeInteger(value) {
    if ((value << 3 >> 3) === value) { // Does the value fit into 29 bits?
      this.#dynbuf.writeByte(Markers.AMF3.INTEGER);
      this.#writeUint29(value & 0x1FFFFFFF); // Signed conversion to int29
    } else {
      this.#dynbuf.writeByte(Markers.AMF3.DOUBLE);
      this.#dynbuf.writeDouble(value);
    }
  }

  /**
   * Serializes a boolean
   * @private
   * @param {boolean} value - The boolean to serialize
   */
  #serializeBoolean(value) {
    this.#dynbuf.writeByte(value ? Markers.AMF3.TRUE : Markers.AMF3.FALSE);
  }

  /**
   * Serializes a string
   * @private
   * @param {string} value - The string to serialize
   * @param {boolean} [marker=true] - Whether the string marker should be included
   */
  #serializeString(value, marker = true) {
    if (marker) this.#dynbuf.writeByte(Markers.AMF3.STRING);

    const length = Buffer.byteLength(value);
    if (length === 0) return this.#writeUint29(1);

    const cache = this.#reference.has(value, 'strings');
    if (cache.referenced) return this.#writeUint29(cache.index << 1);

    this.#writeUint29((length << 1) | 1);
    this.#dynbuf.writeUTFBytes(value);
  }

  /**
   * Serializes an object
   * @private
   * @param {object} value - The object to serialize
   * @throws {ReferenceError} If an attempt is made to serialize an unregistered externalizable class
   */
  #serializeObject(value) {
    if (this.#dynamicPropertyWriter) this.#dynamicPropertyWriter(value);

    this.#dynbuf.writeByte(Markers.AMF3.OBJECT);

    const cacheObj = this.#reference.has(value, 'objects');
    if (cacheObj.referenced) return this.#writeUint29(cacheObj.index << 1); // U29O-ref

    const proto = Object.getPrototypeOf(value);
    const traits = {};

    // The identified class name of the value. Empty for regular objects, else it's defined by a registered class alias
    traits.className = this.#classAlias.getAliasByClass(proto.constructor) || '';
    // Whether the value is externalizable. It gives more functionality in how to write/read properties
    traits.externalizable = ('writeExternal' in value) && ('readExternal' in value); // Todo - Decorators, but this is viable for now
    // Whether the value's class is dynamic. This is a tough one as every object in JS is dynamic; you can always add properties
    // To give functionality to mark a class as being dynamic, we check a getter named 'dynamic'. We replicate AVM as best as possible for anything else
    traits.dynamic = (Object.getOwnPropertyDescriptor(proto, 'dynamic')?.get && value?.dynamic) || (!traits.className && proto.constructor.name === 'Object');
    // The identified sealed members of the value, only applicable to typed objects
    traits.keys = (traits.externalizable || proto.constructor.name === 'Object') ? [] : Object.keys(value);
    // Last, the amount of sealed members
    traits.count = traits.keys.length;

    // AMF3 requires externalizable objects to have a registered alias
    if (traits.externalizable && !traits.className) {
      throw new ReferenceError(`Tried to serialize an unregistered externalizable class: '${proto.constructor.name}'.`);
    }

    const cacheTraits = this.#reference.has(traits, 'traits');
    if (cacheTraits.referenced) {
      this.#writeUint29((cacheTraits.index << 2) | 1); // U29O-traits-ref
    } else {
      this.#writeUint29(3 | (traits.externalizable ? 4 : 0) | (traits.dynamic ? 8 : 0) | (traits.count << 4)); // U29O-traits
      this.#serializeString(traits.className, false); // class-name

      traits.keys.forEach((traitKey) => this.#serializeString(traitKey, false)); // Write sealed member names first, as specified in the spec
    }

    // Write sealed member values
    for (let i = 0; i < traits.count; i++) {
      this.serialize(value[traits.keys[i]]);
    }

    if (traits.externalizable) {
      value.writeExternal(this.#dynbuf);
    } else if (traits.dynamic) {
      for (const key in value) {
        if (traits.keys.includes(key)) continue; // This is necessary, because it prevents writing sealed member names a second time for registered classes

        this.#serializeString(key, false);
        this.serialize(value[key]);
      }

      this.#writeUint29(1); // Dynamic object empty string terminator
    }
  }

  /**
   * Serializes an array
   * @private
   * @param {any[]} value - The array to serialize
   */
  #serializeArray(value) {
    if (Object.getOwnPropertyDescriptor(value, 'VectorObject')) {
      return this.#serializeVector(value, 'ObjectArray');
    }

    this.#dynbuf.writeByte(Markers.AMF3.ARRAY);

    const cacheObj = this.#reference.has(value, 'objects');
    if (cacheObj.referenced) return this.#writeUint29(cacheObj.index << 1);

    this.#writeUint29((value.length << 1) | 1); // The AMF3 spec mentions only to encode the count of the dense (and for us in JS, also sparse) portion of the array

    /*
    Once again, AVM is weird in handling arrays. Setting by index turns it into an associative array.

    Option 1 - Associative:
    var value:* = [];
    value[1] = 1; // or even ["1"]
    buffer: 09 01 03 31 04 01 01

    Option 2 - Dense:
    var value:* = [,1];
    buffer: 09 05 01 00 04 01

    Since there's no way for us to tell them apart, we're going with option 2, because that's how it's done in JS. The serialized output will differ but its functionality won't break!
    */

    // AVM writes the associative part first
    for (const key in value) {
      if (isNaN(key)) { // Skip dense values
        this.#serializeString(key, false);
        this.serialize(value[key]);
      }
    }

    this.#writeUint29(1); // Associative array empty string terminator

    for (let i = 0; i < value.length; i++) {
      this.serialize(value[i]);
    }
  }

  /**
   * Serializes a date
   * @private
   * @param {Date} value - The date to serialize
   */
  #serializeDate(value) {
    this.#dynbuf.writeByte(Markers.AMF3.DATE);

    const cache = this.#reference.has(value, 'objects');
    if (cache.referenced) return this.#writeUint29(cache.index << 1);

    this.#writeUint29(1); // U29D-value
    this.#dynbuf.writeDouble(value.getTime());
  }

  /**
   * Serializes a ByteArray (DynBuffer)
   * @private
   * @param {DynBuffer|Buffer} value - The ByteArray to serialize
   */
  #serializeByteArray(value) {
    this.#dynbuf.writeByte(Markers.AMF3.BYTE_ARRAY);

    const cache = this.#reference.has(value, 'objects');
    if (cache.referenced) return this.#writeUint29(cache.index << 1);

    this.#writeUint29((value.length << 1) | 1);
    this.#dynbuf.writeBytes(value);
  }

  /**
   * Serializes a Vector (typed array)
   * @private
   * @param {Int32Array|Uint32Array|Float64Array|any[]} value - The Vector to serialize
   * @param {'Int32Array'|'Uint32Array'|'Float64Array'|'ObjectArray'} type - The type of the Vector to serialize
   * @throws {TypeError} If a Vector Object was tried to serialize with multiple object types
   */
  #serializeVector(value, type) {
    this.#dynbuf.writeByte(
      type === 'Int32Array' ? Markers.AMF3.VECTOR_INT :
        type === 'Uint32Array' ? Markers.AMF3.VECTOR_UINT :
          type === 'Float64Array' ? Markers.AMF3.VECTOR_DOUBLE : Markers.AMF3.VECTOR_OBJECT);

    const cache = this.#reference.has(value, 'objects');
    if (cache.referenced) return this.#writeUint29(cache.index << 1);

    this.#writeUint29((value.length << 1) | 1);
    this.#dynbuf.writeBoolean(!Object.isExtensible(value));

    if (type === 'ObjectArray') {
      // Loosely check if the type of the first value matches to the others
      const isTyped = value.every((item) => (typeof item === typeof value[0]));
      if (!isTyped) throw new TypeError('Tried to serialize a Vector Object with multiple object types');

      const aliasName = Object.getOwnPropertyDescriptor(value, 'VectorObject').value;
      const classObj = this.#classAlias.getClassByAlias(aliasName);

      classObj ? this.#serializeString(aliasName, false) : this.#writeUint29(1);
    }

    for (let i = 0; i < value.length; i++) {
      type === 'Int32Array' ? this.#dynbuf.writeInt(value[i]) :
        type === 'Uint32Array' ? this.#dynbuf.writeUnsignedInt(value[i]) :
          type === 'Float64Array' ? this.#dynbuf.writeDouble(value[i]) :
            this.serialize(value[i]);
    }
  }

  /**
   * Serializes a Dictionary (Map)
   * @private
   * @param {Map} value - The Dictionary to serialize
   */
  #serializeDictionary(value) {
    this.#dynbuf.writeByte(Markers.AMF3.DICTIONARY);

    const cache = this.#reference.has(value, 'objects');
    if (cache.referenced) return this.#writeUint29(cache.index << 1);

    const isWeak = false; // Todo

    this.#writeUint29((value.size << 1) | 1);
    this.#dynbuf.writeBoolean(isWeak);

    for (const [key, keyVal] of value) {
      this.serialize(key);
      this.serialize(keyVal);
    }
  }

  /**
   * Serializes an unidentified object
   * @private
   * @param {any} value - The unidentified object to serialize
   * @throws {ReferenceError} If an unknown AMF3 type has been found
   */
  #serializeUnidentifiedObject(value) {
    const { constructor } = Object.getPrototypeOf(value);
    const aliasName = this.#classAlias.getAliasByClass(constructor);

    if (aliasName || !isNativeObject(constructor)) {
      this.#serializeObject(value); // Serialize typed and anonymous objects. Unlike AMF0, this all gets handled by a single 'serializeObject' method
    } else if (constructor.name === 'Set') {
      this.#serializeArray([...value]);
    } else {
      throw new ReferenceError(`Unknown or unsupported AMF3 type found: '${constructor.name}'.`);
    }
  }
}