import iconv from 'iconv-lite';
import { promisify } from 'node:util';
import zlib from 'node:zlib';
const deflate = promisify(zlib.deflate);
const inflate = promisify(zlib.inflate);
const deflateRaw = promisify(zlib.deflateRaw);
const inflateRaw = promisify(zlib.inflateRaw);
const gzip = promisify(zlib.gzip);
const gunzip = promisify(zlib.gunzip);
const brotliCompress = promisify(zlib.brotliCompress);
const brotliDecompress = promisify(zlib.brotliDecompress);
/**
* @author SeirDotExe
* @license BSD-3-Clause
*/
export class DynBuffer {
/**
* Initialize a new stream utilizing Buffer and ArrayBuffer with the recommended max byte length
* @private
* @type {Buffer}
* @see https://tc39.es/ecma262/multipage/structured-data.html#sec-resizable-arraybuffer-guidelines
*/
#stream;
/**
* The current position in the buffer
* @private
* @type {number}
*/
#position;
/**
* The byte order
* @private
* @type {'BE'|'LE'}
* @default 'BE' - Defaults to BE for big endian
*/
#endian;
/**
* Creates a new DynBuffer
*/
constructor() {
this.#stream = Buffer.from(new ArrayBuffer(0, { maxByteLength: 2 ** 30 }));
this.#position = 0;
this.#endian = 'BE';
}
/**
* Overwrite for inspecting on the DynBuffer class
* @param {number} depth - The 'depth' param of util.inspect
* @param {Object} options - The util.InspectOptionsStylized options
* @param {Function} inspect - The util.inspect function
* @returns {string} A pretty printed representation of the DynBuffer class
*/
[Symbol.for('nodejs.util.inspect.custom')](depth, options, inspect) {
return `${options.stylize('DynBuffer', 'special')} {
\r stream: ${inspect(this.#stream, options)},
\r position: ${options.stylize(this.#position, 'number')},
\r length: ${options.stylize(this.length, 'number')},
\r bytesAvailable: ${options.stylize(this.bytesAvailable, 'number')},
\r endian: '${options.stylize(this.#endian, 'string')}'
\r}`;
}
/**
* Returns the number of bytes available from the current position in the buffer
* @returns {number}
*/
get bytesAvailable() {
return (this.#stream.length - this.#position);
}
/**
* Returns the whole buffer
* @returns {Buffer}
*/
get stream() {
return this.#stream;
}
/**
* Returns the length of the buffer
* @returns {number}
*/
get length() {
return this.#stream.length;
}
/**
* Sets the length of the buffer
* @param {number} value - The length to set the buffer to
*/
set length(value) {
if (value === 0) { // Clear the buffer
this.#stream = Buffer.from(new ArrayBuffer(0, { maxByteLength: 2 ** 30 }));
this.#position = 0;
} else if (value > this.#stream.length) { // Larger than the current length, the right side of the buffer is filled with zeros
this.#ensureCapacity(value - this.#stream.length);
} else { // Smaller than the current length, the buffer is truncated
this.#stream = this.#stream.subarray(0, value);
this.#stream.buffer.resize(value);
this.#position = value;
}
}
/**
* Returns the current position in the buffer
* @returns {number}
*/
get position() {
return this.#position;
}
/**
* Sets the current position in the buffer
* @param {number} value - The value to set the position to
*/
set position(value) {
this.#position = value;
}
/**
* Returns the byte order
* @returns {'BE'|'LE'}
*/
get endian() {
return this.#endian;
}
/**
* Sets the byte order
* @param {'BE'|'LE'} value - The endianness to set the byte order to, either BE for big endian, or LE for little endian
*/
set endian(value) {
this.#endian = value;
}
/**
* Ensures there's enough capacity to write X amount of bytes to the buffer, and if not, it'll resize the buffer appropriately
* @private
* @param {number} bytes - The initial amount of bytes needed to be written
*/
#ensureCapacity(bytes) {
if (this.bytesAvailable < bytes) {
const newLength = (this.#stream.length + (bytes - this.bytesAvailable));
this.#stream.buffer.resize(newLength);
}
}
/**
* A simple wrapper around executing functions on the Buffer class
* @private
* @param {string} method - The function name to execute
* @param {number} bytes - The amount of bytes to ensure capacity and increment the position with
* @param {number} [value] - The value to write to the buffer
* @returns {number|undefined} When reading, a value is returned
* @throws {RangeError} When reading, there must be sufficient data available to read
*/
#executeCall(method, bytes, value) {
method = (bytes === 1) ? method : `${method}${this.#endian}`; // Methods with 1 byte have no endianness
if (arguments.length === 3) { // Write when 'value' argument is present
this.#ensureCapacity(bytes);
this.#stream[method](value, this.#position);
this.#position += bytes;
} else { // Read
if (this.bytesAvailable === 0) {
throw new RangeError('There is not sufficient data available to read.');
}
const value = this.#stream[method](this.#position);
this.#position += bytes;
return value;
}
}
/**
* Converts an unsigned integer as a signed integer with the given bit width (two's complement)
* @private
* @param {number} value - The unsigned integer value to sign
* @param {number} bits - The number of bits the value should be treated as
* @returns {number} The signed integer representation of the value
*/
#signedOverflow(value, bits) {
const sign = 1 << (bits - 1);
return (value & (sign - 1)) - (value & sign);
}
/**
* Converts the buffer to string
* @param {'ascii'|'utf8'|'utf16le'|'ucs2'|'base64'|'base64url'|'latin1'|'binary'|'hex'} [encoding=utf8] - The encoding set to encode the buffer to
* @param {boolean} [prettyHex=false] - When using hex for encoding, define if the hex string is pretty (with spacing and formatted) or not
* @returns {string} The buffer represented as a string
*/
toString(encoding = 'utf8', prettyHex = false) {
if (encoding === 'hex' && prettyHex) {
return this.#stream.toString('hex').match(/.{1,2}/g)?.join(' ');
}
return this.#stream.toString(encoding);
}
/**
* Clears the contents of the buffer and resets the position to 0
*/
clear() {
this.length = 0;
}
/**
* Compresses the buffer
* @async
* @param {'zlib'|'deflate'|'gzip'|'brotli'} [algorithm=zlib] - The algorithm to compress the buffer with
* @throws {ReferenceError} The value must be a valid compression algorithm
*/
async compress(algorithm = 'zlib') {
if (this.length === 0) return; // Don't try to compress when there's nothing to compress
const bytes = (algorithm === 'zlib') ? await deflate(this.#stream, { level: zlib.constants.Z_BEST_COMPRESSION }) :
(algorithm === 'deflate') ? await deflateRaw(this.#stream) :
(algorithm === 'gzip') ? await gzip(this.#stream) :
(algorithm === 'brotli') ? await brotliCompress(this.#stream) : undefined;
if (!bytes) {
throw new ReferenceError(`Invalid compression algorithm '${algorithm}' for compress.`);
}
this.position = 0; // The position has to be set to 0 so that we can start writing the compressed bytes to the beginning
this.writeBytes(bytes);
}
/**
* Decompresses the buffer
* @async
* @param {'zlib'|'deflate'|'gzip'|'brotli'} [algorithm=zlib] - The algorithm to decompress the buffer with
* @throws {ReferenceError} The value must be a valid compression algorithm
*/
async uncompress(algorithm = 'zlib') {
if (this.length === 0) return; // Don't try to uncompress when there's nothing to uncompress
const bytes = (algorithm === 'zlib') ? await inflate(this.#stream, { level: zlib.constants.Z_BEST_COMPRESSION }) :
(algorithm === 'deflate') ? await inflateRaw(this.#stream) :
(algorithm === 'gzip') ? await gunzip(this.#stream) :
(algorithm === 'brotli') ? await brotliDecompress(this.#stream) : undefined;
if (!bytes) {
throw new ReferenceError(`Invalid compression algorithm '${algorithm}' for uncompress.`);
}
this.length = bytes.length; // Resize the buffer to the needed length of uncompressed data
this.position = 0; // The position has to be set to 0 so that we can start writing the uncompressed bytes to the beginning
this.writeBytes(bytes);
this.position = 0; // Reset the position back to 0 so that the program can immediately start reading from the beginning
}
/**
* Writes a byte
* @param {number} value - The byte to write to the buffer
*/
writeByte(value) {
this.#executeCall('writeInt8', 1, this.#signedOverflow(value, 8));
}
/**
* Reads a signed byte from the buffer
* @returns {number} The signed byte
*/
readByte() {
return this.#executeCall('readInt8', 1);
}
/**
* Reads an unsigned byte from the buffer
* @returns {number} The unsigned byte
*/
readUnsignedByte() {
return this.#executeCall('readUInt8', 1);
}
/**
* Writes a sequence of 'length' bytes from the specified buffer, 'bytes', starting 'position' (zero-based index) bytes
* @param {DynBuffer|Buffer} bytes - The DynBuffer (or Buffer) to write the bytes from, and into the buffer
* @param {number} [position=0] - A zero-based index indicating the position into the array to begin writing
* @param {number} [length=0] - An unsigned integer indicating how far into the buffer to write
* @throws {RangeError} If the given length is greater than the remaining space in the destination buffer
*/
writeBytes(bytes, position = 0, length = 0) {
if (!bytes?.stream && Buffer.isBuffer(bytes)) {
bytes = { stream: bytes, length: bytes.length };
}
position = Math.min(position, bytes.length);
length = (length === 0) ? (bytes.length - position) : length;
if (length > (bytes.length - position)) {
throw new RangeError('The supplied index is out of bounds.');
}
this.#ensureCapacity(length);
bytes.stream.copy(this.#stream, this.#position, position, position + length);
this.#position += length;
}
/**
* Reads the number of data bytes, specified by the 'length' parameter, from the buffer
* The bytes are read into the DynBuffer object specified by the 'bytes' parameter
* The bytes are written into the destination DynBuffer starting at the position specified by 'position'
* @param {DynBuffer} bytes - The DynBuffer to read data into
* @param {number} [position=0] - The position at which the read data should be written
* @param {number} [length=0] - The number of bytes to read
* @throws {RangeError} If the given length is greater than the amount of bytes available
*/
readBytes(bytes, position = 0, length = 0) {
length = (length === 0) ? this.bytesAvailable : length;
if (length > this.bytesAvailable) {
throw new RangeError('End of buffer was encountered.');
}
bytes.length = Math.max(bytes.length, position + length);
this.#stream.copy(bytes.#stream, position, this.#position, length);
this.#position += length;
}
/**
* Writes a boolean based value
* @param {boolean|number} value - The boolean based value to write to the buffer
*/
writeBoolean(value) {
this.writeByte(value ? 1 : 0);
}
/**
* Reads a boolean based value from the buffer
* @returns {boolean} The boolean based value
*/
readBoolean() {
return (this.readByte() !== 0);
}
/**
* Writes a 16-bit integer
* @param {number} value - The 16-bit integer to write to the buffer
*/
writeShort(value) {
this.#executeCall('writeInt16', 2, this.#signedOverflow(value, 16));
}
/**
* Reads a signed 16-bit integer from the buffer
* @returns {number} The signed 16-bit integer
*/
readShort() {
return this.#executeCall('readInt16', 2);
}
/**
* Reads an unsigned 16-bit integer from the buffer
* @returns {number} The unsigned 16-bit integer
*/
readUnsignedShort() {
return this.#executeCall('readUInt16', 2);
}
/**
* Writes a 32-bit signed integer
* @param {number} value - The 32-bit signed integer to write to the buffer
*/
writeInt(value) {
this.#executeCall('writeInt32', 4, value);
}
/**
* Reads a signed 32-bit integer from the buffer
* @returns {number} The signed 32-bit integer
*/
readInt() {
return this.#executeCall('readInt32', 4);
}
/**
* Writes an unsigned 32-bit integer
* @param {number} value - The unsigned 32-bit integer to write to the buffer
*/
writeUnsignedInt(value) {
this.#executeCall('writeUInt32', 4, value);
}
/**
* Reads an unsigned 32-bit integer from the buffer
* @returns {number} The unsigned 32-bit integer
*/
readUnsignedInt() {
return this.#executeCall('readUInt32', 4);
}
/**
* Writes an IEEE 754 single-precision (32-bit) floating-point number
* @param {number} value - The IEEE 754 single-precision (32-bit) floating-point number to write to the buffer
*/
writeFloat(value) {
this.#executeCall('writeFloat', 4, value);
}
/**
* Reads an IEEE 754 single-precision (32-bit) floating-point number from the buffer
* @returns {number} The IEEE 754 single-precision (32-bit) floating-point number
*/
readFloat() {
return this.#executeCall('readFloat', 4);
}
/**
* Writes an IEEE 754 double-precision (64-bit) floating-point number
* @param {number} value - The IEEE 754 double-precision (64-bit) floating-point number to write to the buffer
*/
writeDouble(value) {
this.#executeCall('writeDouble', 8, value);
}
/**
* Reads an IEEE 754 double-precision (64-bit) floating-point number from the buffer
* @returns {number} The IEEE 754 double-precision (64-bit) floating-point number
*/
readDouble() {
return this.#executeCall('readDouble', 8);
}
/**
* Writes a signed long
* @param {bigint} value - The signed long to write to the buffer
*/
writeLong(value) {
this.#executeCall('writeBigInt64', 8, value);
}
/**
* Reads a signed long from the buffer
* @returns {bigint} The signed long
*/
readLong() {
return this.#executeCall('readBigInt64', 8);
}
/**
* Writes an unsigned long
* @param {bigint} value - The unsigned long to write to the buffer
*/
writeUnsignedLong(value) {
this.#executeCall('writeBigUint64', 8, value);
}
/**
* Reads an unsigned long from the buffer
* @returns {bigint} The unsigned long
*/
readUnsignedLong() {
return this.#executeCall('readBigUint64', 8);
}
/**
* Writes a multibyte string using the specified character set
* @see https://github.com/pillarjs/iconv-lite/wiki/Supported-Encodings
* @param {string} value - The multibyte string to write to the buffer
* @param {string} [charSet=utf8] - The character set to encode the value with
* @param {boolean} [source=false] - An internal parameter used for writeUTF to write the length of the string
* @throws {Error} The character set must exist
*/
writeMultiByte(value, charSet = 'utf8', source = false) {
if (!iconv.encodingExists(charSet)) {
throw new Error(`Invalid encoding '${charSet}' for writeMultiByte.`);
}
const encoded = iconv.encode(value, charSet, { addBOM: false });
// Internal support for 'writeUTF' to write its length
if (source) {
this.writeShort(encoded.length);
}
this.writeBytes(encoded, 0, encoded.length);
}
/**
* Reads a multibyte string of specified length from the buffer using the specified character set
* @see https://github.com/pillarjs/iconv-lite/wiki/Supported-Encodings
* @param {number} length - The number of bytes to read
* @param {string} [charSet=utf8] - The character set to decode the value to
* @returns {string} The multibyte string
* @throws {Error} The character set must exist
*/
readMultiByte(length, charSet = 'utf8') {
if (!iconv.encodingExists(charSet)) {
throw new Error(`Invalid encoding '${charSet}' for readMultiByte.`);
}
const buffer = this.#stream.subarray(this.#position, this.#position + length);
const value = iconv.decode(buffer, charSet);
this.#position += length;
return value;
}
/**
* Writes a UTF-8 string
* @param {string} value - The UTF-8 string to write to the buffer
*/
writeUTF(value) {
this.writeMultiByte(value, 'utf8', true);
}
/**
* Reads a UTF-8 string from the buffer
* @returns {string} The UTF-8 string
*/
readUTF() {
return this.readMultiByte(this.readUnsignedShort());
}
/**
* Writes a UTF-8 string but without the length of the string
* @param {string} value - The UTF-8 string to write to the buffer
*/
writeUTFBytes(value) {
this.writeMultiByte(value);
}
/**
* Reads a sequence of UTF-8 bytes, specified by the length parameter, from the buffer
* @param {number} length - The number of bytes to read
* @returns {string} The UTF-8 string
*/
readUTFBytes(length) {
return this.readMultiByte(length);
}
}