import type { BitPackerSchema, PackingContext, PackingStrategy, } from "@suaveplan/bitpacker"; import { BITPACKER_CODES, BitpackerError, isFixedFloatSchema, isFloatSchema, isUuidSchema, } from "@suaveplan/bitpacker"; function bitsRequired(maxValue: number): number { if (maxValue <= 0) return 0; return Math.floor(Math.log2(maxValue)) + 1; } /** * Strategy for string schemas. * * Handles: * - Fixed strings: `"20"` → exactly 20 bytes * - Variable strings:`"-20"` → up to 20 bytes with length prefix * - UUID: `"uuid"` → 16 bytes * - Float: `"f32"`, `"f64"` → IEEE-754 floats * - Fixed float: `"4.2"`, `"-4.2"` → fixed-point numbers encoded as strings */ export class StringPackingStrategy implements PackingStrategy { canHandle(schema: BitPackerSchema): boolean { return typeof schema === "string"; } pack( data: unknown, schema: BitPackerSchema, context: PackingContext ): void { const s = schema as string; if (isUuidSchema(s)) { this.#packUuid(data, context); } else if (isFloatSchema(s)) { this.#packFloat(data as number, s, context); } else if (isFixedFloatSchema(s)) { this.#packFixedFloat(data as number | string, s, context); } else { this.#packString(data as string, s, context); } } unpack(schema: BitPackerSchema, context: PackingContext): unknown { const s = schema as string; if (isUuidSchema(s)) { return this.#unpackUuid(context); } if (isFloatSchema(s)) { return this.#unpackFloat(s, context); } if (isFixedFloatSchema(s)) { return this.#unpackFixedFloat(s, context); } return this.#unpackString(s, context); } #packString(value: string, schema: string, context: PackingContext): void { const isFixed = !schema.startsWith("-"); const maxLength = Number(isFixed ? schema : schema.slice(1)); if (typeof value !== "string") { throw new BitpackerError( BITPACKER_CODES.INVALID_SCHEMA, `Expected string for schema "${schema}", got ${typeof value}`, { context: { schema, value } } ); } const bytes = new TextEncoder().encode(value); const length = Math.min(bytes.length, maxLength); if (isFixed && bytes.length > maxLength) { throw new BitpackerError( BITPACKER_CODES.VALUE_OUT_OF_RANGE, `String length ${bytes.length} exceeds fixed schema length ${maxLength}`, { context: { schema, length: bytes.length, maxLength } } ); } if (!isFixed) { context.writer.writeUint(length, bitsRequired(maxLength)); } const writeCount = isFixed ? maxLength : length; for (let i = 0; i < writeCount; i++) { context.writer.writeUint(i < bytes.length ? bytes[i]! : 0, 8); } } #unpackString(schema: string, context: PackingContext): string { const isFixed = !schema.startsWith("-"); const maxLength = Number(isFixed ? schema : schema.slice(1)); let length = maxLength; if (!isFixed) { length = context.reader.readUint(bitsRequired(maxLength)); } const bytes = new Uint8Array(length); for (let i = 0; i < length; i++) { bytes[i] = context.reader.readUint(8); } let actualLength = length; while (actualLength > 0 && bytes[actualLength - 1] === 0) { actualLength--; } return new TextDecoder().decode(bytes.subarray(0, actualLength)); } #packUuid(value: unknown, context: PackingContext): void { let bytes: Uint8Array; if (typeof value === "string" && value.includes("-")) { const hex = value.replace(/-/g, ""); if (hex.length !== 32) { throw new BitpackerError( BITPACKER_CODES.VALUE_OUT_OF_RANGE, `Invalid UUID string: ${value}`, { context: { value } } ); } bytes = new Uint8Array(16); for (let i = 0; i < 16; i++) { bytes[i] = Number.parseInt(hex.substring(i * 2, i * 2 + 2), 16); } } else if (value instanceof Uint8Array && value.length === 16) { bytes = value; } else { throw new BitpackerError( BITPACKER_CODES.INVALID_SCHEMA, "UUID must be a 36-char string or 16-byte Uint8Array", { context: { value } } ); } context.writer.writeBytes(bytes); } #unpackUuid(context: PackingContext): string { const bytes = context.reader.readBytes(16); const hex = Array.from(bytes) .map((b) => b.toString(16).padStart(2, "0")) .join(""); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; } #packFloat(value: number, schema: string, context: PackingContext): void { if (typeof value !== "number") { throw new BitpackerError( BITPACKER_CODES.INVALID_SCHEMA, `Expected number for float schema "${schema}", got ${typeof value}`, { context: { schema, value } } ); } if (schema === "f32") { const buf = new ArrayBuffer(4); new DataView(buf).setFloat32(0, value, false); context.writer.writeBytes(new Uint8Array(buf)); } else { const buf = new ArrayBuffer(8); new DataView(buf).setFloat64(0, value, false); context.writer.writeBytes(new Uint8Array(buf)); } } #unpackFloat(schema: string, context: PackingContext): number { const bytes = context.reader.readBytes(schema === "f32" ? 4 : 8); const view = new DataView( bytes.buffer, bytes.byteOffset, bytes.byteLength ); if (schema === "f32") { return view.getFloat32(0, false); } return view.getFloat64(0, false); } #packFixedFloat( value: number | string, schema: string, context: PackingContext ): void { const [wholeStr, fracStr] = schema.split("."); const wholeBits = Math.abs(Number(wholeStr)); const fracBits = Number(fracStr); const totalBits = wholeBits + fracBits; const isSigned = Number(wholeStr) < 0; const numValue = typeof value === "string" ? Number(value) : value; const factor = 1 << fracBits; const scaled = Math.round(numValue * factor); if (isSigned) { context.writer.writeInt(scaled, totalBits); } else { context.writer.writeUint(scaled, totalBits); } } #unpackFixedFloat(schema: string, context: PackingContext): number { const [wholeStr, fracStr] = schema.split("."); const wholeBits = Math.abs(Number(wholeStr)); const fracBits = Number(fracStr); const totalBits = wholeBits + fracBits; const isSigned = Number(wholeStr) < 0; const scaled = isSigned ? context.reader.readInt(totalBits) : context.reader.readUint(totalBits); return scaled / (1 << fracBits); } }