Source: AMF0/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
 *
 * @typedef {import('../AMF/remoting/header.js').default} Header
 * @typedef {import('../AMF/remoting/message.js').default} Message
 * @typedef {import('../AMF/remoting/packet.js').default} Packet
 */

/** @module AMF0/Serializer */
export default class Serializer {
  /**
   * A list of types (along null and undefined) to serialize with AMF0 when facing possible AVM+ challenges
   * @static
   * @private
   * @type {string[]}
   */
  static #AVM_AMF0_ALLOWED = ['number', 'boolean', 'string'];

  /**
   * The AMF class alias holder
   * @private
   * @type {ClassAlias}
   */
  #classAlias;
  /**
   * The 'serialize' function holder
   * @private
   * @type {Function}
   */
  #AMF_Serialize;
  /**
   * The DynBuffer instance containing AMF0 bytes for this instance
   * @private
   * @type {DynBuffer}
   */
  #dynbuf;
  /**
   * The AMF0 reference holder
   * @private
   * @type {Reference}
   */
  #reference;
  /**
   * The Dynamic Property Writer function holder
   * @private
   * @type {Function}
   */
  #dynamicPropertyWriter;

  /**
   * Creates a new AMF0 serializer
   * @param {ClassAlias} classAlias - The class alias internally coming from the AMF entrypoint class
   * @param {Function} AMF_Serialize - The 'serialize' function coming from the AMF entrypoint class
   */
  constructor(classAlias, AMF_Serialize) {
    this.#classAlias = classAlias;
    this.#AMF_Serialize = AMF_Serialize;
    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;
  }

  /**
   * 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.#serializeNumber(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;
        default: this.#serializeUnidentifiedObject(value);
      }
    }

    return this;
  }

  /**
   * Serializes a packet into AMF binary data
   * @param {Packet} packet - The AMF packet to serialize
   * @param {Function} AMF3_REFERENCE_RESET - The 'reset' function coming from the AMF3 Reference class
   * @returns {Serializer} Returns the AMF serializer to perform a swift flush in AMF entrypoint class
   */
  serializePacket(packet, AMF3_REFERENCE_RESET) {
    let positionBeforeAMF = 0, positionAfterAMF = 0;

    this.#dynbuf.writeShort(packet.version);

    // Write headers
    this.#dynbuf.writeShort(packet.headerCount);
    for (const header of packet.headers) {
      // Serializers and deserializers must reset reference indices to 0 each time a new header is processed
      this.#reference.reset(); AMF3_REFERENCE_RESET();
      // Write 'name' and 'mustUnderstand'
      this.#dynbuf.writeUTF(header.name);
      this.#dynbuf.writeBoolean(header.mustUnderstand);
      // Store the position before writing AMF data, and temporarily write -1 for length
      positionBeforeAMF = this.#dynbuf.position; this.#dynbuf.writeInt(-1);
      // Serialize AMF data
      this.#serializeAVMPlus(header.data);
      // Store the position after writing AMF data, then go back to before we serialized 'data'
      positionAfterAMF = this.#dynbuf.position; this.#dynbuf.position = positionBeforeAMF;
      // Write the amount of bytes that were needed to serialize the AMF data, then reset back for closure
      this.#dynbuf.writeInt(positionAfterAMF - positionBeforeAMF - 4); this.#dynbuf.position = positionAfterAMF;
    }

    // Write messages
    this.#dynbuf.writeShort(packet.messageCount);
    for (const message of packet.messages) {
      // Serializers and deserializers must reset reference indices to 0 each time a new message is processed
      this.#reference.reset(); AMF3_REFERENCE_RESET();
      // Write 'targetURI' and 'responseURI'
      this.#dynbuf.writeUTF(message.targetURI);
      this.#dynbuf.writeUTF(message.responseURI);
      // Store the position before writing AMF data and temporarily write -1 for length
      positionBeforeAMF = this.#dynbuf.position; this.#dynbuf.writeInt(-1);
      // Serialize AMF data
      this.#serializeStrictArray(message.data);
      // Store the position after writing AMF data, then go back to before we serialized 'data'
      positionAfterAMF = this.#dynbuf.position; this.#dynbuf.position = positionBeforeAMF;
      // Write the amount of bytes that were needed to serialize the AMF data, then reset back for closure
      this.#dynbuf.writeInt(positionAfterAMF - positionBeforeAMF - 4); this.#dynbuf.position = positionAfterAMF;
    }

    return this;
  }

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

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

