obj2buf
Version:
A type-safe encoder/decoder for structured binary data with snake_case API design
1,572 lines (1,401 loc) • 59.3 kB
JavaScript
/**
* @fileoverview Type definitions for binary encoding and decoding
*/
/**
* Custom error class for parsing errors
* @class
* @extends Error
*/
class ParserError extends Error {}
/**
* Global helper function to validate non-nullish values
* @param {*} value - The value to check
* @param {string} typeName - The type name for error messages
* @throws {ParserError} If value is undefined or null
*/
function _throw_if_nullish(value, typeName) {
if (value === undefined || value === null) {
throw new ParserError(`Cannot encode ${value} as ${typeName}. Expected a valid value.`);
}
}
/**
* Base class for all data types
* @abstract
* @class
*/
class Type {
/**
* Create a new Type instance
* @constructor
*/
constructor() {}
/**
* Static byte length for the type
* @type {number}
* @static
*/
static byte_length = 0;
/**
* Get the byte length for this type instance
* @type {number}
* @readonly
*/
get byte_length() {
return this.constructor.byte_length;
}
/**
* Check if this type has a static (compile-time known) length
* @type {boolean}
* @readonly
*/
get is_static_length() {
return this.byte_length !== null && this.byte_length !== undefined;
}
/**
* Calculate the byte length for a specific value (for variable-length types)
* @param {*} value - The value to calculate length for
* @returns {number} The byte length for the given value
* @throws {Error} For fixed-length types that don't support value-specific calculation
*/
calculate_byte_length(value) {
// Default implementation for fixed-length types
if (this.is_static_length) {
return this.byte_length;
}
throw new Error("Variable-length types must implement calculate_byte_length");
}
/**
* Decode data from a buffer
* @abstract
* @param {Buffer} buffer - The buffer to decode from
* @param {number} offset - The offset in the buffer to start decoding
* @returns {{value: *, bytes_read: number}} Object containing the decoded value and number of bytes read
* @throws {Error} Always throws as this is an abstract method
*/
decode(buffer, offset) {
throw new Error("Not implemented");
}
/**
* Encode a value into a buffer at the given offset
* @abstract
* @param {*} value - The value to encode
* @param {Buffer} buffer - The buffer to encode into
* @param {number} offset - The offset in the buffer to start encoding
* @returns {number} The number of bytes written
* @throws {Error} Always throws as this is an abstract method
*/
encode(value, buffer, offset) {
throw new Error("Not implemented");
}
/**
* Validate a value without encoding it
* @abstract
* @param {*} value - The value to validate
* @returns {boolean} True if valid
* @throws {ParserError} If value is invalid
*/
validate(value) {
throw new Error("Not implemented");
}
/**
* Convert the type to a JSON representation
* @returns {{type: string}} JSON representation of the type
*/
to_json() {
return { type: this.constructor.name };
}
/**
* Convert the type to a JSON representation (non-snake_case alias)
* @returns {{type: string}} JSON representation of the type
*/
toJSON() {
return this.to_json();
}
}
/**
* Unified unsigned integer type with configurable byte length
* @class
* @extends Type
*/
class UInt extends Type {
/**
* Create a new unsigned integer type
* @param {number} byte_length - The number of bytes (1, 2, or 4)
*/
constructor(byte_length) {
super();
if (![1, 2, 4].includes(byte_length)) {
throw new Error(`UInt byte_length must be 1, 2, or 4, got ${byte_length}`);
}
this._byte_length = byte_length;
this._max_value = Math.pow(2, byte_length * 8) - 1;
}
/** @type {number} */
get byte_length() {
return this._byte_length;
}
/**
* Decode an unsigned integer from buffer
* @param {Buffer} buffer - The buffer to decode from
* @param {number} offset - The offset in the buffer
* @returns {{value: number, bytes_read: number}} Object containing the decoded value and bytes read
*/
decode(buffer, offset) {
let value;
switch (this._byte_length) {
case 1: value = buffer.readUInt8(offset); break;
case 2: value = buffer.readUInt16LE(offset); break;
case 4: value = buffer.readUInt32LE(offset); break;
}
return { value, bytes_read: this._byte_length };
}
/**
* Encode an unsigned integer into buffer
* @param {number} value - The value to encode
* @param {Buffer} buffer - The buffer to encode into
* @param {number} offset - The offset in the buffer
* @param {Object} [options={}] - Encoding options
* @param {boolean} [options.unsafe=false] - Skip validation for performance
* @returns {number} The number of bytes written
* @throws {ParserError} If value is invalid (unless unsafe=true)
*/
encode(value, buffer, offset, { unsafe = false } = {}) {
if (!unsafe) {
this.validate(value);
}
switch (this._byte_length) {
case 1: buffer.writeUInt8(value, offset); break;
case 2: buffer.writeUInt16LE(value, offset); break;
case 4: buffer.writeUInt32LE(value, offset); break;
}
return this._byte_length;
}
/**
* Validate an unsigned integer value
* @param {number} value - The value to validate
* @returns {boolean} True if valid
* @throws {ParserError} If value is invalid
*/
validate(value) {
_throw_if_nullish(value, `UInt${this._byte_length * 8}`);
if (typeof value !== 'number') {
throw new ParserError(`UInt${this._byte_length * 8} value must be a number, got ${typeof value}`);
}
if (!Number.isInteger(value)) {
throw new ParserError(`UInt${this._byte_length * 8} value must be an integer, got ${value}`);
}
if (value < 0 || value > this._max_value) {
throw new ParserError(`UInt${this._byte_length * 8} value must be between 0 and ${this._max_value}, got ${value}`);
}
return true;
}
to_json() {
return { type: 'UInt', byte_length: this._byte_length };
}
}
/**
* Unified signed integer type with configurable byte length
* @class
* @extends Type
*/
class Int extends Type {
/**
* Create a new signed integer type
* @param {number} byte_length - The number of bytes (1, 2, or 4)
*/
constructor(byte_length) {
super();
if (![1, 2, 4].includes(byte_length)) {
throw new Error(`Int byte_length must be 1, 2, or 4, got ${byte_length}`);
}
this._byte_length = byte_length;
this._max_value = Math.pow(2, byte_length * 8 - 1) - 1;
this._min_value = -Math.pow(2, byte_length * 8 - 1);
}
/** @type {number} */
get byte_length() {
return this._byte_length;
}
/**
* Decode a signed integer from buffer
* @param {Buffer} buffer - The buffer to decode from
* @param {number} offset - The offset in the buffer
* @returns {{value: number, bytes_read: number}} Object containing the decoded value and bytes read
*/
decode(buffer, offset) {
let value;
switch (this._byte_length) {
case 1: value = buffer.readInt8(offset); break;
case 2: value = buffer.readInt16LE(offset); break;
case 4: value = buffer.readInt32LE(offset); break;
}
return { value, bytes_read: this._byte_length };
}
/**
* Encode a signed integer into buffer
* @param {number} value - The value to encode
* @param {Buffer} buffer - The buffer to encode into
* @param {number} offset - The offset in the buffer
* @param {Object} [options={}] - Encoding options
* @param {boolean} [options.unsafe=false] - Skip validation for performance
* @returns {number} The number of bytes written
* @throws {ParserError} If value is invalid (unless unsafe=true)
*/
encode(value, buffer, offset, { unsafe = false } = {}) {
if (!unsafe) {
this.validate(value);
}
switch (this._byte_length) {
case 1: buffer.writeInt8(value, offset); break;
case 2: buffer.writeInt16LE(value, offset); break;
case 4: buffer.writeInt32LE(value, offset); break;
}
return this._byte_length;
}
/**
* Validate a signed integer value
* @param {number} value - The value to validate
* @returns {boolean} True if valid
* @throws {ParserError} If value is invalid
*/
validate(value) {
_throw_if_nullish(value, `Int${this._byte_length * 8}`);
if (typeof value !== 'number') {
throw new ParserError(`Int${this._byte_length * 8} value must be a number, got ${typeof value}`);
}
if (!Number.isInteger(value)) {
throw new ParserError(`Int${this._byte_length * 8} value must be an integer, got ${value}`);
}
if (value < this._min_value || value > this._max_value) {
throw new ParserError(`Int${this._byte_length * 8} value must be between ${this._min_value} and ${this._max_value}, got ${value}`);
}
return true;
}
to_json() {
return { type: 'Int', byte_length: this._byte_length };
}
}
/**
* Unified floating point type with configurable byte length
* @class
* @extends Type
*/
class Float extends Type {
/**
* Create a new floating point type
* @param {number} byte_length - The number of bytes (4 or 8)
*/
constructor(byte_length) {
super();
if (![4, 8].includes(byte_length)) {
throw new Error(`Float byte_length must be 4 or 8, got ${byte_length}`);
}
this._byte_length = byte_length;
}
/** @type {number} */
get byte_length() {
return this._byte_length;
}
/**
* Decode a floating point number from buffer
* @param {Buffer} buffer - The buffer to decode from
* @param {number} offset - The offset in the buffer
* @returns {{value: number, bytes_read: number}} Object containing the decoded value and bytes read
*/
decode(buffer, offset) {
let value;
switch (this._byte_length) {
case 4: value = buffer.readFloatLE(offset); break;
case 8: value = buffer.readDoubleLE(offset); break;
}
return { value, bytes_read: this._byte_length };
}
/**
* Encode a floating point number into buffer
* @param {number} value - The value to encode
* @param {Buffer} buffer - The buffer to encode into
* @param {number} offset - The offset in the buffer
* @param {Object} [options={}] - Encoding options
* @param {boolean} [options.unsafe=false] - Skip validation for performance
* @returns {number} The number of bytes written
* @throws {ParserError} If value is invalid (unless unsafe=true)
*/
encode(value, buffer, offset, { unsafe = false } = {}) {
if (!unsafe) {
this.validate(value);
}
switch (this._byte_length) {
case 4: buffer.writeFloatLE(value, offset); break;
case 8: buffer.writeDoubleLE(value, offset); break;
}
return this._byte_length;
}
/**
* Validate a floating point value
* @param {number} value - The value to validate
* @returns {boolean} True if valid
* @throws {ParserError} If value is invalid
*/
validate(value) {
_throw_if_nullish(value, `Float${this._byte_length * 8}`);
if (typeof value !== 'number') {
throw new ParserError(`Float${this._byte_length * 8} value must be a number, got ${typeof value}`);
}
if (!Number.isFinite(value)) {
throw new ParserError(`Float${this._byte_length * 8} value must be finite, got ${value}`);
}
return true;
}
to_json() {
return { type: 'Float', byte_length: this._byte_length };
}
}
/**
* Unsigned 8-bit integer type (0-255)
* @class
* @extends UInt
*/
class UInt8 extends UInt {
constructor() {
super(1);
}
/**
* Convert to JSON representation (backward compatibility)
* @returns {{type: string}} JSON representation
*/
to_json() {
return { type: 'UInt8' };
}
}
/**
* Unsigned 16-bit integer type (0-65535)
* @class
* @extends UInt
*/
class UInt16 extends UInt {
constructor() {
super(2);
}
/**
* Convert to JSON representation (backward compatibility)
* @returns {{type: string}} JSON representation
*/
to_json() {
return { type: 'UInt16' };
}
}
/**
* Unsigned 32-bit integer type (0-4294967295)
* @class
* @extends UInt
*/
class UInt32 extends UInt {
constructor() {
super(4);
}
/**
* Convert to JSON representation (backward compatibility)
* @returns {{type: string}} JSON representation
*/
to_json() {
return { type: 'UInt32' };
}
}
/**
* Signed 8-bit integer type (-128 to 127)
* @class
* @extends Int
*/
class Int8 extends Int {
constructor() {
super(1);
}
/**
* Convert to JSON representation (backward compatibility)
* @returns {{type: string}} JSON representation
*/
to_json() {
return { type: 'Int8' };
}
}
/**
* Signed 16-bit integer type (-32768 to 32767)
* @class
* @extends Int
*/
class Int16 extends Int {
constructor() {
super(2);
}
/**
* Convert to JSON representation (backward compatibility)
* @returns {{type: string}} JSON representation
*/
to_json() {
return { type: 'Int16' };
}
}
/**
* Signed 32-bit integer type (-2147483648 to 2147483647)
* @class
* @extends Int
*/
class Int32 extends Int {
constructor() {
super(4);
}
/**
* Convert to JSON representation (backward compatibility)
* @returns {{type: string}} JSON representation
*/
to_json() {
return { type: 'Int32' };
}
}
/**
* 32-bit floating point type
* @class
* @extends Float
*/
class Float32 extends Float {
constructor() {
super(4);
}
/**
* Convert to JSON representation (backward compatibility)
* @returns {{type: string}} JSON representation
*/
to_json() {
return { type: 'Float32' };
}
}
/**
* 64-bit floating point type
* @class
* @extends Float
*/
class Float64 extends Float {
constructor() {
super(8);
}
/**
* Convert to JSON representation (backward compatibility)
* @returns {{type: string}} JSON representation
*/
to_json() {
return { type: 'Float64' };
}
}
class BooleanType extends Type {
/** @type {number} @static */
static byte_length = 1;
/**
* Decode a boolean from buffer (0 = false, non-zero = true)
* @param {Buffer} buffer - The buffer to decode from
* @param {number} offset - The offset in the buffer
* @returns {{value: boolean, bytes_read: number}} Object containing the decoded value and bytes read
*/
decode(buffer, offset) {
return {
value: buffer.readUInt8(offset) !== 0,
bytes_read: 1
};
}
/**
* Encode a boolean into buffer (false = 0, true = 1)
* @param {boolean} value - The boolean value to encode
* @param {Buffer} buffer - The buffer to encode into
* @param {number} offset - The offset in the buffer
* @param {Object} [options={}] - Encoding options
* @param {boolean} [options.unsafe=false] - Skip validation for performance
* @returns {number} Number of bytes written
* @throws {ParserError} If value is undefined or null (unless unsafe=true)
*/
encode(value, buffer, offset, { unsafe = false } = {}) {
if (!unsafe) {
this.validate(value);
}
buffer.writeUInt8(value ? 1 : 0, offset);
return 1;
}
/**
* Validate a boolean value
* @param {*} value - The value to validate (must be strictly boolean)
* @returns {boolean} True if valid
* @throws {ParserError} If value is invalid
*/
validate(value) {
_throw_if_nullish(value, 'BooleanType');
if (typeof value !== 'boolean') {
throw new ParserError(`BooleanType value must be a boolean, got ${typeof value}`);
}
return true;
}
}
/**
* Single character type
* @class
* @extends Type
*/
class Char extends Type {
static byte_length = 1;
/**
* Decode a single character from buffer
* @param {Buffer} buffer - The buffer to decode from
* @param {number} offset - The offset in the buffer
* @returns {{value: string, bytes_read: number}} Object containing the decoded value and bytes read
*/
decode(buffer, offset) {
return {
value: String.fromCharCode(buffer.readUInt8(offset)),
bytes_read: 1
};
}
/**
* Encode a single character into buffer
* @param {string} value - The character to encode (uses first character if multi-character string)
* @param {Buffer} buffer - The buffer to encode into
* @param {number} offset - The offset in the buffer
* @param {Object} [options={}] - Encoding options
* @param {boolean} [options.unsafe=false] - Skip validation for performance
* @returns {number} Number of bytes written
* @throws {ParserError} If value is undefined or null (unless unsafe=true)
*/
encode(value, buffer, offset, { unsafe = false } = {}) {
if (!unsafe) {
this.validate(value);
}
// Take first character if string is longer than 1
const char = value.length > 0 ? value[0] : '\0';
buffer.writeUInt8(char.charCodeAt(0), offset);
return 1;
}
/**
* Validate a character value
* @param {string} value - The value to validate
* @returns {boolean} True if valid
* @throws {ParserError} If value is invalid
*/
validate(value) {
_throw_if_nullish(value, 'Char');
if (typeof value !== 'string') {
throw new ParserError(`Char value must be a string, got ${typeof value}`);
}
return true;
}
}
class ArrayType extends Type {
/**
* Create a new ArrayType instance
* @param {Type} element_type - The type of elements in the array
* @param {number|null} [length=null] - The fixed length of the array, or null for variable length
*/
constructor(element_type, length = null) {
super();
/**
* The type of elements in the array
* @type {Type}
* @private
*/
this.element_type = element_type;
/**
* The fixed length of the array, or null for variable length
* @type {number|null}
* @private
*/
this.length = length;
}
/**
* Get the total byte length for the array
* Returns null for variable-length arrays
* @type {number|null}
* @readonly
*/
get byte_length() {
if (this.length === null) {
return null; // Variable length array
}
// For fixed-length arrays, also check if element type is variable length
if (this.element_type.byte_length === null) {
return null; // ArrayType contains variable-length elements
}
return this.element_type.byte_length * this.length;
}
/**
* Check if this type has a static (compile-time known) length
* @type {boolean}
* @readonly
*/
get is_static_length() {
// ArrayType is static only if it has fixed length AND elements are static
return this.length !== null && this.element_type.is_static_length;
}
/**
* Calculate the byte length for a specific array value
* @param {Array} value - The array value to calculate length for
* @returns {number} The total byte length for encoding this array
*/
calculate_byte_length(value) {
if (!Array.isArray(value)) {
throw new ParserError(`ArrayType value must be an array, got ${typeof value}`);
}
let totalLength = 0;
// Add header for variable-length arrays (UInt32 for array length)
if (this.length === null) {
totalLength += 4; // 4 bytes for UInt32 length prefix
}
// Calculate total size of all elements
for (const element of value) {
totalLength += this.element_type.calculate_byte_length(element);
}
return totalLength;
}
/**
* Decode an array from buffer
* @param {Buffer} buffer - The buffer to decode from
* @param {number} offset - The offset in the buffer
* @returns {{value: Array, bytes_read: number}} Object with decoded array and bytes read
*/
decode(buffer, offset) {
if (this.length === null) {
// Variable-length array: read length prefix first
if (buffer.length < offset + 4) {
throw new ParserError('Buffer too small to read array length');
}
const arrayLength = buffer.readUInt32LE(offset);
let currentOffset = offset + 4;
const arr = [];
for (let i = 0; i < arrayLength; i++) {
const decoded = this.element_type.decode(buffer, currentOffset);
arr.push(decoded.value);
currentOffset += decoded.bytes_read;
}
return { value: arr, bytes_read: currentOffset - offset };
} else {
// Fixed-length array: decode exactly 'length' elements
const arr = [];
let currentOffset = offset;
for (let i = 0; i < this.length; i++) {
const decoded = this.element_type.decode(buffer, currentOffset);
arr.push(decoded.value);
currentOffset += decoded.bytes_read;
}
return { value: arr, bytes_read: currentOffset - offset };
}
}
/**
* Encode an array into buffer
* @param {Array} value - The array to encode
* @param {Buffer} buffer - The buffer to encode into
* @param {number} offset - The offset in the buffer
* @param {Object} [options={}] - Encoding options
* @param {boolean} [options.unsafe=false] - Skip validation for performance
* @returns {number} Number of bytes written
* @throws {ParserError} If value is invalid (unless unsafe=true)
*/
encode(value, buffer, offset, { unsafe = false } = {}) {
if (!unsafe) {
this.validate(value);
}
if (this.length === null) {
// Variable-length array: write length prefix, then elements
let currentOffset = offset;
// Write array length as UInt32
buffer.writeUInt32LE(value.length, currentOffset);
currentOffset += 4;
// Write each element
for (const element of value) {
if (this.element_type.byte_length === null) {
// Variable-length elements
const bytesWritten = this.element_type.encode(element, buffer, currentOffset, { unsafe });
currentOffset += bytesWritten;
} else {
// Fixed-length elements
this.element_type.encode(element, buffer, currentOffset, { unsafe });
currentOffset += this.element_type.byte_length;
}
}
return currentOffset - offset; // Return total bytes written
} else {
// Fixed-length array: encode exactly 'length' elements
for (let i = 0; i < this.length; i++) {
this.element_type.encode(value[i], buffer, offset + i * this.element_type.byte_length, { unsafe:false }); // Can skip validation, because we already handled it above
}
// Return total bytes written for fixed arrays too
return this.byte_length;
}
}
/**
* Validate an array value
* @param {Array} value - The array to validate
* @returns {boolean} True if valid
* @throws {ParserError} If value is invalid
*/
validate(value) {
_throw_if_nullish(value, 'ArrayType');
if (!Array.isArray(value)) {
throw new ParserError(`ArrayType value must be an array, got ${typeof value}`);
}
// For fixed-length arrays, check length matches
if (this.length !== null && value.length !== this.length) {
throw new ParserError(`ArrayType length mismatch: expected ${this.length}, got ${value.length}`);
}
// Validate each element in the array
for (let i = 0; i < value.length; i++) {
try {
this.element_type.validate(value[i]);
} catch (error) {
throw new ParserError(`ArrayType element at index ${i} is invalid: ${error.message}`);
}
}
return true;
}
/**
* Convert the array type to JSON representation
* @returns {{type: string, element_type: Object, length: number|null}} JSON representation
*/
to_json() {
return {
type: "ArrayType",
element_type: this.element_type.to_json(),
length: this.length
};
}
}
/**
* Fixed-length string type with UTF-8 encoding
* @class
* @extends Type
*/
class FixedStringType extends Type {
/**
* Create a new FixedStringType instance
* @param {number} length - The maximum byte length of the string
*/
constructor(length) {
super();
/**
* The maximum byte length of the string
* @type {number}
* @private
*/
this.length = length;
}
/**
* Get the byte length for the string
* @type {number}
* @readonly
*/
get byte_length() {
return this.length;
}
/**
* Decode a null-terminated string from buffer
* @param {Buffer} buffer - The buffer to decode from
* @param {number} offset - The offset in the buffer
* @returns {{value: string, bytes_read: number}} Object containing the decoded value and bytes read
*/
decode(buffer, offset) {
return {
value: buffer.toString('utf8', offset, offset + this.length).replace(/\0.*$/g,''),
bytes_read: this.length
};
}
/**
* Encode a string into buffer with null-termination if shorter than max length
* @param {string} value - The string to encode
* @param {Buffer} buffer - The buffer to encode into
* @param {number} offset - The offset in the buffer
* @param {Object} [options={}] - Encoding options
* @param {boolean} [options.unsafe=false] - Skip validation for performance
* @returns {number} The number of bytes written
* @throws {ParserError} If value is undefined/null or string length exceeds maximum length (unless unsafe=true)
*/
encode(value, buffer, offset, { unsafe = false } = {}) {
if (!unsafe) {
this.validate(value);
}
buffer.write(value, offset, this.length, 'utf8');
// Null-terminate if shorter
if (value.length < this.length) {
buffer.fill(0, offset + value.length, offset + this.length);
}
return this.length;
}
/**
* Validate a string value
* @param {string} value - The string to validate
* @returns {boolean} True if valid
* @throws {ParserError} If value is invalid
*/
validate(value) {
_throw_if_nullish(value, 'FixedStringType');
if (typeof value !== 'string') {
throw new ParserError(`FixedStringType value must be a string, got ${typeof value}`);
}
if (value.length > this.length) {
throw new ParserError(`FixedStringType length exceeds fixed length: ${value.length} > ${this.length}`);
}
return true;
}
/**
* Convert the string type to JSON representation
* @returns {{type: string, length: number}} JSON representation
*/
to_json() {
return {
type: "FixedStringType",
length: this.length
};
}
}
/**
* Variable-length string type that uses a length prefix (UInt16)
* @class
* @extends Type
*/
class VarStringType extends Type {
/**
* Create a new VarStringType instance
* @param {number} [max_length=65535] - The maximum byte length of the string
*/
constructor(max_length = 65535) {
super();
if ( max_length < 1 || max_length > 4294967295 ) {
throw new Error("max_length must be between 1 and 4294967296");
}
/**
* The maximum byte length of the string
* @type {number}
* @private
*/
this.max_length = max_length;
}
/**
* Get the byte length for this type (returns null for variable length types)
* @type {number|null}
* @readonly
*/
get byte_length() {
return null; // Variable length type
}
/**
* Check if this type has a static (compile-time known) length
* @type {boolean}
* @readonly
*/
get is_static_length() {
return false; // Variable length type
}
/**
* Get the header size (1 or 2 bytes) based on max_length
* @returns {number} Header size in bytes
* @private
*/
get _header_size() {
return this.max_length < 256 ? 1 : (this.max_length < 65536 ? 2 : 4);
}
/**
* Calculate the byte length for a specific string value
* @param {string} value - The string value to calculate length for
* @returns {number} The total byte length (header size + string bytes)
*/
calculate_byte_length(value) {
if (typeof value !== 'string') {
throw new ParserError(`VarStringType value must be a string, got ${typeof value}`);
}
const string_bytes = Buffer.byteLength(value, 'utf8');
return this._header_size + string_bytes;
}
/**
* Decode a variable-length string from buffer
* @param {Buffer} buffer - The buffer to decode from
* @param {number} offset - The offset in the buffer
* @returns {{value: string, bytes_read: number}} The decoded string and bytes consumed
*/
decode(buffer, offset) {
const header_size = this._header_size;
if (buffer.length < offset + header_size) {
throw new ParserError('Buffer too small to read string length');
}
const length = header_size === 1
? buffer.readUInt8(offset)
: header_size === 2
? buffer.readUInt16LE(offset)
: buffer.readUInt32LE(offset);
if (buffer.length < offset + header_size + length) {
throw new ParserError(`Buffer too small to read string of length ${length}`);
}
const value = buffer.toString('utf8', offset + header_size, offset + header_size + length);
return { value, bytes_read: header_size + length };
}
/**
* Encode a string into buffer with length prefix
* @param {string} value - The string to encode
* @param {Buffer} buffer - The buffer to encode into
* @param {number} offset - The offset in the buffer
* @param {Object} [options={}] - Encoding options
* @param {boolean} [options.unsafe=false] - Skip validation for performance
* @returns {number} The number of bytes written
* @throws {ParserError} If value is invalid (unless unsafe=true)
*/
encode(value, buffer, offset, { unsafe = false } = {}) {
if (!unsafe) {
this.validate(value);
}
const string_bytes = Buffer.byteLength(value, 'utf8');
const header_size = this._header_size;
const total_bytes = header_size + string_bytes;
if (buffer.length < offset + total_bytes) {
throw new ParserError(`Buffer too small to encode string. Required: ${total_bytes}, Available: ${buffer.length - offset}`);
}
// Write length prefix
if (header_size === 1) {
buffer.writeUInt8(string_bytes, offset);
} else if (header_size === 2) {
buffer.writeUInt16LE(string_bytes, offset);
} else {
buffer.writeUInt32LE(string_bytes, offset);
}
// Write string
buffer.write(value, offset + header_size, string_bytes, 'utf8');
return total_bytes;
}
/**
* Validate a string value
* @param {string} value - The string to validate
* @returns {boolean} True if valid
* @throws {ParserError} If value is invalid
*/
validate(value) {
_throw_if_nullish(value, 'VarStringType');
if (typeof value !== 'string') {
throw new ParserError(`VarStringType value must be a string, got ${typeof value}`);
}
const string_bytes = Buffer.byteLength(value, 'utf8');
if (string_bytes > this.max_length) {
throw new ParserError(`FixedStringType byte length exceeds maximum: ${string_bytes} > ${this.max_length}`);
}
// Check header size limits
const max_header_value = this._header_size === 1 ? 255 : (this._header_size == 2 ? 65535 : 4294967295);
if (string_bytes > max_header_value) {
throw new ParserError(`FixedStringType byte length exceeds header maximum: ${string_bytes} > ${max_header_value}`);
}
return true;
}
/**
* Convert the string type to JSON representation
* @returns {{type: string, max_length: number}} JSON representation
*/
to_json() {
return {
type: "VarStringType",
max_length: this.max_length
};
}
}
/**
* Enumeration type for representing a fixed set of string options
* @class
* @extends Type
*/
class EnumType extends Type {
constructor(options=[]) {
super();
/**
* Array of string options for the enum
* @type {string[]}
* @private
*/
this.options = options;
/**
* Number of bytes needed to store the enum index (1, 2, or 4 bytes)
* @type {number}
* @private
*/
this._bytes_length = options.length <= 256 ? 1 : options.length <= 65536 ? 2 : 4;
}
/**
* Get the byte length needed for this enum type
* @type {number}
* @readonly
*/
get byte_length() {
return this._bytes_length;
}
/**
* Decode an enum value from buffer by reading its index
* @param {Buffer} buffer - The buffer to decode from
* @param {number} offset - The offset in the buffer
* @returns {{value: string, bytes_read: number}} Object containing the decoded value and bytes read
* @throws {ParserError} If the index is out of range
*/
decode(buffer, offset) {
let index;
if (this._bytes_length === 1) {
index = buffer.readUInt8(offset);
} else if (this._bytes_length === 2) {
index = buffer.readUInt16LE(offset);
} else {
index = buffer.readUInt32LE(offset);
}
if (index >= this.options.length) {
throw new ParserError(`EnumType index out of range: ${index} >= ${this.options.length}`);
}
return {
value: this.options[index],
bytes_read: this._bytes_length
};
}
/**
* Encode an enum value into buffer by writing its index
* @param {string} value - The enum option string to encode
* @param {Buffer} buffer - The buffer to encode into
* @param {number} offset - The offset in the buffer
* @param {Object} [options={}] - Encoding options
* @param {boolean} [options.unsafe=false] - Skip validation for performance
* @returns {void}
* @throws {ParserError} If value is undefined/null or not found in enum options (unless unsafe=true)
*/
encode(value, buffer, offset, { unsafe = false } = {}) {
if (!unsafe) {
this.validate(value);
}
const index = this.options.indexOf(value);
if (!unsafe && index === -1) {
throw new ParserError(`EnumType value not found: ${value}`);
}
if (this._bytes_length === 1) {
buffer.writeUInt8(index, offset);
} else if (this._bytes_length === 2) {
buffer.writeUInt16LE(index, offset);
} else {
buffer.writeUInt32LE(index, offset);
}
return this._bytes_length;
}
/**
* Validate an enum value
* @param {string} value - The enum value to validate
* @returns {boolean} True if valid
* @throws {ParserError} If value is invalid
*/
validate(value) {
_throw_if_nullish(value, 'EnumType');
if (typeof value !== 'string') {
throw new ParserError(`EnumType value must be a string, got ${typeof value}`);
}
if (this.options.indexOf(value) === -1) {
throw new ParserError(`EnumType value not found: ${value}. Valid options: ${this.options.join(', ')}`);
}
return true;
}
/**
* Convert the enum type to JSON representation
* @returns {{type: string, options: string[]}} JSON representation
*/
to_json() {
return {
type: "EnumType",
options: this.options
};
}
}
/**
* OptionalType type wrapper that can represent null/undefined values
* @class
* @extends Type
*/
class OptionalType extends Type {
/**
* Create a new OptionalType instance
* @param {Type} base_type - The base type to wrap as optional
*/
constructor(base_type) {
super();
/**
* The base type that this optional type wraps
* @type {Type}
* @private
*/
this.base_type = base_type;
}
/**
* Get the byte length (1 byte for presence flag + base type length)
* Returns null if base type has variable length
* @type {number|null}
* @readonly
*/
get byte_length() {
if (!this.base_type.is_static_length) {
return null; // Variable length if base type is variable
}
return 1 + this.base_type.byte_length;
}
/**
* Check if this type has a static (compile-time known) length
* @type {boolean}
* @readonly
*/
get is_static_length() {
return this.base_type.is_static_length;
}
/**
* Calculate the byte length for a specific optional value
* @param {*} value - The value to calculate length for
* @returns {number} The total byte length for encoding this optional value
*/
calculate_byte_length(value) {
if (value === null || value === undefined) {
if (!this.base_type.is_static_length) {
// Variable-length base type - null only takes 1 byte
return 1;
} else {
// Fixed-length base type - always consume full space for alignment
return 1 + this.base_type.byte_length;
}
}
return 1 + this.base_type.calculate_byte_length(value);
}
/**
* Decode an optional value from buffer
* @param {Buffer} buffer - The buffer to decode from
* @param {number} offset - The offset in the buffer
* @returns {{value: *, bytes_read: number}} Object containing the decoded value (or null) and bytes read
*/
decode(buffer, offset) {
const is_present = buffer.readUInt8(offset) !== 0;
if (!is_present) {
if (!this.base_type.is_static_length) {
// Variable-length base type - null only consumes 1 byte
return { value: null, bytes_read: 1 };
} else {
// Fixed-length base type - always consume full space for alignment
return { value: null, bytes_read: 1 + this.base_type.byte_length };
}
}
const decoded = this.base_type.decode(buffer, offset + 1);
return { value: decoded.value, bytes_read: 1 + decoded.bytes_read };
}
/**
* Encode an optional value into buffer
* @param {*|null|undefined} value - The value to encode (null/undefined for absent)
* @param {Buffer} buffer - The buffer to encode into
* @param {number} offset - The offset in the buffer
* @param {Object} [options={}] - Encoding options
* @param {boolean} [options.unsafe=false] - Skip validation for performance
* @returns {number} The number of bytes written
* @throws {ParserError} If value is invalid (unless unsafe=true)
*/
encode(value, buffer, offset, { unsafe = false } = {}) {
if (!unsafe) {
this.validate(value);
}
if (value === null || value === undefined) {
buffer.writeUInt8(0, offset);
if (!this.base_type.is_static_length) {
// Variable-length base type - null only takes 1 byte
return 1;
} else {
// Fixed-length base type - always consume full space for alignment
// Fill with zeros (though they won't be read)
buffer.fill(0, offset + 1, offset + 1 + this.base_type.byte_length);
return 1 + this.base_type.byte_length;
}
} else {
buffer.writeUInt8(1, offset);
if (!this.base_type.is_static_length) {
// Variable-length base type - encode returns bytes written
const bytesWritten = this.base_type.encode(value, buffer, offset + 1, { unsafe:false }); // Can skip validation, because we already handled it above
return 1 + bytesWritten;
} else {
// Fixed-length base type
this.base_type.encode(value, buffer, offset + 1, { unsafe:false }); // Can skip validation, because we already handled it above
return 1 + this.base_type.byte_length;
}
}
}
/**
* Validate an optional value
* @param {*|null|undefined} value - The value to validate
* @returns {boolean} True if valid
* @throws {ParserError} If value is invalid
*/
validate(value) {
// null and undefined are always valid for OptionalType
if (value === null || value === undefined) {
return true;
}
// If value is present, validate it against the base type
return this.base_type.validate(value);
}
/**
* Convert the optional type to JSON representation
* @returns {{type: string, base_type: Object}} JSON representation
*/
to_json() {
return {
type: "OptionalType",
base_type: this.base_type.to_json()
};
}
}
/**
* TupleType type for fixed-length sequences of heterogeneous types
* @class
* @extends Type
*/
class TupleType extends Type {
/**
* Create a new TupleType instance
* @param {...Type} element_types - The types for each element in the tuple
*/
constructor(...element_types) {
super();
// Allow empty tuples for edge cases
/**
* Array of types for each tuple element
* @type {Type[]}
* @private
*/
this.element_types = element_types;
}
/**
* Get the total byte length for the tuple
* Returns null if any element type has variable length
* @type {number|null}
* @readonly
*/
get byte_length() {
let totalLength = 0;
for (const elementType of this.element_types) {
if (elementType.byte_length === null) {
return null; // Variable length tuple if any element is variable
}
totalLength += elementType.byte_length;
}
return totalLength;
}
/**
* Check if this type has a static (compile-time known) length
* @type {boolean}
* @readonly
*/
get is_static_length() {
// TupleType is static only if ALL elements are static
return this.element_types.every(elementType => elementType.is_static_length);
}
/**
* Calculate the byte length for a specific tuple value
* @param {Array} value - The tuple value to calculate length for
* @returns {number} The total byte length for encoding this tuple
*/
calculate_byte_length(value) {
if (!Array.isArray(value)) {
throw new ParserError(`TupleType value must be an array, got ${typeof value}`);
}
if (value.length !== this.element_types.length) {
throw new ParserError(`TupleType length mismatch: expected ${this.element_types.length}, got ${value.length}`);
}
let totalLength = 0;
for (let i = 0; i < this.element_types.length; i++) {
totalLength += this.element_types[i].calculate_byte_length(value[i]);
}
return totalLength;
}
/**
* Decode a tuple from buffer
* @param {Buffer} buffer - The buffer to decode from
* @param {number} offset - The offset in the buffer
* @returns {{value: Array, bytes_read: number}} Object with decoded tuple and bytes read
*/
decode(buffer, offset) {
const tuple = [];
let currentOffset = offset;
for (let i = 0; i < this.element_types.length; i++) {
const decoded = this.element_types[i].decode(buffer, currentOffset);
tuple.push(decoded.value);
currentOffset += decoded.bytes_read;
}
return { value: tuple, bytes_read: currentOffset - offset };
}
/**
* Encode a tuple into buffer
* @param {Array} value - The tuple to encode
* @param {Buffer} buffer - The buffer to encode into
* @param {number} offset - The offset in the buffer
* @param {Object} [options={}] - Encoding options
* @param {boolean} [options.unsafe=false] - Skip validation for performance
* @returns {number} Number of bytes written
* @throws {ParserError} If value is invalid (unless unsafe=true)
*/
encode(value, buffer, offset, { unsafe = false } = {}) {
if (!unsafe) {
this.validate(value);
}
let currentOffset = offset;
for (let i = 0; i < this.element_types.length; i++) {
const bytesWritten = this.element_types[i].encode(value[i], buffer, currentOffset, { unsafe: false }); // Can skip validation, because we already handled it above
currentOffset += bytesWritten;
}
return currentOffset - offset;
}
/**
* Validate a tuple value
* @param {Array} value - The tuple to validate
* @returns {boolean} True if valid
* @throws {ParserError} If value is invalid
*/
validate(value) {
_throw_if_nullish(value, 'TupleType');
if (!Array.isArray(value)) {
throw new ParserError(`TupleType value must be an array, got ${typeof value}`);
}
if (value.length !== this.element_types.length) {
throw new ParserError(`TupleType length mismatch: expected ${this.element_types.length}, got ${value.length}`);
}
// Validate each element in the tuple
for (let i = 0; i < this.element_types.length; i++) {
try {
this.element_types[i].validate(value[i]);
} catch (error) {
throw new ParserError(`TupleType element at index ${i} is invalid: ${error.message}`);
}
}
return true;
}
/**
* Convert the tuple type to JSON representation
* @returns {{type: string, element_types: Object[]}} JSON representation
*/
to_json() {
return {
type: "TupleType",
element_types: this.element_types.map(elementType => elementType.to_json())
};
}
}
/**
* MapType type for key-value pairs with defined value types
* @class
* @extends Type
*/
class MapType extends Type {
/**
* Create a new MapType instance
* @param {Array<[string, Type]>} field_pairs - Array of [key, Type] pairs
*/
constructor(field_pairs) {
super();
if (!Array.isArray(field_pairs)) {
throw new ParserError('MapType requires an array of [key, Type] pairs');
}
// Allow empty maps for edge cases
// Validate each pair
for (let i = 0; i < field_pairs.length; i++) {
const pair = field_pairs[i];
if (!Array.isArray(pair) || pair.length !== 2) {
throw new ParserError(`Field pair at index ${i} must be [key, Type] array`);
}
if (typeof pair[0] !== 'string') {
throw new ParserError(`Field key at