/** * A utility class for reading various primitive types and strings from an ArrayBuffer, * supporting configurable byte order (endianness). */ export class Reader { private buffer: ArrayBuffer; private view: DataView; private offset: number; private littleEndian: boolean; constructor(buffer: ArrayBuffer | ArrayBufferLike | Uint8Array, littleEndian: boolean = true) { if (buffer instanceof ArrayBuffer) { this.buffer = buffer; } else if (buffer instanceof Uint8Array || ArrayBuffer.isView(buffer)) { const srcBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); this.buffer = srcBuffer instanceof ArrayBuffer ? srcBuffer : new ArrayBuffer(srcBuffer.byteLength); } else { throw new TypeError('Invalid buffer type. Expected ArrayBuffer, Uint8Array, or ArrayBufferView.'); } this.view = new DataView(this.buffer); this.offset = 0; this.littleEndian = littleEndian; } /** * Read a boolean value from the buffer. A boolean is stored as a single byte, where 0 represents * `false` and any non-zero value represents `true`. * * @returns A boolean value read from the buffer. */ public bool(): boolean { this.checkBounds(1); const value = this.view.getUint8(this.offset); this.offset += 1; return value !== 0; } /** * Read an 8-bit signed integer from the buffer at the current offset, and advance the offset by 1 byte. * * @returns An 8-bit signed integer read from the buffer. */ public int8(): number { this.checkBounds(1); const value = this.view.getInt8(this.offset); this.offset += 1; return value; } /** * Read a 16-bit signed integer from the buffer at the current offset, using the specified byte order, and * advance the offset by 2 bytes. * * @returns A 16-bit signed integer read from the buffer. */ public int16(): number { this.checkBounds(2); const value = this.view.getInt16(this.offset, this.littleEndian); this.offset += 2; return value; } /** * Read a 32-bit signed integer from the buffer at the current offset, using the specified byte order, and * advance the offset by 4 bytes. * * @returns A 32-bit signed integer read from the buffer. */ public int32(): number { this.checkBounds(4); const value = this.view.getInt32(this.offset, this.littleEndian); this.offset += 4; return value; } /** * Read a 64-bit signed integer from the buffer at the current offset, using the specified byte order, and * advance the offset by 8 bytes. * * @returns A 64-bit signed integer read from the buffer. */ public int64(): bigint { this.checkBounds(8); const value = this.view.getBigInt64(this.offset, this.littleEndian); this.offset += 8; return value; } /** * Read an 8-bit unsigned integer from the buffer at the current offset, and advance the offset by 1 byte. * * @returns An 8-bit unsigned integer read from the buffer. */ public uint8(): number { this.checkBounds(1); const value = this.view.getUint8(this.offset); this.offset += 1; return value; } /** * Read a 16-bit unsigned integer from the buffer at the current offset, using the specified byte order, and * advance the offset by 2 bytes. * * @returns A 16-bit unsigned integer read from the buffer. */ public uint16(): number { this.checkBounds(2); const value = this.view.getUint16(this.offset, this.littleEndian); this.offset += 2; return value; } /** * Read a 32-bit unsigned integer from the buffer at the current offset, using the specified byte order, and * advance the offset by 4 bytes. * * @returns A 32-bit unsigned integer read from the buffer. */ public uint32(): number { this.checkBounds(4); const value = this.view.getUint32(this.offset, this.littleEndian); this.offset += 4; return value; } /** * Read a 64-bit unsigned integer from the buffer at the current offset, using the specified byte order, and * advance the offset by 8 bytes. * * @returns A 64-bit unsigned integer read from the buffer. */ public uint64(): bigint { this.checkBounds(8); const value = this.view.getBigUint64(this.offset, this.littleEndian); this.offset += 8; return value; } /** * Read a 32-bit floating point number from the buffer at the current offset, using the specified byte order, and * advance the offset by 4 bytes. * * @returns A 32-bit floating point number read from the buffer. */ public float32(): number { this.checkBounds(4); const value = this.view.getFloat32(this.offset, this.littleEndian); this.offset += 4; return value; } /** * Read a 64-bit floating point number from the buffer at the current offset, using the specified byte order, and * advance the offset by 8 bytes. * * @returns A 64-bit floating point number read from the buffer. */ public float64(): number { this.checkBounds(8); const value = this.view.getFloat64(this.offset, this.littleEndian); this.offset += 8; return value; } /** * Read a variable-length unsigned integer from the buffer using LEB128 encoding. The method reads * bytes until it encounters a byte with the most significant bit (MSB) set to 0, which indicates * the end of the varint. * * The value is constructed by concatenating the lower 7 bits of each byte, and shifting them * according to their position. * * @returns A number representing the decoded unsigned varint value. */ public varint(): number { let result = 0; let shift = 0; while (true) { this.checkBounds(1); const byte = this.view.getUint8(this.offset++); result |= (byte & 0x7F) << shift; if ((byte & 0x80) === 0) { break; // Last byte of the varint } shift += 7; } return result; } /** * Read a variable-length signed integer from the buffer using LEB128 encoding. The method reads * bytes until it encounters a byte with the most significant bit (MSB) set to 0, which indicates * the end of the varint. * * The value is constructed by concatenating the lower 7 bits of each byte, and shifting them * according to their position. If the sign bit (the highest bit of the last byte) is set, the * result is sign-extended to produce a negative number. * * @returns A number representing the decoded signed varint value. */ public varsint(): number { let result = 0; let shift = 0; while (true) { this.checkBounds(1); const byte = this.view.getUint8(this.offset++); result |= (byte & 0x7F) << shift; if ((byte & 0x80) === 0) { break; // Last byte of the varint } shift += 7; } // Sign-extend the result if the sign bit is set if (shift < 32 && (result & (1 << (shift - 1))) !== 0) { result |= ~0 << shift; } return result; } /** * Read a 32-bit date (stored as 32-bit seconds since epoch). * * @returns A Date object representing the date/time read from the buffer. */ public date32(): Date { this.checkBounds(4); const timestamp = this.view.getUint32(this.offset, this.littleEndian); this.offset += 4; return new Date(timestamp * 1000); // Convert seconds to milliseconds } /** * Read a 64-bit date (stored as 64-bit milliseconds since epoch). * * @returns A Date object representing the date/time read from the buffer. */ public date64(): Date { this.checkBounds(8); const timestamp = this.view.getBigUint64(this.offset, this.littleEndian); this.offset += 8; return new Date(Number(timestamp)); // Convert bigint to number (ms since epoch) } /** * Read a sequence of bytes from the buffer. If length is not provided, reads until the end of the buffer. * * @param length The number of bytes to read. If not provided, reads until the end of the buffer. * @returns A Uint8Array containing the bytes read from the buffer. */ public bytes(length?: number): Uint8Array { if (length === undefined) { length = this.view.byteLength - this.offset; } this.checkBounds(length); const bytes = new Uint8Array(this.view.buffer, this.offset, length); this.offset += length; return bytes; } /** * Read a null-terminated C-style string from the buffer. Reads bytes until a null terminator * (0 byte) is found or the end of the buffer is reached, and decodes them as a UTF-8 string. * * @returns A string containing the decoded characters up to the null terminator or the end of the buffer. */ public cString(): string { const bytes = []; while (this.offset < this.view.byteLength) { const byte = this.view.getUint8(this.offset++); if (byte === 0) { break; // Null terminator found } bytes.push(byte); } return new TextDecoder().decode(new Uint8Array(bytes)); } /** * Read a UTF-8 encoded string from the buffer. If length is provided, reads that many bytes and * decodes them as a UTF-8 string. * * @param length The number of bytes to read. If not provided, reads until the end of the buffer. * @returns A string containing the decoded characters. */ public utf8String(length?: number): string { if (length === undefined) { length = this.view.byteLength - this.offset; } this.checkBounds(length); const bytes = new Uint8Array(this.view.buffer, this.offset, length); this.offset += length; return new TextDecoder().decode(bytes); } /** * Read an array of 16-bit words. * * @param length The number of 16-bit words to read. * @returns A Uint16Array containing the words read from the buffer. */ public words(length: number): Uint16Array { this.checkBounds(length * 2); const bytes = new Uint8Array(this.view.buffer, this.offset, length * 2); const copy = bytes.slice(); const words = new Uint16Array(copy.buffer); this.offset += length * 2; return words; } /** * Read an array of 32-bit double words. * * @param length The number of 32-bit double words to read. * @returns A Uint32Array containing the double words read from the buffer. */ public dwords(length: number): Uint32Array { this.checkBounds(length * 4); const bytes = new Uint8Array(this.view.buffer, this.offset, length * 4); const copy = bytes.slice(); const dwords = new Uint32Array(copy.buffer); this.offset += length * 4; return dwords; } /** * Read an array of 64-bit quad words. * * @param length The number of 64-bit quad words to read. * @returns A BigUint64Array containing the quad words read from the buffer. */ public qwords(length: number): BigUint64Array { this.checkBounds(length * 8); const bytes = new Uint8Array(this.view.buffer, this.offset, length * 8); const copy = bytes.slice(); const qwords = new BigUint64Array(copy.buffer); this.offset += length * 8; return qwords; } public static fromBytes(bytes: Uint8Array, littleEndian: boolean = true): Reader { return new Reader(bytes, littleEndian); } public static fromString(str: string, encoding: 'utf8' | 'ascii' | 'hex' | 'base64' | 'rawbase64' | 'urlbase64' | 'rawurlbase64' = 'utf8', littleEndian: boolean = true): Reader { let bytes: Uint8Array; switch (encoding) { case 'utf8': bytes = new TextEncoder().encode(str); break; case 'ascii': bytes = new Uint8Array(str.split('').map(c => c.charCodeAt(0))); break; case 'hex': bytes = new Uint8Array(str.length / 2); for (let i = 0; i < bytes.length; i++) { bytes[i] = parseInt(str.substr(i * 2, 2), 16); } break; case 'base64': bytes = Uint8Array.from(atob(str), c => c.charCodeAt(0)); break; case 'rawbase64': bytes = Uint8Array.from(atob(str.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)); break; case 'urlbase64': bytes = Uint8Array.from(atob(str.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)); break; case 'rawurlbase64': bytes = Uint8Array.from(atob(str.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)); break; } return new Reader(bytes.slice().buffer, littleEndian); } /* Aliases */ /* Alias for bool */ public flag(): boolean { return this.bool(); } /* Alias for utf8String */ public string(length: number): string { return this.utf8String(length); } private checkBounds(length: number) { if (this.offset + length > this.view.byteLength) { throw new RangeError(`Attempt to read beyond end of buffer: offset=${this.offset}, length=${length}, bufferLength=${this.view.byteLength}`); } } } /** * A utility class for writing various primitive types and strings into an ArrayBuffer, * supporting configurable byte order (endianness). * * @remarks * The `BufferWriter` provides methods to write signed and unsigned integers, floating-point numbers, * booleans, dates, raw bytes, and strings into an internal buffer. The byte order for multi-byte * values is determined by the provided `ByteOrder` implementation. * * @example * ```typescript * const writer = new BufferWriter(16, LittleEndian); * writer.int32(42); * writer.string("hello"); * ``` */ export class Writer { private buffer: ArrayBuffer; private view: DataView; private offset: number; private littleEndian: boolean; constructor(size: number, littleEndian: boolean = true) { this.buffer = new ArrayBuffer(size); this.view = new DataView(this.buffer); this.offset = 0; this.littleEndian = littleEndian; } // Methods for writing various types will go here (e.g., writeInt8, writeUInt16LE, etc.) public bool(value: boolean): void { this.checkBounds(1); this.view.setUint8(this.offset, value ? 1 : 0); this.offset += 1; } /** * Write an 8-bit signed integer to the buffer at the current offset, and advance the offset by * 1 byte. * * @param value The 8-bit signed integer to write. */ public int8(value: number): void { this.checkBounds(1); this.view.setInt8(this.offset, value); this.offset += 1; } /** * Write a 16-bit signed integer to the buffer at the current offset, using the specified byte * order, and advance the offset by 2 bytes. * * @param value The 16-bit signed integer to write. */ public int16(value: number): void { this.checkBounds(2); this.view.setInt16(this.offset, value, this.littleEndian); this.offset += 2; } /** * Write a 32-bit signed integer to the buffer at the current offset, using the specified byte * order, and advance the offset by 4 bytes. * * @param value The 32-bit signed integer to write. */ public int32(value: number): void { this.checkBounds(4); this.view.setInt32(this.offset, value, this.littleEndian); this.offset += 4; } /** * Write a 64-bit signed integer to the buffer at the current offset, using the specified byte * order, and advance the offset by 8 bytes. * * @param value The 64-bit signed integer to write. */ public int64(value: number | bigint): void { this.checkBounds(8); if (typeof value === 'number') { value = BigInt(value); } this.view.setBigInt64(this.offset, value, this.littleEndian); this.offset += 8; } /** * Write an 8-bit unsigned integer to the buffer at the current offset, and advance the offset by * 1 byte. * * @param value The 8-bit unsigned integer to write. */ public uint8(value: number): void { this.checkBounds(1); this.view.setUint8(this.offset, value); this.offset += 1; } /** * Write a 16-bit unsigned integer to the buffer at the current offset, using the specified byte * order, and advance the offset by 2 bytes. * * @param value The 16-bit unsigned integer to write. */ public uint16(value: number): void { this.checkBounds(2); this.view.setUint16(this.offset, value, this.littleEndian); this.offset += 2; } /** * Write a 32-bit unsigned integer to the buffer at the current offset, using the specified byte * order, and advance the offset by 4 bytes. * * @param value The 32-bit unsigned integer to write. */ public uint32(value: number): void { this.checkBounds(4); this.view.setUint32(this.offset, value, this.littleEndian); this.offset += 4; } /** * Write a 64-bit unsigned integer to the buffer at the current offset, using the specified byte * order, and advance the offset by 8 bytes. * * @param value The 64-bit unsigned integer to write. */ public uint64(value: number | bigint): void { this.checkBounds(8); if (typeof value === 'number') { value = BigInt(value); } this.view.setBigUint64(this.offset, value, this.littleEndian); this.offset += 8; } /** * Write a 32-bit floating point number to the buffer at the current offset, using the specified byte * order, and advance the offset by 4 bytes. * * @param value The 32-bit floating point number to write. */ public float32(value: number): void { this.checkBounds(4); this.view.setFloat32(this.offset, value, this.littleEndian); this.offset += 4; } /** * Write a 64-bit floating point number to the buffer at the current offset, using the specified byte * order, and advance the offset by 8 bytes. * * @param value The 64-bit floating point number to write. */ public float64(value: number): void { this.checkBounds(8); this.view.setFloat64(this.offset, value, this.littleEndian); this.offset += 8; } /** * Write a variable-length unsigned integer to the buffer using LEB128 encoding. The method encodes the * value in one or more bytes, where each byte has the most significant bit (MSB) set to 1 if there * are more bytes to follow, and 0 if it is the last byte. The lower 7 bits of each byte contain * the data, and the value is constructed by concatenating these bits and shifting them according to * their position. * * @param value The unsigned integer to encode as a varint. */ public varint(value: number): void { this.checkBounds(Writer.varintSize(value)); // Calculate the number of bytes needed to encode the varint // (does not actually advance the offset or write) // Useful for pre-sizing buffers. let remaining = value >>> 0; // Ensure unsigned while (remaining >= 0x80) { this.view.setUint8(this.offset++, (remaining & 0x7F) | 0x80); remaining >>>= 7; } this.view.setUint8(this.offset++, remaining); } /** * Write a variable-length signed integer to the buffer using LEB128 encoding. The method encodes the * value in one or more bytes, where each byte has the most significant bit (MSB) set to 1 if there * are more bytes to follow, and 0 if it is the last byte. The lower 7 bits of each byte contain * the data, and the value is constructed by concatenating these bits and shifting them according to * their position. If the sign bit (the highest bit of the last byte) is set, the result is * sign-extended to produce a negative number. * * @param value The signed integer to encode as a varint. */ public varsint(value: number): void { this.checkBounds(Writer.varintSize(value)); let remaining = value >>> 0; // Ensure unsigned const isNegative = value < 0; while (remaining >= 0x80 || (isNegative && remaining < 0x80)) { this.view.setUint8(this.offset++, (remaining & 0x7F) | 0x80); remaining >>>= 7; } this.view.setUint8(this.offset++, remaining); } private static varintSize(value: number): number { let size = 1; let v = value >>> 0; while (v >= 0x80) { v >>>= 7; size++; } return size; } /** * Write a 32-bit date (stored as seconds since epoch) to the buffer at the current offset, using the * specified byte order, and advance the offset by 4 bytes. * * @param value The Date object to write as a 32-bit timestamp. */ public date32(value: Date): void { const timestamp = Math.floor(value.getTime() / 1000); // Convert ms to seconds this.checkBounds(4); this.view.setUint32(this.offset, timestamp, this.littleEndian); this.offset += 4; } /** * Write a 64-bit date (stored as milliseconds since epoch) to the buffer at the current offset, * using the specified byte order, and advance the offset by 8 bytes. * * @param value The Date object to write as a 64-bit timestamp. */ public date64(value: Date): void { const timestamp = BigInt(value.getTime()); // Get time in ms as bigint this.checkBounds(8); this.view.setBigUint64(this.offset, timestamp, this.littleEndian); this.offset += 8; } /** * Write a sequence of bytes to the buffer at the current offset, and advance the offset by the * length of the data. * * @param data The sequence of bytes to write. */ public bytes(data: Uint8Array): void { this.checkBounds(data.length); new Uint8Array(this.buffer, this.offset, data.length).set(data); this.offset += data.length; } /** * Write a null-terminated C-style string to the buffer at the current offset, and advance the * offset by the length of the string plus one byte for the null terminator. * * @param value The string to write as a null-terminated C-style string. */ public cString(value: string): void { const encoder = new TextEncoder(); const bytes = encoder.encode(value); this.checkBounds(bytes.length + 1); // +1 for null terminator new Uint8Array(this.buffer, this.offset, bytes.length).set(bytes); this.view.setUint8(this.offset + bytes.length, 0); // Null terminator this.offset += bytes.length + 1; } /** * Write a UTF-8 encoded string to the buffer at the current offset, and advance the offset by the * length of the encoded string. * * @param value The string to write as a UTF-8 encoded string. */ public utf8String(value: string): void { const encoder = new TextEncoder(); const bytes = encoder.encode(value); this.checkBounds(bytes.length); new Uint8Array(this.buffer, this.offset, bytes.length).set(bytes); this.offset += bytes.length; } /** * Convert the internal buffer to a Uint8Array containing the bytes that have been written so far. * The returned Uint8Array shares the same underlying ArrayBuffer, but is sliced to include only * the bytes up to the current offset. * * @returns A Uint8Array containing the bytes that have been written so far. */ public toBytes(): Uint8Array { return new Uint8Array(this.buffer, 0, this.offset); } /** * Convert the internal buffer to a string using the specified encoding. The method first converts * the buffer to a Uint8Array containing the bytes that have been written so far, and then encodes * it according to the specified encoding. * * The supported encodings are: * - 'utf-8': Decodes the bytes as a UTF-8 string. * - 'hex': Encodes the bytes as a hexadecimal string. * - 'base64': Encodes the bytes as a Base64 string. * - 'rawbase64': Encodes the bytes as a URL-safe Base64 string without padding. * - 'urlbase64': Encodes the bytes as a URL-safe Base64 string with padding. * - 'rawurlbase64': Encodes the bytes as a URL-safe Base64 string without padding. * * @param encoding The encoding to use for the string conversion. * @returns The encoded string. */ public toString(encoding: 'utf-8' | 'hex' | 'base64' | 'rawbase64' | 'urlbase64' | 'rawurlbase64' = 'utf-8'): string { const bytes = this.toBytes(); switch (encoding) { case 'utf-8': return new TextDecoder().decode(bytes); case 'hex': return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); case 'base64': return btoa(String.fromCharCode(...bytes)); case 'rawbase64': return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); case 'urlbase64': return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_'); case 'rawurlbase64': return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); default: throw new Error(`Unsupported encoding: ${encoding}`); } } public static fromString(value: string, encoding: 'utf-8' | 'hex' | 'base64' | 'rawbase64' | 'urlbase64' | 'rawurlbase64' = 'utf-8', littleEndian: boolean = true): Writer { let bytes: Uint8Array; switch (encoding) { case 'utf-8': bytes = new TextEncoder().encode(value); break; case 'hex': bytes = new Uint8Array(value.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16))); break; case 'base64': bytes = Uint8Array.from(atob(value), c => c.charCodeAt(0)); break; case 'rawbase64': bytes = Uint8Array.from(atob(value.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)); break; case 'urlbase64': bytes = Uint8Array.from(atob(value.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)); break; case 'rawurlbase64': bytes = Uint8Array.from(atob(value.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)); break; default: throw new Error(`Unsupported encoding: ${encoding}`); } const writer = new Writer(bytes.length, littleEndian); writer.bytes(bytes); return writer; } /* Aliases */ /* Alias for bool */ public flag(value: boolean): void { this.bool(value); } /* Alias for utf8String */ public string(value: string): void { this.utf8String(value); } private checkBounds(length: number) { if (this.offset + length > this.view.byteLength) { throw new RangeError(`Attempt to write beyond end of buffer: offset=${this.offset}, length=${length}, bufferLength=${this.view.byteLength}`); } } } export { FieldType } from './types';