  /**
   * Serializes a number
   * @private
   * @param {number} value - The number to serialize
   */
  #serializeNumber(value) {
    this.#dynbuf.writeByte(Markers.AMF0.NUMBER);
    this.#dynbuf.writeDouble(value);
  }

  /**
   * Serializes a boolean
   * @private
   * @param {boolean} value - The boolean to serialize
   */
  #serializeBoolean(value) {
    this.#dynbuf.writeByte(Markers.AMF0.BOOLEAN);
    this.#dynbuf.writeBoolean(value);
  }

  /**
   * Serializes a (long) string
   * @private
   * @param {string} value - The string to serialize
   */
  #serializeString(value) {
    const length = Buffer.byteLength(value);

    if (length > 65535) {
      this.#dynbuf.writeByte(Markers.AMF0.LONG_STRING);
      this.#dynbuf.writeUnsignedInt(length);
      this.#dynbuf.writeUTFBytes(value);
    } else {
      this.#dynbuf.writeByte(Markers.AMF0.STRING);
      this.#dynbuf.writeUTF(value);
    }
  }

  /**
   * Serializes an object
   * @private
   * @param {object} value - The object to serialize
   */
  #serializeObject(value) {
    if (this.#dynamicPropertyWriter) this.#dynamicPropertyWriter(value);

    const cache = this.#reference.has(value);
    if (cache.referenced) return this.#serializeReference(cache.index);

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

    for (const key in value) {
      this.#dynbuf.writeUTF(key);
      this.serialize(value[key]);
    }

    this.#dynbuf.writeShort(0);
    this.#dynbuf.writeByte(Markers.AMF0.OBJECT_END);
  }

  /**
   * Serializes an array
   * @private
   * @param {any[]} value - The array to serialize
   */
  #serializeArray(value) {
    const cache = this.#reference.has(value);
    if (cache.referenced) return this.#serializeReference(cache.index);

    this.#dynbuf.writeByte(Markers.AMF0.ECMA_ARRAY);
    this.#dynbuf.writeUnsignedInt(value.length); //! Undocumented behavior - An associative array will always write 0 here by AVM. This is done on purpose; we must treat it as an object! The keys will turn into sparse entries which is unwanted

    const arrInfo = determineArray(value);

    /*
    AVM secretly cleans up sparse entries in an array. But very important to note: it only does this when setting array values by index. It does this to optimize buffer space

    var value:Array = []; // It won't optimize when you do [,,1] - Also Node is unable to determine the difference. AVM probably inspects this behavior through a proxy class
    value[2] = 1; // Optimized

    The AMF0 serialized hex will be: 08 00 00 00 03 00 01 32 00 3f f0 00 00 00 00 00 00 00 00 09
    01 32 = writeUTF length and the letter '2' followed by the number 1 in writeDouble

    Because the length is still written, sparse entries will naturally return, and the deserialized value will be unaffected
    */

    // Write sparse and/or dense values
    if (arrInfo.sparse || arrInfo.dense) {
      for (let i = 0; i < value.length; i++) {
        if (!Object.hasOwn(value, i)) continue; //! Undocumented behavior - Skip sparse entries, used to preserve buffer bytes

        this.#dynbuf.writeUTF(String(i));
        this.serialize(value[i]);
      }
    }

    // Write associative values
    if (arrInfo.associative) {
      for (const key in value) {
        if (isNaN(key)) { // Skip dense values
          this.#dynbuf.writeUTF(key);
          this.serialize(value[key]);
        }
      }
    }

    this.#dynbuf.writeShort(0);
    this.#dynbuf.writeByte(Markers.AMF0.OBJECT_END);
  }

  /**
   * Serializes a date
   * @private
   * @param {Date} value - The date to serialize
   */
  #serializeDate(value) {
    const cache = this.#reference.has(value);
    if (cache.referenced) return this.#serializeReference(cache.index);

    this.#dynbuf.writeByte(Markers.AMF0.DATE);
    this.#dynbuf.writeDouble(value.getTime());
    this.#dynbuf.writeShort(value.getTimezoneOffset()); //! Undocumented behavior - The spec clearly says '0x0000' should be written, but this isn't true. It does actually write the timezone offset
  }

  /**
   * Serializes a strict array
   * @private
   * @param {any[]} value - The strict array to serialize
   */
  #serializeStrictArray(value) {
    const cache = this.#reference.has(value);
    if (cache.referenced) return this.#serializeReference(cache.index);

    this.#dynbuf.writeByte(Markers.AMF0.STRICT_ARRAY);
    this.#dynbuf.writeUnsignedInt(value.length);

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

  /**
   * Serializes an AVMPlus marker and its value in AMF3
   * @private
   * @param {any} value - The value to serialize in AMF3
   */
  #serializeAVMPlus(value) {
    // Certain types aren't allowed to be serialized in AMF3 format
    if ((value === null) || (value === undefined) || Serializer.#AVM_AMF0_ALLOWED.includes(typeof value)) {
      this.serialize(value);
    } else {
      this.#dynbuf.writeByte(Markers.AMF0.AVMPLUS);
      this.#dynbuf.writeBytes(this.#AMF_Serialize(value));
    }
  }

  /**
   * Serializes an unidentified object
   * @private
   * @param {any} value - The unidentified object to serialize
   */
  #serializeUnidentifiedObject(value) {
    const { constructor } = Object.getPrototypeOf(value);
    const aliasName = this.#classAlias.getAliasByClass(constructor);

    if (aliasName) { // This is a registered typed object, so we serialize it as one
      this.#serializeTypedObject(value, aliasName);
    } else if (!isNativeObject(constructor)) { // This is an unregistered typed object (AVM calls this an anonymous object), so we serialize it as an object
      this.#serializeObject(value);
    } else {
      if (constructor.name === 'Map') {
        this.#serializeObject(Object.fromEntries(value));
      } else if (constructor.name === 'Set') {
        this.#serializeArray([...value]);
      } else { // An unknown base type from JS/Node was found (example: Regex)
        this.#serializeUnsupported(); //! We do the right thing and write the unsupported marker
      }
    }
  }

  /**
   * Serializes a typed object
   * @private
   * @param {any} value - The typed object to serialize
   * @param {string} aliasName - The alias name of the typed object
   */
  #serializeTypedObject(value, aliasName) {
    const cache = this.#reference.has(value);
    if (cache.referenced) return this.#serializeReference(cache.index);

    this.#dynbuf.writeByte(Markers.AMF0.TYPED_OBJECT);
    this.#dynbuf.writeUTF(aliasName);

    for (const key in value) {
      this.#dynbuf.writeUTF(key);
      this.serialize(value[key]);
    }

    this.#dynbuf.writeShort(0);
    this.#dynbuf.writeByte(Markers.AMF0.OBJECT_END);
  }

  /**
   * Serializes a reference
   * @private
   * @param {number} index - The index of the referenced value to serialize
   */
  #serializeReference(index) {
    this.#dynbuf.writeByte(Markers.AMF0.REFERENCE);
    this.#dynbuf.writeShort(index);
  }

  /**
   * Serializes an unsupported value
   * @private
   */
  #serializeUnsupported() {
    this.#dynbuf.writeByte(Markers.AMF0.UNSUPPORTED);
  }
}